From f51602b3fe50af7b9d1c758e7a49b1d8326e8552 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Thu, 16 Apr 2026 09:55:30 -0700 Subject: [PATCH] Support group chats across other surfaces (#10266) --- src/components/AvatarBubbles.tsx | 20 +- .../PostControls/ShareMenu/RecentChats.tsx | 78 ++++--- .../dialogs/SearchablePeopleList.tsx | 217 +++++++++++++++--- src/components/dms/dialogs/NewChatDialog.tsx | 17 +- .../dms/dialogs/ShareViaChatDialog.tsx | 15 +- src/components/dms/dialogs/TextInput.tsx | 1 - src/components/dms/dialogs/TextInput.web.tsx | 1 - src/components/dms/util.ts | 101 +++++++- .../Messages/components/ChatListItem.tsx | 114 +++------ .../Messages/components/RequestListItem.tsx | 27 ++- .../queries/messages/list-conversations.tsx | 6 +- 11 files changed, 436 insertions(+), 161 deletions(-) delete mode 100644 src/components/dms/dialogs/TextInput.tsx delete mode 100644 src/components/dms/dialogs/TextInput.web.tsx diff --git a/src/components/AvatarBubbles.tsx b/src/components/AvatarBubbles.tsx index 2dd2f3b203..44fd7f4e90 100644 --- a/src/components/AvatarBubbles.tsx +++ b/src/components/AvatarBubbles.tsx @@ -18,7 +18,7 @@ import type * as bsky from '#/types/bsky' type Props = { animate?: boolean profiles: bsky.profile.AnyProfileView[] - size?: 'small' | 'medium' | 'large' + size?: 'small' | 'medium' | 'large' | number } export function AvatarBubbles({ @@ -28,8 +28,22 @@ export function AvatarBubbles({ }: Props) { const {currentAccount} = useSession() const profiles = allProfiles.filter(p => p.did !== currentAccount?.did) - const containerSize = size === 'small' ? 40 : size === 'medium' ? 56 : 120 - const scale = size === 'small' ? 40 / 120 : size === 'medium' ? 56 / 120 : 1 + const containerSize = + typeof size === 'number' + ? size + : size === 'small' + ? 40 + : size === 'medium' + ? 56 + : 120 + const scale = + typeof size === 'number' + ? size / 120 + : size === 'small' + ? 40 / 120 + : size === 'medium' + ? 56 / 120 + : 1 const marginOffset = size === 'small' || size === 'medium' ? -2 : 0 const initialValue = animate ? 0 : 1 diff --git a/src/components/PostControls/ShareMenu/RecentChats.tsx b/src/components/PostControls/ShareMenu/RecentChats.tsx index 24fcc87b3a..5e3cecf077 100644 --- a/src/components/PostControls/ShareMenu/RecentChats.tsx +++ b/src/components/PostControls/ShareMenu/RecentChats.tsx @@ -6,21 +6,21 @@ import {Trans} from '@lingui/react/macro' import {useNavigation} from '@react-navigation/native' import {isBlockedOrBlocking, isMuted} from '#/lib/moderation/blocked-and-muted' +import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' import {type NavigationProp} from '#/lib/routes/types' -import {sanitizeDisplayName} from '#/lib/strings/display-names' -import {sanitizeHandle} from '#/lib/strings/handles' import {useProfileShadow} from '#/state/cache/profile-shadow' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useListConvosQuery} from '#/state/queries/messages/list-conversations' import {useSession} from '#/state/session' import {UserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, tokens, useTheme} from '#/alf' +import {AvatarBubbles} from '#/components/AvatarBubbles' import {Button} from '#/components/Button' import {useDialogContext} from '#/components/Dialog' +import {type ConvoWithDetails, parseConvoView} from '#/components/dms/util' import {ProfileBadges} from '#/components/ProfileBadges' import {Text} from '#/components/Typography' import {useAnalytics} from '#/analytics' -import type * as bsky from '#/types/bsky' export function RecentChats({ postUri, @@ -60,23 +60,24 @@ export function RecentChats({ showsHorizontalScrollIndicator={false} nestedScrollEnabled> {convos && convos.length > 0 ? ( - convos.map(convo => { - const otherMember = convo.members.find( - member => member.did !== currentAccount?.did, - ) + convos.map(c => { + const convo = parseConvoView(c, currentAccount?.did) + + if (!convo) return null if ( - !otherMember || - otherMember.handle === 'missing.invalid' || - convo.muted - ) + (convo.kind === 'direct' && + convo.primaryMember.handle === 'missing.invalid') || + convo.view.muted + ) { return null + } return ( onSelectChat(convo.id)} + key={convo.view.id} + convo={convo} + onPress={() => onSelectChat(convo.view.id)} moderationOpts={moderationOpts} /> ) @@ -99,26 +100,33 @@ export function RecentChats({ const WIDTH = 80 function RecentChatItem({ - profile: profileUnshadowed, onPress, moderationOpts, + convo, }: { - profile: bsky.profile.AnyProfileView onPress: () => void moderationOpts: ModerationOpts + convo: ConvoWithDetails }) { const {_} = useLingui() const t = useTheme() - const profile = useProfileShadow(profileUnshadowed) + const primaryProfile = useProfileShadow(convo.primaryMember) - const moderation = moderateProfile(profile, moderationOpts) - const name = sanitizeDisplayName( - profile.displayName || sanitizeHandle(profile.handle), - moderation.ui('displayName'), - ) + const moderation = moderateProfile(primaryProfile, moderationOpts) + const name = + convo.kind === 'group' + ? convo.details.name + : createSanitizedDisplayName( + primaryProfile, + true, + moderation.ui('displayName'), + ) - if (isBlockedOrBlocking(profile) || isMuted(profile)) { + if ( + convo.kind === 'direct' && + (isBlockedOrBlocking(primaryProfile) || isMuted(primaryProfile)) + ) { return null } @@ -133,12 +141,16 @@ function RecentChatItem({ a.justify_start, a.align_center, ]}> - + {convo.kind === 'group' ? ( + + ) : ( + + )} {name} - + {convo.kind === 'direct' && ( + + )} ) diff --git a/src/components/dialogs/SearchablePeopleList.tsx b/src/components/dialogs/SearchablePeopleList.tsx index 9471d48c04..83b59580f3 100644 --- a/src/components/dialogs/SearchablePeopleList.tsx +++ b/src/components/dialogs/SearchablePeopleList.tsx @@ -8,11 +8,9 @@ import { } from 'react' import {TextInput, View} from 'react-native' import {moderateProfile, type ModerationOpts} from '@atproto/api' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' -import {Trans} from '@lingui/react/macro' +import {Plural, Trans, useLingui} from '@lingui/react/macro' -import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' import {sanitizeHandle} from '#/lib/strings/handles' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' @@ -23,7 +21,11 @@ import {type ListMethods} from '#/view/com/util/List' import {android, atoms as a, native, useTheme, web} from '#/alf' import {Button, ButtonIcon} from '#/components/Button' import * as Dialog from '#/components/Dialog' -import {canBeMessaged} from '#/components/dms/util' +import { + canBeMessaged, + type ConvoWithDetails, + parseConvoView, +} from '#/components/dms/util' import {useInteractionState} from '#/components/hooks/useInteractionState' import {MagnifyingGlass_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass' import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' @@ -31,6 +33,9 @@ import * as ProfileCard from '#/components/ProfileCard' import {Text} from '#/components/Typography' import {IS_WEB} from '#/env' import type * as bsky from '#/types/bsky' +import {AvatarBubbles} from '../AvatarBubbles' +import {Error} from '../Error' +import {ProfileBadges} from '../ProfileBadges' export type ProfileItem = { type: 'profile' @@ -38,6 +43,12 @@ export type ProfileItem = { profile: bsky.profile.AnyProfileView } +type ExistingChatItem = { + type: 'existingChat' + key: string + convo: ConvoWithDetails +} + type EmptyItem = { type: 'empty' key: string @@ -54,7 +65,12 @@ type ErrorItem = { key: string } -type Item = ProfileItem | EmptyItem | PlaceholderItem | ErrorItem +type Item = + | ProfileItem + | ExistingChatItem + | EmptyItem + | PlaceholderItem + | ErrorItem export function SearchablePeopleList({ title, @@ -72,12 +88,14 @@ export function SearchablePeopleList({ onSelectChat?: undefined } | { - onSelectChat: (did: string) => void + onSelectChat: ( + chat: {kind: 'user'; did: string} | {kind: 'convo'; id: string}, + ) => void renderProfileCard?: undefined } )) { const t = useTheme() - const {_} = useLingui() + const {t: l} = useLingui() const moderationOpts = useModerationOpts() const control = Dialog.useDialogContext() const [headerHeight, setHeaderHeight] = useState(0) @@ -105,7 +123,7 @@ export function SearchablePeopleList({ _items.push({ type: 'empty', key: 'empty', - message: _(msg`We're having network issues, try again`), + message: l`We're having network issues, try again`, }) } else if (searchText.length) { if (results?.length) { @@ -139,20 +157,27 @@ export function SearchablePeopleList({ const usedDids = new Set() for (const page of convos.pages) { - for (const convo of page.convos) { - const profiles = convo.members.filter( - m => m.did !== currentAccount?.did, - ) + for (const convoView of page.convos) { + const convo = parseConvoView(convoView, currentAccount?.did) - for (const profile of profiles) { - if (usedDids.has(profile.did)) continue + if (!convo) continue - usedDids.add(profile.did) + if (convo.kind === 'group') { + _items.push({ + type: 'existingChat', + key: convo.view.id, + convo, + }) + } else { + if (convo.primaryMember.handle === 'missing.invalid') continue + if (usedDids.has(convo.primaryMember.did)) continue + + usedDids.add(convo.primaryMember.did) _items.push({ - type: 'profile', - key: profile.did, - profile, + type: 'existingChat', + key: convo.view.id, + convo: convo, }) } } @@ -209,7 +234,7 @@ export function SearchablePeopleList({ return _items }, [ - _, + l, searchText, results, isError, @@ -221,12 +246,27 @@ export function SearchablePeopleList({ ]) if (searchText && !isFetching && !items.length && !isError) { - items.push({type: 'empty', key: 'empty', message: _(msg`No results`)}) + items.push({type: 'empty', key: 'empty', message: l`No results`}) } const renderItems = useCallback( ({item}: {item: Item}) => { switch (item.type) { + case 'existingChat': { + if (renderProfileCard) { + // should be unreachable + return null + } else { + return ( + onSelectChat({kind: 'convo', id})} + /> + ) + } + } case 'profile': { if (renderProfileCard) { return {renderProfileCard(item)} @@ -236,7 +276,7 @@ export function SearchablePeopleList({ key={item.key} profile={item.profile} moderationOpts={moderationOpts!} - onPress={onSelectChat} + onPress={did => onSelectChat({kind: 'user', did})} /> ) } @@ -247,11 +287,14 @@ export function SearchablePeopleList({ case 'empty': { return } + case 'error': { + return + } default: return null } }, - [moderationOpts, onSelectChat, renderProfileCard], + [moderationOpts, onSelectChat, renderProfileCard, l], ) useLayoutEffect(() => { @@ -293,7 +336,7 @@ export function SearchablePeopleList({ {IS_WEB ? ( + ) +} + function ProfileCardSkeleton() { const t = useTheme() @@ -488,7 +639,7 @@ function SearchInput({ inputRef: React.RefObject }) { const t = useTheme() - const {_} = useLingui() + const {t: l} = useLingui() const { state: hovered, onIn: onMouseEnter, @@ -512,7 +663,7 @@ function SearchInput({ ) diff --git a/src/components/dms/dialogs/NewChatDialog.tsx b/src/components/dms/dialogs/NewChatDialog.tsx index f0861baf45..6686a6ffe2 100644 --- a/src/components/dms/dialogs/NewChatDialog.tsx +++ b/src/components/dms/dialogs/NewChatDialog.tsx @@ -77,6 +77,15 @@ export function NewChat({ [control, createGroupChat], ) + const onSelectExistingChat = useCallback( + (chatId: string) => { + control.close(() => { + onNewChat(chatId) + }) + }, + [control, onNewChat], + ) + const onPress = useCallback(() => { control.open() }, [control]) @@ -112,7 +121,13 @@ export function NewChat({ ) : ( { + if (chat.kind === 'user') { + onCreateChat(chat.did) + } else { + onSelectExistingChat(chat.id) + } + }} sortByMessageDeclaration /> )} diff --git a/src/components/dms/dialogs/ShareViaChatDialog.tsx b/src/components/dms/dialogs/ShareViaChatDialog.tsx index faf3545519..30cd80862d 100644 --- a/src/components/dms/dialogs/ShareViaChatDialog.tsx +++ b/src/components/dms/dialogs/ShareViaChatDialog.tsx @@ -53,6 +53,13 @@ function SendViaChatDialogInner({ }, }) + const onSelectExistingChat = useCallback( + (chatId: string) => { + control.close(() => onSelectChat(chatId)) + }, + [control, onSelectChat], + ) + const onCreateChat = useCallback( (did: string) => { control.close(() => createChat([did])) @@ -63,7 +70,13 @@ function SendViaChatDialogInner({ return ( { + if (chat.kind === 'user') { + onCreateChat(chat.did) + } else { + onSelectExistingChat(chat.id) + } + }} showRecentConvos sortByMessageDeclaration /> diff --git a/src/components/dms/dialogs/TextInput.tsx b/src/components/dms/dialogs/TextInput.tsx deleted file mode 100644 index b4e77e3e07..0000000000 --- a/src/components/dms/dialogs/TextInput.tsx +++ /dev/null @@ -1 +0,0 @@ -export {BottomSheetTextInput as TextInput} from '@discord/bottom-sheet/src' diff --git a/src/components/dms/dialogs/TextInput.web.tsx b/src/components/dms/dialogs/TextInput.web.tsx deleted file mode 100644 index 5371a534f1..0000000000 --- a/src/components/dms/dialogs/TextInput.web.tsx +++ /dev/null @@ -1 +0,0 @@ -export {TextInput} from 'react-native' diff --git a/src/components/dms/util.ts b/src/components/dms/util.ts index 2bcc9c3bdf..491023cf2f 100644 --- a/src/components/dms/util.ts +++ b/src/components/dms/util.ts @@ -1,7 +1,8 @@ -import {type ChatBskyConvoDefs} from '@atproto/api' +import {type $Typed, ChatBskyActorDefs, ChatBskyConvoDefs} from '@atproto/api' import {EMOJI_REACTION_LIMIT} from '#/lib/constants' -import type * as bsky from '#/types/bsky' +import {logger} from '#/logger' +import * as bsky from '#/types/bsky' export function canBeMessaged(profile: bsky.profile.AnyProfileView) { switch (profile.associated?.chat?.allowIncoming) { @@ -54,3 +55,99 @@ export function hasReachedReactionLimit( ) return myReactions.length >= EMOJI_REACTION_LIMIT } + +type GroupConvoMember = ChatBskyActorDefs.ProfileViewBasic & { + // can be missing if account deleted + kind?: $Typed +} + +type DirectConvoMember = ChatBskyActorDefs.ProfileViewBasic & { + kind: $Typed +} + +export type ConvoWithDetails = {view: ChatBskyConvoDefs.ConvoView} & ( + | { + kind: 'group' + details: ChatBskyConvoDefs.GroupConvo + primaryMember: GroupConvoMember // the owner + members: Array + } + | { + kind: 'direct' + details: ChatBskyConvoDefs.DirectConvo + primaryMember: DirectConvoMember // the other user + members: Array + } +) + +/** + * Converts a raw convoView into something easier to use (i.e. extracts chat owner) + * and enforces the correct type for convo members. + */ +export function parseConvoView( + convoView: ChatBskyConvoDefs.ConvoView, + ownDid: string | undefined, +): ConvoWithDetails | null { + if ( + bsky.dangerousIsType( + convoView.kind, + ChatBskyConvoDefs.isGroupConvo, + ) + ) { + let owner: GroupConvoMember | undefined = undefined + + for (const member of convoView.members) { + if ( + bsky.dangerousIsType( + member.kind, + ChatBskyActorDefs.isGroupConvoMember, + ) + ) { + if (member.kind.role === 'owner') { + // have to do a type assertion here + // this works: {...member, kind: member.kind} + // however that's creating a new object for no good reason + owner = member as GroupConvoMember + } + } else { + throw new Error( + 'Expected a GroupConvoMember, got an unknown kind of member', + ) + } + } + + if (!owner) { + throw new Error('No owner found in group convo') + } + + return { + view: convoView, + kind: 'group', + details: convoView.kind, + primaryMember: owner, + members: convoView.members as Array, + } + } else if ( + bsky.dangerousIsType( + convoView.kind, + ChatBskyConvoDefs.isDirectConvo, + ) + ) { + const otherUser = convoView.members.find(m => m.did !== ownDid) + + if (!otherUser) { + throw new Error('No other user found in direct convo') + } + + return { + view: convoView, + kind: 'direct', + details: convoView.kind, + primaryMember: otherUser as DirectConvoMember, + members: convoView.members as Array, + } + } else { + logger.warn('Unknown convo kind: ' + JSON.stringify(convoView.kind)) + return null + } +} diff --git a/src/screens/Messages/components/ChatListItem.tsx b/src/screens/Messages/components/ChatListItem.tsx index 68e29620da..1f51656b1c 100644 --- a/src/screens/Messages/components/ChatListItem.tsx +++ b/src/screens/Messages/components/ChatListItem.tsx @@ -2,7 +2,6 @@ import {useCallback, useMemo, useState} from 'react' import {type GestureResponderEvent, View} from 'react-native' import { AppBskyEmbedRecord, - ChatBskyActorDefs, ChatBskyConvoDefs, moderateProfile, type ModerationDecision, @@ -38,6 +37,7 @@ import {AvatarBubbles} from '#/components/AvatarBubbles' import {useDialogControl} from '#/components/Dialog' import {ConvoMenu} from '#/components/dms/ConvoMenu' import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt' +import {type ConvoWithDetails, parseConvoView} from '#/components/dms/util' import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/Bell2' import {Envelope_Open_Stroke2_Corner0_Rounded as EnvelopeOpen} from '#/components/icons/EnveopeOpen' import {Trash_Stroke2_Corner0_Rounded} from '#/components/icons/Trash' @@ -49,7 +49,7 @@ import {ProfileBadges} from '#/components/ProfileBadges' import {Text} from '#/components/Typography' import {useAnalytics} from '#/analytics' import {IS_NATIVE} from '#/env' -import * as bsky from '#/types/bsky' +import type * as bsky from '#/types/bsky' export const ChatListItemPortal = createPortalGroup() @@ -60,7 +60,7 @@ export const ChatListItemPortal = createPortalGroup() */ export function ChatListItem({ - convo, + convo: convoView, showMenu = true, children, }: { @@ -75,83 +75,47 @@ export function ChatListItem({ return null } - if ( - bsky.dangerousIsType( - convo.kind, - ChatBskyConvoDefs.isGroupConvo, - ) - ) { - const owner = convo.members.find(r => { - if ( - bsky.dangerousIsType( - r.kind, - ChatBskyActorDefs.isGroupConvoMember, - ) - ) { - return r.kind.role === 'owner' - } else { - throw new Error( - 'Expected a GroupConvoMember, got an unknown kind of member', - ) - } - }) - if (!owner) { - // TODO: Determine if this is the right thing to do here. Throwing here so that - // if it turns out to be wrong it'll be very visible - throw new Error('Could not find the group owner in the group members') + const convo = parseConvoView(convoView, currentAccount?.did) + + switch (convo?.kind) { + case 'direct': { + return ( + + {children} + + ) } - - return ( - - ) - } else if ( - bsky.dangerousIsType( - convo.kind, - ChatBskyConvoDefs.isDirectConvo, - ) - ) { - const otherMember = convo.members.find( - member => member.did !== currentAccount?.did, - ) - - if (!otherMember) { + case 'group': { + return ( + + ) + } + default: { return null } - return ( - - {children} - - ) - } else { - return null } } function DirectChatItem({ convo, - profile: profileUnshadowed, moderationOpts, showMenu, children, }: { - convo: ChatBskyConvoDefs.ConvoView - profile: bsky.profile.AnyProfileView + convo: Extract moderationOpts: ModerationOpts showMenu?: boolean children?: React.ReactNode }) { const {t: l} = useLingui() - const profile = useProfileShadow(profileUnshadowed) + const profile = useProfileShadow(convo.primaryMember) const moderation = useMemo( () => moderateProfile(profile, moderationOpts), @@ -165,7 +129,7 @@ function DirectChatItem({ return ( moderationOpts: ModerationOpts showMenu?: boolean children?: React.ReactNode }) { const {t: l} = useLingui() - const groupOwner = useProfileShadow(groupOwnerUnshadowed) + const groupOwner = useProfileShadow(convo.primaryMember) const moderation = useMemo( () => moderateProfile(groupOwner, moderationOpts), [groupOwner, moderationOpts], ) - const chatName = groupInfo.name ?? l`${groupOwner.handle}'s group chat` + const chatName = convo.details.name return ( } title={chatName} accessibilityHint={l`Go to the group chat named "${chatName}"`} @@ -507,7 +469,7 @@ function BaseChatItem({ label={title} accessibilityHint={accessibilityHint} accessibilityActions={ - IS_NATIVE + showMenu && IS_NATIVE ? [ { name: 'magicTap', @@ -521,8 +483,8 @@ function BaseChatItem({ : undefined } onPress={onPress} - onLongPress={IS_NATIVE ? onLongPress : undefined} - onAccessibilityAction={onLongPress}> + onLongPress={showMenu && IS_NATIVE ? onLongPress : undefined} + onAccessibilityAction={showMenu ? onLongPress : undefined}> {({hovered, pressed, focused}) => ( member.did !== currentAccount?.did, - ) + const convo = parseConvoView(convoView, currentAccount?.did) - if (!otherUser || !moderationOpts) { + if (!convo || !moderationOpts) { return null } - const isDeletedAccount = otherUser.handle === 'missing.invalid' + const isDeletedAccount = convo.primaryMember.handle === 'missing.invalid' return ( - + {!isDeletedAccount ? ( <> - + ) : ( <> - + )} diff --git a/src/state/queries/messages/list-conversations.tsx b/src/state/queries/messages/list-conversations.tsx index c5457d1cb8..4c21bbbfeb 100644 --- a/src/state/queries/messages/list-conversations.tsx +++ b/src/state/queries/messages/list-conversations.tsx @@ -24,17 +24,20 @@ export const RQKEY_ROOT = 'convo-list' export const RQKEY = ( status: 'accepted' | 'request' | 'all', readState: 'all' | 'unread' = 'all', -) => [RQKEY_ROOT, status, readState] + kind: 'all' | 'group' | 'direct' = 'all', +) => [RQKEY_ROOT, status, readState, kind] type RQPageParam = string | undefined export function useListConvosQuery({ enabled, status, readState = 'all', + kind = 'all', }: { enabled?: boolean status?: 'request' | 'accepted' readState?: 'all' | 'unread' + kind?: 'all' | 'group' | 'direct' } = {}) { const agent = useAgent() @@ -47,6 +50,7 @@ export function useListConvosQuery({ limit: 20, cursor: pageParam, readState: readState === 'unread' ? 'unread' : undefined, + kind: kind === 'all' ? undefined : kind, status, }, {headers: DM_SERVICE_HEADERS},