Group runs of system messages under a single toggle (#10388)
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user