From 0618a10e1c86c9bc9cd8cd5e652757ec9aa3ea72 Mon Sep 17 00:00:00 2001 From: Elias Nahum Date: Tue, 22 Mar 2022 14:32:57 -0300 Subject: [PATCH] Reactions screen --- .../post/body/reactions/reactions.tsx | 36 +++++--- app/screens/index.tsx | 6 ++ app/screens/reactions/emoji_aliases/index.tsx | 44 +++++++++ app/screens/reactions/emoji_bar/index.tsx | 91 +++++++++++++++++++ app/screens/reactions/emoji_bar/item.tsx | 81 +++++++++++++++++ app/screens/reactions/index.ts | 32 +++++++ app/screens/reactions/reactions.tsx | 84 +++++++++++++++++ app/screens/reactions/reactors_list/index.tsx | 70 ++++++++++++++ .../reactions/reactors_list/reactor/index.ts | 33 +++++++ .../reactors_list/reactor/reactor.tsx | 33 +++++++ 10 files changed, 497 insertions(+), 13 deletions(-) create mode 100644 app/screens/reactions/emoji_aliases/index.tsx create mode 100644 app/screens/reactions/emoji_bar/index.tsx create mode 100644 app/screens/reactions/emoji_bar/item.tsx create mode 100644 app/screens/reactions/index.ts create mode 100644 app/screens/reactions/reactions.tsx create mode 100644 app/screens/reactions/reactors_list/index.tsx create mode 100644 app/screens/reactions/reactors_list/reactor/index.ts create mode 100644 app/screens/reactions/reactors_list/reactor/reactor.tsx 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;