From 2390a02a0f76f9522ff83b5b7b2396816071e55b Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 10 Apr 2026 12:50:31 -0700 Subject: [PATCH] Support groups in ChatListItem/MessageListHeader (#10229) --- src/components/AvatarBubbles.tsx | 12 +- src/components/dms/MessagesListHeader.tsx | 148 +++---- src/screens/Messages/Conversation.tsx | 6 +- .../Messages/components/ChatListItem.tsx | 360 ++++++++++++------ src/state/messages/convo/agent.ts | 46 +++ src/state/messages/convo/types.ts | 24 ++ 6 files changed, 404 insertions(+), 192 deletions(-) diff --git a/src/components/AvatarBubbles.tsx b/src/components/AvatarBubbles.tsx index ab9af42584..dc975fee0f 100644 --- a/src/components/AvatarBubbles.tsx +++ b/src/components/AvatarBubbles.tsx @@ -1,5 +1,6 @@ import {type StyleProp, View, type ViewStyle} from 'react-native' +import {useSession} from '#/state/session' import {UserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, useTheme} from '#/alf' import {Person_Filled_Corner2_Rounded as PersonIcon} from '#/components/icons/Person' @@ -10,10 +11,9 @@ type Props = { size?: 'small' | 'medium' | 'large' } -/** - * TODO This is just layout for now. - */ -export function AvatarBubbles({profiles, size = 'large'}: Props) { +export function AvatarBubbles({profiles: allProfiles, size = 'large'}: 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 marginOffset = size === 'small' || size === 'medium' ? -2 : 0 @@ -21,14 +21,14 @@ export function AvatarBubbles({profiles, size = 'large'}: Props) { let avatars = ( <> 0 ? profiles[0] : undefined} + profile={profiles[0] ?? allProfiles[0]} size={76} x={-2} y={-2} style={[a.z_20]} /> = 1 ? profiles[1] : undefined} + profile={profiles[1]} size={76} x={42} y={42} diff --git a/src/components/dms/MessagesListHeader.tsx b/src/components/dms/MessagesListHeader.tsx index 3f7694a342..9178652457 100644 --- a/src/components/dms/MessagesListHeader.tsx +++ b/src/components/dms/MessagesListHeader.tsx @@ -5,18 +5,19 @@ import { type ModerationCause, type ModerationDecision, } from '@atproto/api' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' +import {useLingui} from '@lingui/react/macro' import {makeProfileLink} from '#/lib/routes/links' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {type Shadow} from '#/state/cache/profile-shadow' import {isConvoActive, useConvo} from '#/state/messages/convo' import {type ConvoItem} from '#/state/messages/convo/types' +import {useSession} from '#/state/session' import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' -import {atoms as a, useTheme, web} from '#/alf' +import {atoms as a, useTheme} from '#/alf' +import {AvatarBubbles} from '#/components/AvatarBubbles' import {ConvoMenu} from '#/components/dms/ConvoMenu' -import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/Bell2' +import {Bell2Off_Filled_Corner0_Rounded as BellOffIcon} from '#/components/icons/Bell2' import * as Layout from '#/components/Layout' import {Link} from '#/components/Link' import {PostAlerts} from '#/components/moderation/PostAlerts' @@ -34,6 +35,7 @@ export function MessagesListHeader({ moderation?: ModerationDecision }) { const t = useTheme() + const convoState = useConvo() const blockInfo = useMemo(() => { if (!moderation) return @@ -78,13 +80,15 @@ export function MessagesListHeader({ a.mt_xs, ]} /> - + {!convoState.isGroup?.() && ( + + )} @@ -108,22 +112,28 @@ function HeaderReady({ userBlock?: ModerationCause } }) { - const {_} = useLingui() + const {t: l} = useLingui() const t = useTheme() const convoState = useConvo() + const {currentAccount} = useSession() + + const groupInfo = convoState.getGroupInfo?.() + const isGroupChat = groupInfo != null const isDeletedAccount = profile?.handle === 'missing.invalid' - const displayName = isDeletedAccount - ? _(msg`Deleted Account`) - : sanitizeDisplayName( - profile.displayName || profile.handle, - moderation.ui('displayName'), - ) + const displayName = isGroupChat + ? (groupInfo.name ?? l`${profile.handle}'s group chat`) + : isDeletedAccount + ? l`Deleted Account` + : sanitizeDisplayName( + profile.displayName || profile.handle, + moderation.ui('displayName'), + ) - // @ts-ignore findLast is polyfilled - esb const latestMessageFromOther = convoState.items.findLast( (item: ConvoItem) => - item.type === 'message' && item.message.sender.did === profile.did, + item.type === 'message' && + item.message.sender.did !== currentAccount?.did, ) const latestReportableMessage = @@ -134,54 +144,58 @@ function HeaderReady({ return ( - - - - - - {displayName} - - - - {!isDeletedAccount && ( - - @{profile.handle} - {convoState.convo?.muted && ( - <> - {' '} - ·{' '} - - - )} - - )} + {isGroupChat ? ( + + + + {displayName} + - + ) : ( + + + + + + {displayName} + + + + {!isDeletedAccount && ( + + @{profile.handle} + {convoState.convo?.muted && ( + <> + {' '} + ·{' '} + + + )} + + )} + + + )} diff --git a/src/screens/Messages/Conversation.tsx b/src/screens/Messages/Conversation.tsx index 509907b331..9582386a96 100644 --- a/src/screens/Messages/Conversation.tsx +++ b/src/screens/Messages/Conversation.tsx @@ -101,7 +101,7 @@ function Inner() { const moderationOpts = useModerationOpts() const {data: recipientUnshadowed} = useProfileQuery({ - did: convoState.recipients?.[0].did, + did: convoState.getPrimaryMember?.()?.did, }) const recipient = useMaybeProfileShadow(recipientUnshadowed) @@ -135,7 +135,7 @@ function Inner() { <> {moderation ? ( - + ) : ( )} @@ -156,7 +156,7 @@ function Inner() { {isFocused && IS_WEB && } {!readyToShow && (moderation ? ( - + ) : ( ))} diff --git a/src/screens/Messages/components/ChatListItem.tsx b/src/screens/Messages/components/ChatListItem.tsx index a57450e637..7d49071e15 100644 --- a/src/screens/Messages/components/ChatListItem.tsx +++ b/src/screens/Messages/components/ChatListItem.tsx @@ -1,36 +1,39 @@ -import {memo, useCallback, useMemo, useState} from 'react' +import {useCallback, useMemo, useState} from 'react' import {type GestureResponderEvent, View} from 'react-native' import { AppBskyEmbedRecord, ChatBskyConvoDefs, moderateProfile, + type ModerationDecision, type ModerationOpts, } from '@atproto/api' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' +import {useLingui} from '@lingui/react/macro' import {useQueryClient} from '@tanstack/react-query' import {GestureActionView} from '#/lib/custom-animations/GestureActionView' import {useHaptics} from '#/lib/haptics' +import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' import {decrementBadgeCount} from '#/lib/notifications/notifications' import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {sanitizeHandle} from '#/lib/strings/handles' import { postUriToRelativePath, toBskyAppUrl, toShortUrl, } from '#/lib/strings/url-helpers' -import {useProfileShadow} from '#/state/cache/profile-shadow' +import {type Shadow, useProfileShadow} from '#/state/cache/profile-shadow' import {useModerationOpts} from '#/state/preferences/moderation-opts' import { precacheConvoQuery, useMarkAsReadMutation, } from '#/state/queries/messages/conversation' -import {precacheProfile} from '#/state/queries/profile' +import {unstableCacheProfileView} from '#/state/queries/profile' import {useSession} from '#/state/session' import {TimeElapsed} from '#/view/com/util/TimeElapsed' import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' import * as tokens from '#/alf/tokens' +import {AvatarBubbles} from '#/components/AvatarBubbles' import {useDialogControl} from '#/components/Dialog' import {ConvoMenu} from '#/components/dms/ConvoMenu' import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt' @@ -45,11 +48,17 @@ import {ProfileBadges} from '#/components/ProfileBadges' import {Text} from '#/components/Typography' import {useAnalytics} from '#/analytics' import {IS_NATIVE} from '#/env' -import type * as bsky from '#/types/bsky' +import * as bsky from '#/types/bsky' export const ChatListItemPortal = createPortalGroup() -export let ChatListItem = ({ +/** + * IMPORTANT NOTE: THIS IS CURRENTLY JANKY AF AND PROBABLY BROKEN, JUST WANTED TO ADD GROUPCHAT SUPPPORT + * + * TAKE A SECOND PASS PLEASE -sfn + */ + +export function ChatListItem({ convo, showMenu = true, children, @@ -57,32 +66,61 @@ export let ChatListItem = ({ convo: ChatBskyConvoDefs.ConvoView showMenu?: boolean children?: React.ReactNode -}): React.ReactNode => { +}) { const {currentAccount} = useSession() const moderationOpts = useModerationOpts() - const otherUser = convo.members.find( - member => member.did !== currentAccount?.did, - ) - - if (!otherUser || !moderationOpts) { + if (!moderationOpts) { return null } - return ( - - {children} - - ) + switch (convo.kind) { + case 'group': { + const groupInfo = convo.kindData + // TODO: members are missing the role property - find out if intentional + // const owner = convo.members.find(member => member.role === 'owner') + const owner = convo.members[0] // owner will always be the first member + if ( + !bsky.dangerousIsType( + groupInfo, + ChatBskyConvoDefs.isGroupConvoData, + ) || + !owner + ) { + return null + } + return ( + + ) + } + case 'direct': { + const otherMember = convo.members.find( + member => member.did !== currentAccount?.did, + ) + + if (!otherMember) { + return null + } + return ( + + {children} + + ) + } + } } -ChatListItem = memo(ChatListItem) - -function ChatListItemReady({ +function DirectChatItem({ convo, profile: profileUnshadowed, moderationOpts, @@ -95,25 +133,140 @@ function ChatListItemReady({ showMenu?: boolean children?: React.ReactNode }) { - const ax = useAnalytics() - const t = useTheme() - const {_} = useLingui() - const {currentAccount} = useSession() - const menuControl = useMenuControl() - const leaveConvoControl = useDialogControl() - const {gtMobile} = useBreakpoints() + const {t: l} = useLingui() const profile = useProfileShadow(profileUnshadowed) - const {mutate: markAsRead} = useMarkAsReadMutation() + const moderation = useMemo( () => moderateProfile(profile, moderationOpts), [profile, moderationOpts], ) + + const isDeletedAccount = profile.handle === 'missing.invalid' + const displayName = isDeletedAccount + ? l`Deleted Account` + : createSanitizedDisplayName(profile, true, moderation.ui('displayName')) + + return ( + + } + primaryProfile={profile} + primaryProfileModeration={moderation} + title={displayName} + subtitle={isDeletedAccount ? undefined : sanitizeHandle(profile.handle)} + accessibilityHint={ + !isDeletedAccount + ? l`Go to conversation with ${profile.handle}` + : l`This conversation is with a deleted or a deactivated account. Press for options` + } + showMenu={showMenu} + isDeletedAccount={isDeletedAccount} + isBlockedAccount={moderation.blocked} + showProfileBadges + postAlerts={ + + }> + {children} + + ) +} + +function GroupChatItem({ + convo, + groupOwner: groupOwnerUnshadowed, + groupInfo, + moderationOpts, + showMenu, + children, +}: { + convo: ChatBskyConvoDefs.ConvoView + groupOwner: bsky.profile.AnyProfileView + groupInfo: ChatBskyConvoDefs.GroupConvoData + moderationOpts: ModerationOpts + showMenu?: boolean + children?: React.ReactNode +}) { + const {t: l} = useLingui() + const groupOwner = useProfileShadow(groupOwnerUnshadowed) + + const moderation = useMemo( + () => moderateProfile(groupOwner, moderationOpts), + [groupOwner, moderationOpts], + ) + + const chatName = groupInfo.name ?? l`${groupOwner.handle}'s group chat` + + return ( + } + title={chatName} + accessibilityHint={l`Go to the group chat named "${chatName}"`} + primaryProfile={groupOwner} + primaryProfileModeration={moderation} + isBlockedAccount={false} + isDeletedAccount={false} + showProfileBadges={false} + showMenu={showMenu}> + {children} + + ) +} + +function BaseChatItem({ + convo, + avatar, + title, + subtitle, + accessibilityHint, + isDeletedAccount, + isBlockedAccount, + primaryProfile, + primaryProfileModeration, + showMenu, + showProfileBadges, + postAlerts, + children, +}: { + convo: ChatBskyConvoDefs.ConvoView + avatar: React.ReactNode + title: string + subtitle?: string + accessibilityHint: string + isDeletedAccount: boolean + isBlockedAccount: boolean + primaryProfile: Shadow + primaryProfileModeration: ModerationDecision + showMenu?: boolean + showProfileBadges: boolean + postAlerts?: React.ReactNode + children?: React.ReactNode +}) { + const ax = useAnalytics() + const t = useTheme() + const {t: l} = useLingui() + const {currentAccount} = useSession() + const menuControl = useMenuControl() + const leaveConvoControl = useDialogControl() + const {mutate: markAsRead} = useMarkAsReadMutation() + const {gtMobile} = useBreakpoints() + const playHaptic = useHaptics() const queryClient = useQueryClient() const isUnread = convo.unreadCount > 0 const blockInfo = useMemo(() => { - const modui = moderation.ui('profileView') + const modui = primaryProfileModeration.ui('profileView') const blocks = modui.alerts.filter(alert => alert.type === 'blocking') const listBlocks = blocks.filter(alert => alert.source.type === 'list') const userBlock = blocks.find(alert => alert.source.type === 'user') @@ -121,21 +274,13 @@ function ChatListItemReady({ listBlocks, userBlock, } - }, [moderation]) + }, [primaryProfileModeration]) - const isDeletedAccount = profile.handle === 'missing.invalid' - const displayName = isDeletedAccount - ? _(msg`Deleted Account`) - : sanitizeDisplayName( - profile.displayName || profile.handle, - moderation.ui('displayName'), - ) - - const isDimStyle = convo.muted || moderation.blocked || isDeletedAccount + const isDimStyle = convo.muted || isBlockedAccount || isDeletedAccount const {lastMessage, lastMessageSentAt, latestReportableMessage} = useMemo(() => { - let lastMessage = _(msg`No messages yet`) + let lastMessage = l`No messages yet` let lastMessageSentAt: string | null = null @@ -150,14 +295,12 @@ function ChatListItemReady({ if (convo.lastMessage.text) { if (isFromMe) { - lastMessage = _(msg`You: ${convo.lastMessage.text}`) + lastMessage = l`You: ${convo.lastMessage.text}` } else { lastMessage = convo.lastMessage.text } } else if (convo.lastMessage.embed) { - const defaultEmbeddedContentMessage = _( - msg`(contains embedded content)`, - ) + const defaultEmbeddedContentMessage = l`(contains embedded content)` if (AppBskyEmbedRecord.isView(convo.lastMessage.embed)) { const embed = convo.lastMessage.embed @@ -172,14 +315,14 @@ function ChatListItemReady({ ? toShortUrl(href) : defaultEmbeddedContentMessage if (isFromMe) { - lastMessage = _(msg`You: ${short}`) + lastMessage = l`You: ${short}` } else { lastMessage = short } } } else { if (isFromMe) { - lastMessage = _(msg`You: ${defaultEmbeddedContentMessage}`) + lastMessage = l`You: ${defaultEmbeddedContentMessage}` } else { lastMessage = defaultEmbeddedContentMessage } @@ -192,8 +335,8 @@ function ChatListItemReady({ lastMessageSentAt = convo.lastMessage.sentAt lastMessage = isDeletedAccount - ? _(msg`Conversation deleted`) - : _(msg`Message deleted`) + ? l`Conversation deleted` + : l`Message deleted` } if (ChatBskyConvoDefs.isMessageAndReactionView(convo.lastReaction)) { @@ -205,44 +348,36 @@ function ChatListItemReady({ const isFromMe = convo.lastReaction.reaction.sender.did === currentAccount?.did const lastMessageText = convo.lastReaction.message.text - const fallbackMessage = _( - msg({ - message: 'a message', - comment: `If last message does not contain text, fall back to "{user} reacted to {a message}"`, - }), - ) + const fallbackMessage = l({ + message: 'a message', + comment: `If last message does not contain text, fall back to "{user} reacted to {a message}"`, + }) if (isFromMe) { - lastMessage = _( - msg`You reacted ${convo.lastReaction.reaction.value} to ${ - lastMessageText - ? `"${convo.lastReaction.message.text}"` - : fallbackMessage - }`, - ) + lastMessage = l`You reacted ${convo.lastReaction.reaction.value} to ${ + lastMessageText + ? `"${convo.lastReaction.message.text}"` + : fallbackMessage + }` } else { const senderDid = convo.lastReaction.reaction.sender.did const sender = convo.members.find( member => member.did === senderDid, ) if (sender) { - lastMessage = _( - msg`${sanitizeDisplayName( - sender.displayName || sender.handle, - )} reacted ${convo.lastReaction.reaction.value} to ${ - lastMessageText - ? `"${convo.lastReaction.message.text}"` - : fallbackMessage - }`, - ) + lastMessage = l`${sanitizeDisplayName( + sender.displayName || sender.handle, + )} reacted ${convo.lastReaction.reaction.value} to ${ + lastMessageText + ? `"${convo.lastReaction.message.text}"` + : fallbackMessage + }` } else { - lastMessage = _( - msg`Someone reacted ${convo.lastReaction.reaction.value} to ${ - lastMessageText - ? `"${convo.lastReaction.message.text}"` - : fallbackMessage - }`, - ) + lastMessage = l`Someone reacted ${convo.lastReaction.reaction.value} to ${ + lastMessageText + ? `"${convo.lastReaction.message.text}"` + : fallbackMessage + }` } } } @@ -254,7 +389,7 @@ function ChatListItemReady({ latestReportableMessage, } }, [ - _, + l, convo.lastMessage, convo.lastReaction, currentAccount?.did, @@ -279,9 +414,11 @@ function ChatListItemReady({ const onPress = useCallback( (e: GestureResponderEvent) => { - precacheProfile(queryClient, profile) + for (const member of convo.members) { + unstableCacheProfileView(queryClient, member) + } precacheConvoQuery(queryClient, convo) - decrementBadgeCount(convo.unreadCount) + void decrementBadgeCount(convo.unreadCount) if (isDeletedAccount) { e.preventDefault() menuControl.open() @@ -290,7 +427,7 @@ function ChatListItemReady({ ax.metric('chat:open', {logContext: 'ChatsList'}) } }, - [ax, isDeletedAccount, menuControl, queryClient, profile, convo], + [ax, isDeletedAccount, menuControl, queryClient, convo], ) const onLongPress = useCallback(() => { @@ -345,33 +482,23 @@ function ChatListItemReady({ a.absolute, {top: tokens.space.md, left: tokens.space.lg}, ]}> - + {avatar} - {displayName} + {title} - + + {showProfileBadges && ( + + )} + {lastMessageSentAt && ( @@ -432,7 +563,7 @@ function ChatListItemReady({ )} - {(convo.muted || moderation.blocked) && ( + {(convo.muted || isBlockedAccount) && ( - {!isDeletedAccount && ( + {subtitle && ( - @{profile.handle} + {subtitle} )} @@ -474,11 +605,7 @@ function ChatListItemReady({ {lastMessage} - + {postAlerts} {children} @@ -509,7 +636,7 @@ function ChatListItemReady({ {showMenu && ( 0} @@ -529,6 +656,7 @@ function ChatListItemReady({ latestReportableMessage={latestReportableMessage} /> )} + ( + this.convo.kindData, + ChatBskyConvoDefs.isGroupConvoData, + ) + ) { + return this.convo.kindData + } + return undefined + } + + getPrimaryMember(): ChatBskyActorDefs.ProfileViewBasic | undefined { + if (this.convo?.kind === 'group') { + return ( + this.recipients?.find(r => r.role === 'owner') ?? this.recipients?.[0] + ) + } else { + return this.recipients?.find(r => r.did !== this.senderUserDid) + } + } } diff --git a/src/state/messages/convo/types.ts b/src/state/messages/convo/types.ts index 7053877935..3ea9443543 100644 --- a/src/state/messages/convo/types.ts +++ b/src/state/messages/convo/types.ts @@ -144,6 +144,9 @@ type FetchMessageHistory = () => Promise type MarkConvoAccepted = () => void type AddReaction = (messageId: string, reaction: string) => Promise type RemoveReaction = (messageId: string, reaction: string) => Promise +type IsGroup = () => boolean | undefined +type GetGroupInfo = () => ChatBskyConvoDefs.GroupConvoData | undefined +type GetPrimaryMember = () => ChatBskyActorDefs.ProfileViewBasic | undefined export type ConvoStateUninitialized = { status: ConvoStatus.Uninitialized @@ -159,6 +162,9 @@ export type ConvoStateUninitialized = { markConvoAccepted: undefined addReaction: undefined removeReaction: undefined + isGroup: IsGroup + getGroupInfo: GetGroupInfo + getPrimaryMember: GetPrimaryMember } export type ConvoStateInitializing = { status: ConvoStatus.Initializing @@ -174,6 +180,9 @@ export type ConvoStateInitializing = { markConvoAccepted: undefined addReaction: undefined removeReaction: undefined + isGroup: IsGroup + getGroupInfo: GetGroupInfo + getPrimaryMember: GetPrimaryMember } export type ConvoStateReady = { status: ConvoStatus.Ready @@ -189,6 +198,9 @@ export type ConvoStateReady = { markConvoAccepted: MarkConvoAccepted addReaction: AddReaction removeReaction: RemoveReaction + isGroup: IsGroup + getGroupInfo: GetGroupInfo + getPrimaryMember: GetPrimaryMember } export type ConvoStateBackgrounded = { status: ConvoStatus.Backgrounded @@ -204,6 +216,9 @@ export type ConvoStateBackgrounded = { markConvoAccepted: MarkConvoAccepted addReaction: AddReaction removeReaction: RemoveReaction + isGroup: IsGroup + getGroupInfo: GetGroupInfo + getPrimaryMember: GetPrimaryMember } export type ConvoStateSuspended = { status: ConvoStatus.Suspended @@ -219,6 +234,9 @@ export type ConvoStateSuspended = { markConvoAccepted: MarkConvoAccepted addReaction: AddReaction removeReaction: RemoveReaction + isGroup: IsGroup + getGroupInfo: GetGroupInfo + getPrimaryMember: GetPrimaryMember } export type ConvoStateError = { status: ConvoStatus.Error @@ -234,6 +252,9 @@ export type ConvoStateError = { markConvoAccepted: undefined addReaction: undefined removeReaction: undefined + isGroup: undefined + getGroupInfo: undefined + getPrimaryMember: undefined } export type ConvoStateDisabled = { status: ConvoStatus.Disabled @@ -249,6 +270,9 @@ export type ConvoStateDisabled = { markConvoAccepted: MarkConvoAccepted addReaction: AddReaction removeReaction: RemoveReaction + isGroup: IsGroup + getGroupInfo: GetGroupInfo + getPrimaryMember: GetPrimaryMember } export type ConvoState = | ConvoStateUninitialized