From dcc06a90a074320dc6cd8e5fcd7a8e92c51f82d0 Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:03:53 -0700 Subject: [PATCH] Display sender name in last message for group clip clops (#10320) Co-authored-by: Samuel Newman --- package.json | 2 +- src/components/AvatarBubbles.tsx | 7 +- src/components/dms/SystemMessageItem.tsx | 5 +- src/components/dms/getMessageInfo.ts | 93 +++++++++++ src/components/dms/getReactionInfo.ts | 54 +++++++ ...stemMessage.ts => getSystemMessageInfo.ts} | 33 +++- src/components/dms/util.ts | 9 +- .../Messages/components/ChatListItem.tsx | 152 +++++------------- src/state/messages/convo/agent.ts | 23 ++- src/state/messages/convo/types.ts | 1 + yarn.lock | 8 +- 11 files changed, 253 insertions(+), 134 deletions(-) create mode 100644 src/components/dms/getMessageInfo.ts create mode 100644 src/components/dms/getReactionInfo.ts rename src/components/dms/{systemMessage.ts => getSystemMessageInfo.ts} (68%) diff --git a/package.json b/package.json index 9ed7549b55..d4b0992d7a 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "icons:optimize": "svgo -f ./assets/icons" }, "dependencies": { - "@atproto/api": "^0.19.10", + "@atproto/api": "^0.19.11", "@atproto/syntax": "0.5.2", "@bitdrift/react-native": "^0.6.8", "@braintree/sanitize-url": "^6.0.2", diff --git a/src/components/AvatarBubbles.tsx b/src/components/AvatarBubbles.tsx index 2ed58f5ad3..0c6f67db37 100644 --- a/src/components/AvatarBubbles.tsx +++ b/src/components/AvatarBubbles.tsx @@ -45,7 +45,12 @@ export function AvatarBubbles({ : size === 'medium' ? 56 / 120 : 1 - const marginOffset = size === 'small' || size === 'medium' ? -2 : 0 + const marginOffset = + (typeof size === 'number' && size < 120) || + size === 'small' || + size === 'medium' + ? -2 + : 0 const initialValue = animate ? 0 : 1 const p0 = useSharedValue(initialValue) diff --git a/src/components/dms/SystemMessageItem.tsx b/src/components/dms/SystemMessageItem.tsx index d863871d18..91075597d5 100644 --- a/src/components/dms/SystemMessageItem.tsx +++ b/src/components/dms/SystemMessageItem.tsx @@ -3,7 +3,7 @@ import {useLingui} from '@lingui/react/macro' import {type ConvoItem} from '#/state/messages/convo/types' import {atoms as a, useTheme} from '#/alf' -import {getSystemMessageInfo} from '#/components/dms/systemMessage' +import {getSystemMessageInfo} from '#/components/dms/getSystemMessageInfo' import {Text} from '#/components/Typography' export function SystemMessageItem({ @@ -14,7 +14,7 @@ export function SystemMessageItem({ const t = useTheme() const {i18n} = useLingui() - const info = getSystemMessageInfo(item.message.data) + const info = getSystemMessageInfo(item.message.data, item.relatedProfiles) if (!info) return null const {Icon, message} = info @@ -28,6 +28,7 @@ export function SystemMessageItem({ a.justify_center, a.px_md, a.mt_md, + a.mb_xs, ]}> m.did === senderDid) + const name = sender ? createSanitizedDisplayName(sender) : null + const isGroup = ChatBskyConvoDefs.isGroupConvo(convo.kind) + + const reportableMessage = isFromMe ? undefined : lastMessage + + const prefix = (message: string) => { + if (isFromMe) { + return i18n._( + msg({ + message: `You: ${message}`, + comment: 'When the last message in a chat was made by you.', + }), + ) + } else if (isGroup && name) { + return i18n._( + msg({ + message: `${name}: ${message}`, + comment: + 'When the last message in a group chat came from someone other than you.', + }), + ) + } + return message + } + + let message: string | null = null + + if (lastMessage.text) { + message = prefix(lastMessage.text) + } else if (lastMessage.embed) { + const defaultEmbeddedContentMessage = i18n._( + msg`(contains embedded content)`, + ) + + if (AppBskyEmbedRecord.isView(lastMessage.embed)) { + const embed = lastMessage.embed + + if (AppBskyEmbedRecord.isViewRecord(embed.record)) { + const record = embed.record + const path = postUriToRelativePath(record.uri, { + handle: record.author.handle, + }) + const href = path ? toBskyAppUrl(path) : undefined + const short = href ? toShortUrl(href) : defaultEmbeddedContentMessage + message = prefix(short) + } else { + message = prefix(defaultEmbeddedContentMessage) + } + } else { + message = prefix(defaultEmbeddedContentMessage) + } + } + + return { + message, + sentAt: lastMessage.sentAt, + reportableMessage, + } +} diff --git a/src/components/dms/getReactionInfo.ts b/src/components/dms/getReactionInfo.ts new file mode 100644 index 0000000000..351c659698 --- /dev/null +++ b/src/components/dms/getReactionInfo.ts @@ -0,0 +1,54 @@ +import {ChatBskyConvoDefs} from '@atproto/api' +import {type I18n} from '@lingui/core' +import {msg} from '@lingui/core/macro' + +import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' + +export type UserReactionInfo = { + message: string + createdAt: string +} + +export function getReactionInfo({ + convo, + currentAccountDid, + i18n, +}: { + convo: ChatBskyConvoDefs.ConvoView + currentAccountDid: string | undefined + i18n: I18n +}): UserReactionInfo | null { + if (!ChatBskyConvoDefs.isMessageAndReactionView(convo.lastReaction)) { + return null + } + + const {reaction, message: reactedTo} = convo.lastReaction + const isFromMe = reaction.sender.did === currentAccountDid + const senderDid = reaction.sender.did + const sender = convo.members.find(m => m.did === senderDid) + const name = sender ? createSanitizedDisplayName(sender) : null + + const lastMessageText = reactedTo.text + const fallbackMessage = i18n._( + msg({ + message: 'a message', + comment: + 'If last message does not contain text, fall back to "{user} reacted to {a message}"', + }), + ) + const target = lastMessageText ? `"${lastMessageText}"` : fallbackMessage + + let message: string + if (isFromMe) { + message = i18n._(msg`You reacted ${reaction.value} to ${target}`) + } else if (name) { + message = i18n._(msg`${name} reacted ${reaction.value} to ${target}`) + } else { + message = i18n._(msg`Someone reacted ${reaction.value} to ${target}`) + } + + return { + message, + createdAt: reaction.createdAt, + } +} diff --git a/src/components/dms/systemMessage.ts b/src/components/dms/getSystemMessageInfo.ts similarity index 68% rename from src/components/dms/systemMessage.ts rename to src/components/dms/getSystemMessageInfo.ts index 5764a2a637..f811e2b92c 100644 --- a/src/components/dms/systemMessage.ts +++ b/src/components/dms/getSystemMessageInfo.ts @@ -1,4 +1,4 @@ -import {ChatBskyConvoDefs} from '@atproto/api' +import {type ChatBskyActorDefs, ChatBskyConvoDefs} from '@atproto/api' import {type MessageDescriptor} from '@lingui/core' import {msg} from '@lingui/core/macro' @@ -21,28 +21,47 @@ export type SystemMessageInfo = { Icon: React.ComponentType } +function getReferredDisplayName( + user: ChatBskyConvoDefs.SystemMessageReferredUser, + relatedProfiles: ChatBskyActorDefs.ProfileViewBasic[], +): string | null { + const profile = relatedProfiles.find(p => p.did === user.did) + return profile ? createSanitizedDisplayName(profile) : null +} + export function getSystemMessageInfo( data: ChatBskyConvoDefs.SystemMessageView['data'], + relatedProfiles: ChatBskyActorDefs.ProfileViewBasic[], ): SystemMessageInfo | null { if (ChatBskyConvoDefs.isSystemMessageDataAddMember(data)) { + const name = getReferredDisplayName(data.member, relatedProfiles) return { Icon: JoinIcon, - message: msg`${createSanitizedDisplayName(data.member)} was added to the group`, + message: name + ? msg`${name} was added to the group` + : msg`Someone was added to the group`, } } else if (ChatBskyConvoDefs.isSystemMessageDataRemoveMember(data)) { + const name = getReferredDisplayName(data.member, relatedProfiles) return { Icon: LeaveIcon, - message: msg`${createSanitizedDisplayName(data.member)} was removed from the group`, + message: name + ? msg`${name} was removed from the group` + : msg`Someone was removed from the group`, } } else if (ChatBskyConvoDefs.isSystemMessageDataMemberJoin(data)) { + const name = getReferredDisplayName(data.member, relatedProfiles) return { Icon: JoinIcon, - message: msg`${createSanitizedDisplayName(data.member)} joined the group`, + message: name + ? msg`${name} joined the group` + : msg`Someone joined the group`, } } else if (ChatBskyConvoDefs.isSystemMessageDataMemberLeave(data)) { + const name = getReferredDisplayName(data.member, relatedProfiles) return { Icon: LeaveIcon, - message: msg`${createSanitizedDisplayName(data.member)} left the group`, + message: name ? msg`${name} left the group` : msg`Someone left the group`, } } else if (ChatBskyConvoDefs.isSystemMessageDataLockConvo(data)) { return {Icon: LockIcon, message: msg`Chat locked`} @@ -53,7 +72,9 @@ export function getSystemMessageInfo( } else if (ChatBskyConvoDefs.isSystemMessageDataEditGroup(data)) { return { Icon: PencilIcon, - message: msg`Chat title changed to ${data.newName ?? ''}`, + message: data.newName + ? msg`Chat title changed to ${data.newName}` + : msg`Chat title changed`, } } else if (ChatBskyConvoDefs.isSystemMessageDataCreateJoinLink(data)) { return {Icon: ChainLinkIcon, message: msg`Invite link created`} diff --git a/src/components/dms/util.ts b/src/components/dms/util.ts index 64e6bb16c6..5c159a72f0 100644 --- a/src/components/dms/util.ts +++ b/src/components/dms/util.ts @@ -110,14 +110,16 @@ export function parseConvoView( owner = member as GroupConvoMember } } else { - throw new Error( + logger.warn( 'Expected a GroupConvoMember, got an unknown kind of member', ) + return null } } if (!owner) { - throw new Error('No owner found in group convo') + logger.warn('No owner found in group convo') + return null } return { @@ -136,7 +138,8 @@ export function parseConvoView( const otherUser = convoView.members.find(m => m.did !== ownDid) if (!otherUser) { - throw new Error('No other user found in direct convo') + logger.warn('No other user found in direct convo') + return null } return { diff --git a/src/screens/Messages/components/ChatListItem.tsx b/src/screens/Messages/components/ChatListItem.tsx index 6d3d5a783f..7cedb5d4be 100644 --- a/src/screens/Messages/components/ChatListItem.tsx +++ b/src/screens/Messages/components/ChatListItem.tsx @@ -1,7 +1,6 @@ import {useCallback, useMemo, useState} from 'react' import {type GestureResponderEvent, View} from 'react-native' import { - AppBskyEmbedRecord, ChatBskyConvoDefs, moderateProfile, type ModerationDecision, @@ -14,13 +13,7 @@ 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 {type Shadow, useProfileShadow} from '#/state/cache/profile-shadow' import {useModerationOpts} from '#/state/preferences/moderation-opts' import { @@ -36,8 +29,10 @@ import * as tokens from '#/alf/tokens' import {AvatarBubbles} from '#/components/AvatarBubbles' import {useDialogControl} from '#/components/Dialog' import {ConvoMenu} from '#/components/dms/ConvoMenu' +import {getMessageInfo} from '#/components/dms/getMessageInfo' +import {getReactionInfo} from '#/components/dms/getReactionInfo' +import {getSystemMessageInfo} from '#/components/dms/getSystemMessageInfo' import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt' -import {getSystemMessageInfo} from '#/components/dms/systemMessage' 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' @@ -54,12 +49,6 @@ import type * as bsky from '#/types/bsky' export const ChatListItemPortal = createPortalGroup() -/** - * 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: convoView, showMenu = true, @@ -94,8 +83,9 @@ export function ChatListItem({ + showMenu={showMenu}> + {children} + ) } default: { @@ -189,7 +179,7 @@ function GroupChatItem({ return ( } + avatar={} title={chatName} accessibilityHint={l`Go to the group chat named "${chatName}"`} primaryProfile={groupOwner} @@ -243,7 +233,7 @@ function BaseChatItem({ const playHaptic = useHaptics() const queryClient = useQueryClient() - const isUnread = convo.unreadCount > 0 + const hasUnread = convo.unreadCount > 0 && !isDeletedAccount const blockInfo = useMemo(() => { const modui = primaryProfileModeration.ui('profileView') @@ -266,53 +256,6 @@ function BaseChatItem({ let latestReportableMessage: ChatBskyConvoDefs.MessageView | undefined - // Message - if (ChatBskyConvoDefs.isMessageView(convo.lastMessage)) { - const isFromMe = convo.lastMessage.sender?.did === currentAccount?.did - - if (!isFromMe) { - latestReportableMessage = convo.lastMessage - } - - if (convo.lastMessage.text) { - if (isFromMe) { - lastMessage = l`You: ${convo.lastMessage.text}` - } else { - lastMessage = convo.lastMessage.text - } - } else if (convo.lastMessage.embed) { - const defaultEmbeddedContentMessage = l`(contains embedded content)` - - if (AppBskyEmbedRecord.isView(convo.lastMessage.embed)) { - const embed = convo.lastMessage.embed - - if (AppBskyEmbedRecord.isViewRecord(embed.record)) { - const record = embed.record - const path = postUriToRelativePath(record.uri, { - handle: record.author.handle, - }) - const href = path ? toBskyAppUrl(path) : undefined - const short = href - ? toShortUrl(href) - : defaultEmbeddedContentMessage - if (isFromMe) { - lastMessage = l`You: ${short}` - } else { - lastMessage = short - } - } - } else { - if (isFromMe) { - lastMessage = l`You: ${defaultEmbeddedContentMessage}` - } else { - lastMessage = defaultEmbeddedContentMessage - } - } - } - - lastMessageSentAt = convo.lastMessage.sentAt - } - // Deleted message if (ChatBskyConvoDefs.isDeletedMessageView(convo.lastMessage)) { lastMessageSentAt = convo.lastMessage.sentAt @@ -322,56 +265,43 @@ function BaseChatItem({ : l`Message deleted` } + // Message + if (ChatBskyConvoDefs.isMessageView(convo.lastMessage)) { + const info = getMessageInfo({ + convo, + currentAccountDid: currentAccount?.did, + i18n, + }) + if (info) { + lastMessage = info.message ?? lastMessage + lastMessageSentAt = info.sentAt + latestReportableMessage = info.reportableMessage + } + } + // Reaction if (ChatBskyConvoDefs.isMessageAndReactionView(convo.lastReaction)) { + const info = getReactionInfo({ + convo, + currentAccountDid: currentAccount?.did, + i18n, + }) if ( - !lastMessageSentAt || - new Date(lastMessageSentAt) < - new Date(convo.lastReaction.reaction.createdAt) + info && + (!lastMessageSentAt || + new Date(lastMessageSentAt) < new Date(info.createdAt)) ) { - const isFromMe = - convo.lastReaction.reaction.sender.did === currentAccount?.did - const lastMessageText = convo.lastReaction.message.text - 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 = 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 = l`${sanitizeDisplayName( - sender.displayName || sender.handle, - )} reacted ${convo.lastReaction.reaction.value} to ${ - lastMessageText - ? `"${convo.lastReaction.message.text}"` - : fallbackMessage - }` - } else { - lastMessage = l`Someone reacted ${convo.lastReaction.reaction.value} to ${ - lastMessageText - ? `"${convo.lastReaction.message.text}"` - : fallbackMessage - }` - } - } + lastMessage = info.message + lastMessageSentAt = info.createdAt } } // System message if (ChatBskyConvoDefs.isSystemMessageView(convo.lastMessage)) { - const info = getSystemMessageInfo(convo.lastMessage.data) + const info = getSystemMessageInfo(convo.lastMessage.data, convo.members) if (info) { lastMessage = i18n._(info.message) + lastMessageSentAt = convo.lastMessage.sentAt } } @@ -380,15 +310,7 @@ function BaseChatItem({ lastMessageSentAt, latestReportableMessage, } - }, [ - l, - i18n, - convo.lastMessage, - convo.lastReaction, - currentAccount?.did, - isDeletedAccount, - convo.members, - ]) + }, [l, convo, currentAccount?.did, isDeletedAccount, i18n]) const [showActions, setShowActions] = useState(false) @@ -448,7 +370,7 @@ function BaseChatItem({ }, } - const actions = isUnread + const actions = hasUnread ? { leftFirst: markReadAction, leftSecond: deleteAction, @@ -457,8 +379,6 @@ function BaseChatItem({ leftFirst: deleteAction, } - const hasUnread = convo.unreadCount > 0 && !isDeletedAccount - return ( diff --git a/src/state/messages/convo/agent.ts b/src/state/messages/convo/agent.ts index 77b59581b4..3552de39e6 100644 --- a/src/state/messages/convo/agent.ts +++ b/src/state/messages/convo/agent.ts @@ -102,6 +102,10 @@ export class Convo { {id: string; message: ChatBskyConvoSendMessage.InputSchema['message']} > = new Map() private deletedMessages: Set = new Set() + private systemMessageProfiles: Map< + string, + ChatBskyActorDefs.ProfileViewBasic + > = new Map() private isProcessingPendingMessages = false @@ -469,6 +473,7 @@ export class Convo { this.newMessages = new Map() this.pendingMessages = new Map() this.deletedMessages = new Set() + this.systemMessageProfiles = new Map() this.pendingMessageFailure = null this.fetchMessageHistoryError = undefined @@ -689,10 +694,16 @@ export class Convo { {headers: DM_SERVICE_HEADERS}, ) }) - const {cursor, messages} = response.data + const {cursor, messages, relatedProfiles} = response.data this.oldestRev = cursor ?? null + if (relatedProfiles) { + for (const profile of relatedProfiles) { + this.systemMessageProfiles.set(profile.did, profile) + } + } + /* * If the response contained fewer messages than the limit, we know * there are no more pages, regardless of whether a cursor was returned. @@ -861,6 +872,14 @@ export class Convo { const systemView = toSystemMessageView(ev) if (systemView) { this.newMessages.set(systemView.id, systemView) + if ( + 'relatedProfiles' in ev && + Array.isArray(ev.relatedProfiles) + ) { + for (const profile of ev.relatedProfiles) { + this.systemMessageProfiles.set(profile.did, profile) + } + } needsCommit = true } } @@ -1155,6 +1174,7 @@ export class Convo { type: 'system-message', key: m.id, message: m, + relatedProfiles: Array.from(this.systemMessageProfiles.values()), }) } }) @@ -1192,6 +1212,7 @@ export class Convo { type: 'system-message', key: m.id, message: m, + relatedProfiles: Array.from(this.systemMessageProfiles.values()), }) } }) diff --git a/src/state/messages/convo/types.ts b/src/state/messages/convo/types.ts index ef81e9009d..dad3b7f82a 100644 --- a/src/state/messages/convo/types.ts +++ b/src/state/messages/convo/types.ts @@ -130,6 +130,7 @@ export type ConvoItem = type: 'system-message' key: string message: ChatBskyConvoDefs.SystemMessageView + relatedProfiles: ChatBskyActorDefs.ProfileViewBasic[] } | { type: 'error' diff --git a/yarn.lock b/yarn.lock index a0f45c607a..edd987d5a8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20,10 +20,10 @@ "@jridgewell/gen-mapping" "^0.3.0" "@jridgewell/trace-mapping" "^0.3.9" -"@atproto/api@^0.19.10": - version "0.19.10" - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.19.10.tgz#311a3fb8c642ee8bb1eea96acadfedbf500e9f08" - integrity sha512-HrHWjL6aEEfUKBSZFZ7Fz+MF3WcGOdhTlX6f71j4fgf91pMhwxgdo2K13Qjn2CxIh8/iHAJi+oiLWKOgZnOLNA== +"@atproto/api@^0.19.11": + version "0.19.11" + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.19.11.tgz#73885a47959907f22b68d671011ee70f80afd44b" + integrity sha512-7V4Sg6hcv/UxoXobjfvy/Ox2ioKQtZ3DzbsiFndYCcBfsZ5GO8rNEroHPq3hT0CFBJK1NAD6JfOtTBN2z267Xg== dependencies: "@atproto/common-web" "^0.4.21" "@atproto/lexicon" "^0.6.2"