forked from Ivasoft/mattermost-mobile
Merge pull request #6083 from mattermost/gekidou-reactions
[Gekidou] reactions
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
import {SYSTEM_IDENTIFIERS} from '@constants/database';
|
||||
import DatabaseManager from '@database/manager';
|
||||
import {getRecentReactions} from '@queries/servers/system';
|
||||
import {getEmojiFirstAlias} from '@utils/emoji/helpers';
|
||||
|
||||
const MAXIMUM_RECENT_EMOJI = 27;
|
||||
|
||||
@@ -22,16 +23,17 @@ export const addRecentReaction = async (serverUrl: string, emojiNames: string[],
|
||||
|
||||
try {
|
||||
const recentEmojis = new Set(recent);
|
||||
for (const name of emojiNames) {
|
||||
if (recentEmojis.has(name)) {
|
||||
recentEmojis.delete(name);
|
||||
const aliases = emojiNames.map((e) => getEmojiFirstAlias(e));
|
||||
for (const alias of aliases) {
|
||||
if (recentEmojis.has(alias)) {
|
||||
recentEmojis.delete(alias);
|
||||
}
|
||||
}
|
||||
|
||||
recent = Array.from(recentEmojis);
|
||||
|
||||
for (const name of emojiNames) {
|
||||
recent.unshift(name);
|
||||
for (const alias of aliases) {
|
||||
recent.unshift(alias);
|
||||
}
|
||||
return operator.handleSystem({
|
||||
systems: [{
|
||||
|
||||
@@ -7,8 +7,9 @@ import {addRecentReaction} from '@actions/local/reactions';
|
||||
import DatabaseManager from '@database/manager';
|
||||
import NetworkManager from '@init/network_manager';
|
||||
import {getRecentPostsInChannel, getRecentPostsInThread} from '@queries/servers/post';
|
||||
import {queryReaction} from '@queries/servers/reactions';
|
||||
import {queryReaction} from '@queries/servers/reaction';
|
||||
import {getCurrentChannelId, getCurrentUserId} from '@queries/servers/system';
|
||||
import {getEmojiFirstAlias} from '@utils/emoji/helpers';
|
||||
|
||||
import {forceLogoutIfNecessary} from './session';
|
||||
|
||||
@@ -30,29 +31,41 @@ export const addReaction = async (serverUrl: string, postId: string, emojiName:
|
||||
|
||||
try {
|
||||
const currentUserId = await getCurrentUserId(operator.database);
|
||||
const reaction = await client.addReaction(currentUserId, postId, emojiName);
|
||||
const models: Model[] = [];
|
||||
const emojiAlias = getEmojiFirstAlias(emojiName);
|
||||
const reacted = await queryReaction(operator.database, emojiAlias, postId, currentUserId).fetchCount() > 0;
|
||||
if (!reacted) {
|
||||
const reaction = await client.addReaction(currentUserId, postId, emojiAlias);
|
||||
const models: Model[] = [];
|
||||
|
||||
const reactions = await operator.handleReactions({
|
||||
postsReactions: [{
|
||||
const reactions = await operator.handleReactions({
|
||||
postsReactions: [{
|
||||
post_id: postId,
|
||||
reactions: [reaction],
|
||||
}],
|
||||
prepareRecordsOnly: true,
|
||||
skipSync: true, // this prevents the handler from deleting previous reactions
|
||||
});
|
||||
models.push(...reactions);
|
||||
|
||||
const recent = await addRecentReaction(serverUrl, [emojiName], true);
|
||||
if (Array.isArray(recent)) {
|
||||
models.push(...recent);
|
||||
}
|
||||
|
||||
if (models.length) {
|
||||
await operator.batchRecords(models);
|
||||
}
|
||||
|
||||
return {reaction};
|
||||
}
|
||||
return {
|
||||
reaction: {
|
||||
user_id: currentUserId,
|
||||
post_id: postId,
|
||||
reactions: [reaction],
|
||||
}],
|
||||
prepareRecordsOnly: true,
|
||||
skipSync: true, // this prevents the handler from deleting previous reactions
|
||||
});
|
||||
models.push(...reactions);
|
||||
|
||||
const recent = await addRecentReaction(serverUrl, [emojiName], true);
|
||||
if (Array.isArray(recent)) {
|
||||
models.push(...recent);
|
||||
}
|
||||
|
||||
if (models.length) {
|
||||
await operator.batchRecords(models);
|
||||
}
|
||||
|
||||
return {reaction};
|
||||
emoji_name: emojiAlias,
|
||||
create_at: 0,
|
||||
} as Reaction,
|
||||
};
|
||||
} catch (error) {
|
||||
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
|
||||
return {error};
|
||||
@@ -74,10 +87,11 @@ export const removeReaction = async (serverUrl: string, postId: string, emojiNam
|
||||
|
||||
try {
|
||||
const currentUserId = await getCurrentUserId(database);
|
||||
await client.removeReaction(currentUserId, postId, emojiName);
|
||||
const emojiAlias = getEmojiFirstAlias(emojiName);
|
||||
await client.removeReaction(currentUserId, postId, emojiAlias);
|
||||
|
||||
// should return one or no reaction
|
||||
const reaction = await queryReaction(database, emojiName, postId, currentUserId).fetch();
|
||||
const reaction = await queryReaction(database, emojiAlias, postId, currentUserId).fetch();
|
||||
|
||||
if (reaction.length) {
|
||||
await database.write(async () => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import DatabaseManager from '@database/manager';
|
||||
import {queryReaction} from '@queries/servers/reactions';
|
||||
import {queryReaction} from '@queries/servers/reaction';
|
||||
|
||||
export async function handleAddCustomEmoji(serverUrl: string, msg: WebSocketMessage): Promise<void> {
|
||||
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback} from 'react';
|
||||
import {Text, View} from 'react-native';
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||
|
||||
import ChannelIcon from '@components/channel_icon';
|
||||
import CustomStatusEmoji from '@components/custom_status/custom_status_emoji';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import ProfilePicture from '@components/profile_picture';
|
||||
import {BotTag, GuestTag} from '@components/tag';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {General} from '@constants';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
|
||||
import {getUserCustomStatus, isGuest, isShared} from '@utils/user';
|
||||
|
||||
type AtMentionItemProps = {
|
||||
user: UserProfile;
|
||||
currentUserId: string;
|
||||
onPress: (username: string) => void;
|
||||
showFullName: boolean;
|
||||
testID?: string;
|
||||
isCustomStatusEnabled: boolean;
|
||||
}
|
||||
|
||||
const getName = (user: UserProfile, showFullName: boolean, isCurrentUser: boolean) => {
|
||||
let name = '';
|
||||
const hasNickname = user.nickname.length > 0;
|
||||
|
||||
if (showFullName) {
|
||||
name += `${user.first_name} ${user.last_name} `;
|
||||
}
|
||||
|
||||
if (hasNickname && !isCurrentUser) {
|
||||
name += name.length > 0 ? `(${user.nickname})` : user.nickname;
|
||||
}
|
||||
|
||||
return name.trim();
|
||||
};
|
||||
|
||||
const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
return {
|
||||
row: {
|
||||
height: 40,
|
||||
paddingVertical: 8,
|
||||
paddingTop: 4,
|
||||
paddingHorizontal: 16,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
rowPicture: {
|
||||
marginRight: 10,
|
||||
marginLeft: 2,
|
||||
width: 24,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
rowInfo: {
|
||||
flexDirection: 'row',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
rowFullname: {
|
||||
fontSize: 15,
|
||||
color: theme.centerChannelColor,
|
||||
paddingLeft: 4,
|
||||
flexShrink: 1,
|
||||
},
|
||||
rowUsername: {
|
||||
color: changeOpacity(theme.centerChannelColor, 0.56),
|
||||
fontSize: 15,
|
||||
flexShrink: 5,
|
||||
},
|
||||
icon: {
|
||||
marginLeft: 4,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const AtMentionItem = ({
|
||||
user,
|
||||
currentUserId,
|
||||
onPress,
|
||||
showFullName,
|
||||
testID,
|
||||
isCustomStatusEnabled,
|
||||
}: AtMentionItemProps) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const theme = useTheme();
|
||||
const style = getStyleFromTheme(theme);
|
||||
|
||||
const guest = isGuest(user.roles);
|
||||
const shared = isShared(user);
|
||||
|
||||
const completeMention = useCallback(() => {
|
||||
onPress(user.username);
|
||||
}, [user.username]);
|
||||
|
||||
const isCurrentUser = currentUserId === user.id;
|
||||
const name = getName(user, showFullName, isCurrentUser);
|
||||
const customStatus = getUserCustomStatus(user);
|
||||
|
||||
return (
|
||||
<TouchableWithFeedback
|
||||
testID={testID}
|
||||
key={user.id}
|
||||
onPress={completeMention}
|
||||
underlayColor={changeOpacity(theme.buttonBg, 0.08)}
|
||||
style={{marginLeft: insets.left, marginRight: insets.right}}
|
||||
type={'native'}
|
||||
>
|
||||
<View style={style.row}>
|
||||
<View style={style.rowPicture}>
|
||||
<ProfilePicture
|
||||
author={user}
|
||||
size={24}
|
||||
showStatus={false}
|
||||
testID='at_mention_item.profile_picture'
|
||||
/>
|
||||
</View>
|
||||
<View
|
||||
style={[style.rowInfo, {maxWidth: shared ? '75%' : '80%'}]}
|
||||
>
|
||||
{Boolean(user.is_bot) && (<BotTag/>)}
|
||||
{guest && (<GuestTag/>)}
|
||||
{Boolean(name.length) && (
|
||||
<Text
|
||||
style={style.rowFullname}
|
||||
numberOfLines={1}
|
||||
testID='at_mention_item.name'
|
||||
>
|
||||
{name}
|
||||
</Text>
|
||||
)}
|
||||
{isCurrentUser && (
|
||||
<FormattedText
|
||||
id='suggestion.mention.you'
|
||||
defaultMessage=' (you)'
|
||||
style={style.rowUsername}
|
||||
/>
|
||||
)}
|
||||
<Text
|
||||
style={style.rowUsername}
|
||||
numberOfLines={1}
|
||||
testID='at_mention_item.username'
|
||||
>
|
||||
{` @${user.username}`}
|
||||
</Text>
|
||||
</View>
|
||||
{isCustomStatusEnabled && !user.is_bot && customStatus && (
|
||||
<CustomStatusEmoji
|
||||
customStatus={customStatus}
|
||||
style={style.icon}
|
||||
/>
|
||||
)}
|
||||
{shared && (
|
||||
<ChannelIcon
|
||||
name={name}
|
||||
isActive={false}
|
||||
isArchived={false}
|
||||
isInfo={true}
|
||||
isUnread={true}
|
||||
size={18}
|
||||
shared={true}
|
||||
type={General.DM_CHANNEL}
|
||||
style={style.icon}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</TouchableWithFeedback>
|
||||
);
|
||||
};
|
||||
|
||||
export default AtMentionItem;
|
||||
49
app/components/autocomplete/at_mention_item/index.tsx
Normal file
49
app/components/autocomplete/at_mention_item/index.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback} from 'react';
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import UserItem from '@components/user_item';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {changeOpacity} from '@utils/theme';
|
||||
|
||||
import type UserModel from '@typings/database/models/servers/user';
|
||||
|
||||
type AtMentionItemProps = {
|
||||
user: UserProfile | UserModel;
|
||||
onPress?: (username: string) => void;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
const AtMentionItem = ({
|
||||
user,
|
||||
onPress,
|
||||
testID,
|
||||
}: AtMentionItemProps) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const theme = useTheme();
|
||||
|
||||
const completeMention = useCallback(() => {
|
||||
onPress?.(user.username);
|
||||
}, [user.username]);
|
||||
|
||||
return (
|
||||
<TouchableWithFeedback
|
||||
testID={testID}
|
||||
key={user.id}
|
||||
onPress={completeMention}
|
||||
underlayColor={changeOpacity(theme.buttonBg, 0.08)}
|
||||
style={{marginLeft: insets.left, marginRight: insets.right}}
|
||||
type={'native'}
|
||||
>
|
||||
<UserItem
|
||||
user={user}
|
||||
testID='at_mention'
|
||||
/>
|
||||
</TouchableWithFeedback>
|
||||
);
|
||||
};
|
||||
|
||||
export default AtMentionItem;
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback} from 'react';
|
||||
import React, {useCallback, useMemo} from 'react';
|
||||
import {TouchableOpacity, View} from 'react-native';
|
||||
import AnimatedNumbers from 'react-native-animated-numbers';
|
||||
|
||||
@@ -14,17 +14,20 @@ type ReactionProps = {
|
||||
emojiName: string;
|
||||
highlight: boolean;
|
||||
onPress: (emojiName: string, highlight: boolean) => void;
|
||||
onLongPress: () => void;
|
||||
onLongPress: (initialEmoji: string) => void;
|
||||
theme: Theme;
|
||||
}
|
||||
|
||||
const MIN_WIDTH = 50;
|
||||
const DIGIT_WIDTH = 5;
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
return {
|
||||
count: {
|
||||
color: changeOpacity(theme.centerChannelColor, 0.56),
|
||||
...typography('Body', 100, 'SemiBold'),
|
||||
},
|
||||
countContainer: {marginRight: 5},
|
||||
countContainer: {marginRight: 8},
|
||||
countHighlight: {
|
||||
color: theme.buttonBg,
|
||||
},
|
||||
@@ -44,13 +47,22 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
justifyContent: 'center',
|
||||
marginBottom: 12,
|
||||
marginRight: 8,
|
||||
minWidth: 50,
|
||||
minWidth: MIN_WIDTH,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const Reaction = ({count, emojiName, highlight, onPress, onLongPress, theme}: ReactionProps) => {
|
||||
const styles = getStyleSheet(theme);
|
||||
const digits = String(count).length;
|
||||
const containerStyle = useMemo(() => {
|
||||
const minWidth = MIN_WIDTH + (digits * DIGIT_WIDTH);
|
||||
return [styles.reaction, (highlight && styles.highlight), {minWidth}];
|
||||
}, [styles.reaction, highlight, digits]);
|
||||
|
||||
const handleLongPress = useCallback(() => {
|
||||
onLongPress(emojiName);
|
||||
}, []);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
onPress(emojiName, highlight);
|
||||
@@ -59,9 +71,9 @@ const Reaction = ({count, emojiName, highlight, onPress, onLongPress, theme}: Re
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
onLongPress={onLongPress}
|
||||
onLongPress={handleLongPress}
|
||||
delayLongPress={350}
|
||||
style={[styles.reaction, (highlight && styles.highlight)]}
|
||||
style={containerStyle}
|
||||
>
|
||||
<View style={styles.emoji}>
|
||||
<Emoji
|
||||
|
||||
@@ -3,13 +3,16 @@
|
||||
|
||||
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';
|
||||
|
||||
@@ -58,13 +61,14 @@ 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) => r.emojiName)));
|
||||
const [sortedReactions, setSortedReactions] = useState(new Set(reactions.map((r) => getEmojiFirstAlias(r.emojiName))));
|
||||
const styles = getStyleSheet(theme);
|
||||
|
||||
useEffect(() => {
|
||||
// This helps keep the reactions in the same position at all times until unmounted
|
||||
const rs = reactions.map((r) => r.emojiName);
|
||||
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);
|
||||
@@ -78,14 +82,20 @@ const Reactions = ({currentUserId, canAddReaction, canRemoveReaction, disabled,
|
||||
|
||||
const reactionsByName = reactions.reduce((acc, reaction) => {
|
||||
if (reaction) {
|
||||
if (acc.has(reaction.emojiName)) {
|
||||
acc.get(reaction.emojiName)!.push(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(reaction.emojiName, [reaction]);
|
||||
acc.set(emojiAlias, [reaction]);
|
||||
}
|
||||
|
||||
if (reaction.userId === currentUserId) {
|
||||
highlightedReactions.push(reaction.emojiName);
|
||||
highlightedReactions.push(emojiAlias);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,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);
|
||||
@@ -109,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);
|
||||
@@ -121,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, isTablet, postId, theme]);
|
||||
|
||||
let addMoreReactions = null;
|
||||
const {reactionsByName, highlightedReactions} = buildReactionsMap();
|
||||
@@ -175,4 +192,4 @@ const Reactions = ({currentUserId, canAddReaction, canRemoveReaction, disabled,
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(Reactions);
|
||||
export default Reactions;
|
||||
|
||||
@@ -8,7 +8,7 @@ import {switchMap} from 'rxjs/operators';
|
||||
|
||||
import {observeConfig, observeCurrentUserId} from '@queries/servers/system';
|
||||
|
||||
import AtMentionItem from './at_mention_item';
|
||||
import UserItem from './user_item';
|
||||
|
||||
import type {WithDatabaseArgs} from '@typings/database/database';
|
||||
|
||||
@@ -28,4 +28,4 @@ const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
|
||||
};
|
||||
});
|
||||
|
||||
export default withDatabase(enhanced(AtMentionItem));
|
||||
export default withDatabase(enhanced(UserItem));
|
||||
174
app/components/user_item/user_item.tsx
Normal file
174
app/components/user_item/user_item.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {IntlShape, useIntl} from 'react-intl';
|
||||
import {StyleProp, Text, View, ViewStyle} from 'react-native';
|
||||
|
||||
import ChannelIcon from '@components/channel_icon';
|
||||
import CustomStatusEmoji from '@components/custom_status/custom_status_emoji';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import ProfilePicture from '@components/profile_picture';
|
||||
import {BotTag, GuestTag} from '@components/tag';
|
||||
import {General} from '@constants';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
|
||||
import {getUserCustomStatus, isBot, isGuest, isShared} from '@utils/user';
|
||||
|
||||
import type UserModel from '@typings/database/models/servers/user';
|
||||
|
||||
type AtMentionItemProps = {
|
||||
user?: UserProfile | UserModel;
|
||||
containerStyle?: StyleProp<ViewStyle>;
|
||||
currentUserId: string;
|
||||
showFullName: boolean;
|
||||
testID?: string;
|
||||
isCustomStatusEnabled: boolean;
|
||||
}
|
||||
|
||||
const getName = (user: UserProfile | UserModel | undefined, showFullName: boolean, isCurrentUser: boolean, intl: IntlShape) => {
|
||||
let name = '';
|
||||
if (!user) {
|
||||
return intl.formatMessage({id: 'channel_loader.someone', defaultMessage: 'Someone'});
|
||||
}
|
||||
|
||||
const hasNickname = user.nickname.length > 0;
|
||||
|
||||
if (showFullName) {
|
||||
const first = 'first_name' in user ? user.first_name : user.firstName;
|
||||
const last = 'last_name' in user ? user.last_name : user.lastName;
|
||||
name += `${first} ${last} `;
|
||||
}
|
||||
|
||||
if (hasNickname && !isCurrentUser) {
|
||||
name += name.length > 0 ? `(${user.nickname})` : user.nickname;
|
||||
}
|
||||
|
||||
return name.trim();
|
||||
};
|
||||
|
||||
const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
return {
|
||||
row: {
|
||||
height: 40,
|
||||
paddingVertical: 8,
|
||||
paddingTop: 4,
|
||||
paddingHorizontal: 16,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
rowPicture: {
|
||||
marginRight: 10,
|
||||
marginLeft: 2,
|
||||
width: 24,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
rowInfo: {
|
||||
flexDirection: 'row',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
rowFullname: {
|
||||
fontSize: 15,
|
||||
color: theme.centerChannelColor,
|
||||
fontFamily: 'OpenSans',
|
||||
paddingLeft: 4,
|
||||
flexShrink: 1,
|
||||
},
|
||||
rowUsername: {
|
||||
color: changeOpacity(theme.centerChannelColor, 0.56),
|
||||
fontSize: 15,
|
||||
fontFamily: 'OpenSans',
|
||||
flexShrink: 5,
|
||||
},
|
||||
icon: {
|
||||
marginLeft: 4,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const UserItem = ({
|
||||
containerStyle,
|
||||
user,
|
||||
currentUserId,
|
||||
showFullName,
|
||||
testID,
|
||||
isCustomStatusEnabled,
|
||||
}: AtMentionItemProps) => {
|
||||
const theme = useTheme();
|
||||
const style = getStyleFromTheme(theme);
|
||||
const intl = useIntl();
|
||||
|
||||
const bot = user ? isBot(user) : false;
|
||||
const guest = user ? isGuest(user.roles) : false;
|
||||
const shared = user ? isShared(user) : false;
|
||||
|
||||
const isCurrentUser = currentUserId === user?.id;
|
||||
const name = getName(user, showFullName, isCurrentUser, intl);
|
||||
const customStatus = getUserCustomStatus(user);
|
||||
|
||||
return (
|
||||
<View style={[style.row, containerStyle]}>
|
||||
<View style={style.rowPicture}>
|
||||
<ProfilePicture
|
||||
author={user}
|
||||
size={24}
|
||||
showStatus={false}
|
||||
testID={`${testID}.profile_picture`}
|
||||
/>
|
||||
</View>
|
||||
<View
|
||||
style={[style.rowInfo, {maxWidth: shared ? '75%' : '80%'}]}
|
||||
>
|
||||
{bot && <BotTag/>}
|
||||
{guest && <GuestTag/>}
|
||||
{Boolean(name.length) &&
|
||||
<Text
|
||||
style={style.rowFullname}
|
||||
numberOfLines={1}
|
||||
testID={`${testID}.name`}
|
||||
>
|
||||
{name}
|
||||
</Text>
|
||||
}
|
||||
{isCurrentUser &&
|
||||
<FormattedText
|
||||
id='suggestion.mention.you'
|
||||
defaultMessage=' (you)'
|
||||
style={style.rowUsername}
|
||||
/>
|
||||
}
|
||||
{Boolean(user) &&
|
||||
<Text
|
||||
style={style.rowUsername}
|
||||
numberOfLines={1}
|
||||
testID='at_mention_item.username'
|
||||
>
|
||||
{` @${user!.username}`}
|
||||
</Text>
|
||||
}
|
||||
</View>
|
||||
{isCustomStatusEnabled && !bot && customStatus && (
|
||||
<CustomStatusEmoji
|
||||
customStatus={customStatus}
|
||||
style={style.icon}
|
||||
/>
|
||||
)}
|
||||
{shared && (
|
||||
<ChannelIcon
|
||||
name={name}
|
||||
isActive={false}
|
||||
isArchived={false}
|
||||
isInfo={true}
|
||||
isUnread={true}
|
||||
size={18}
|
||||
shared={true}
|
||||
type={General.DM_CHANNEL}
|
||||
style={style.icon}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserItem;
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
export const ABOUT = 'About';
|
||||
export const ACCOUNT = 'Account';
|
||||
export const EMOJI_PICKER = 'AddReaction';
|
||||
export const EMOJI_PICKER = 'EmojiPicker';
|
||||
export const APP_FORM = 'AppForm';
|
||||
export const BOTTOM_SHEET = 'BottomSheet';
|
||||
export const BROWSE_CHANNELS = 'BrowseChannels';
|
||||
@@ -11,6 +11,7 @@ export const CHANNEL = 'Channel';
|
||||
export const CHANNEL_ADD_PEOPLE = 'ChannelAddPeople';
|
||||
export const CHANNEL_DETAILS = 'ChannelDetails';
|
||||
export const CHANNEL_EDIT = 'ChannelEdit';
|
||||
export const CREATE_DIRECT_MESSAGE = 'CreateDirectMessage';
|
||||
export const CUSTOM_STATUS_CLEAR_AFTER = 'CustomStatusClearAfter';
|
||||
export const CUSTOM_STATUS = 'CustomStatus';
|
||||
export const EDIT_POST = 'EditPost';
|
||||
@@ -24,16 +25,16 @@ export const IN_APP_NOTIFICATION = 'InAppNotification';
|
||||
export const LOGIN = 'Login';
|
||||
export const MENTIONS = 'Mentions';
|
||||
export const MFA = 'MFA';
|
||||
export const CREATE_DIRECT_MESSAGE = 'CreateDirectMessage';
|
||||
export const PERMALINK = 'Permalink';
|
||||
export const POST_OPTIONS = 'PostOptions';
|
||||
export const REACTIONS = 'Reactions';
|
||||
export const SAVED_POSTS = 'SavedPosts';
|
||||
export const SEARCH = 'Search';
|
||||
export const SERVER = 'Server';
|
||||
export const SETTINGS_SIDEBAR = 'SettingsSidebar';
|
||||
export const SSO = 'SSO';
|
||||
export const THREAD = 'Thread';
|
||||
export const USER_PROFILE = 'UserProfile';
|
||||
export const POST_OPTIONS = 'PostOptions';
|
||||
export const SAVED_POSTS = 'SavedPosts';
|
||||
|
||||
export default {
|
||||
ABOUT,
|
||||
@@ -46,6 +47,7 @@ export default {
|
||||
CHANNEL_ADD_PEOPLE,
|
||||
CHANNEL_EDIT,
|
||||
CHANNEL_DETAILS,
|
||||
CREATE_DIRECT_MESSAGE,
|
||||
CUSTOM_STATUS_CLEAR_AFTER,
|
||||
CUSTOM_STATUS,
|
||||
EDIT_POST,
|
||||
@@ -59,14 +61,21 @@ export default {
|
||||
LOGIN,
|
||||
MENTIONS,
|
||||
MFA,
|
||||
CREATE_DIRECT_MESSAGE,
|
||||
PERMALINK,
|
||||
POST_OPTIONS,
|
||||
REACTIONS,
|
||||
SAVED_POSTS,
|
||||
SEARCH,
|
||||
SERVER,
|
||||
SETTINGS_SIDEBAR,
|
||||
SSO,
|
||||
THREAD,
|
||||
USER_PROFILE,
|
||||
POST_OPTIONS,
|
||||
SAVED_POSTS,
|
||||
};
|
||||
|
||||
export const MODAL_SCREENS_WITHOUT_BACK = [
|
||||
CREATE_DIRECT_MESSAGE,
|
||||
EMOJI_PICKER,
|
||||
EDIT_POST,
|
||||
PERMALINK,
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
|
||||
44
app/screens/reactions/emoji_aliases/index.tsx
Normal file
44
app/screens/reactions/emoji_aliases/index.tsx
Normal 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;
|
||||
91
app/screens/reactions/emoji_bar/index.tsx
Normal file
91
app/screens/reactions/emoji_bar/index.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}, [onPress, 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;
|
||||
81
app/screens/reactions/emoji_bar/item.tsx
Normal file
81
app/screens/reactions/emoji_bar/item.tsx
Normal 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);
|
||||
}, [onPress, emojiName]);
|
||||
|
||||
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;
|
||||
30
app/screens/reactions/index.ts
Normal file
30
app/screens/reactions/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
// 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 {of as of$} from 'rxjs';
|
||||
import {switchMap} from 'rxjs/operators';
|
||||
|
||||
import {observePost} from '@queries/servers/post';
|
||||
|
||||
import Reactions from './reactions';
|
||||
|
||||
import type {WithDatabaseArgs} from '@typings/database/database';
|
||||
|
||||
type EnhancedProps = WithDatabaseArgs & {
|
||||
postId: string;
|
||||
}
|
||||
|
||||
const enhanced = withObservables([], ({postId, database}: EnhancedProps) => {
|
||||
const post = observePost(database, postId);
|
||||
|
||||
return {
|
||||
reactions: post.pipe(
|
||||
switchMap((p) => (p ? p.reactions.observe() : of$(undefined))),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
export default withDatabase(enhanced(Reactions));
|
||||
|
||||
87
app/screens/reactions/reactions.tsx
Normal file
87
app/screens/reactions/reactions.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
// 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];
|
||||
if (!reactionsByName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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;
|
||||
70
app/screens/reactions/reactors_list/index.tsx
Normal file
70
app/screens/reactions/reactors_list/index.tsx
Normal 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 [direction, setDirection] = useState<'down' | 'up'>('down');
|
||||
const listRef = useRef<FlatList>(null);
|
||||
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;
|
||||
|
||||
18
app/screens/reactions/reactors_list/reactor/index.ts
Normal file
18
app/screens/reactions/reactors_list/reactor/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// 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 {observeUser} from '@app/queries/servers/user';
|
||||
import {WithDatabaseArgs} from '@typings/database/database';
|
||||
|
||||
import Reactor from './reactor';
|
||||
|
||||
import type ReactionModel from '@typings/database/models/servers/reaction';
|
||||
|
||||
const enhance = withObservables(['reaction'], ({database, reaction}: {reaction: ReactionModel} & WithDatabaseArgs) => ({
|
||||
user: observeUser(database, reaction.userId),
|
||||
}));
|
||||
|
||||
export default withDatabase(enhance(Reactor));
|
||||
30
app/screens/reactions/reactors_list/reactor/reactor.tsx
Normal file
30
app/screens/reactions/reactors_list/reactor/reactor.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
// 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: {
|
||||
marginBottom: 8,
|
||||
},
|
||||
});
|
||||
|
||||
const Reactor = ({user}: Props) => {
|
||||
return (
|
||||
<UserItem
|
||||
containerStyle={style.container}
|
||||
user={user}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Reactor;
|
||||
@@ -193,6 +193,10 @@ export function doesMatchNamedEmoji(emojiName: string) {
|
||||
return false;
|
||||
}
|
||||
|
||||
export const getEmojiFirstAlias = (emoji: string) => {
|
||||
return getEmojiByName(emoji, [])?.short_names?.[0] || emoji;
|
||||
};
|
||||
|
||||
export function getEmojiByName(emojiName: string, customEmojis: CustomEmojiModel[]) {
|
||||
if (EmojiIndicesByAlias.has(emojiName)) {
|
||||
return Emojis[EmojiIndicesByAlias.get(emojiName)!];
|
||||
|
||||
@@ -6,27 +6,12 @@ import {StatusBar, StyleSheet} from 'react-native';
|
||||
import tinyColor from 'tinycolor2';
|
||||
|
||||
import {Preferences} from '@constants';
|
||||
import {MODAL_SCREENS_WITHOUT_BACK} from '@constants/screens';
|
||||
import {appearanceControlledScreens, mergeNavigationOptions} from '@screens/navigation';
|
||||
import EphemeralStore from '@store/ephemeral_store';
|
||||
|
||||
import type {Options} from 'react-native-navigation';
|
||||
|
||||
const MODAL_SCREENS_WITHOUT_BACK = [
|
||||
'AddReaction',
|
||||
'ChannelInfo',
|
||||
'ClientUpgrade',
|
||||
'CreateChannel',
|
||||
'EditPost',
|
||||
'ErrorTeamsList',
|
||||
'MoreChannels',
|
||||
'MoreDirectMessages',
|
||||
'Permalink',
|
||||
'SelectTeam',
|
||||
'Settings',
|
||||
'TermsOfService',
|
||||
'UserProfile',
|
||||
];
|
||||
|
||||
const rgbPattern = /^rgba?\((\d+),(\d+),(\d+)(?:,([\d.]+))?\)$/;
|
||||
|
||||
export function getComponents(inColor: string): {red: number; green: number; blue: number; alpha: number} {
|
||||
|
||||
@@ -123,13 +123,13 @@ export const getTimezone = (timezone: UserTimezone | null) => {
|
||||
return timezone.manualTimezone;
|
||||
};
|
||||
|
||||
export const getUserCustomStatus = (user: UserModel | UserProfile): UserCustomStatus | undefined => {
|
||||
export const getUserCustomStatus = (user?: UserModel | UserProfile): UserCustomStatus | undefined => {
|
||||
try {
|
||||
if (typeof user.props?.customStatus === 'string') {
|
||||
if (typeof user?.props?.customStatus === 'string') {
|
||||
return JSON.parse(user.props.customStatus) as UserCustomStatus;
|
||||
}
|
||||
|
||||
return user.props?.customStatus;
|
||||
return user?.props?.customStatus;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
@@ -192,8 +192,12 @@ export function confirmOutOfOfficeDisabled(intl: IntlShape, status: string, upda
|
||||
);
|
||||
}
|
||||
|
||||
export function isShared(user: UserProfile): boolean {
|
||||
return Boolean(user.remote_id);
|
||||
export function isBot(user: UserProfile | UserModel): boolean {
|
||||
return 'is_bot' in user ? Boolean(user.is_bot) : Boolean(user.isBot);
|
||||
}
|
||||
|
||||
export function isShared(user: UserProfile | UserModel): boolean {
|
||||
return 'remote_id' in user ? Boolean(user.remote_id) : Boolean(user.props?.remote_id);
|
||||
}
|
||||
|
||||
export function removeUserFromList(userId: string, originalList: UserProfile[]): UserProfile[] {
|
||||
|
||||
1
types/api/users.d.ts
vendored
1
types/api/users.d.ts
vendored
@@ -43,7 +43,6 @@ type UserProfile = {
|
||||
last_picture_update: number;
|
||||
remote_id?: string;
|
||||
status?: string;
|
||||
remote_id?: string;
|
||||
};
|
||||
|
||||
type UsersState = {
|
||||
|
||||
Reference in New Issue
Block a user