Group runs of system messages under a single toggle (#10388)
This commit is contained in:
@@ -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,
|
||||
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<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({
|
||||
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 (
|
||||
<MessageItem
|
||||
item={item}
|
||||
isGroupChat={convoState.convo.kind === 'group'}
|
||||
prevMessage={getNeighborMessage(convoState.items, index - 1)}
|
||||
nextMessage={getNeighborMessage(convoState.items, index + 1)}
|
||||
prevMessage={getNeighborMessage(renderItems, index - 1)}
|
||||
nextMessage={getNeighborMessage(renderItems, index + 1)}
|
||||
relatedProfiles={convoState.relatedProfiles}
|
||||
/>
|
||||
)
|
||||
@@ -410,6 +427,17 @@ export function MessagesList({
|
||||
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') {
|
||||
return <MessageListError item={item} />
|
||||
}
|
||||
@@ -439,7 +467,7 @@ export function MessagesList({
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<InviteLinkDialogProvider convo={convoState.convo}>
|
||||
<KeyboardGestureArea
|
||||
interpolator="ios"
|
||||
// HACKFIX: https://github.com/kirillzyusko/react-native-keyboard-controller/issues/1419
|
||||
@@ -451,7 +479,7 @@ export function MessagesList({
|
||||
<ScrollProvider onScroll={onScroll}>
|
||||
<List
|
||||
ref={flatListRef}
|
||||
data={convoState.items}
|
||||
data={renderItems}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
disableFullWindowScroll={true}
|
||||
@@ -543,7 +571,7 @@ export function MessagesList({
|
||||
</KeyboardGestureArea>
|
||||
|
||||
{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
|
||||
}
|
||||
Reference in New Issue
Block a user