Group runs of system messages under a single toggle (#10388)

This commit is contained in:
DS Boyce
2026-05-08 15:24:34 -07:00
committed by GitHub
parent 8b9361016a
commit 75d8fb3dbd
8 changed files with 398 additions and 43 deletions
+1 -3
View File
@@ -45,6 +45,7 @@ import {Text} from '#/components/Typography'
import {DateDivider} from './DateDivider' import {DateDivider} from './DateDivider'
import {MessageItemEmbed} from './MessageItemEmbed' import {MessageItemEmbed} from './MessageItemEmbed'
import {ReactionsDialog} from './ReactionsDialog' import {ReactionsDialog} from './ReactionsDialog'
import {CLUSTERED_MESSAGE_THRESHOLD_MS, MESSAGE_GAP_THRESHOLD_MS} from './util'
const AVATAR_SIZE = 28 const AVATAR_SIZE = 28
const CLUSTERED_MESSAGE_GAP = 2 const CLUSTERED_MESSAGE_GAP = 2
@@ -52,9 +53,6 @@ const BORDER_RADIUS = 18
const SQUARED_BORDER_RADIUS = 4 const SQUARED_BORDER_RADIUS = 4
const DISPLAY_NAME_INSET = 22 const DISPLAY_NAME_INSET = 22
const CLUSTERED_MESSAGE_THRESHOLD_MS = 5 * 60 * 1000
const MESSAGE_GAP_THRESHOLD_MS = 60 * 60 * 1000
function isWithinClusterBoundary({ function isWithinClusterBoundary({
isPending, isPending,
adjacentMessage, adjacentMessage,
+100
View File
@@ -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<string, ChatBskyActorDefs.ProfileViewBasic>
}) {
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 (
<View>
<Pressable
testID="systemMessageGroupToggle"
accessibilityRole="button"
accessibilityLabel={label}
accessibilityHint={
expanded ? l`Hide group chat updates` : l`Show group chat updates`
}
accessibilityState={{expanded}}
hitSlop={HITSLOP_10}
onPress={() => onToggle(item.key)}
style={[
a.w_full,
a.flex_row,
a.align_center,
a.justify_center,
a.px_md,
a.mt_md,
]}>
<Text
style={[
a.text_xs,
a.text_center,
t.atoms.text_contrast_medium,
{includeFontPadding: false, textAlignVertical: 'center'},
]}>
{label}
</Text>
<Animated.View style={[a.ml_2xs, chevronStyle]}>
<ChevronDown size="xs" style={t.atoms.text_contrast_medium} />
</Animated.View>
</Pressable>
<Animated.View layout={LinearTransition.duration(ANIMATION_DURATION_MS)}>
{expanded
? item.items.map(child => (
<Animated.View
key={child.key}
entering={FadeIn.duration(ANIMATION_DURATION_MS)}
exiting={FadeOut.duration(ANIMATION_DURATION_MS)}>
<SystemMessageItem
item={child}
relatedProfiles={relatedProfiles}
/>
</Animated.View>
))
: null}
</Animated.View>
</View>
)
}
+32 -4
View File
@@ -2,9 +2,13 @@ import {View} from 'react-native'
import {type ChatBskyActorDefs} from '@atproto/api' import {type ChatBskyActorDefs} from '@atproto/api'
import {useLingui} from '@lingui/react/macro' import {useLingui} from '@lingui/react/macro'
import {makeProfileLink} from '#/lib/routes/links'
import {type ConvoItem} from '#/state/messages/convo/types' import {type ConvoItem} from '#/state/messages/convo/types'
import {useInviteLinkDialog} from '#/screens/Messages/components/InviteLinkDialogProvider'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {getSystemMessageInfo} from '#/components/dms/getSystemMessageInfo' import {getSystemMessageInfo} from '#/components/dms/getSystemMessageInfo'
import {Link} from '#/components/Link'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
export function SystemMessageItem({ export function SystemMessageItem({
@@ -15,14 +19,16 @@ export function SystemMessageItem({
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic> relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>
}) { }) {
const t = useTheme() const t = useTheme()
const {i18n} = useLingui() const {i18n, t: l} = useLingui()
const inviteLinkControl = useInviteLinkDialog()
const info = getSystemMessageInfo(item.message.data, relatedProfiles) const info = getSystemMessageInfo(item.message.data, relatedProfiles)
if (!info) return null if (!info) return null
const {Icon, message} = info const {Icon, action} = info
const text = i18n._(info.message)
return ( const row = (
<View <View
style={[ style={[
a.w_full, a.w_full,
@@ -40,8 +46,30 @@ export function SystemMessageItem({
t.atoms.text_contrast_medium, t.atoms.text_contrast_medium,
{includeFontPadding: false, textAlignVertical: 'center'}, {includeFontPadding: false, textAlignVertical: 'center'},
]}> ]}>
{i18n._(message)} {text}
</Text> </Text>
</View> </View>
) )
switch (action?.kind) {
case 'profile':
return (
<Link
to={makeProfileLink(action.profile)}
label={text}
accessibilityHint={l`Opens profile`}
style={a.w_full}>
{row}
</Link>
)
case 'inviteLink':
if (!inviteLinkControl) return row
return (
<Button label={text} onPress={inviteLinkControl.open} style={a.w_full}>
{row}
</Button>
)
default:
return row
}
} }
+54 -18
View File
@@ -16,17 +16,31 @@ import {
} from '#/components/icons/Lock' } from '#/components/icons/Lock'
import {PencilLine_Stroke2_Corner0_Rounded as PencilIcon} from '#/components/icons/Pencil' 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 = { export type SystemMessageInfo = {
message: MessageDescriptor message: MessageDescriptor
Icon: React.ComponentType<SVGIconProps> Icon: React.ComponentType<SVGIconProps>
action?: SystemMessageAction
} }
function getReferredDisplayName( function getProfileAction(
user: ChatBskyConvoDefs.SystemMessageReferredUser, user: ChatBskyConvoDefs.SystemMessageReferredUser,
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>, relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>,
): string | null { ): Extract<SystemMessageAction, {kind: 'profile'}> | null {
const profile = relatedProfiles.get(user.did) 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( export function getSystemMessageInfo(
@@ -34,34 +48,40 @@ export function getSystemMessageInfo(
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>, relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>,
): SystemMessageInfo | null { ): SystemMessageInfo | null {
if (ChatBskyConvoDefs.isSystemMessageDataAddMember(data)) { if (ChatBskyConvoDefs.isSystemMessageDataAddMember(data)) {
const name = getReferredDisplayName(data.member, relatedProfiles) const action = getProfileAction(data.member, relatedProfiles)
return { return {
Icon: JoinIcon, Icon: JoinIcon,
message: name message: action
? msg`${name} was added to the group` ? msg`${action.displayName} was added to the group`
: msg`Someone was added to the group`, : msg`Someone was added to the group`,
action: action ?? undefined,
} }
} else if (ChatBskyConvoDefs.isSystemMessageDataRemoveMember(data)) { } else if (ChatBskyConvoDefs.isSystemMessageDataRemoveMember(data)) {
const name = getReferredDisplayName(data.member, relatedProfiles) const action = getProfileAction(data.member, relatedProfiles)
return { return {
Icon: LeaveIcon, Icon: LeaveIcon,
message: name message: action
? msg`${name} was removed from the group` ? msg`${action.displayName} was removed from the group`
: msg`Someone was removed from the group`, : msg`Someone was removed from the group`,
action: action ?? undefined,
} }
} else if (ChatBskyConvoDefs.isSystemMessageDataMemberJoin(data)) { } else if (ChatBskyConvoDefs.isSystemMessageDataMemberJoin(data)) {
const name = getReferredDisplayName(data.member, relatedProfiles) const action = getProfileAction(data.member, relatedProfiles)
return { return {
Icon: JoinIcon, Icon: JoinIcon,
message: name message: action
? msg`${name} joined the group` ? msg`${action.displayName} joined the group`
: msg`Someone joined the group`, : msg`Someone joined the group`,
action: action ?? undefined,
} }
} else if (ChatBskyConvoDefs.isSystemMessageDataMemberLeave(data)) { } else if (ChatBskyConvoDefs.isSystemMessageDataMemberLeave(data)) {
const name = getReferredDisplayName(data.member, relatedProfiles) const action = getProfileAction(data.member, relatedProfiles)
return { return {
Icon: LeaveIcon, 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)) { } else if (ChatBskyConvoDefs.isSystemMessageDataLockConvo(data)) {
return {Icon: LockIcon, message: msg`Chat locked`} return {Icon: LockIcon, message: msg`Chat locked`}
@@ -77,13 +97,29 @@ export function getSystemMessageInfo(
: msg`Chat title changed`, : msg`Chat title changed`,
} }
} else if (ChatBskyConvoDefs.isSystemMessageDataCreateJoinLink(data)) { } 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)) { } 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)) { } 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)) { } 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 return null
} }
+3
View File
@@ -4,6 +4,9 @@ import {EMOJI_REACTION_LIMIT} from '#/lib/constants'
import {logger} from '#/logger' import {logger} from '#/logger'
import * as bsky from '#/types/bsky' 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) { export function canBeMessaged(profile: bsky.profile.AnyProfileView) {
switch (profile.associated?.chat?.allowIncoming) { switch (profile.associated?.chat?.allowIncoming) {
case 'none': case 'none':
@@ -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<Dialog.DialogControlProps | null>(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 (
<GroupInviteLinkDialogProvider convo={convo}>
{children}
</GroupInviteLinkDialogProvider>
)
}
function GroupInviteLinkDialogProvider({
convo,
children,
}: {
convo: Extract<ConvoWithDetails, {kind: 'group'}>
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 (
<Context.Provider value={control}>
{children}
<InviteLinkDialog
convo={convo}
control={control}
owner={owner}
isOwner={isOwner}
moderationOpts={moderationOpts}
/>
</Context.Provider>
)
}
@@ -43,11 +43,7 @@ import {
isConvoActive, isConvoActive,
useConvoActive, useConvoActive,
} from '#/state/messages/convo' } from '#/state/messages/convo'
import { import {type ConvoState, ConvoStatus} from '#/state/messages/convo/types'
type ConvoItem,
type ConvoState,
ConvoStatus,
} from '#/state/messages/convo/types'
import {useGetPost} from '#/state/queries/post' import {useGetPost} from '#/state/queries/post'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {List, type ListMethods} from '#/view/com/util/List' 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 {MessageListError} from '#/screens/Messages/components/MessageListError'
import {atoms as a, platform, tokens, useTheme, web} from '#/alf' import {atoms as a, platform, tokens, useTheme, web} from '#/alf'
import {ChatEmptyPill} from '#/components/dms/ChatEmptyPill' import {ChatEmptyPill} from '#/components/dms/ChatEmptyPill'
import {DateDivider} from '#/components/dms/DateDivider'
import {MessageItem} from '#/components/dms/MessageItem' import {MessageItem} from '#/components/dms/MessageItem'
import {NewMessagesPill} from '#/components/dms/NewMessagesPill' import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
import {SystemMessageGroup} from '#/components/dms/SystemMessageGroup'
import {SystemMessageItem} from '#/components/dms/SystemMessageItem' import {SystemMessageItem} from '#/components/dms/SystemMessageItem'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
import {IS_ANDROID, IS_NATIVE, IS_WEB} from '#/env' import {IS_ANDROID, IS_NATIVE, IS_WEB} from '#/env'
import {ChatStatusInfo} from './ChatStatusInfo' import {ChatStatusInfo} from './ChatStatusInfo'
import {groupSystemMessages, type RenderItem} from './groupSystemMessages'
import {InviteLinkDialogProvider} from './InviteLinkDialogProvider'
import {MessageInputEmbed, useMessageEmbed} from './MessageInputEmbed' import {MessageInputEmbed, useMessageEmbed} from './MessageInputEmbed'
import {MessagesListInfoPanel} from './MessagesListInfoPanel' import {MessagesListInfoPanel} from './MessagesListInfoPanel'
import {KeyboardStickyView} from './vendor/KeyboardStickyView' 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 return item.key
} }
function getNeighborMessage( function getNeighborMessage(
items: ConvoItem[], items: RenderItem[],
index: number, index: number,
): ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.DeletedMessageView | null { ): ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.DeletedMessageView | null {
const neighbor = items[index] const neighbor = items[index]
@@ -134,6 +134,23 @@ export function MessagesList({
const textInputId = 'chat-input-' + useId() const textInputId = 'chat-input-' + useId()
const flatListRef = useAnimatedRef<ListMethods>() const flatListRef = useAnimatedRef<ListMethods>()
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(
() => 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({ const [newMessagesPill, setNewMessagesPill] = useState({
show: false, show: false,
startContentOffset: 0, startContentOffset: 0,
@@ -210,7 +227,7 @@ export function MessagesList({
// Initial scroll to bottom — unconditional, not gated on isAtBottom. This is separated because contentInset // 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. // 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 hasInitiallyScrolled.current = true
flatListRef.current?.scrollToOffset({offset: height, animated: false}) flatListRef.current?.scrollToOffset({offset: height, animated: false})
// If history is already done loading, mark ready after a frame for the scroll to settle. // 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 prevContentHeight.current = height
prevItemCount.current = convoState.items.length prevItemCount.current = renderItems.length
return return
} }
@@ -235,7 +252,7 @@ export function MessagesList({
didBackground.current && didBackground.current &&
hasScrolled && hasScrolled &&
height - prevContentHeight.current > layoutHeight.get() - 50 && height - prevContentHeight.current > layoutHeight.get() - 50 &&
convoState.items.length - prevItemCount.current > 1 renderItems.length - prevItemCount.current > 1
) { ) {
flatListRef.current?.scrollToOffset({ flatListRef.current?.scrollToOffset({
offset: prevContentHeight.current - 65, offset: prevContentHeight.current - 65,
@@ -254,14 +271,14 @@ export function MessagesList({
} }
prevContentHeight.current = height prevContentHeight.current = height
prevItemCount.current = convoState.items.length prevItemCount.current = renderItems.length
didBackground.current = false didBackground.current = false
}, },
[ [
hasScrolled, hasScrolled,
setHasScrolled, setHasScrolled,
convoState.isFetchingHistory, convoState.isFetchingHistory,
convoState.items.length, renderItems.length,
// these are stable // these are stable
flatListRef, flatListRef,
isAtTop, isAtTop,
@@ -390,14 +407,14 @@ export function MessagesList({
}) })
}, [flatListRef]) }, [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') { if (item.type === 'message' || item.type === 'pending-message') {
return ( return (
<MessageItem <MessageItem
item={item} item={item}
isGroupChat={convoState.convo.kind === 'group'} isGroupChat={convoState.convo.kind === 'group'}
prevMessage={getNeighborMessage(convoState.items, index - 1)} prevMessage={getNeighborMessage(renderItems, index - 1)}
nextMessage={getNeighborMessage(convoState.items, index + 1)} nextMessage={getNeighborMessage(renderItems, index + 1)}
relatedProfiles={convoState.relatedProfiles} relatedProfiles={convoState.relatedProfiles}
/> />
) )
@@ -410,6 +427,17 @@ export function MessagesList({
relatedProfiles={convoState.relatedProfiles} relatedProfiles={convoState.relatedProfiles}
/> />
) )
} else if (item.type === 'system-message-group') {
return (
<SystemMessageGroup
item={item}
expanded={expandedGroups.has(item.key)}
onToggle={onToggleGroup}
relatedProfiles={convoState.relatedProfiles}
/>
)
} else if (item.type === 'system-message-date-divider') {
return <DateDivider date={item.sentAt} />
} else if (item.type === 'error') { } else if (item.type === 'error') {
return <MessageListError item={item} /> return <MessageListError item={item} />
} }
@@ -439,7 +467,7 @@ export function MessagesList({
) )
return ( return (
<> <InviteLinkDialogProvider convo={convoState.convo}>
<KeyboardGestureArea <KeyboardGestureArea
interpolator="ios" interpolator="ios"
// HACKFIX: https://github.com/kirillzyusko/react-native-keyboard-controller/issues/1419 // HACKFIX: https://github.com/kirillzyusko/react-native-keyboard-controller/issues/1419
@@ -451,7 +479,7 @@ export function MessagesList({
<ScrollProvider onScroll={onScroll}> <ScrollProvider onScroll={onScroll}>
<List <List
ref={flatListRef} ref={flatListRef}
data={convoState.items} data={renderItems}
renderItem={renderItem} renderItem={renderItem}
keyExtractor={keyExtractor} keyExtractor={keyExtractor}
disableFullWindowScroll={true} disableFullWindowScroll={true}
@@ -543,7 +571,7 @@ export function MessagesList({
</KeyboardGestureArea> </KeyboardGestureArea>
{newMessagesPill.show && <NewMessagesPill onPress={scrollToEndOnPress} />} {newMessagesPill.show && <NewMessagesPill onPress={scrollToEndOnPress} />}
</> </InviteLinkDialogProvider>
) )
} }
@@ -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<ConvoItem, {type: 'system-message'}>
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
}