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..b20efe8245 --- /dev/null +++ b/src/components/chatGroupSheets/chatNewGroupSheet.tsx @@ -0,0 +1,264 @@ +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 [searchFailed, setSearchFailed] = 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); + setSearchFailed(false); + const timer = setTimeout(() => { + searchMattermostUsers(query) + .then((users) => { + if (!cancelled) { + setResults(Array.isArray(users) ? users : []); + } + }) + .catch(() => { + if (!cancelled) { + setResults([]); + setSearchFailed(true); + } + }) + .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}`} + + )) + ) : ( + + {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.', + })} + + )} + + )} + + {!!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..01d9885325 --- /dev/null +++ b/src/components/chatGroupSheets/chatReactorsSheet.tsx @@ -0,0 +1,135 @@ +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..f3b05a1499 100644 --- a/src/config/locales/en-US.json +++ b/src/config/locales/en-US.json @@ -712,7 +712,32 @@ "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_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.", + "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", + "group": "Group" }, "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..d8f8354ae7 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,8 +55,43 @@ 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 = ( + + {/* 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/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/OnlineUsersModal.tsx b/src/screens/chats/children/OnlineUsersModal.tsx index 468c86a17c..1baef792fc 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 ?? 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/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..3f04ff1f00 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(); @@ -179,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); @@ -230,6 +262,43 @@ 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[] }>( + () => ({ 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((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); + return () => { + cancelled = true; + }; + }, [isGroup, channelId]); const canPinUnpin = isDM || canModerate; // Bootstrap user ID extraction @@ -288,6 +357,14 @@ export const ChatThreadContainer: React.FC = ({ return; } + // A group was renamed: its header is its name. + if (channelType === 'G' && post?.type === 'system_header_change') { + 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 const pendingPostId = post.pending_post_id as string | undefined; const pendingMatch = @@ -383,7 +460,7 @@ export const ChatThreadContainer: React.FC = ({ .catch(console.error); } }, - [bootstrapUserId, _updateMentionState, channelId], + [bootstrapUserId, _updateMentionState, channelId, channelType], ), onMessageEdited: useCallback( (post: any) => { @@ -522,15 +599,42 @@ 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( () => - 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 () => { @@ -1787,6 +1891,15 @@ export const ChatThreadContainer: React.FC = ({ // Header title const headerTitle = useMemo(() => { + if (isGroup) { + return ( + groupInfo.name || + getGroupMembersTitle( + groupInfo.users, + channelName || intl.formatMessage({ id: 'chats.group', defaultMessage: 'Group' }), + ) + ); + } let title = ''; if (headerUser) { title = headerUser.display_name || headerUser.name || channelName || channelId; @@ -1794,7 +1907,28 @@ export const ChatThreadContainer: React.FC = ({ title = channelName || channelId; } return title; - }, [headerUser, channelName, channelId]); + }, [headerUser, channelName, channelId, isGroup, groupInfo, intl]); + + 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') { + groupRenamedLiveRef.current = true; + 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 +2096,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 +2166,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 +2360,7 @@ export const ChatThreadContainer: React.FC = ({ onBack={handleBack} onMembersPress={() => setOnlineUsersModalVisible(true)} onPinnedPress={() => setPinnedMessagesModalVisible(true)} + onOptionsPress={isGroup ? _showGroupOptions : undefined} isDM={isDM} /> @@ -2259,6 +2435,7 @@ export const ChatThreadContainer: React.FC = ({ userLookup={userLookup} onlineUserIds={onlineUserIds} memberCount={memberCount || 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 c5f699e5a7..63cb67df9f 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,13 @@ import { import { Header } from '../../../components'; import { chatsStyles as styles } from '../styles/chats.styles'; import { safeExtractCommunityIdentifier } from '../utils/userLookupHelpers'; +import { + GROUP_RENAMED_EVENT, + getGroupMemberNames, + getGroupTitle, + isConversationChannel, + isGroupChannel, +} from '../utils/groupUtils'; import { StatusPill } from '../children/StatusPill'; import { SearchBar } from '../children/SearchBar'; import { ChannelListItem } from '../children/ChannelListItem'; @@ -66,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; @@ -228,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); @@ -245,7 +282,7 @@ const ChatsContainer = () => { setIsRefreshing(false); } }, - [_ensureBootstrap, intl, isConnected, isLoggedIn, _resolveUserProfiles], + [_ensureBootstrap, intl, isConnected, isLoggedIn, _resolveUserProfiles, _withNewerRenames], ); // ============================================================================ @@ -432,12 +469,32 @@ 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') { + _rememberGroupRename(channelId, result.name); + _updateChannelState(channelId, { group_name: result.name || undefined }); + } + }, + [_getChannelId, _rememberGroupRename, _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 +507,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 +725,59 @@ 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 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, + 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 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, _withNewerRenames, currentAccount?.name]); + // ============================================================================ // Search // ============================================================================ @@ -687,7 +802,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, ...getGroupMemberNames(channel)].join(' ').toLowerCase() + : ''; + return displayName.includes(lowerQuery) || groupTitle.includes(lowerQuery); }); // Search API for public channels and users @@ -778,6 +897,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 +987,31 @@ 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) { + _rememberGroupRename(channelId, name); + _updateChannelState(channelId, { group_name: name || undefined }); + } + }, + ); + return () => subscription.remove(); + }, [_rememberGroupRename, _updateChannelState]); + useEffect(() => { if (currentAccount) { _loadChannels(true); @@ -966,6 +1081,7 @@ const ChatsContainer = () => { onSearchChange={(text) => setSearchQuery(text.toLowerCase())} sortByName={sortByName} onToggleSort={() => setSortByName(!sortByName)} + onNewGroup={isLoggedIn && bootstrapResult ? _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..7234b9c868 100644 --- a/src/screens/chats/styles/chats.styles.ts +++ b/src/screens/chats/styles/chats.styles.ts @@ -80,6 +80,34 @@ export const chatsStyles = EStyleSheet.create({ marginRight: 12, borderWidth: 0, }, + groupAvatar: { + width: 40, + height: 40, + marginRight: 12, + }, + groupAvatarBackSlot: { + position: 'absolute', + top: 0, + left: 0, + }, + groupAvatarFrontSlot: { + position: 'absolute', + top: 12, + left: 12, + }, + groupAvatarImage: { + width: 28, + height: 28, + borderRadius: 14, + borderWidth: 0, + }, + groupAvatarFrontImage: { + 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..f6825777c5 --- /dev/null +++ b/src/screens/chats/utils/groupUtils.test.ts @@ -0,0 +1,95 @@ +import { + findMissingUserIds, + getGroupMemberNames, + 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('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); + }); + + 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..60e5b7ed10 --- /dev/null +++ b/src/screens/chats/utils/groupUtils.ts @@ -0,0 +1,107 @@ +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 || ''; + +/** 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. + */ +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() : '';