diff --git a/app/components/post_list/post/body/reactions/reactions.tsx b/app/components/post_list/post/body/reactions/reactions.tsx
index 8baa12c84..ff63db352 100644
--- a/app/components/post_list/post/body/reactions/reactions.tsx
+++ b/app/components/post_list/post/body/reactions/reactions.tsx
@@ -3,13 +3,15 @@
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
-import {TouchableOpacity, View} from 'react-native';
+import {Keyboard, TouchableOpacity, View} from 'react-native';
import {addReaction, removeReaction} from '@actions/remote/reactions';
import CompassIcon from '@components/compass_icon';
+import {Screens} from '@constants';
import {MAX_ALLOWED_REACTIONS} from '@constants/emoji';
import {useServerUrl} from '@context/server';
-import {showModal, showModalOverCurrentContext} from '@screens/navigation';
+import {useIsTablet} from '@hooks/device';
+import {bottomSheetModalOptions, showModal, showModalOverCurrentContext} from '@screens/navigation';
import {getEmojiFirstAlias} from '@utils/emoji/helpers';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
@@ -59,6 +61,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
const Reactions = ({currentUserId, canAddReaction, canRemoveReaction, disabled, postId, reactions, theme}: ReactionsProps) => {
const intl = useIntl();
const serverUrl = useServerUrl();
+ const isTablet = useIsTablet();
const pressed = useRef(false);
const [sortedReactions, setSortedReactions] = useState(new Set(reactions.map((r) => getEmojiFirstAlias(r.emojiName))));
const styles = getStyleSheet(theme);
@@ -106,8 +109,7 @@ const Reactions = ({currentUserId, canAddReaction, canRemoveReaction, disabled,
addReaction(serverUrl, postId, emoji);
};
- const handleAddReaction = preventDoubleTap(() => {
- const screen = 'AddReaction';
+ const handleAddReaction = useCallback(preventDoubleTap(() => {
const title = intl.formatMessage({id: 'mobile.post_info.add_reaction', defaultMessage: 'Add Reaction'});
const closeButton = CompassIcon.getImageSourceSync('close', 24, theme.sidebarHeaderTextColor);
@@ -116,10 +118,10 @@ const Reactions = ({currentUserId, canAddReaction, canRemoveReaction, disabled,
onEmojiPress: handleAddReactionToPost,
};
- showModal(screen, title, passProps);
- });
+ showModal(Screens.EMOJI_PICKER, title, passProps);
+ }), [intl, theme]);
- const handleReactionPress = async (emoji: string, remove: boolean) => {
+ const handleReactionPress = useCallback(async (emoji: string, remove: boolean) => {
pressed.current = true;
if (remove && canRemoveReaction && !disabled) {
await removeReaction(serverUrl, postId, emoji);
@@ -128,18 +130,26 @@ const Reactions = ({currentUserId, canAddReaction, canRemoveReaction, disabled,
}
pressed.current = false;
- };
+ }, [canRemoveReaction, canAddReaction, disabled]);
- const showReactionList = () => {
- const screen = 'ReactionList';
+ const showReactionList = useCallback((initialEmoji: string) => {
+ const screen = Screens.REACTIONS;
const passProps = {
+ initialEmoji,
postId,
};
+ Keyboard.dismiss();
+ const title = isTablet ? intl.formatMessage({id: 'post.reactions.title', defaultMessage: 'Reactions'}) : '';
+
if (!pressed.current) {
- showModalOverCurrentContext(screen, passProps);
+ if (isTablet) {
+ showModal(screen, title, passProps, bottomSheetModalOptions(theme, 'close-post-reactions'));
+ } else {
+ showModalOverCurrentContext(screen, passProps);
+ }
}
- };
+ }, [intl, postId, theme]);
let addMoreReactions = null;
const {reactionsByName, highlightedReactions} = buildReactionsMap();
@@ -182,4 +192,4 @@ const Reactions = ({currentUserId, canAddReaction, canRemoveReaction, disabled,
);
};
-export default React.memo(Reactions);
+export default Reactions;
diff --git a/app/screens/index.tsx b/app/screens/index.tsx
index ab684bccb..bfbb67bae 100644
--- a/app/screens/index.tsx
+++ b/app/screens/index.tsx
@@ -80,6 +80,9 @@ Navigation.setLazyComponentRegistrator((screenName) => {
require('@screens/custom_status_clear_after').default,
);
break;
+ case Screens.CREATE_DIRECT_MESSAGE:
+ screen = withServerDatabase(require('@screens/create_direct_message').default);
+ break;
case Screens.EDIT_POST:
screen = withServerDatabase(require('@screens/edit_post').default);
break;
@@ -127,6 +130,9 @@ Navigation.setLazyComponentRegistrator((screenName) => {
require('@screens/post_options').default,
);
break;
+ case Screens.REACTIONS:
+ screen = withServerDatabase(require('@screens/reactions').default);
+ break;
case Screens.SAVED_POSTS:
screen = withServerDatabase((require('@screens/home/saved_posts').default));
break;
diff --git a/app/screens/reactions/emoji_aliases/index.tsx b/app/screens/reactions/emoji_aliases/index.tsx
new file mode 100644
index 000000000..5e5cb6f25
--- /dev/null
+++ b/app/screens/reactions/emoji_aliases/index.tsx
@@ -0,0 +1,44 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react';
+import {Text, View} from 'react-native';
+
+import {useTheme} from '@context/theme';
+import {getEmojiByName} from '@utils/emoji/helpers';
+import {makeStyleSheetFromTheme} from '@utils/theme';
+import {typography} from '@utils/typography';
+
+type Props = {
+ emoji: string;
+};
+
+const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
+ container: {
+ marginBottom: 16,
+ },
+ title: {
+ color: theme.centerChannelColor,
+ ...typography('Body', 75, 'SemiBold'),
+ },
+}));
+
+const EmojiAliases = ({emoji}: Props) => {
+ const theme = useTheme();
+ const style = getStyleSheet(theme);
+ const aliases = getEmojiByName(emoji, [])?.short_names?.map((n: string) => `:${n}:`).join(' ') || `:${emoji}:`;
+
+ return (
+
+
+ {aliases}
+
+
+ );
+};
+
+export default EmojiAliases;
diff --git a/app/screens/reactions/emoji_bar/index.tsx b/app/screens/reactions/emoji_bar/index.tsx
new file mode 100644
index 000000000..092dbbad0
--- /dev/null
+++ b/app/screens/reactions/emoji_bar/index.tsx
@@ -0,0 +1,91 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React, {useCallback, useEffect, useRef} from 'react';
+import {StyleSheet} from 'react-native';
+import {FlatList} from 'react-native-gesture-handler';
+
+import Item from './item';
+
+import type ReactionModel from '@typings/database/models/servers/reaction';
+
+type Props = {
+ emojiSelected: string;
+ reactionsByName: Map;
+ setIndex: (idx: number) => void;
+ sortedReactions: string[];
+}
+
+type ScrollIndexFailed = {
+ index: number;
+ highestMeasuredFrameIndex: number;
+ averageItemLength: number;
+};
+
+const style = StyleSheet.create({
+ container: {
+ maxHeight: 44,
+ },
+});
+
+const EmojiBar = ({emojiSelected, reactionsByName, setIndex, sortedReactions}: Props) => {
+ const listRef = useRef>(null);
+
+ const scrollToIndex = (index: number, animated = false) => {
+ listRef.current?.scrollToIndex({
+ animated,
+ index,
+ viewOffset: 0,
+ viewPosition: 1, // 0 is at bottom
+ });
+ };
+
+ const onPress = useCallback((emoji: string) => {
+ const index = sortedReactions.indexOf(emoji);
+ setIndex(index);
+ }, [sortedReactions]);
+
+ const onScrollToIndexFailed = useCallback((info: ScrollIndexFailed) => {
+ const index = Math.min(info.highestMeasuredFrameIndex, info.index);
+
+ scrollToIndex(index);
+ }, []);
+
+ const renderItem = useCallback(({item}) => {
+ return (
+
+ );
+ }, [sortedReactions, emojiSelected, reactionsByName]);
+
+ useEffect(() => {
+ const t = setTimeout(() => {
+ listRef.current?.scrollToItem({
+ item: emojiSelected,
+ animated: false,
+ viewPosition: 1,
+ });
+ }, 100);
+
+ return () => clearTimeout(t);
+ }, []);
+
+ return (
+
+ );
+};
+
+export default EmojiBar;
diff --git a/app/screens/reactions/emoji_bar/item.tsx b/app/screens/reactions/emoji_bar/item.tsx
new file mode 100644
index 000000000..3464b4e3b
--- /dev/null
+++ b/app/screens/reactions/emoji_bar/item.tsx
@@ -0,0 +1,81 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React, {useCallback} from 'react';
+import {TouchableOpacity, View} from 'react-native';
+import AnimatedNumbers from 'react-native-animated-numbers';
+
+import Emoji from '@components/emoji';
+import {useTheme} from '@context/theme';
+import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
+import {typography} from '@utils/typography';
+
+type ReactionProps = {
+ count: number;
+ emojiName: string;
+ highlight: boolean;
+ onPress: (emojiName: string) => void;
+}
+
+const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
+ return {
+ count: {
+ color: changeOpacity(theme.centerChannelColor, 0.56),
+ ...typography('Body', 100, 'SemiBold'),
+ },
+ countContainer: {marginRight: 5},
+ countHighlight: {
+ color: theme.buttonBg,
+ },
+ customEmojiStyle: {color: '#000'},
+ emoji: {marginHorizontal: 5},
+ highlight: {
+ backgroundColor: changeOpacity(theme.buttonBg, 0.08),
+ },
+ reaction: {
+ alignItems: 'center',
+ borderRadius: 4,
+ backgroundColor: theme.centerChannelBg,
+ flexDirection: 'row',
+ height: 32,
+ justifyContent: 'center',
+ marginRight: 12,
+ minWidth: 50,
+ },
+ };
+});
+
+const Reaction = ({count, emojiName, highlight, onPress}: ReactionProps) => {
+ const theme = useTheme();
+ const styles = getStyleSheet(theme);
+
+ const handlePress = useCallback(() => {
+ onPress(emojiName);
+ }, [highlight]);
+
+ return (
+
+
+
+
+
+
+
+
+ );
+};
+
+export default Reaction;
diff --git a/app/screens/reactions/index.ts b/app/screens/reactions/index.ts
new file mode 100644
index 000000000..0d76d0cd2
--- /dev/null
+++ b/app/screens/reactions/index.ts
@@ -0,0 +1,32 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
+import withObservables from '@nozbe/with-observables';
+import {switchMap} from 'rxjs/operators';
+
+import {MM_TABLES} from '@constants/database';
+
+import Reactions from './reactions';
+
+import type {WithDatabaseArgs} from '@typings/database/database';
+import type PostModel from '@typings/database/models/servers/post';
+
+type EnhancedProps = WithDatabaseArgs & {
+ postId: string;
+}
+
+const {POST} = MM_TABLES.SERVER;
+
+const enhanced = withObservables([], ({postId, database}: EnhancedProps) => {
+ const post = database.get(POST).findAndObserve(postId);
+
+ return {
+ reactions: post.pipe(
+ switchMap((p) => p.reactions.observe()),
+ ),
+ };
+});
+
+export default withDatabase(enhanced(Reactions));
+
diff --git a/app/screens/reactions/reactions.tsx b/app/screens/reactions/reactions.tsx
new file mode 100644
index 000000000..6208e1a1e
--- /dev/null
+++ b/app/screens/reactions/reactions.tsx
@@ -0,0 +1,84 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React, {useCallback, useEffect, useMemo, useState} from 'react';
+
+import {Screens} from '@constants';
+import BottomSheet from '@screens/bottom_sheet';
+import {getEmojiFirstAlias} from '@utils/emoji/helpers';
+
+import EmojiAliases from './emoji_aliases';
+import EmojiBar from './emoji_bar';
+import ReactorsList from './reactors_list';
+
+import type ReactionModel from '@typings/database/models/servers/reaction';
+
+type Props = {
+ initialEmoji: string;
+ reactions: ReactionModel[];
+}
+
+const Reactions = ({initialEmoji, reactions}: Props) => {
+ const [sortedReactions, setSortedReactions] = useState(Array.from(new Set(reactions.map((r) => getEmojiFirstAlias(r.emojiName)))));
+ const [index, setIndex] = useState(sortedReactions.indexOf(initialEmoji));
+ const reactionsByName = useMemo(() => {
+ return reactions.reduce((acc, reaction) => {
+ const emojiAlias = getEmojiFirstAlias(reaction.emojiName);
+ if (acc.has(emojiAlias)) {
+ const rs = acc.get(emojiAlias);
+ // eslint-disable-next-line max-nested-callbacks
+ const present = rs!.findIndex((r) => r.userId === reaction.userId) > -1;
+ if (!present) {
+ rs!.push(reaction);
+ }
+ } else {
+ acc.set(emojiAlias, [reaction]);
+ }
+
+ return acc;
+ }, new Map());
+ }, [reactions]);
+
+ const renderContent = useCallback(() => {
+ const emojiAlias = sortedReactions[index];
+
+ return (
+ <>
+
+
+
+ >
+ );
+ }, [index, reactions, sortedReactions]);
+
+ useEffect(() => {
+ // This helps keep the reactions in the same position at all times until unmounted
+ const rs = reactions.map((r) => getEmojiFirstAlias(r.emojiName));
+ const sorted = new Set([...sortedReactions]);
+ const added = rs.filter((r) => !sorted.has(r));
+ added.forEach(sorted.add, sorted);
+ const removed = [...sorted].filter((s) => !rs.includes(s));
+ removed.forEach(sorted.delete, sorted);
+ setSortedReactions(Array.from(sorted));
+ }, [reactions]);
+
+ return (
+
+ );
+};
+
+export default Reactions;
diff --git a/app/screens/reactions/reactors_list/index.tsx b/app/screens/reactions/reactors_list/index.tsx
new file mode 100644
index 000000000..032ae1e5d
--- /dev/null
+++ b/app/screens/reactions/reactors_list/index.tsx
@@ -0,0 +1,70 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React, {useCallback, useEffect, useRef, useState} from 'react';
+import {NativeScrollEvent, NativeSyntheticEvent, PanResponder} from 'react-native';
+import {FlatList} from 'react-native-gesture-handler';
+
+import {fetchUsersByIds} from '@actions/remote/user';
+import {useServerUrl} from '@context/server';
+
+import Reactor from './reactor';
+
+import type ReactionModel from '@typings/database/models/servers/reaction';
+
+type Props = {
+ reactions: ReactionModel[];
+}
+
+const ReactorsList = ({reactions}: Props) => {
+ const serverUrl = useServerUrl();
+ const [enabled, setEnabled] = useState(false);
+ const listRef = useRef(null);
+ const [direction, setDirection] = useState<'down' | 'up'>('down');
+ const prevOffset = useRef(0);
+ const panResponder = useRef(PanResponder.create({
+ onMoveShouldSetPanResponderCapture: (evt, g) => {
+ const dir = prevOffset.current < g.dy ? 'down' : 'up';
+ prevOffset.current = g.dy;
+ if (!enabled && dir === 'up') {
+ setEnabled(true);
+ }
+ setDirection(dir);
+ return false;
+ },
+ })).current;
+
+ const renderItem = useCallback(({item}) => (
+
+ ), [reactions]);
+
+ const onScroll = useCallback((e: NativeSyntheticEvent) => {
+ if (e.nativeEvent.contentOffset.y <= 0 && enabled && direction === 'down') {
+ setEnabled(false);
+ listRef.current?.scrollToOffset({animated: true, offset: 0});
+ }
+ }, [enabled, direction]);
+
+ useEffect(() => {
+ const userIds = reactions.map((r) => r.userId);
+
+ // Fetch any missing user
+ fetchUsersByIds(serverUrl, userIds);
+ }, []);
+
+ return (
+
+ );
+};
+
+export default ReactorsList;
+
diff --git a/app/screens/reactions/reactors_list/reactor/index.ts b/app/screens/reactions/reactors_list/reactor/index.ts
new file mode 100644
index 000000000..3a3a19056
--- /dev/null
+++ b/app/screens/reactions/reactors_list/reactor/index.ts
@@ -0,0 +1,33 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {Q} from '@nozbe/watermelondb';
+import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
+import withObservables from '@nozbe/with-observables';
+import {of as of$} from 'rxjs';
+import {switchMap} from 'rxjs/operators';
+
+import {MM_TABLES} from '@constants/database';
+import {WithDatabaseArgs} from '@typings/database/database';
+
+import Reactor from './reactor';
+
+import type ReactionModel from '@typings/database/models/servers/reaction';
+import type UserModel from '@typings/database/models/servers/user';
+
+const {SERVER: {USER}} = MM_TABLES;
+
+const enhance = withObservables(['reaction'], ({database, reaction}: {reaction: ReactionModel} & WithDatabaseArgs) => {
+ const user = database.get(USER).query(
+ Q.where('id', reaction.userId),
+ Q.take(1),
+ ).observe().pipe(
+ switchMap((result) => (result.length ? result[0].observe() : of$(undefined))),
+ );
+
+ return {
+ user,
+ };
+});
+
+export default withDatabase(enhance(Reactor));
diff --git a/app/screens/reactions/reactors_list/reactor/reactor.tsx b/app/screens/reactions/reactors_list/reactor/reactor.tsx
new file mode 100644
index 000000000..ab59b878e
--- /dev/null
+++ b/app/screens/reactions/reactors_list/reactor/reactor.tsx
@@ -0,0 +1,33 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react';
+import {StyleSheet} from 'react-native';
+
+import UserItem from '@components/user_item';
+
+import type UserModel from '@typings/database/models/servers/user';
+
+type Props = {
+ user?: UserModel;
+}
+
+const style = StyleSheet.create({
+ container: {
+ paddingVertical: 0,
+ paddingHorizontal: 0,
+ paddingTop: 0,
+ marginBottom: 8,
+ },
+});
+
+const Reactor = ({user}: Props) => {
+ return (
+
+ );
+};
+
+export default Reactor;