From 75d8fb3dbd7319709546f3cdadcbbb61f0eb8e45 Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Fri, 8 May 2026 15:24:34 -0700 Subject: [PATCH] Group runs of system messages under a single toggle (#10388) --- src/components/dms/MessageItem.tsx | 4 +- src/components/dms/SystemMessageGroup.tsx | 100 ++++++++++++++++++ src/components/dms/SystemMessageItem.tsx | 36 ++++++- src/components/dms/getSystemMessageInfo.ts | 72 +++++++++---- src/components/dms/util.ts | 3 + .../components/InviteLinkDialogProvider.tsx | 62 +++++++++++ .../Messages/components/MessagesList.tsx | 64 +++++++---- .../components/groupSystemMessages.ts | 100 ++++++++++++++++++ 8 files changed, 398 insertions(+), 43 deletions(-) create mode 100644 src/components/dms/SystemMessageGroup.tsx create mode 100644 src/screens/Messages/components/InviteLinkDialogProvider.tsx create mode 100644 src/screens/Messages/components/groupSystemMessages.ts diff --git a/src/components/dms/MessageItem.tsx b/src/components/dms/MessageItem.tsx index 0f39e78ac2..55b64f43b6 100644 --- a/src/components/dms/MessageItem.tsx +++ b/src/components/dms/MessageItem.tsx @@ -45,6 +45,7 @@ import {Text} from '#/components/Typography' import {DateDivider} from './DateDivider' import {MessageItemEmbed} from './MessageItemEmbed' import {ReactionsDialog} from './ReactionsDialog' +import {CLUSTERED_MESSAGE_THRESHOLD_MS, MESSAGE_GAP_THRESHOLD_MS} from './util' const AVATAR_SIZE = 28 const CLUSTERED_MESSAGE_GAP = 2 @@ -52,9 +53,6 @@ const BORDER_RADIUS = 18 const SQUARED_BORDER_RADIUS = 4 const DISPLAY_NAME_INSET = 22 -const CLUSTERED_MESSAGE_THRESHOLD_MS = 5 * 60 * 1000 -const MESSAGE_GAP_THRESHOLD_MS = 60 * 60 * 1000 - function isWithinClusterBoundary({ isPending, adjacentMessage, diff --git a/src/components/dms/SystemMessageGroup.tsx b/src/components/dms/SystemMessageGroup.tsx new file mode 100644 index 0000000000..930242ca46 --- /dev/null +++ b/src/components/dms/SystemMessageGroup.tsx @@ -0,0 +1,100 @@ +import {Pressable, View} from 'react-native' +import Animated, { + FadeIn, + FadeOut, + LinearTransition, + useAnimatedStyle, + useDerivedValue, + withTiming, +} from 'react-native-reanimated' +import {type ChatBskyActorDefs} from '@atproto/api' +import {plural} from '@lingui/core/macro' +import {useLingui} from '@lingui/react/macro' + +import {HITSLOP_10} from '#/lib/constants' +import {type SystemMessageGroupItem} from '#/screens/Messages/components/groupSystemMessages' +import {atoms as a, useTheme} from '#/alf' +import {SystemMessageItem} from '#/components/dms/SystemMessageItem' +import {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDown} from '#/components/icons/Chevron' +import {Text} from '#/components/Typography' + +const ANIMATION_DURATION_MS = 200 + +export function SystemMessageGroup({ + item, + expanded, + onToggle, + relatedProfiles, +}: { + item: SystemMessageGroupItem + expanded: boolean + onToggle: (key: string) => void + relatedProfiles: Map +}) { + const t = useTheme() + const {t: l} = useLingui() + const count = item.items.length + + const label = plural(count, { + one: '# chat update', + other: '# chat updates', + }) + + const rotation = useDerivedValue(() => + withTiming(expanded ? -180 : 0, {duration: ANIMATION_DURATION_MS}), + ) + const chevronStyle = useAnimatedStyle(() => ({ + transform: [{rotate: `${rotation.get()}deg`}], + })) + + return ( + + onToggle(item.key)} + style={[ + a.w_full, + a.flex_row, + a.align_center, + a.justify_center, + a.px_md, + a.mt_md, + ]}> + + {label} + + + + + + + {expanded + ? item.items.map(child => ( + + + + )) + : null} + + + ) +} diff --git a/src/components/dms/SystemMessageItem.tsx b/src/components/dms/SystemMessageItem.tsx index 33f5455dac..9854255c84 100644 --- a/src/components/dms/SystemMessageItem.tsx +++ b/src/components/dms/SystemMessageItem.tsx @@ -2,9 +2,13 @@ import {View} from 'react-native' import {type ChatBskyActorDefs} from '@atproto/api' import {useLingui} from '@lingui/react/macro' +import {makeProfileLink} from '#/lib/routes/links' import {type ConvoItem} from '#/state/messages/convo/types' +import {useInviteLinkDialog} from '#/screens/Messages/components/InviteLinkDialogProvider' import {atoms as a, useTheme} from '#/alf' +import {Button} from '#/components/Button' import {getSystemMessageInfo} from '#/components/dms/getSystemMessageInfo' +import {Link} from '#/components/Link' import {Text} from '#/components/Typography' export function SystemMessageItem({ @@ -15,14 +19,16 @@ export function SystemMessageItem({ relatedProfiles: Map }) { const t = useTheme() - const {i18n} = useLingui() + const {i18n, t: l} = useLingui() + const inviteLinkControl = useInviteLinkDialog() const info = getSystemMessageInfo(item.message.data, relatedProfiles) if (!info) return null - const {Icon, message} = info + const {Icon, action} = info + const text = i18n._(info.message) - return ( + const row = ( - {i18n._(message)} + {text} ) + + switch (action?.kind) { + case 'profile': + return ( + + {row} + + ) + case 'inviteLink': + if (!inviteLinkControl) return row + return ( + + ) + default: + return row + } } diff --git a/src/components/dms/getSystemMessageInfo.ts b/src/components/dms/getSystemMessageInfo.ts index fe9525d4b2..f01d72cdde 100644 --- a/src/components/dms/getSystemMessageInfo.ts +++ b/src/components/dms/getSystemMessageInfo.ts @@ -16,17 +16,31 @@ import { } from '#/components/icons/Lock' import {PencilLine_Stroke2_Corner0_Rounded as PencilIcon} from '#/components/icons/Pencil' +export type SystemMessageAction = + | { + kind: 'profile' + profile: ChatBskyActorDefs.ProfileViewBasic + displayName: string + } + | {kind: 'inviteLink'} + export type SystemMessageInfo = { message: MessageDescriptor Icon: React.ComponentType + action?: SystemMessageAction } -function getReferredDisplayName( +function getProfileAction( user: ChatBskyConvoDefs.SystemMessageReferredUser, relatedProfiles: Map, -): string | null { +): Extract | null { const profile = relatedProfiles.get(user.did) - return profile ? createSanitizedDisplayName(profile) : null + if (!profile) return null + return { + kind: 'profile', + profile, + displayName: createSanitizedDisplayName(profile), + } } export function getSystemMessageInfo( @@ -34,34 +48,40 @@ export function getSystemMessageInfo( relatedProfiles: Map, ): SystemMessageInfo | null { if (ChatBskyConvoDefs.isSystemMessageDataAddMember(data)) { - const name = getReferredDisplayName(data.member, relatedProfiles) + const action = getProfileAction(data.member, relatedProfiles) return { Icon: JoinIcon, - message: name - ? msg`${name} was added to the group` + message: action + ? msg`${action.displayName} was added to the group` : msg`Someone was added to the group`, + action: action ?? undefined, } } else if (ChatBskyConvoDefs.isSystemMessageDataRemoveMember(data)) { - const name = getReferredDisplayName(data.member, relatedProfiles) + const action = getProfileAction(data.member, relatedProfiles) return { Icon: LeaveIcon, - message: name - ? msg`${name} was removed from the group` + message: action + ? msg`${action.displayName} was removed from the group` : msg`Someone was removed from the group`, + action: action ?? undefined, } } else if (ChatBskyConvoDefs.isSystemMessageDataMemberJoin(data)) { - const name = getReferredDisplayName(data.member, relatedProfiles) + const action = getProfileAction(data.member, relatedProfiles) return { Icon: JoinIcon, - message: name - ? msg`${name} joined the group` + message: action + ? msg`${action.displayName} joined the group` : msg`Someone joined the group`, + action: action ?? undefined, } } else if (ChatBskyConvoDefs.isSystemMessageDataMemberLeave(data)) { - const name = getReferredDisplayName(data.member, relatedProfiles) + const action = getProfileAction(data.member, relatedProfiles) return { Icon: LeaveIcon, - message: name ? msg`${name} left the group` : msg`Someone left the group`, + message: action + ? msg`${action.displayName} left the group` + : msg`Someone left the group`, + action: action ?? undefined, } } else if (ChatBskyConvoDefs.isSystemMessageDataLockConvo(data)) { return {Icon: LockIcon, message: msg`Chat locked`} @@ -77,13 +97,29 @@ export function getSystemMessageInfo( : msg`Chat title changed`, } } else if (ChatBskyConvoDefs.isSystemMessageDataCreateJoinLink(data)) { - return {Icon: ChainLinkIcon, message: msg`Invite link created`} + return { + Icon: ChainLinkIcon, + message: msg`Invite link created`, + action: {kind: 'inviteLink'}, + } } else if (ChatBskyConvoDefs.isSystemMessageDataEditJoinLink(data)) { - return {Icon: ChainLinkIcon, message: msg`Invite link edited`} + return { + Icon: ChainLinkIcon, + message: msg`Invite link edited`, + action: {kind: 'inviteLink'}, + } } else if (ChatBskyConvoDefs.isSystemMessageDataEnableJoinLink(data)) { - return {Icon: ChainLinkIcon, message: msg`Invite link enabled`} + return { + Icon: ChainLinkIcon, + message: msg`Invite link enabled`, + action: {kind: 'inviteLink'}, + } } else if (ChatBskyConvoDefs.isSystemMessageDataDisableJoinLink(data)) { - return {Icon: ChainLinkBrokenIcon, message: msg`Invite link disabled`} + return { + Icon: ChainLinkBrokenIcon, + message: msg`Invite link disabled`, + action: {kind: 'inviteLink'}, + } } return null } diff --git a/src/components/dms/util.ts b/src/components/dms/util.ts index 490db31c6b..0fc11d0d5e 100644 --- a/src/components/dms/util.ts +++ b/src/components/dms/util.ts @@ -4,6 +4,9 @@ import {EMOJI_REACTION_LIMIT} from '#/lib/constants' import {logger} from '#/logger' import * as bsky from '#/types/bsky' +export const MESSAGE_GAP_THRESHOLD_MS = 60 * 60 * 1000 +export const CLUSTERED_MESSAGE_THRESHOLD_MS = 5 * 60 * 1000 + export function canBeMessaged(profile: bsky.profile.AnyProfileView) { switch (profile.associated?.chat?.allowIncoming) { case 'none': diff --git a/src/screens/Messages/components/InviteLinkDialogProvider.tsx b/src/screens/Messages/components/InviteLinkDialogProvider.tsx new file mode 100644 index 0000000000..ab5185c5e6 --- /dev/null +++ b/src/screens/Messages/components/InviteLinkDialogProvider.tsx @@ -0,0 +1,62 @@ +import {createContext, useContext} from 'react' + +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useSession} from '#/state/session' +import * as Dialog from '#/components/Dialog' +import {type ConvoWithDetails} from '#/components/dms/util' +import {InviteLinkDialog} from './InviteLinkDialog' + +const Context = createContext(null) + +export function useInviteLinkDialog() { + return useContext(Context) +} + +export function InviteLinkDialogProvider({ + convo, + children, +}: { + convo: ConvoWithDetails | undefined + children: React.ReactNode +}) { + if (convo?.kind !== 'group') { + return <>{children} + } + return ( + + {children} + + ) +} + +function GroupInviteLinkDialogProvider({ + convo, + children, +}: { + convo: Extract + children: React.ReactNode +}) { + const {currentAccount} = useSession() + const control = Dialog.useDialogControl() + const moderationOpts = useModerationOpts() + const owner = convo.primaryMember + + if (!owner || !moderationOpts) { + return <>{children} + } + + const isOwner = owner.did === currentAccount?.did + + return ( + + {children} + + + ) +} diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx index d42a32f71a..aefad42223 100644 --- a/src/screens/Messages/components/MessagesList.tsx +++ b/src/screens/Messages/components/MessagesList.tsx @@ -43,11 +43,7 @@ import { isConvoActive, useConvoActive, } from '#/state/messages/convo' -import { - type ConvoItem, - type ConvoState, - ConvoStatus, -} from '#/state/messages/convo/types' +import {type ConvoState, ConvoStatus} from '#/state/messages/convo/types' import {useGetPost} from '#/state/queries/post' import {useAgent} from '#/state/session' import {List, type ListMethods} from '#/view/com/util/List' @@ -56,14 +52,18 @@ import {MessageInput} from '#/screens/Messages/components/MessageInput' import {MessageListError} from '#/screens/Messages/components/MessageListError' import {atoms as a, platform, tokens, useTheme, web} from '#/alf' import {ChatEmptyPill} from '#/components/dms/ChatEmptyPill' +import {DateDivider} from '#/components/dms/DateDivider' import {MessageItem} from '#/components/dms/MessageItem' import {NewMessagesPill} from '#/components/dms/NewMessagesPill' +import {SystemMessageGroup} from '#/components/dms/SystemMessageGroup' import {SystemMessageItem} from '#/components/dms/SystemMessageItem' import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' import {useAnalytics} from '#/analytics' import {IS_ANDROID, IS_NATIVE, IS_WEB} from '#/env' import {ChatStatusInfo} from './ChatStatusInfo' +import {groupSystemMessages, type RenderItem} from './groupSystemMessages' +import {InviteLinkDialogProvider} from './InviteLinkDialogProvider' import {MessageInputEmbed, useMessageEmbed} from './MessageInputEmbed' import {MessagesListInfoPanel} from './MessagesListInfoPanel' import {KeyboardStickyView} from './vendor/KeyboardStickyView' @@ -82,12 +82,12 @@ function MaybeLoader({isLoading}: {isLoading: boolean}) { ) } -function keyExtractor(item: ConvoItem) { +function keyExtractor(item: RenderItem) { return item.key } function getNeighborMessage( - items: ConvoItem[], + items: RenderItem[], index: number, ): ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.DeletedMessageView | null { const neighbor = items[index] @@ -134,6 +134,23 @@ export function MessagesList({ const textInputId = 'chat-input-' + useId() const flatListRef = useAnimatedRef() + const [expandedGroups, setExpandedGroups] = useState>( + () => new Set(), + ) + const onToggleGroup = (key: string) => { + setExpandedGroups(prev => { + const next = new Set(prev) + if (next.has(key)) { + next.delete(key) + } else { + next.add(key) + } + return next + }) + } + + const renderItems = groupSystemMessages(convoState.items) + const [newMessagesPill, setNewMessagesPill] = useState({ show: false, startContentOffset: 0, @@ -210,7 +227,7 @@ export function MessagesList({ // Initial scroll to bottom — unconditional, not gated on isAtBottom. This is separated because contentInset // can cause an early onScroll with a negative offset that sets isAtBottom to false before we get here. - if (!hasInitiallyScrolled.current && convoState.items.length > 0) { + if (!hasInitiallyScrolled.current && renderItems.length > 0) { hasInitiallyScrolled.current = true flatListRef.current?.scrollToOffset({offset: height, animated: false}) // If history is already done loading, mark ready after a frame for the scroll to settle. @@ -221,7 +238,7 @@ export function MessagesList({ }) } prevContentHeight.current = height - prevItemCount.current = convoState.items.length + prevItemCount.current = renderItems.length return } @@ -235,7 +252,7 @@ export function MessagesList({ didBackground.current && hasScrolled && height - prevContentHeight.current > layoutHeight.get() - 50 && - convoState.items.length - prevItemCount.current > 1 + renderItems.length - prevItemCount.current > 1 ) { flatListRef.current?.scrollToOffset({ offset: prevContentHeight.current - 65, @@ -254,14 +271,14 @@ export function MessagesList({ } prevContentHeight.current = height - prevItemCount.current = convoState.items.length + prevItemCount.current = renderItems.length didBackground.current = false }, [ hasScrolled, setHasScrolled, convoState.isFetchingHistory, - convoState.items.length, + renderItems.length, // these are stable flatListRef, isAtTop, @@ -390,14 +407,14 @@ export function MessagesList({ }) }, [flatListRef]) - const renderItem = ({item, index}: {item: ConvoItem; index: number}) => { + const renderItem = ({item, index}: {item: RenderItem; index: number}) => { if (item.type === 'message' || item.type === 'pending-message') { return ( ) @@ -410,6 +427,17 @@ export function MessagesList({ relatedProfiles={convoState.relatedProfiles} /> ) + } else if (item.type === 'system-message-group') { + return ( + + ) + } else if (item.type === 'system-message-date-divider') { + return } else if (item.type === 'error') { return } @@ -439,7 +467,7 @@ export function MessagesList({ ) return ( - <> + {newMessagesPill.show && } - + ) } diff --git a/src/screens/Messages/components/groupSystemMessages.ts b/src/screens/Messages/components/groupSystemMessages.ts new file mode 100644 index 0000000000..5a6de8fb92 --- /dev/null +++ b/src/screens/Messages/components/groupSystemMessages.ts @@ -0,0 +1,100 @@ +import {type ConvoItem} from '#/state/messages/convo/types' +import {localDateString, MESSAGE_GAP_THRESHOLD_MS} from '#/components/dms/util' + +export type SystemMessageItem = Extract + +export type SystemMessageGroupItem = { + type: 'system-message-group' + key: string + items: SystemMessageItem[] +} + +export type SystemMessageDateDividerItem = { + type: 'system-message-date-divider' + key: string + sentAt: string +} + +export type RenderItem = + | ConvoItem + | SystemMessageGroupItem + | SystemMessageDateDividerItem + +function getSentAt(item: ConvoItem): string | null { + if ( + item.type === 'message' || + item.type === 'pending-message' || + item.type === 'deleted-message' || + item.type === 'system-message' + ) { + return item.message.sentAt + } + return null +} + +export function groupSystemMessages(items: ConvoItem[]): RenderItem[] { + const result: RenderItem[] = [] + let run: SystemMessageItem[] = [] + let lastSentAt: string | null = null + let runAnchor: string | null = null + + const flush = () => { + if (run.length === 0) return + + const firstSentAt = run[0].message.sentAt + const hasLargeGap = + runAnchor === null || + new Date(firstSentAt).getTime() - new Date(runAnchor).getTime() > + MESSAGE_GAP_THRESHOLD_MS + + if (hasLargeGap) { + result.push({ + type: 'system-message-date-divider', + key: `system-message-date-divider:${run[0].key}`, + sentAt: firstSentAt, + }) + } + + if (run.length < 4) { + for (const item of run) result.push(item) + } else { + // Key off the first member's id so the key stays stable when a new + // system message arrives at the end of the run (the common case). + // Trade-off: If older history pagination prepends a system message + // that extends the run backward, the first member changes and this + // group collapses. + result.push({ + type: 'system-message-group', + key: `system-message-group:${run[0].key}`, + items: run, + }) + } + run = [] + } + + for (const item of items) { + if (item.type === 'system-message') { + const day = localDateString(new Date(item.message.sentAt)) + const lastDay = + run.length > 0 + ? localDateString(new Date(run[run.length - 1].message.sentAt)) + : null + if (lastDay !== null && lastDay !== day) { + flush() + } + if (run.length === 0) { + runAnchor = lastSentAt + } + run.push(item) + } else { + flush() + result.push(item) + } + + const sentAt = getSentAt(item) + if (sentAt) lastSentAt = sentAt + } + flush() + + return result +}