From 4e8ea6078e3b0658c50e1ce09879eb5c764d3b50 Mon Sep 17 00:00:00 2001 From: feruzm Date: Fri, 9 Oct 2026 07:47:22 +0000 Subject: [PATCH 1/4] feat(chat): group conversations and who reacted Groups (#3581): - New group button next to search: pick 2 to 7 people and start a group; the server's message is shown when someone does not accept messages or a limit is reached. - Groups are titled by the name their owner gave them, else by the other members, with two member avatars, in the list, the conversation header and name sorting. - A group's options offer its members and, for its owner, Rename; leaving reads as leaving a conversation. - A rename shows as a plain-text notice in the conversation and updates the header and the list. Who reacted (#3580): long-press a reaction to see who left each emoji, looking up anyone the screen has not loaded yet. --- .../container/chatChannelOptionsSheet.tsx | 80 ++++-- .../chatGroupSheets/chatGroupSheets.styles.ts | 153 +++++++++++ .../chatGroupSheets/chatNewGroupSheet.tsx | 251 ++++++++++++++++++ .../chatGroupSheets/chatReactorsSheet.tsx | 130 +++++++++ .../chatGroupSheets/chatRenameGroupSheet.tsx | 133 ++++++++++ src/components/chatGroupSheets/index.ts | 5 + src/config/locales/en-US.json | 25 +- src/navigation/sheets.tsx | 35 +++ src/navigation/types.ts | 3 + src/providers/chat/mattermost.ts | 16 ++ .../chats/children/ChannelListItem.tsx | 18 ++ src/screens/chats/children/ChatHeader.tsx | 6 +- .../chats/children/GroupRenameNotice.tsx | 28 ++ .../chats/children/MessageReactions.tsx | 19 +- src/screens/chats/children/SearchBar.tsx | 30 ++- .../chats/container/chatThreadContainer.tsx | 133 +++++++++- .../chats/container/chatsContainer.tsx | 131 +++++++-- src/screens/chats/screen/chatThreadScreen.tsx | 9 + src/screens/chats/styles/chats.styles.ts | 24 ++ src/screens/chats/utils/groupUtils.test.ts | 83 ++++++ src/screens/chats/utils/groupUtils.ts | 103 +++++++ 21 files changed, 1353 insertions(+), 62 deletions(-) create mode 100644 src/components/chatGroupSheets/chatGroupSheets.styles.ts create mode 100644 src/components/chatGroupSheets/chatNewGroupSheet.tsx create mode 100644 src/components/chatGroupSheets/chatReactorsSheet.tsx create mode 100644 src/components/chatGroupSheets/chatRenameGroupSheet.tsx create mode 100644 src/components/chatGroupSheets/index.ts create mode 100644 src/screens/chats/children/GroupRenameNotice.tsx create mode 100644 src/screens/chats/utils/groupUtils.test.ts create mode 100644 src/screens/chats/utils/groupUtils.ts diff --git a/src/components/chatChannelOptionsSheet/container/chatChannelOptionsSheet.tsx b/src/components/chatChannelOptionsSheet/container/chatChannelOptionsSheet.tsx index 73cf75215d..3b5cf9959a 100644 --- a/src/components/chatChannelOptionsSheet/container/chatChannelOptionsSheet.tsx +++ b/src/components/chatChannelOptionsSheet/container/chatChannelOptionsSheet.tsx @@ -19,6 +19,10 @@ interface ChatChannelOptionsSheetProps { onToggleFavorite?: () => void; onToggleMute?: () => void; onLeave?: () => void; + /** Groups: list who is in it. */ + onShowMembers?: () => void; + /** Groups the viewer started: name it. */ + onRename?: () => void; }; } @@ -64,33 +68,55 @@ const ChatChannelOptionsSheet = ({ payload }: ChatChannelOptionsSheetProps) => { }); } - items.push({ - key: 'favorite', - label: payload?.isFavorite - ? intl.formatMessage({ id: 'chats.unfavorite', defaultMessage: 'Remove favorite' }) - : intl.formatMessage({ id: 'chats.favorite', defaultMessage: 'Favorite' }), - onPress: payload?.onToggleFavorite, - }); - - items.push({ - key: 'mute', - label: payload?.isMuted - ? intl.formatMessage({ id: 'chats.unmute', defaultMessage: 'Unmute' }) - : intl.formatMessage({ id: 'chats.mute', defaultMessage: 'Mute' }), - onPress: payload?.onToggleMute, - }); - - items.push({ - key: 'leave', - label: payload?.isDM - ? intl.formatMessage({ - id: 'chats.leave_conversation', - defaultMessage: 'Leave conversation', - }) - : intl.formatMessage({ id: 'chats.leave', defaultMessage: 'Leave channel' }), - destructive: true, - onPress: payload?.onLeave, - }); + if (payload?.onShowMembers) { + items.push({ + key: 'members', + label: intl.formatMessage({ id: 'chats.group_members', defaultMessage: 'Group members' }), + onPress: payload.onShowMembers, + }); + } + + if (payload?.onRename) { + items.push({ + key: 'rename', + label: intl.formatMessage({ id: 'chats.rename_group', defaultMessage: 'Rename group' }), + onPress: payload.onRename, + }); + } + + if (payload?.onToggleFavorite) { + items.push({ + key: 'favorite', + label: payload?.isFavorite + ? intl.formatMessage({ id: 'chats.unfavorite', defaultMessage: 'Remove favorite' }) + : intl.formatMessage({ id: 'chats.favorite', defaultMessage: 'Favorite' }), + onPress: payload.onToggleFavorite, + }); + } + + if (payload?.onToggleMute) { + items.push({ + key: 'mute', + label: payload?.isMuted + ? intl.formatMessage({ id: 'chats.unmute', defaultMessage: 'Unmute' }) + : intl.formatMessage({ id: 'chats.mute', defaultMessage: 'Mute' }), + onPress: payload.onToggleMute, + }); + } + + if (payload?.onLeave) { + items.push({ + key: 'leave', + label: payload?.isDM + ? intl.formatMessage({ + id: 'chats.leave_conversation', + defaultMessage: 'Leave conversation', + }) + : intl.formatMessage({ id: 'chats.leave', defaultMessage: 'Leave channel' }), + destructive: true, + onPress: payload.onLeave, + }); + } return items; }, [intl, payload]); diff --git a/src/components/chatGroupSheets/chatGroupSheets.styles.ts b/src/components/chatGroupSheets/chatGroupSheets.styles.ts new file mode 100644 index 0000000000..6e42fd7af6 --- /dev/null +++ b/src/components/chatGroupSheets/chatGroupSheets.styles.ts @@ -0,0 +1,153 @@ +import EStyleSheet from 'react-native-extended-stylesheet'; + +export default EStyleSheet.create({ + sheetContainer: { + paddingHorizontal: 0, + backgroundColor: '$primaryBackgroundColor', + }, + container: { + paddingHorizontal: 20, + paddingVertical: 24, + paddingBottom: 40, + }, + title: { + fontSize: 20, + fontWeight: 'bold', + color: '$primaryBlack', + textAlign: 'center', + marginBottom: 8, + }, + description: { + fontSize: 14, + color: '$primaryDarkGray', + textAlign: 'center', + marginBottom: 16, + lineHeight: 20, + }, + input: { + borderWidth: 1, + borderColor: '$primaryLightGray', + borderRadius: 8, + paddingHorizontal: 14, + paddingVertical: 12, + fontSize: 15, + color: '$primaryBlack', + backgroundColor: '$primaryLightBackground', + }, + chips: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 6, + marginBottom: 12, + }, + chip: { + flexDirection: 'row', + alignItems: 'center', + borderWidth: 1, + borderColor: '$primaryLightGray', + borderRadius: 16, + paddingVertical: 3, + paddingLeft: 3, + paddingRight: 10, + backgroundColor: '$primaryLightBackground', + }, + chipAvatar: { + width: 22, + height: 22, + borderRadius: 11, + marginRight: 6, + }, + chipText: { + fontSize: 13, + color: '$primaryBlack', + }, + chipRemove: { + fontSize: 15, + color: '$primaryDarkGray', + marginLeft: 6, + }, + results: { + maxHeight: 240, + marginTop: 8, + borderWidth: 1, + borderColor: '$primaryLightGray', + borderRadius: 8, + }, + resultsSpinner: { + marginVertical: 12, + }, + emptyText: { + fontSize: 13, + color: '$primaryDarkGray', + padding: 12, + }, + errorText: { + fontSize: 13, + color: '$primaryRed', + marginTop: 10, + }, + confirmButton: { + marginTop: 16, + marginBottom: 0, + }, + cancelButton: { + backgroundColor: 'transparent', + marginTop: 8, + }, + cancelButtonText: { + color: '$primaryDarkGray', + }, + tabs: { + flexGrow: 0, + marginBottom: 12, + }, + tab: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 16, + marginRight: 8, + borderWidth: 1, + borderColor: '$primaryLightGray', + }, + tabActive: { + borderColor: '$primaryBlue', + backgroundColor: '$primaryLightBackground', + }, + tabEmoji: { + fontSize: 16, + }, + tabCount: { + fontSize: 13, + fontWeight: '600', + color: '$primaryDarkGray', + marginLeft: 6, + }, + tabCountActive: { + color: '$primaryBlue', + }, + list: { + maxHeight: 360, + }, + personRow: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 8, + paddingHorizontal: 8, + }, + personAvatar: { + width: 32, + height: 32, + borderRadius: 16, + marginRight: 10, + }, + personAvatarEmpty: { + backgroundColor: '$primaryLightGray', + }, + personName: { + flex: 1, + fontSize: 15, + color: '$primaryBlack', + }, +}); diff --git a/src/components/chatGroupSheets/chatNewGroupSheet.tsx b/src/components/chatGroupSheets/chatNewGroupSheet.tsx new file mode 100644 index 0000000000..b7b165d6ea --- /dev/null +++ b/src/components/chatGroupSheets/chatNewGroupSheet.tsx @@ -0,0 +1,251 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { + ActivityIndicator, + ScrollView, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; +import { useIntl } from 'react-intl'; +import ActionSheet, { SheetManager, SheetProps } from 'react-native-actions-sheet'; +import EStyleSheet from 'react-native-extended-stylesheet'; +import { MainButton } from '../mainButton'; +import { UserAvatar } from '../userAvatar'; +import { + createMattermostGroup, + getHiveUsernameFromMattermostUser, + searchMattermostUsers, +} from '../../providers/chat/mattermost'; +import { GROUP_MAX_OTHERS, GROUP_MIN_OTHERS } from '../../screens/chats/utils/groupUtils'; +import styles from './chatGroupSheets.styles'; + +const SHEET_ID = 'chat_new_group'; + +/** + * Result of the sheet. An object in both cases: react-native-actions-sheet + * replaces a falsy result with the payload, which would read as success. + */ +export interface ChatNewGroupResult { + channelId?: string; + cancelled?: boolean; +} + +const usernameOf = (user: any): string => + (getHiveUsernameFromMattermostUser(user) || user?.username || '').toLowerCase(); + +/** + * Picks 2 to 7 people and opens a group conversation with them. The server + * checks that everyone accepts messages from everyone else and applies the + * same limits as direct messages; its message is shown when it refuses. + */ +const ChatNewGroupSheet: React.FC> = ({ sheetId, payload }) => { + const intl = useIntl(); + const [term, setTerm] = useState(''); + const [results, setResults] = useState([]); + const [isSearching, setIsSearching] = useState(false); + const [selected, setSelected] = useState([]); + const [isCreating, setIsCreating] = useState(false); + const [error, setError] = useState(null); + const closedRef = useRef(false); + + const self = (payload?.currentUsername || '').toLowerCase(); + const atLimit = selected.length >= GROUP_MAX_OTHERS; + + useEffect(() => { + const query = term.trim(); + if (query.length < 2 || atLimit) { + setResults([]); + setIsSearching(false); + return; + } + let cancelled = false; + setIsSearching(true); + const timer = setTimeout(() => { + searchMattermostUsers(query) + .then((users) => { + if (!cancelled) { + setResults(Array.isArray(users) ? users : []); + } + }) + .catch(() => { + if (!cancelled) { + setResults([]); + } + }) + .finally(() => { + if (!cancelled) { + setIsSearching(false); + } + }); + }, 350); + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [term, atLimit]); + + const _close = (result: ChatNewGroupResult) => { + if (closedRef.current) { + return; + } + closedRef.current = true; + SheetManager.hide(sheetId || SHEET_ID, { payload: result }); + }; + + const _add = (username: string) => { + if (atLimit || !username || selected.includes(username)) { + return; + } + setSelected((prev) => [...prev, username]); + setTerm(''); + setError(null); + }; + + const _remove = (username: string) => { + setSelected((prev) => prev.filter((name) => name !== username)); + setError(null); + }; + + const _create = async () => { + if (selected.length < GROUP_MIN_OTHERS || isCreating) { + return; + } + setIsCreating(true); + setError(null); + try { + const { channelId } = await createMattermostGroup(selected); + _close({ channelId }); + } catch (err: any) { + setError( + err?.message || + intl.formatMessage({ + id: 'chats.new_group_failed', + defaultMessage: 'Could not start the group. Please try again.', + }), + ); + } finally { + setIsCreating(false); + } + }; + + const visibleResults = results + .map((user) => ({ user, username: usernameOf(user) })) + .filter(({ username }) => username && username !== self && !selected.includes(username)); + + return ( + + + + {intl.formatMessage({ id: 'chats.new_group', defaultMessage: 'New group' })} + + + {intl.formatMessage( + { + id: 'chats.new_group_hint', + defaultMessage: 'Add {min} to {max} people to start a group conversation.', + }, + { min: GROUP_MIN_OTHERS, max: GROUP_MAX_OTHERS }, + )} + + + {selected.length > 0 && ( + + {selected.map((username) => ( + _remove(username)} + accessibilityLabel={intl.formatMessage( + { id: 'chats.new_group_remove', defaultMessage: 'Remove @{username}' }, + { username }, + )} + > + + {`@${username}`} + × + + ))} + + )} + + + + {term.trim().length >= 2 && !atLimit && ( + + {isSearching && !visibleResults.length ? ( + + ) : visibleResults.length ? ( + visibleResults.map(({ user, username }) => ( + _add(username)} + > + + {`@${username}`} + + )) + ) : ( + + {intl.formatMessage({ + id: 'chats.new_group_no_results', + defaultMessage: 'No one found on chat with that name.', + })} + + )} + + )} + + {!!error && ( + + {error} + + )} + + + _close({ cancelled: true })} + text={intl.formatMessage({ id: 'chats.cancel', defaultMessage: 'Cancel' })} + style={styles.cancelButton} + textStyle={styles.cancelButtonText} + /> + + + ); +}; + +export default ChatNewGroupSheet; diff --git a/src/components/chatGroupSheets/chatReactorsSheet.tsx b/src/components/chatGroupSheets/chatReactorsSheet.tsx new file mode 100644 index 0000000000..133c14ddc4 --- /dev/null +++ b/src/components/chatGroupSheets/chatReactorsSheet.tsx @@ -0,0 +1,130 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { ScrollView, Text, TouchableOpacity, View } from 'react-native'; +import { useIntl } from 'react-intl'; +import ActionSheet, { SheetManager, SheetProps } from 'react-native-actions-sheet'; +import { UserAvatar } from '../userAvatar'; +import { + ensureMattermostUsersHaveHiveNames, + fetchMattermostUsersByIds, +} from '../../providers/chat/mattermost'; +import { getEmojiDisplay } from '../../screens/chats/utils/messageFormatters'; +import { + findMissingUserIds, + getUsernameForId, + groupReactions, +} from '../../screens/chats/utils/groupUtils'; +import styles from './chatGroupSheets.styles'; + +const SHEET_ID = 'chat_reactors'; + +/** + * Who reacted to a message, one tab per emoji. Reactors the screen has not + * loaded yet, such as someone whose reaction arrived live, are looked up once. + */ +const ChatReactorsSheet: React.FC> = ({ sheetId, payload }) => { + const intl = useIntl(); + const groups = useMemo( + () => groupReactions(payload?.reactions, payload?.currentUserId), + [payload?.reactions, payload?.currentUserId], + ); + const [selected, setSelected] = useState( + payload?.initialEmoji || groups[0]?.emojiName, + ); + const [lookup, setLookup] = useState>(payload?.userLookup || {}); + + useEffect(() => { + const missing = findMissingUserIds( + groups.flatMap((group) => group.userIds), + payload?.userLookup || {}, + ); + if (!missing.length) { + return; + } + let cancelled = false; + fetchMattermostUsersByIds(missing) + .then((users) => { + if (!cancelled && Array.isArray(users)) { + setLookup((prev) => ({ ...prev, ...ensureMattermostUsersHaveHiveNames(users) })); + } + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, [groups, payload?.userLookup]); + + const current = groups.find((group) => group.emojiName === selected) || groups[0]; + const ordered = useMemo(() => { + const ids = current?.userIds || []; + // The viewer first, as chat apps commonly do. + return [...ids].sort((a, b) => + a === payload?.currentUserId ? -1 : b === payload?.currentUserId ? 1 : 0, + ); + }, [current, payload?.currentUserId]); + + const _openProfile = (username: string) => { + SheetManager.hide(sheetId || SHEET_ID); + SheetManager.show('quick_profile', { payload: { username } }); + }; + + return ( + + + + {intl.formatMessage({ id: 'chats.reactions_title', defaultMessage: 'Reactions' })} + + + + {groups.map((group) => { + const active = group.emojiName === current?.emojiName; + return ( + setSelected(group.emojiName)} + accessibilityRole="tab" + accessibilityState={{ selected: active }} + > + {getEmojiDisplay(group.emojiName)} + + {group.userIds.length} + + + ); + })} + + + + {ordered.map((userId) => { + const isYou = userId === payload?.currentUserId; + const username = getUsernameForId(userId, lookup); + const label = isYou + ? intl.formatMessage({ id: 'chats.reactor_you', defaultMessage: 'You' }) + : username + ? `@${username}` + : intl.formatMessage({ id: 'chats.reactor_unknown', defaultMessage: 'Someone' }); + return ( + username && _openProfile(username)} + > + {username ? ( + + ) : ( + + )} + + {label} + + + ); + })} + + + + ); +}; + +export default ChatReactorsSheet; diff --git a/src/components/chatGroupSheets/chatRenameGroupSheet.tsx b/src/components/chatGroupSheets/chatRenameGroupSheet.tsx new file mode 100644 index 0000000000..f2570223ab --- /dev/null +++ b/src/components/chatGroupSheets/chatRenameGroupSheet.tsx @@ -0,0 +1,133 @@ +import React, { useRef, useState } from 'react'; +import { Text, TextInput, View } from 'react-native'; +import { useIntl } from 'react-intl'; +import ActionSheet, { SheetManager, SheetProps } from 'react-native-actions-sheet'; +import EStyleSheet from 'react-native-extended-stylesheet'; +import { MainButton } from '../mainButton'; +import { renameMattermostGroup } from '../../providers/chat/mattermost'; +import { GROUP_NAME_MAX_LENGTH, groupNameLength } from '../../screens/chats/utils/groupUtils'; +import styles from './chatGroupSheets.styles'; + +const SHEET_ID = 'chat_rename_group'; + +/** An object in both cases, see ChatNewGroupResult. */ +export interface ChatRenameGroupResult { + name?: string; + cancelled?: boolean; +} + +/** + * Names a group the viewer started, or clears its name. Only the owner is + * offered this; the server checks it again. + */ +const ChatRenameGroupSheet: React.FC> = ({ sheetId, payload }) => { + const intl = useIntl(); + const [value, setValue] = useState(payload?.currentName || ''); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(null); + const closedRef = useRef(false); + + const trimmed = value.trim(); + const unchanged = trimmed === (payload?.currentName || '').trim(); + // Counted as the server counts, so an emoji is one character. + const tooLong = groupNameLength(trimmed) > GROUP_NAME_MAX_LENGTH; + + const _close = (result: ChatRenameGroupResult) => { + if (closedRef.current) { + return; + } + closedRef.current = true; + SheetManager.hide(sheetId || SHEET_ID, { payload: result }); + }; + + const _save = async () => { + if (!payload?.channelId || unchanged || tooLong || isSaving) { + return; + } + setIsSaving(true); + setError(null); + try { + const { name } = await renameMattermostGroup(payload.channelId, trimmed); + _close({ name: typeof name === 'string' ? name : trimmed }); + } catch (err: any) { + const code = err?.response?.data?.code; + setError( + code === 'not_owner' + ? intl.formatMessage({ + id: 'chats.rename_group_not_owner', + defaultMessage: 'Only the person who started this group can rename it.', + }) + : intl.formatMessage({ + id: 'chats.rename_group_failed', + defaultMessage: 'Could not rename the group. Please try again.', + }), + ); + } finally { + setIsSaving(false); + } + }; + + return ( + + + + {intl.formatMessage({ id: 'chats.rename_group', defaultMessage: 'Rename group' })} + + + {intl.formatMessage({ + id: 'chats.rename_group_hint', + defaultMessage: + 'Give this group a name everyone in it will see. ' + + "Leave it empty to show the members' names instead.", + })} + + + + + {(tooLong || !!error) && ( + + {tooLong + ? intl.formatMessage( + { + id: 'chats.rename_group_too_long', + defaultMessage: 'A group name can be up to {max} characters.', + }, + { max: GROUP_NAME_MAX_LENGTH }, + ) + : error} + + )} + + + _close({ cancelled: true })} + text={intl.formatMessage({ id: 'chats.cancel', defaultMessage: 'Cancel' })} + style={styles.cancelButton} + textStyle={styles.cancelButtonText} + /> + + + ); +}; + +export default ChatRenameGroupSheet; diff --git a/src/components/chatGroupSheets/index.ts b/src/components/chatGroupSheets/index.ts new file mode 100644 index 0000000000..d23daab94d --- /dev/null +++ b/src/components/chatGroupSheets/index.ts @@ -0,0 +1,5 @@ +export { default as ChatReactorsSheet } from './chatReactorsSheet'; +export { default as ChatNewGroupSheet } from './chatNewGroupSheet'; +export type { ChatNewGroupResult } from './chatNewGroupSheet'; +export { default as ChatRenameGroupSheet } from './chatRenameGroupSheet'; +export type { ChatRenameGroupResult } from './chatRenameGroupSheet'; diff --git a/src/config/locales/en-US.json b/src/config/locales/en-US.json index 616ad6c198..b5f135e3d8 100644 --- a/src/config/locales/en-US.json +++ b/src/config/locales/en-US.json @@ -712,7 +712,30 @@ "ban-remaining-soon": "in under a minute", "ban-remaining-minutes": "in about {count} minutes", "ban-remaining-hours": "in about {count} hours", - "ban-remaining-days": "in about {count} days" + "ban-remaining-days": "in about {count} days", + "new_group": "New group", + "new_group_hint": "Add {min} to {max} people to start a group conversation.", + "new_group_search": "Search people by username", + "new_group_full": "A group can have up to {max} other people.", + "new_group_no_results": "No one found on chat with that name.", + "new_group_remove": "Remove @{username}", + "new_group_create": "Start group", + "new_group_failed": "Could not start the group. Please try again.", + "rename_group": "Rename group", + "rename_group_hint": "Give this group a name everyone in it will see. Leave it empty to show the members' names instead.", + "group_name": "Group name", + "rename_group_too_long": "A group name can be up to {max} characters.", + "rename_group_not_owner": "Only the person who started this group can rename it.", + "rename_group_failed": "Could not rename the group. Please try again.", + "group_renamed": "{user} renamed the group to \"{name}\"", + "group_name_cleared": "{user} removed the group name", + "group_members": "Group members", + "save": "Save", + "cancel": "Cancel", + "reactions_title": "Reactions", + "reactor_you": "You", + "reactor_unknown": "Someone", + "see_who_reacted": "Long press to see who reacted" }, "free_estm": { "timer_text": "Next free spin in", diff --git a/src/navigation/sheets.tsx b/src/navigation/sheets.tsx index 3c20c69bc5..e3ed596ee9 100644 --- a/src/navigation/sheets.tsx +++ b/src/navigation/sheets.tsx @@ -30,6 +30,11 @@ import type { CommunityManageAction } from '../components/communityManageSheet/c import type { CommunityRoleEditResult } from '../components/communityRoleEditSheet/communityRoleEditSheet'; import type { SearchFilters } from '../components/searchFiltersSheet'; import { ShareIntentSheet } from '../components/shareIntentSheet'; +import { + ChatNewGroupSheet, + ChatReactorsSheet, + ChatRenameGroupSheet, +} from '../components/chatGroupSheets'; import SignConfirmSheet from '../screens/dappBrowser/components/signConfirmSheet'; import ReceiveQrSheet from '../components/receiveQrSheet/receiveQrSheet'; import WalletHistoryFiltersSheet from '../components/walletHistoryFiltersSheet/walletHistoryFiltersSheet'; @@ -37,6 +42,7 @@ import BalanceAnalyticsSheet from '../components/balanceAnalyticsSheet/balanceAn import { TippingDialog } from '../components/tipping'; import { TTSSettingsSheet } from '../components/textToSpeech/ttsSettingsSheet'; import { ActionModalPayload } from '../components/actionModal/container/actionModalContainer'; +import type { ChatNewGroupResult, ChatRenameGroupResult } from '../components/chatGroupSheets'; export enum SheetNames { POST_TRANSLATION = 'post_translation', @@ -48,6 +54,9 @@ export enum SheetNames { QR_SCAN = 'qr_sheet', CHAT_OPTIONS = 'chat_options', CHAT_CHANNEL_OPTIONS = 'chat_channel_options', + CHAT_REACTORS = 'chat_reactors', + CHAT_NEW_GROUP = 'chat_new_group', + CHAT_RENAME_GROUP = 'chat_rename_group', TIPPING_DIALOG = 'tipping_dialog', TTS_SETTINGS = 'tts_settings', POSTING_AUTHORITY_PROMPT = 'posting_authority_prompt', @@ -79,6 +88,9 @@ registerSheet(SheetNames.ACCOUNTS_SHEET, AccountsBottomSheet); registerSheet(SheetNames.QR_SCAN, QRModal); registerSheet(SheetNames.CHAT_OPTIONS, ChatOptionsSheet); registerSheet(SheetNames.CHAT_CHANNEL_OPTIONS, ChatChannelOptionsSheet); +registerSheet(SheetNames.CHAT_REACTORS, ChatReactorsSheet); +registerSheet(SheetNames.CHAT_NEW_GROUP, ChatNewGroupSheet); +registerSheet(SheetNames.CHAT_RENAME_GROUP, ChatRenameGroupSheet); registerSheet(SheetNames.TIPPING_DIALOG, TippingDialog); registerSheet(SheetNames.TTS_SETTINGS, TTSSettingsSheet); registerSheet(SheetNames.POSTING_AUTHORITY_PROMPT, PostingAuthoritySheet); @@ -171,7 +183,30 @@ declare module 'react-native-actions-sheet' { onToggleFavorite?: () => void; onToggleMute?: () => void; onLeave?: () => void; + onShowMembers?: () => void; + onRename?: () => void; + }; + }>; + chat_reactors: SheetDefinition<{ + payload: { + reactions: { emoji_name: string; user_id: string }[]; + userLookup: Record; + currentUserId?: string; + initialEmoji?: string; + }; + }>; + chat_new_group: SheetDefinition<{ + payload: { + currentUsername?: string; + }; + returnValue: ChatNewGroupResult; + }>; + chat_rename_group: SheetDefinition<{ + payload: { + channelId: string; + currentName?: string; }; + returnValue: ChatRenameGroupResult; }>; tipping_dialog: SheetDefinition<{ payload: { diff --git a/src/navigation/types.ts b/src/navigation/types.ts index a2ccbb04e0..17a6c474e0 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -162,6 +162,9 @@ export type AppParamList = { userLookup?: any; lastViewedAt?: number; channelType?: string; + groupOwner?: boolean; + groupName?: string; + groupUsers?: any[]; }; [ROUTES.SCREENS.ACCOUNT_LIST]: { users?: any[]; title?: string } | undefined; [ROUTES.SCREENS.BACKUP_KEYS]: undefined; diff --git a/src/providers/chat/mattermost.ts b/src/providers/chat/mattermost.ts index f9a6aa2000..f471968af1 100644 --- a/src/providers/chat/mattermost.ts +++ b/src/providers/chat/mattermost.ts @@ -497,6 +497,22 @@ export const startMattermostDirectMessage = async ( return data.channel || data; }; +/** + * Opens (or returns the existing) group conversation with 2 to 7 other people. + * Errors carry the server's message: someone not on chat, someone who does not + * accept messages, or a limit reached. + */ +export const createMattermostGroup = async (usernames: string[]) => { + const { data } = await chatApi.post('/api/mattermost/group', { usernames }); + return data as { channelId: string }; +}; + +/** Names a group the viewer started, or clears its name with "". */ +export const renameMattermostGroup = async (channelId: string, name: string) => { + const { data } = await chatApi.put(`/api/mattermost/channels/${channelId}/name`, { name }); + return data as { name: string }; +}; + export const getHiveUsernameFromMattermostUser = (user: any): string | undefined => user?.props?.hive_username || user?.nickname || user?.username || user?.name; diff --git a/src/screens/chats/children/ChannelListItem.tsx b/src/screens/chats/children/ChannelListItem.tsx index 7bf2ada3cc..727fb2cad6 100644 --- a/src/screens/chats/children/ChannelListItem.tsx +++ b/src/screens/chats/children/ChannelListItem.tsx @@ -5,6 +5,7 @@ import EStyleSheet from 'react-native-extended-stylesheet'; import { Icon, UserAvatar } from '../../../components'; import { getHiveUsernameFromMattermostUser } from '../../../providers/chat/mattermost'; import { chatsStyles as styles } from '../styles/chats.styles'; +import { getGroupTitle, isGroupChannel } from '../utils/groupUtils'; interface ChannelListItemProps { channel: any; @@ -54,6 +55,23 @@ export const ChannelListItem: React.FC = React.memo( } } + if (isGroupChannel(channel)) { + channelName = getGroupTitle(channel, channelName); + // Two of the other members, overlapped. + const names = (channel?.groupUsers || []) + .map((user: any) => getHiveUsernameFromMattermostUser(user)) + .filter(Boolean) + .slice(0, 2); + if (names.length === 2) { + channelAvatar = ( + + + + + ); + } + } + if (!channelAvatar) { const communityId = safeExtractCommunityIdentifier(channel); if (communityId) { diff --git a/src/screens/chats/children/ChatHeader.tsx b/src/screens/chats/children/ChatHeader.tsx index 4cdc60fdb7..4c0dc7baf8 100644 --- a/src/screens/chats/children/ChatHeader.tsx +++ b/src/screens/chats/children/ChatHeader.tsx @@ -23,7 +23,7 @@ export const ChatHeader: React.FC = ({ onMembersPress, onPinnedPress, onOptionsPress, - isDM, + isDM: _isDM, }) => { return ( @@ -64,8 +64,8 @@ export const ChatHeader: React.FC = ({ )} - {/* Options Menu (DMs only) */} - {isDM && onOptionsPress && ( + {/* Options Menu */} + {onOptionsPress && ( = React.memo( + ({ text, showUnreadMarker }) => ( + + + + + {text} + + + + ), +); + +GroupRenameNotice.displayName = 'GroupRenameNotice'; diff --git a/src/screens/chats/children/MessageReactions.tsx b/src/screens/chats/children/MessageReactions.tsx index f4f45b7375..efdcc2cc52 100644 --- a/src/screens/chats/children/MessageReactions.tsx +++ b/src/screens/chats/children/MessageReactions.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { View, Text, TouchableOpacity } from 'react-native'; +import { useIntl } from 'react-intl'; import { getEmojiDisplay } from '../utils/messageFormatters'; import { chatThreadStyles as styles } from '../styles/chatThread.styles'; @@ -14,10 +15,14 @@ interface MessageReactionsProps { isOwnMessage: boolean; bootstrapUserId: string; onReactionPress?: (emojiName: string) => void; + /** Long-press: who reacted. */ + onReactionLongPress?: (emojiName: string) => void; } export const MessageReactions: React.FC = React.memo( - ({ reactions, isOwnMessage, bootstrapUserId, onReactionPress }) => { + ({ reactions, isOwnMessage, bootstrapUserId, onReactionPress, onReactionLongPress }) => { + const intl = useIntl(); + if (!reactions || reactions.length === 0) { return null; } @@ -48,6 +53,18 @@ export const MessageReactions: React.FC = React.memo( key={emojiName} style={[styles.reactionPill, hasCurrentUserReaction && styles.reactionPillActive]} onPress={() => onReactionPress?.(emojiName)} + onLongPress={onReactionLongPress ? () => onReactionLongPress(emojiName) : undefined} + delayLongPress={350} + accessibilityRole="button" + accessibilityLabel={`${emojiDisplay} ${count}`} + accessibilityHint={ + onReactionLongPress + ? intl.formatMessage({ + id: 'chats.see_who_reacted', + defaultMessage: 'Long press to see who reacted', + }) + : undefined + } activeOpacity={0.7} > {emojiDisplay} diff --git a/src/screens/chats/children/SearchBar.tsx b/src/screens/chats/children/SearchBar.tsx index b0ab322b23..bcf6b9443b 100644 --- a/src/screens/chats/children/SearchBar.tsx +++ b/src/screens/chats/children/SearchBar.tsx @@ -10,11 +10,20 @@ interface SearchBarProps { onSearchChange: (text: string) => void; sortByName: boolean; onToggleSort: () => void; + /** Opens the New group sheet. */ + onNewGroup?: () => void; searchError: string | null; } export const SearchBar: React.FC = React.memo( - ({ searchQuery, onSearchChange, sortByName, onToggleSort, searchError: _searchError }) => { + ({ + searchQuery, + onSearchChange, + sortByName, + onToggleSort, + onNewGroup, + searchError: _searchError, + }) => { const intl = useIntl(); return ( @@ -63,6 +72,25 @@ export const SearchBar: React.FC = React.memo( color={EStyleSheet.value(sortByName ? '$primaryBlue' : '$iconColor')} /> + + {onNewGroup && ( + + + + )} ); diff --git a/src/screens/chats/container/chatThreadContainer.tsx b/src/screens/chats/container/chatThreadContainer.tsx index 1fab7908c4..29609ebbea 100644 --- a/src/screens/chats/container/chatThreadContainer.tsx +++ b/src/screens/chats/container/chatThreadContainer.tsx @@ -1,5 +1,13 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Alert, BackHandler, Dimensions, Keyboard, Platform, View } from 'react-native'; +import { + Alert, + BackHandler, + DeviceEventEmitter, + Dimensions, + Keyboard, + Platform, + View, +} from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { useNavigation, useFocusEffect } from '@react-navigation/native'; import LinkifyIt from 'linkify-it'; @@ -38,6 +46,8 @@ import { pinMattermostPost, unpinMattermostPost, calculateGlobalUnreadTotal, + fetchMattermostChannels, + normalizeMattermostChannels, } from '../../../providers/chat/mattermost'; import { uploadImage } from '../../../providers/ecency/ecency'; import { signImage } from '../../../providers/hive/hive'; @@ -82,6 +92,12 @@ import { ThreadMessageItem } from '../children/ThreadMessageItem'; import { GroupedSystemMessages } from '../children/GroupedSystemMessages'; import { SystemMessageItem } from '../children/SystemMessageItem'; import { MessageReactions } from '../children/MessageReactions'; +import { GroupRenameNotice } from '../children/GroupRenameNotice'; +import { + GROUP_RENAMED_EVENT, + getGroupMembersTitle, + getRenamedGroupName, +} from '../utils/groupUtils'; import { ChatHeader } from '../children/ChatHeader'; import { PinnedMessagesModal } from '../children/PinnedMessagesModal'; import { OnlineUsersModal } from '../children/OnlineUsersModal'; @@ -118,6 +134,12 @@ export interface ChatThreadContainerProps { initialLastViewedAt?: number; communityIdentifier?: string; channelType?: string; + /** Group channels: whether the viewer started it and may rename it. */ + groupOwner?: boolean; + /** Group channels: the name its owner gave it, if any. */ + groupName?: string; + /** Group channels: the other members. */ + groupUsers?: any[]; } export const ChatThreadContainer: React.FC = ({ @@ -129,6 +151,9 @@ export const ChatThreadContainer: React.FC = ({ initialLastViewedAt, communityIdentifier: paramCommunityIdentifier, channelType, + groupOwner, + groupName, + groupUsers, }) => { const intl = useIntl(); const insets = useSafeAreaInsets(); @@ -230,6 +255,36 @@ export const ChatThreadContainer: React.FC = ({ // Derive pin/unpin permission: DMs allow both members, channels require moderator status const isDM = channelType === 'D'; + const isGroup = channelType === 'G'; + // Seeded from the channel list, then refreshed so a rename made elsewhere, + // or ownership, is never stale. + const [groupInfo, setGroupInfo] = useState<{ name: string; owner: boolean; users: any[] }>( + () => ({ name: groupName || '', owner: !!groupOwner, users: groupUsers || [] }), + ); + + useEffect(() => { + if (!isGroup || !channelId) { + return undefined; + } + let cancelled = false; + fetchMattermostChannels() + .then((response) => { + const channel = normalizeMattermostChannels(response).find( + (item: any) => item?.id === channelId, + ); + if (!cancelled && channel) { + setGroupInfo({ + name: typeof channel.group_name === 'string' ? channel.group_name : '', + owner: !!channel.group_owner, + users: Array.isArray(channel.groupUsers) ? channel.groupUsers : [], + }); + } + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, [isGroup, channelId]); const canPinUnpin = isDM || canModerate; // Bootstrap user ID extraction @@ -288,6 +343,11 @@ export const ChatThreadContainer: React.FC = ({ return; } + // A group was renamed: its header is its name. + if (channelType === 'G' && post?.type === 'system_header_change') { + setGroupInfo((prev) => ({ ...prev, name: getRenamedGroupName(post) })); + } + // Clear input when we get confirmation of our own message via WebSocket const pendingPostId = post.pending_post_id as string | undefined; const pendingMatch = @@ -383,7 +443,7 @@ export const ChatThreadContainer: React.FC = ({ .catch(console.error); } }, - [bootstrapUserId, _updateMentionState, channelId], + [bootstrapUserId, _updateMentionState, channelId, channelType], ), onMessageEdited: useCallback( (post: any) => { @@ -1787,6 +1847,9 @@ export const ChatThreadContainer: React.FC = ({ // Header title const headerTitle = useMemo(() => { + if (isGroup) { + return groupInfo.name || getGroupMembersTitle(groupInfo.users, channelName || channelId); + } let title = ''; if (headerUser) { title = headerUser.display_name || headerUser.name || channelName || channelId; @@ -1794,7 +1857,27 @@ export const ChatThreadContainer: React.FC = ({ title = channelName || channelId; } return title; - }, [headerUser, channelName, channelId]); + }, [headerUser, channelName, channelId, isGroup, groupInfo]); + + const _showGroupOptions = useCallback(() => { + SheetManager.show(SheetNames.CHAT_CHANNEL_OPTIONS, { + payload: { + title: headerTitle, + onShowMembers: () => setOnlineUsersModalVisible(true), + onRename: groupInfo.owner + ? async () => { + const result = await SheetManager.show(SheetNames.CHAT_RENAME_GROUP, { + payload: { channelId, currentName: groupInfo.name }, + }); + if (typeof result?.name === 'string') { + setGroupInfo((prev) => ({ ...prev, name: result.name || '' })); + DeviceEventEmitter.emit(GROUP_RENAMED_EVENT, { channelId, name: result.name }); + } + } + : undefined, + }, + }); + }, [channelId, groupInfo, headerTitle]); // Header handlers const handleBack = useCallback(() => { @@ -1962,10 +2045,20 @@ export const ChatThreadContainer: React.FC = ({ onReactionPress={(emojiName: string) => { _handleAddReaction(post, emojiName); }} + onReactionLongPress={(emojiName: string) => { + SheetManager.show(SheetNames.CHAT_REACTORS, { + payload: { + reactions: reactions || [], + userLookup, + currentUserId: bootstrapUserId, + initialEmoji: emojiName, + }, + }); + }} /> ); }, - [bootstrapUserId, _handleAddReaction], + [bootstrapUserId, _handleAddReaction, userLookup], ); const _renderMessageLinkPreview = useCallback( @@ -2022,6 +2115,35 @@ export const ChatThreadContainer: React.FC = ({ const authorId = (postItem as any).user_id || (postItem as any).user?.id; const isOwnMessage = authorId && bootstrapUserId === authorId; + if (isGroup && postItem?.type === 'system_header_change') { + const actor = + getHiveUsernameFromMattermostUser(userLookup[authorId]) || + intl.formatMessage({ id: 'chats.reactor_unknown', defaultMessage: 'Someone' }); + const newName = getRenamedGroupName(postItem); + return ( + + ); + } + if (isSystemAddMessage) { return ( = ({ _renderMessageLinkPreview, linkifyInstance, handleLink, + isGroup, + intl, ], ); @@ -2185,6 +2309,7 @@ export const ChatThreadContainer: React.FC = ({ onBack={handleBack} onMembersPress={() => setOnlineUsersModalVisible(true)} onPinnedPress={() => setPinnedMessagesModalVisible(true)} + onOptionsPress={isGroup ? _showGroupOptions : undefined} isDM={isDM} /> diff --git a/src/screens/chats/container/chatsContainer.tsx b/src/screens/chats/container/chatsContainer.tsx index c5f699e5a7..948100cd56 100644 --- a/src/screens/chats/container/chatsContainer.tsx +++ b/src/screens/chats/container/chatsContainer.tsx @@ -1,5 +1,12 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { ActivityIndicator, FlatList, RefreshControl, Text, View } from 'react-native'; +import { + ActivityIndicator, + DeviceEventEmitter, + FlatList, + RefreshControl, + Text, + View, +} from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { useIntl } from 'react-intl'; import { SheetManager } from 'react-native-actions-sheet'; @@ -35,6 +42,12 @@ import { import { Header } from '../../../components'; import { chatsStyles as styles } from '../styles/chats.styles'; import { safeExtractCommunityIdentifier } from '../utils/userLookupHelpers'; +import { + GROUP_RENAMED_EVENT, + getGroupTitle, + isConversationChannel, + isGroupChannel, +} from '../utils/groupUtils'; import { StatusPill } from '../children/StatusPill'; import { SearchBar } from '../children/SearchBar'; import { ChannelListItem } from '../children/ChannelListItem'; @@ -432,12 +445,31 @@ const ChatsContainer = () => { [_ensureBootstrap, _getChannelId, _updateChannelState, _refreshGlobalUnreadChatCount, intl], ); + const _handleRenameGroup = useCallback( + async (channel: any) => { + const channelId = _getChannelId(channel); + if (!channelId) { + return; + } + const result = await SheetManager.show(SheetNames.CHAT_RENAME_GROUP, { + payload: { channelId, currentName: channel?.group_name || '' }, + }); + if (typeof result?.name === 'string') { + _updateChannelState(channelId, { group_name: result.name || undefined }); + } + }, + [_getChannelId, _updateChannelState], + ); + const _confirmChannelOptions = useCallback( (channel: any) => { - const name = channel.display_name || channel.name; + const name = isGroupChannel(channel) + ? getGroupTitle(channel) + : channel.display_name || channel.name; const { totalUnread } = _getUnreadMeta(channel); const hasUnread = (totalUnread || 0) > 0; - const isDM = channel?.type === 'D'; + // Groups are left like direct messages: closed for this viewer. + const isDM = isConversationChannel(channel); SheetManager.show(SheetNames.CHAT_CHANNEL_OPTIONS, { payload: { @@ -450,12 +482,17 @@ const ChatsContainer = () => { onToggleFavorite: () => _handleToggleFavorite(channel), onToggleMute: () => _handleToggleMute(channel), onLeave: () => _handleLeaveChannel(channel), + onRename: + isGroupChannel(channel) && channel?.group_owner + ? () => _handleRenameGroup(channel) + : undefined, }, }); }, [ _getUnreadMeta, _handleLeaveChannel, + _handleRenameGroup, _handleMarkChannelRead, _handleToggleFavorite, _handleToggleMute, @@ -663,6 +700,54 @@ const ChatsContainer = () => { [_ensureBootstrap, _resolveDirectChannel, bootstrapResult, navigation, userLookup], ); + const _openChannel = useCallback( + (item: any) => { + const channelId = item.id || item.channel_id || item.name; + const description = item.header || item.purpose || ''; + const communityIdentifier = safeExtractCommunityIdentifier(item); + const isGroup = isGroupChannel(item); + + navigation.navigate(ROUTES.SCREENS.CHAT_THREAD, { + channelId, + channelName: isGroup ? getGroupTitle(item) : item.display_name || item.name || channelId, + // A group's header is its name, not a description. + channelDescription: isGroup ? '' : description, + communityIdentifier, + bootstrapResult, + userLookup, + lastViewedAt: + item?.last_viewed_at || + item?.last_view_at || + item?.lastViewedAt || + item?.lastViewed || + null, + channelType: item.type, + groupOwner: isGroup ? !!item.group_owner : undefined, + groupName: isGroup ? item.group_name || '' : undefined, + groupUsers: isGroup ? item.groupUsers : undefined, + }); + }, + [bootstrapResult, navigation, userLookup], + ); + + const _handleNewGroup = useCallback(async () => { + const result = await SheetManager.show(SheetNames.CHAT_NEW_GROUP, { + payload: { currentUsername: currentAccount?.name }, + }); + const channelId = result?.channelId; + if (!channelId) { + return; + } + try { + const channelList = _normalizeChannels(await fetchMattermostChannels()); + setChannels(channelList); + const created = channelList.find((item) => _getChannelId(item) === channelId); + _openChannel(created || { id: channelId, type: 'G' }); + } catch (err) { + _openChannel({ id: channelId, type: 'G' }); + } + }, [_getChannelId, _normalizeChannels, _openChannel, currentAccount?.name]); + // ============================================================================ // Search // ============================================================================ @@ -778,6 +863,8 @@ const ChatsContainer = () => { let displayName = channel.display_name || channel.name || ''; if (channel?.type === 'D') { displayName = channel.directUser?.username || ''; + } else if (isGroupChannel(channel)) { + displayName = getGroupTitle(channel); } return displayName; }; @@ -866,37 +953,30 @@ const ChatsContainer = () => { userLookup={userLookup} getUnreadMeta={_getUnreadMeta} safeExtractCommunityIdentifier={safeExtractCommunityIdentifier} - onPress={() => { - const channelId = item.id || item.channel_id || item.name; - const description = item.header || item.purpose || ''; - const communityIdentifier = safeExtractCommunityIdentifier(item); - - navigation.navigate(ROUTES.SCREENS.CHAT_THREAD, { - channelId, - channelName: item.display_name || item.name || channelId, - channelDescription: description, - communityIdentifier, - bootstrapResult, - userLookup, - lastViewedAt: - item?.last_viewed_at || - item?.last_view_at || - item?.lastViewedAt || - item?.lastViewed || - null, - channelType: item.type, - }); - }} + onPress={() => _openChannel(item)} onShowOptions={_confirmChannelOptions} /> ), - [currentUserId, userLookup, _getUnreadMeta, bootstrapResult, navigation], + [currentUserId, userLookup, _getUnreadMeta, _openChannel, _confirmChannelOptions], ); // ============================================================================ // Effects // ============================================================================ + // A group renamed from its conversation screen shows its new name here too. + useEffect(() => { + const subscription = DeviceEventEmitter.addListener( + GROUP_RENAMED_EVENT, + ({ channelId, name }: { channelId: string; name: string }) => { + if (channelId) { + _updateChannelState(channelId, { group_name: name || undefined }); + } + }, + ); + return () => subscription.remove(); + }, [_updateChannelState]); + useEffect(() => { if (currentAccount) { _loadChannels(true); @@ -966,6 +1046,7 @@ const ChatsContainer = () => { onSearchChange={(text) => setSearchQuery(text.toLowerCase())} sortByName={sortByName} onToggleSort={() => setSortByName(!sortByName)} + onNewGroup={isLoggedIn ? _handleNewGroup : undefined} searchError={searchError} /> diff --git a/src/screens/chats/screen/chatThreadScreen.tsx b/src/screens/chats/screen/chatThreadScreen.tsx index 9b3beba33a..3a36655c56 100644 --- a/src/screens/chats/screen/chatThreadScreen.tsx +++ b/src/screens/chats/screen/chatThreadScreen.tsx @@ -12,6 +12,9 @@ interface ChatThreadParams { lastViewedAt?: number; communityIdentifier?: string; channelType?: string; + groupOwner?: boolean; + groupName?: string; + groupUsers?: any[]; } const ChatThreadScreen = ({ route }: { route: { params: ChatThreadParams } }) => { @@ -24,6 +27,9 @@ const ChatThreadScreen = ({ route }: { route: { params: ChatThreadParams } }) => userLookup, lastViewedAt, channelType, + groupOwner, + groupName, + groupUsers, } = route.params; return ( @@ -37,6 +43,9 @@ const ChatThreadScreen = ({ route }: { route: { params: ChatThreadParams } }) => initialUserLookup={userLookup} initialLastViewedAt={lastViewedAt} channelType={channelType} + groupOwner={groupOwner} + groupName={groupName} + groupUsers={groupUsers} /> ); diff --git a/src/screens/chats/styles/chats.styles.ts b/src/screens/chats/styles/chats.styles.ts index 32dea98a8c..4c364f2911 100644 --- a/src/screens/chats/styles/chats.styles.ts +++ b/src/screens/chats/styles/chats.styles.ts @@ -80,6 +80,30 @@ export const chatsStyles = EStyleSheet.create({ marginRight: 12, borderWidth: 0, }, + groupAvatar: { + width: 40, + height: 40, + marginRight: 12, + }, + groupAvatarBack: { + position: 'absolute', + top: 0, + left: 0, + width: 28, + height: 28, + borderRadius: 14, + borderWidth: 0, + }, + groupAvatarFront: { + position: 'absolute', + right: 0, + bottom: 0, + width: 28, + height: 28, + borderRadius: 14, + borderWidth: 2, + borderColor: '$primaryBackgroundColor', + }, channelAvatarFallback: { width: 40, height: 40, diff --git a/src/screens/chats/utils/groupUtils.test.ts b/src/screens/chats/utils/groupUtils.test.ts new file mode 100644 index 0000000000..ad846c947a --- /dev/null +++ b/src/screens/chats/utils/groupUtils.test.ts @@ -0,0 +1,83 @@ +import { + findMissingUserIds, + getGroupMembersTitle, + getGroupTitle, + getRenamedGroupName, + getUsernameForId, + groupNameLength, + groupReactions, + isConversationChannel, + isGroupChannel, +} from './groupUtils'; + +jest.mock('../../../config/chatApi', () => ({ + __esModule: true, + default: { get: jest.fn(), post: jest.fn(), put: jest.fn(), delete: jest.fn() }, + setChatApiToken: jest.fn(), +})); + +const user = (username: string) => ({ id: `id-${username}`, username }); + +describe('group channels', () => { + it('tells groups and direct messages apart from channels', () => { + expect(isGroupChannel({ type: 'G' })).toBe(true); + expect(isGroupChannel({ type: 'D' })).toBe(false); + expect(isConversationChannel({ type: 'D' })).toBe(true); + expect(isConversationChannel({ type: 'G' })).toBe(true); + expect(isConversationChannel({ type: 'O' })).toBe(false); + expect(isConversationChannel(undefined)).toBe(false); + }); + + it('names a group by its given name, else by its other members', () => { + const members = [user('alice'), user('bob'), user('carol'), user('dave'), user('erin')]; + expect(getGroupTitle({ group_name: ' Book club ', groupUsers: members })).toBe('Book club'); + expect(getGroupTitle({ group_name: '', groupUsers: members.slice(0, 2) })).toBe('alice, bob'); + expect(getGroupTitle({ groupUsers: members })).toBe('alice, bob, carol +2'); + expect(getGroupTitle({ display_name: 'a, b, me' })).toBe('a, b, me'); + expect(getGroupMembersTitle([], 'fallback')).toBe('fallback'); + }); + + it('counts a name as the server does, so an emoji is one character', () => { + expect(groupNameLength(' 🙂🙂 ')).toBe(2); + expect(groupNameLength('abc')).toBe(3); + }); + + it('reads the new name from a rename notice', () => { + expect(getRenamedGroupName({ props: { new_header: ' Book club ' } })).toBe('Book club'); + expect(getRenamedGroupName({ props: { new_header: '' } })).toBe(''); + expect(getRenamedGroupName({ props: {} })).toBe(''); + expect(getRenamedGroupName(undefined)).toBe(''); + }); +}); + +describe('reactions', () => { + it('groups by emoji in first-use order, once per person, marking the viewer', () => { + const grouped = groupReactions( + [ + { emoji_name: 'heart', user_id: 'u1' }, + { emoji_name: '+1', user_id: 'me' }, + { emoji_name: 'heart', user_id: 'u2' }, + { emoji_name: 'heart', user_id: 'u1' }, + ], + 'me', + ); + expect(grouped).toEqual([ + { emojiName: 'heart', userIds: ['u1', 'u2'], reacted: false }, + { emojiName: '+1', userIds: ['me'], reacted: true }, + ]); + expect(groupReactions(undefined)).toEqual([]); + }); + + it('lists reactors the lookup does not know yet, once and sorted', () => { + expect(findMissingUserIds(['z9', 'known', 'a1', 'z9', ''], { known: user('known') })).toEqual([ + 'a1', + 'z9', + ]); + }); + + it('names a known reactor and leaves an unknown one undefined', () => { + const lookup = { 'id-alice': user('alice') }; + expect(getUsernameForId('id-alice', lookup)).toBe('alice'); + expect(getUsernameForId('ghost', lookup)).toBeUndefined(); + }); +}); diff --git a/src/screens/chats/utils/groupUtils.ts b/src/screens/chats/utils/groupUtils.ts new file mode 100644 index 0000000000..a169122ebf --- /dev/null +++ b/src/screens/chats/utils/groupUtils.ts @@ -0,0 +1,103 @@ +import { getHiveUsernameFromMattermostUser } from '../../../providers/chat/mattermost'; + +/** Mattermost allows 3 to 8 members in a group, the creator included. */ +export const GROUP_MIN_OTHERS = 2; +export const GROUP_MAX_OTHERS = 7; + +/** Characters (code points) a group name may have, as the server counts them. */ +export const GROUP_NAME_MAX_LENGTH = 64; + +/** Emitted with { channelId, name } when the viewer renames a group. */ +export const GROUP_RENAMED_EVENT = 'chat_group_renamed'; + +export const isGroupChannel = (channel: any): boolean => channel?.type === 'G'; + +/** A direct message or a group: private conversations, listed together. */ +export const isConversationChannel = (channel: any): boolean => + channel?.type === 'D' || channel?.type === 'G'; + +/** The length of a group name as the server counts it, so an emoji counts as one. */ +export const groupNameLength = (name: string): number => Array.from(name.trim()).length; + +const userName = (user: any): string => + getHiveUsernameFromMattermostUser(user) || user?.username || ''; + +/** + * "alice, bob, carol +2". Mattermost's own display name for a group lists every + * member's username, the viewer included, cut at 64 characters. + */ +export const getGroupMembersTitle = (users: any[] | undefined, fallback: string, maxNames = 3) => { + const names = (users || []).map(userName).filter(Boolean); + if (!names.length) { + return fallback; + } + if (names.length <= maxNames) { + return names.join(', '); + } + return `${names.slice(0, maxNames).join(', ')} +${names.length - maxNames}`; +}; + +/** The name to show for a group: the name its owner gave it, else its members. */ +export const getGroupTitle = (channel: any, fallback?: string): string => + (typeof channel?.group_name === 'string' && channel.group_name.trim()) || + getGroupMembersTitle( + channel?.groupUsers, + fallback || channel?.display_name || channel?.name || '', + ); + +export interface ReactionLike { + emoji_name: string; + user_id: string; +} + +export interface GroupedReaction { + emojiName: string; + userIds: string[]; + reacted: boolean; +} + +/** One entry per emoji, in the order each emoji was first used. */ +export const groupReactions = ( + reactions: ReactionLike[] | undefined, + currentUserId?: string, +): GroupedReaction[] => { + const byEmoji = new Map(); + (reactions || []).forEach((reaction) => { + if (!reaction?.emoji_name) { + return; + } + const entry = byEmoji.get(reaction.emoji_name) || { + emojiName: reaction.emoji_name, + userIds: [], + reacted: false, + }; + if (reaction.user_id && !entry.userIds.includes(reaction.user_id)) { + entry.userIds.push(reaction.user_id); + } + if (currentUserId && reaction.user_id === currentUserId) { + entry.reacted = true; + } + byEmoji.set(reaction.emoji_name, entry); + }); + return Array.from(byEmoji.values()); +}; + +/** Reactor ids that are not in the users lookup yet, for one batched request. */ +export const findMissingUserIds = (userIds: string[], userLookup: Record): string[] => + Array.from(new Set(userIds.filter((id) => id && !userLookup[id]))).sort(); + +/** The Hive username for a user id, or undefined while it is unknown. */ +export const getUsernameForId = ( + userId: string, + userLookup: Record, +): string | undefined => { + const user = userLookup[userId]; + return user ? userName(user) || undefined : undefined; +}; + +/** + * The new name from a group rename notice. Mattermost announces a header change + * with a `system_header_change` post; in a group the header is its name. + */ +export const getRenamedGroupName = (post: any): string => + typeof post?.props?.new_header === 'string' ? post.props.new_header.trim() : ''; From 03c324070b4068b80e90324fda9477cb6c022e3b Mon Sep 17 00:00:00 2001 From: feruzm Date: Fri, 9 Oct 2026 07:58:10 +0000 Subject: [PATCH 2/4] fix(chat): position group avatars and keep sheet avatars inert The avatar component styles its image rather than its wrapper, so the two group avatars are now positioned by wrapper views and stay inside their box. Avatars inside the group and reactor sheets no longer open a profile behind the open sheet. A group's name is never read as a community, and a new group without a loaded name is titled Group rather than by its id. --- .../chatGroupSheets/chatNewGroupSheet.tsx | 9 +++++-- .../chatGroupSheets/chatReactorsSheet.tsx | 7 ++++- src/config/locales/en-US.json | 3 ++- .../chats/children/ChannelListItem.tsx | 24 ++++++++++++++--- .../chats/container/chatThreadContainer.tsx | 27 ++++++++++++------- .../chats/container/chatsContainer.tsx | 3 ++- src/screens/chats/styles/chats.styles.ts | 14 ++++++---- 7 files changed, 65 insertions(+), 22 deletions(-) diff --git a/src/components/chatGroupSheets/chatNewGroupSheet.tsx b/src/components/chatGroupSheets/chatNewGroupSheet.tsx index b7b165d6ea..e5e96596a2 100644 --- a/src/components/chatGroupSheets/chatNewGroupSheet.tsx +++ b/src/components/chatGroupSheets/chatNewGroupSheet.tsx @@ -166,7 +166,7 @@ const ChatNewGroupSheet: React.FC> = ({ sheetId, pa { username }, )} > - + {`@${username}`} × @@ -209,7 +209,12 @@ const ChatNewGroupSheet: React.FC> = ({ sheetId, pa style={styles.personRow} onPress={() => _add(username)} > - + {`@${username}`} )) diff --git a/src/components/chatGroupSheets/chatReactorsSheet.tsx b/src/components/chatGroupSheets/chatReactorsSheet.tsx index 133c14ddc4..01d9885325 100644 --- a/src/components/chatGroupSheets/chatReactorsSheet.tsx +++ b/src/components/chatGroupSheets/chatReactorsSheet.tsx @@ -111,7 +111,12 @@ const ChatReactorsSheet: React.FC> = ({ sheetId, pay onPress={() => username && _openProfile(username)} > {username ? ( - + ) : ( )} diff --git a/src/config/locales/en-US.json b/src/config/locales/en-US.json index b5f135e3d8..ede8d3f73e 100644 --- a/src/config/locales/en-US.json +++ b/src/config/locales/en-US.json @@ -735,7 +735,8 @@ "reactions_title": "Reactions", "reactor_you": "You", "reactor_unknown": "Someone", - "see_who_reacted": "Long press to see who reacted" + "see_who_reacted": "Long press to see who reacted", + "group": "Group" }, "free_estm": { "timer_text": "Next free spin in", diff --git a/src/screens/chats/children/ChannelListItem.tsx b/src/screens/chats/children/ChannelListItem.tsx index 727fb2cad6..d8f8354ae7 100644 --- a/src/screens/chats/children/ChannelListItem.tsx +++ b/src/screens/chats/children/ChannelListItem.tsx @@ -65,15 +65,33 @@ export const ChannelListItem: React.FC = React.memo( if (names.length === 2) { channelAvatar = ( - - + {/* UserAvatar styles its image, not its wrapper, so position the wrappers. */} + + + + + + ); } } if (!channelAvatar) { - const communityId = safeExtractCommunityIdentifier(channel); + // A group's header is its name, which must not read as a community. + const communityId = isGroupChannel(channel) + ? undefined + : safeExtractCommunityIdentifier(channel); if (communityId) { channelAvatar = ( diff --git a/src/screens/chats/container/chatThreadContainer.tsx b/src/screens/chats/container/chatThreadContainer.tsx index 29609ebbea..6c9d57642b 100644 --- a/src/screens/chats/container/chatThreadContainer.tsx +++ b/src/screens/chats/container/chatThreadContainer.tsx @@ -584,13 +584,16 @@ export const ChatThreadContainer: React.FC = ({ const derivedCommunityIdentifier = useMemo( () => - paramCommunityIdentifier || - safeExtractCommunityIdentifier({ - name: channelName, - display_name: channelName, - header: channelDescription, - }), - [channelDescription, channelName, paramCommunityIdentifier], + // A group's title is its name or its members, never a community. + channelType === 'G' + ? undefined + : paramCommunityIdentifier || + safeExtractCommunityIdentifier({ + name: channelName, + display_name: channelName, + header: channelDescription, + }), + [channelDescription, channelName, channelType, paramCommunityIdentifier], ); const _ensureBootstrap = useCallback(async () => { @@ -1848,7 +1851,13 @@ export const ChatThreadContainer: React.FC = ({ // Header title const headerTitle = useMemo(() => { if (isGroup) { - return groupInfo.name || getGroupMembersTitle(groupInfo.users, channelName || channelId); + return ( + groupInfo.name || + getGroupMembersTitle( + groupInfo.users, + channelName || intl.formatMessage({ id: 'chats.group', defaultMessage: 'Group' }), + ) + ); } let title = ''; if (headerUser) { @@ -1857,7 +1866,7 @@ export const ChatThreadContainer: React.FC = ({ title = channelName || channelId; } return title; - }, [headerUser, channelName, channelId, isGroup, groupInfo]); + }, [headerUser, channelName, channelId, isGroup, groupInfo, intl]); const _showGroupOptions = useCallback(() => { SheetManager.show(SheetNames.CHAT_CHANNEL_OPTIONS, { diff --git a/src/screens/chats/container/chatsContainer.tsx b/src/screens/chats/container/chatsContainer.tsx index 948100cd56..484abb7b7d 100644 --- a/src/screens/chats/container/chatsContainer.tsx +++ b/src/screens/chats/container/chatsContainer.tsx @@ -704,8 +704,9 @@ const ChatsContainer = () => { (item: any) => { const channelId = item.id || item.channel_id || item.name; const description = item.header || item.purpose || ''; - const communityIdentifier = safeExtractCommunityIdentifier(item); const isGroup = isGroupChannel(item); + // A group's header is its name, which must not read as a community. + const communityIdentifier = isGroup ? undefined : safeExtractCommunityIdentifier(item); navigation.navigate(ROUTES.SCREENS.CHAT_THREAD, { channelId, diff --git a/src/screens/chats/styles/chats.styles.ts b/src/screens/chats/styles/chats.styles.ts index 4c364f2911..7234b9c868 100644 --- a/src/screens/chats/styles/chats.styles.ts +++ b/src/screens/chats/styles/chats.styles.ts @@ -85,19 +85,23 @@ export const chatsStyles = EStyleSheet.create({ height: 40, marginRight: 12, }, - groupAvatarBack: { + groupAvatarBackSlot: { position: 'absolute', top: 0, left: 0, + }, + groupAvatarFrontSlot: { + position: 'absolute', + top: 12, + left: 12, + }, + groupAvatarImage: { width: 28, height: 28, borderRadius: 14, borderWidth: 0, }, - groupAvatarFront: { - position: 'absolute', - right: 0, - bottom: 0, + groupAvatarFrontImage: { width: 28, height: 28, borderRadius: 14, From 144e088763073d025a87a5cfa7f14b1e648b8896 Mon Sep 17 00:00:00 2001 From: feruzm Date: Fri, 9 Oct 2026 08:35:10 +0000 Subject: [PATCH 3/4] fix(chat): search group names, carry renames to the list, list only members - Searching the chat list matches a group's given name and its members. - A rename that arrives live from another member updates the list as well as the open header, and a channel fetch already in flight no longer puts the old name back. - The members list of a group or direct message shows only its members; it used to list everyone the screen had looked up, such as post authors and other conversations' partners. - New group waits for the chat session, and a failed people search says so instead of reading as no matches. --- .../chatGroupSheets/chatNewGroupSheet.tsx | 16 ++++++++--- src/config/locales/en-US.json | 1 + .../chats/children/OnlineUsersModal.tsx | 11 ++++++-- .../chats/container/chatThreadContainer.tsx | 27 ++++++++++++++++--- .../chats/container/chatsContainer.tsx | 8 ++++-- 5 files changed, 51 insertions(+), 12 deletions(-) diff --git a/src/components/chatGroupSheets/chatNewGroupSheet.tsx b/src/components/chatGroupSheets/chatNewGroupSheet.tsx index e5e96596a2..b20efe8245 100644 --- a/src/components/chatGroupSheets/chatNewGroupSheet.tsx +++ b/src/components/chatGroupSheets/chatNewGroupSheet.tsx @@ -44,6 +44,7 @@ const ChatNewGroupSheet: React.FC> = ({ sheetId, pa const [term, setTerm] = useState(''); const [results, setResults] = useState([]); const [isSearching, setIsSearching] = useState(false); + const [searchFailed, setSearchFailed] = useState(false); const [selected, setSelected] = useState([]); const [isCreating, setIsCreating] = useState(false); const [error, setError] = useState(null); @@ -61,6 +62,7 @@ const ChatNewGroupSheet: React.FC> = ({ sheetId, pa } let cancelled = false; setIsSearching(true); + setSearchFailed(false); const timer = setTimeout(() => { searchMattermostUsers(query) .then((users) => { @@ -71,6 +73,7 @@ const ChatNewGroupSheet: React.FC> = ({ sheetId, pa .catch(() => { if (!cancelled) { setResults([]); + setSearchFailed(true); } }) .finally(() => { @@ -220,10 +223,15 @@ const ChatNewGroupSheet: React.FC> = ({ sheetId, pa )) ) : ( - {intl.formatMessage({ - id: 'chats.new_group_no_results', - defaultMessage: 'No one found on chat with that name.', - })} + {searchFailed + ? intl.formatMessage({ + id: 'chats.new_group_search_failed', + defaultMessage: 'Search did not work. Check your connection and try again.', + }) + : intl.formatMessage({ + id: 'chats.new_group_no_results', + defaultMessage: 'No one found on chat with that name.', + })} )} diff --git a/src/config/locales/en-US.json b/src/config/locales/en-US.json index ede8d3f73e..f3b05a1499 100644 --- a/src/config/locales/en-US.json +++ b/src/config/locales/en-US.json @@ -718,6 +718,7 @@ "new_group_search": "Search people by username", "new_group_full": "A group can have up to {max} other people.", "new_group_no_results": "No one found on chat with that name.", + "new_group_search_failed": "Search did not work. Check your connection and try again.", "new_group_remove": "Remove @{username}", "new_group_create": "Start group", "new_group_failed": "Could not start the group. Please try again.", diff --git a/src/screens/chats/children/OnlineUsersModal.tsx b/src/screens/chats/children/OnlineUsersModal.tsx index 468c86a17c..3aaad40572 100644 --- a/src/screens/chats/children/OnlineUsersModal.tsx +++ b/src/screens/chats/children/OnlineUsersModal.tsx @@ -14,6 +14,12 @@ interface OnlineUsersModalProps { onClose: () => void; onUserPress: (username: string) => void; memberCount?: number; + /** + * When set, only these users are listed. Passed for groups and direct + * messages, whose member list is complete; the lookup also holds post + * authors, reactors and other conversations' partners. + */ + memberIds?: string[]; } interface UserStatus { @@ -32,6 +38,7 @@ export const OnlineUsersModal: React.FC = ({ onClose, onUserPress, memberCount, + memberIds, }) => { const intl = useIntl(); @@ -40,7 +47,7 @@ export const OnlineUsersModal: React.FC = ({ const processedUserIds = new Set(); // Get all user IDs from userLookup (this should have all channel members) - const allUserIds = Object.keys(userLookup); + const allUserIds = memberIds?.length ? memberIds : Object.keys(userLookup); console.log('[OnlineUsersModal] Total users in lookup:', allUserIds.length); console.log('[OnlineUsersModal] Channel members count:', channelMembers.length); @@ -89,7 +96,7 @@ export const OnlineUsersModal: React.FC = ({ }); return statuses; - }, [userLookup, onlineUserIds, channelMembers.length, memberCount]); + }, [userLookup, onlineUserIds, channelMembers.length, memberCount, memberIds]); const handleUserPress = useCallback( (username: string) => { diff --git a/src/screens/chats/container/chatThreadContainer.tsx b/src/screens/chats/container/chatThreadContainer.tsx index 6c9d57642b..b3c2ac4010 100644 --- a/src/screens/chats/container/chatThreadContainer.tsx +++ b/src/screens/chats/container/chatThreadContainer.tsx @@ -204,6 +204,13 @@ export const ChatThreadContainer: React.FC = ({ const [isFetchingLinkMeta, setIsFetchingLinkMeta] = useState(false); const [pinnedCount, setPinnedCount] = useState(0); const [channelMembers, setChannelMembers] = useState([]); + const channelMemberIds = useMemo( + () => + channelMembers + .map((member: any) => member?.user_id || member?.id) + .filter((id: any): id is string => typeof id === 'string' && !!id), + [channelMembers], + ); const [onlineUserIds, setOnlineUserIds] = useState([]); const [wsEnabled, setWsEnabled] = useState(true); @@ -256,6 +263,9 @@ export const ChatThreadContainer: React.FC = ({ // Derive pin/unpin permission: DMs allow both members, channels require moderator status const isDM = channelType === 'D'; const isGroup = channelType === 'G'; + // Set when a rename arrives live, so a channel fetch already in flight does + // not put the old name back. + const groupRenamedLiveRef = useRef(false); // Seeded from the channel list, then refreshed so a rename made elsewhere, // or ownership, is never stale. const [groupInfo, setGroupInfo] = useState<{ name: string; owner: boolean; users: any[] }>( @@ -273,11 +283,15 @@ export const ChatThreadContainer: React.FC = ({ (item: any) => item?.id === channelId, ); if (!cancelled && channel) { - setGroupInfo({ - name: typeof channel.group_name === 'string' ? channel.group_name : '', + setGroupInfo((prev) => ({ + name: groupRenamedLiveRef.current + ? prev.name + : typeof channel.group_name === 'string' + ? channel.group_name + : '', owner: !!channel.group_owner, users: Array.isArray(channel.groupUsers) ? channel.groupUsers : [], - }); + })); } }) .catch(() => undefined); @@ -345,7 +359,10 @@ export const ChatThreadContainer: React.FC = ({ // A group was renamed: its header is its name. if (channelType === 'G' && post?.type === 'system_header_change') { - setGroupInfo((prev) => ({ ...prev, name: getRenamedGroupName(post) })); + const renamed = getRenamedGroupName(post); + groupRenamedLiveRef.current = true; + setGroupInfo((prev) => ({ ...prev, name: renamed })); + DeviceEventEmitter.emit(GROUP_RENAMED_EVENT, { channelId, name: renamed }); } // Clear input when we get confirmation of our own message via WebSocket @@ -1879,6 +1896,7 @@ export const ChatThreadContainer: React.FC = ({ payload: { channelId, currentName: groupInfo.name }, }); if (typeof result?.name === 'string') { + groupRenamedLiveRef.current = true; setGroupInfo((prev) => ({ ...prev, name: result.name || '' })); DeviceEventEmitter.emit(GROUP_RENAMED_EVENT, { channelId, name: result.name }); } @@ -2393,6 +2411,7 @@ export const ChatThreadContainer: React.FC = ({ userLookup={userLookup} onlineUserIds={onlineUserIds} memberCount={memberCount || undefined} + memberIds={isGroup || isDM ? channelMemberIds : undefined} onClose={() => setOnlineUsersModalVisible(false)} onUserPress={_showUserProfile} /> diff --git a/src/screens/chats/container/chatsContainer.tsx b/src/screens/chats/container/chatsContainer.tsx index 484abb7b7d..1c6f4a78c9 100644 --- a/src/screens/chats/container/chatsContainer.tsx +++ b/src/screens/chats/container/chatsContainer.tsx @@ -773,7 +773,11 @@ const ChatsContainer = () => { // Search existing channels locally const matchingExistingChannels = channels.filter((channel) => { const displayName = (channel?.display_name || channel?.name || '').toLowerCase(); - return displayName.includes(lowerQuery); + // A group also matches its given name and its members' names. + const groupTitle = isGroupChannel(channel) + ? `${channel?.group_name || ''} ${getGroupTitle(channel)}`.toLowerCase() + : ''; + return displayName.includes(lowerQuery) || groupTitle.includes(lowerQuery); }); // Search API for public channels and users @@ -1047,7 +1051,7 @@ const ChatsContainer = () => { onSearchChange={(text) => setSearchQuery(text.toLowerCase())} sortByName={sortByName} onToggleSort={() => setSortByName(!sortByName)} - onNewGroup={isLoggedIn ? _handleNewGroup : undefined} + onNewGroup={isLoggedIn && bootstrapResult ? _handleNewGroup : undefined} searchError={searchError} /> From bce22f3c39fbf8996fecbe0775cea524c3b69dee Mon Sep 17 00:00:00 2001 From: feruzm Date: Fri, 9 Oct 2026 08:49:24 +0000 Subject: [PATCH 4/4] fix(chat): keep renames through list reloads, find named groups by member - A list reload that started before a rename no longer puts the old group name back. - Search finds a named group by any member's name, not only by its name. - The members list of a group or direct message never falls back to everyone this screen has looked up: until members load, a group shows the members the chat list already knew. --- .../chats/children/OnlineUsersModal.tsx | 2 +- .../chats/container/chatThreadContainer.tsx | 26 ++++++++++- .../chats/container/chatsContainer.tsx | 44 ++++++++++++++++--- src/screens/chats/utils/groupUtils.test.ts | 12 +++++ src/screens/chats/utils/groupUtils.ts | 4 ++ 5 files changed, 79 insertions(+), 9 deletions(-) diff --git a/src/screens/chats/children/OnlineUsersModal.tsx b/src/screens/chats/children/OnlineUsersModal.tsx index 3aaad40572..1baef792fc 100644 --- a/src/screens/chats/children/OnlineUsersModal.tsx +++ b/src/screens/chats/children/OnlineUsersModal.tsx @@ -47,7 +47,7 @@ export const OnlineUsersModal: React.FC = ({ const processedUserIds = new Set(); // Get all user IDs from userLookup (this should have all channel members) - const allUserIds = memberIds?.length ? memberIds : Object.keys(userLookup); + const allUserIds = memberIds ?? Object.keys(userLookup); console.log('[OnlineUsersModal] Total users in lookup:', allUserIds.length); console.log('[OnlineUsersModal] Channel members count:', channelMembers.length); diff --git a/src/screens/chats/container/chatThreadContainer.tsx b/src/screens/chats/container/chatThreadContainer.tsx index b3c2ac4010..3f04ff1f00 100644 --- a/src/screens/chats/container/chatThreadContainer.tsx +++ b/src/screens/chats/container/chatThreadContainer.tsx @@ -599,6 +599,30 @@ export const ChatThreadContainer: React.FC = ({ [], ); + // Who to list as members of a group or direct message. Until the member + // list loads, or if it fails, a group falls back to the members the chat + // list already knew, never to everyone this screen has looked up. + const conversationMemberIds = useMemo(() => { + if (channelMemberIds.length) { + return channelMemberIds; + } + if (!isGroup) { + return []; + } + const known = groupInfo.users.map((user: any) => user?.id).filter(Boolean); + return bootstrapUserId ? [bootstrapUserId, ...known] : known; + }, [channelMemberIds, isGroup, groupInfo.users, bootstrapUserId]); + + // Those members need records for the list to show them. + useEffect(() => { + if (isGroup && groupInfo.users.length) { + _mergeUserLookup((prev) => ({ + ...ensureMattermostUsersHaveHiveNames(groupInfo.users), + ...prev, + })); + } + }, [isGroup, groupInfo.users, _mergeUserLookup]); + const derivedCommunityIdentifier = useMemo( () => // A group's title is its name or its members, never a community. @@ -2411,7 +2435,7 @@ export const ChatThreadContainer: React.FC = ({ userLookup={userLookup} onlineUserIds={onlineUserIds} memberCount={memberCount || undefined} - memberIds={isGroup || isDM ? channelMemberIds : undefined} + memberIds={isGroup || isDM ? conversationMemberIds : undefined} onClose={() => setOnlineUsersModalVisible(false)} onUserPress={_showUserProfile} /> diff --git a/src/screens/chats/container/chatsContainer.tsx b/src/screens/chats/container/chatsContainer.tsx index 1c6f4a78c9..63cb67df9f 100644 --- a/src/screens/chats/container/chatsContainer.tsx +++ b/src/screens/chats/container/chatsContainer.tsx @@ -44,6 +44,7 @@ import { chatsStyles as styles } from '../styles/chats.styles'; import { safeExtractCommunityIdentifier } from '../utils/userLookupHelpers'; import { GROUP_RENAMED_EVENT, + getGroupMemberNames, getGroupTitle, isConversationChannel, isGroupChannel, @@ -79,6 +80,25 @@ const ChatsContainer = () => { const [sortByName, setSortByName] = useState(false); const userLookupRef = useRef>({}); + // Group renames seen here, with when, so a channel fetch that started + // before one cannot put the old name back. + const groupRenamesRef = useRef>(new Map()); + + const _rememberGroupRename = useCallback((channelId: string, name: string) => { + groupRenamesRef.current.set(channelId, { name, at: Date.now() }); + }, []); + + const _withNewerRenames = useCallback((list: any[], fetchStartedAt: number) => { + if (!groupRenamesRef.current.size) { + return list; + } + return list.map((channel) => { + const rename = groupRenamesRef.current.get(channel?.id); + return rename && rename.at >= fetchStartedAt + ? { ...channel, group_name: rename.name || undefined } + : channel; + }); + }, []); const searchTimeoutRef = useRef(null); const currentUserId = bootstrapResult?.user?.id; @@ -241,8 +261,12 @@ const ChatsContainer = () => { const session = await _ensureBootstrap(refresh); setBootstrapResult(session); + const fetchStartedAt = Date.now(); const channelResponse = await fetchMattermostChannels(); - const normalizedChannels = _normalizeChannels(channelResponse); + const normalizedChannels = _withNewerRenames( + _normalizeChannels(channelResponse), + fetchStartedAt, + ); setChannels(normalizedChannels); _seedDirectUsers(normalizedChannels); @@ -258,7 +282,7 @@ const ChatsContainer = () => { setIsRefreshing(false); } }, - [_ensureBootstrap, intl, isConnected, isLoggedIn, _resolveUserProfiles], + [_ensureBootstrap, intl, isConnected, isLoggedIn, _resolveUserProfiles, _withNewerRenames], ); // ============================================================================ @@ -455,10 +479,11 @@ const ChatsContainer = () => { payload: { channelId, currentName: channel?.group_name || '' }, }); if (typeof result?.name === 'string') { + _rememberGroupRename(channelId, result.name); _updateChannelState(channelId, { group_name: result.name || undefined }); } }, - [_getChannelId, _updateChannelState], + [_getChannelId, _rememberGroupRename, _updateChannelState], ); const _confirmChannelOptions = useCallback( @@ -740,14 +765,18 @@ const ChatsContainer = () => { return; } try { - const channelList = _normalizeChannels(await fetchMattermostChannels()); + const fetchStartedAt = Date.now(); + const channelList = _withNewerRenames( + _normalizeChannels(await fetchMattermostChannels()), + fetchStartedAt, + ); setChannels(channelList); const created = channelList.find((item) => _getChannelId(item) === channelId); _openChannel(created || { id: channelId, type: 'G' }); } catch (err) { _openChannel({ id: channelId, type: 'G' }); } - }, [_getChannelId, _normalizeChannels, _openChannel, currentAccount?.name]); + }, [_getChannelId, _normalizeChannels, _openChannel, _withNewerRenames, currentAccount?.name]); // ============================================================================ // Search @@ -775,7 +804,7 @@ const ChatsContainer = () => { const displayName = (channel?.display_name || channel?.name || '').toLowerCase(); // A group also matches its given name and its members' names. const groupTitle = isGroupChannel(channel) - ? `${channel?.group_name || ''} ${getGroupTitle(channel)}`.toLowerCase() + ? [channel?.group_name, ...getGroupMemberNames(channel)].join(' ').toLowerCase() : ''; return displayName.includes(lowerQuery) || groupTitle.includes(lowerQuery); }); @@ -975,12 +1004,13 @@ const ChatsContainer = () => { GROUP_RENAMED_EVENT, ({ channelId, name }: { channelId: string; name: string }) => { if (channelId) { + _rememberGroupRename(channelId, name); _updateChannelState(channelId, { group_name: name || undefined }); } }, ); return () => subscription.remove(); - }, [_updateChannelState]); + }, [_rememberGroupRename, _updateChannelState]); useEffect(() => { if (currentAccount) { diff --git a/src/screens/chats/utils/groupUtils.test.ts b/src/screens/chats/utils/groupUtils.test.ts index ad846c947a..f6825777c5 100644 --- a/src/screens/chats/utils/groupUtils.test.ts +++ b/src/screens/chats/utils/groupUtils.test.ts @@ -1,5 +1,6 @@ import { findMissingUserIds, + getGroupMemberNames, getGroupMembersTitle, getGroupTitle, getRenamedGroupName, @@ -37,6 +38,17 @@ describe('group channels', () => { expect(getGroupMembersTitle([], 'fallback')).toBe('fallback'); }); + it('lists every other member for search, beyond the ones in the title', () => { + const members = [user('alice'), user('bob'), user('carol'), user('dave')]; + expect(getGroupMemberNames({ group_name: 'Book club', groupUsers: members })).toEqual([ + 'alice', + 'bob', + 'carol', + 'dave', + ]); + expect(getGroupMemberNames({})).toEqual([]); + }); + it('counts a name as the server does, so an emoji is one character', () => { expect(groupNameLength(' 🙂🙂 ')).toBe(2); expect(groupNameLength('abc')).toBe(3); diff --git a/src/screens/chats/utils/groupUtils.ts b/src/screens/chats/utils/groupUtils.ts index a169122ebf..60e5b7ed10 100644 --- a/src/screens/chats/utils/groupUtils.ts +++ b/src/screens/chats/utils/groupUtils.ts @@ -22,6 +22,10 @@ export const groupNameLength = (name: string): number => Array.from(name.trim()) const userName = (user: any): string => getHiveUsernameFromMattermostUser(user) || user?.username || ''; +/** Every other member's name, for search. */ +export const getGroupMemberNames = (channel: any): string[] => + (channel?.groupUsers || []).map(userName).filter(Boolean); + /** * "alice, bob, carol +2". Mattermost's own display name for a group lists every * member's username, the viewer included, cut at 64 characters.