Reactions screen

This commit is contained in:
Elias Nahum 2022-03-22 14:32:57 -03:00
parent 3807fb0efd
commit 0618a10e1c
No known key found for this signature in database
GPG key ID: E038DB71E0B61702
10 changed files with 497 additions and 13 deletions

View file

@ -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;

View file

@ -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;

View file

@ -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 (
<View style={style.container}>
<Text
ellipsizeMode='tail'
numberOfLines={1}
style={style.title}
>
{aliases}
</Text>
</View>
);
};
export default EmojiAliases;

View file

@ -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<string, ReactionModel[]>;
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<FlatList<string>>(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 (
<Item
count={reactionsByName.get(item)?.length || 0}
emojiName={item}
highlight={item === emojiSelected}
onPress={onPress}
/>
);
}, [sortedReactions, emojiSelected, reactionsByName]);
useEffect(() => {
const t = setTimeout(() => {
listRef.current?.scrollToItem({
item: emojiSelected,
animated: false,
viewPosition: 1,
});
}, 100);
return () => clearTimeout(t);
}, []);
return (
<FlatList
bounces={false}
data={sortedReactions}
horizontal={true}
ref={listRef}
renderItem={renderItem}
style={style.container}
onScrollToIndexFailed={onScrollToIndexFailed}
overScrollMode='never'
/>
);
};
export default EmojiBar;

View file

@ -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 (
<TouchableOpacity
onPress={handlePress}
style={[styles.reaction, (highlight && styles.highlight)]}
>
<View style={styles.emoji}>
<Emoji
emojiName={emojiName}
size={20}
textStyle={styles.customEmojiStyle}
testID={`reaction.emoji.${emojiName}`}
/>
</View>
<View style={styles.countContainer}>
<AnimatedNumbers
includeComma={false}
fontStyle={[styles.count, (highlight && styles.countHighlight)]}
animateToNumber={count}
animationDuration={450}
/>
</View>
</TouchableOpacity>
);
};
export default Reaction;

View file

@ -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<PostModel>(POST).findAndObserve(postId);
return {
reactions: post.pipe(
switchMap((p) => p.reactions.observe()),
),
};
});
export default withDatabase(enhanced(Reactions));

View file

@ -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<string, ReactionModel[]>());
}, [reactions]);
const renderContent = useCallback(() => {
const emojiAlias = sortedReactions[index];
return (
<>
<EmojiBar
emojiSelected={emojiAlias}
reactionsByName={reactionsByName}
setIndex={setIndex}
sortedReactions={sortedReactions}
/>
<EmojiAliases emoji={emojiAlias}/>
<ReactorsList
key={emojiAlias}
reactions={reactionsByName.get(emojiAlias)!}
/>
</>
);
}, [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 (
<BottomSheet
renderContent={renderContent}
closeButtonId='close-post-reactions'
componentId={Screens.REACTIONS}
initialSnapIndex={1}
snapPoints={['90%', '50%', 10]}
/>
);
};
export default Reactions;

View file

@ -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<FlatList>(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}) => (
<Reactor reaction={item}/>
), [reactions]);
const onScroll = useCallback((e: NativeSyntheticEvent<NativeScrollEvent>) => {
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 (
<FlatList
data={reactions}
ref={listRef}
renderItem={renderItem}
onScroll={onScroll}
overScrollMode={'always'}
scrollEnabled={enabled}
scrollEventThrottle={60}
{...panResponder.panHandlers}
/>
);
};
export default ReactorsList;

View file

@ -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<UserModel>(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));

View file

@ -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 (
<UserItem
containerStyle={style.container}
user={user}
/>
);
};
export default Reactor;