Files
bsky-social-app/src/screens/Messages/components/ChatListItem.tsx
T
Samuel Newman 300a50b69a migrate runtime call sites to lex clients and sdk actions
Phase 3 tasks 3-7 (parallel wave): composer/post pipeline on
pdsClient/appviewClient with structural+instance blob guards and a golden
CID fixture test; chat Convo/EventBus/queries on the dedicated chat client;
preferences sugar to SDK actions on the PDS client; remaining state/queries
producers (usePostThread unspecced flip, video scoped-token clients,
notifications, starter packs, lists) to client.call; UI runtime sweep
(AtUri from @atproto/syntax, moderation fns from @bsky.app/sdk/moderation,
SDK RichText, ozone reason tokens, guard rewrites via #/types/bsky).

Intermediate checkpoint (hooks skipped): ~114 typecheck errors remain in
cross-boundary consumer files, resolved by the type-only codemod next.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 18:12:10 +03:00

726 lines
22 KiB
TypeScript

import {useCallback, useMemo, useState} from 'react'
import {type GestureResponderEvent, View} from 'react-native'
import {type ChatBskyConvoDefs} from '@atproto/api'
import {
moderateProfile,
type ModerationDecision,
type ModerationOpts,
} from '@bsky.app/sdk/moderation'
import {plural} from '@lingui/core/macro'
import {useLingui} from '@lingui/react/macro'
import {useQueryClient} from '@tanstack/react-query'
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 {sanitizeHandle} from '#/lib/strings/handles'
import {
type Shadow,
useMaybeProfileShadow,
useProfileShadow,
} from '#/state/cache/profile-shadow'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {
precacheConvoQuery,
useMarkAsReadMutation,
} from '#/state/queries/messages/conversation'
import {JOIN_REQUESTS_THRESHOLD} from '#/state/queries/messages/list-join-requests'
import {unstableCacheProfileView} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {TimeElapsed} from '#/view/com/util/TimeElapsed'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
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 {type ConvoWithDetails, parseConvoView} from '#/components/dms/util'
import {Bell2Off_Filled_Corner0_Rounded as BellStrokeIcon} from '#/components/icons/Bell2'
import {type Props as SVGIconProps} from '#/components/icons/common'
import {Envelope_Open_Stroke2_Corner0_Rounded as EnvelopeOpenIcon} from '#/components/icons/EnveopeOpen'
import {Lock_Stroke2_Corner2_Rounded as LockIcon} from '#/components/icons/Lock'
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
import {Link} from '#/components/Link'
import {useMenuControl} from '#/components/Menu'
import {PostAlerts} from '#/components/moderation/PostAlerts'
import {createPortalGroup} from '#/components/Portal'
import {ProfileBadges} from '#/components/ProfileBadges'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_NATIVE} from '#/env'
import {chat} from '#/lexicons'
import * as bsky from '#/types/bsky'
import {useIsWithinSplitView} from './splitView/context'
export const ChatListItemPortal = createPortalGroup()
export function ChatListItem({
convo: convoView,
showMenu = true,
selected = false,
children,
}: {
convo: ChatBskyConvoDefs.ConvoView
showMenu?: boolean
selected?: boolean
children?: React.ReactNode
}) {
const {currentAccount} = useSession()
const moderationOpts = useModerationOpts()
if (!moderationOpts) {
return null
}
const convo = parseConvoView(convoView, currentAccount?.did)
switch (convo?.kind) {
case 'direct': {
return (
<DirectChatItem
convo={convo}
moderationOpts={moderationOpts}
showMenu={showMenu}
selected={selected}>
{children}
</DirectChatItem>
)
}
case 'group': {
return (
<GroupChatItem
convo={convo}
moderationOpts={moderationOpts}
showMenu={showMenu}
selected={selected}>
{children}
</GroupChatItem>
)
}
default: {
return null
}
}
}
function DirectChatItem({
convo,
moderationOpts,
showMenu,
selected,
children,
}: {
convo: Extract<ConvoWithDetails, {kind: 'direct'}>
moderationOpts: ModerationOpts
showMenu?: boolean
selected?: boolean
children?: React.ReactNode
}) {
const {t: l} = useLingui()
const profile = useProfileShadow(convo.primaryMember)
const {isWithinLeftPanel} = useIsWithinSplitView()
const moderation = useMemo(
// TODO(phase4): drop toLex once useProfileShadow emits #/lexicons views
() => moderateProfile(bsky.toLex(profile), moderationOpts),
[profile, moderationOpts],
)
const isDeletedAccount = profile.handle === 'missing.invalid'
const displayName = isDeletedAccount
? l`Deleted Account`
: createSanitizedDisplayName(profile, true, moderation.ui('displayName'))
return (
<BaseChatItem
convo={convo}
avatar={
<PreviewableUserAvatar
profile={profile}
size={isWithinLeftPanel ? 48 : 52}
moderation={moderation.ui('avatar')}
/>
}
primaryProfile={profile}
primaryProfileModeration={moderation}
title={displayName}
subtitle={
isDeletedAccount ? undefined : sanitizeHandle(profile.handle, '@')
}
accessibilityHint={
!isDeletedAccount
? l`Go to conversation with ${profile.handle}`
: l`This conversation is with a deleted or a deactivated account. Press for options`
}
showMenu={showMenu}
selected={selected}
isDeletedAccount={isDeletedAccount}
isBlockedAccount={moderation.blocked}
showProfileBadges
postAlerts={
isWithinLeftPanel ? null : (
<PostAlerts
modui={moderation.ui('contentList')}
style={[a.pb_2xs, a.max_w_full, a.overflow_hidden]}
/>
)
}>
{children}
</BaseChatItem>
)
}
function GroupChatItem({
convo,
moderationOpts,
showMenu,
selected,
children,
}: {
convo: Extract<ConvoWithDetails, {kind: 'group'}>
moderationOpts: ModerationOpts
showMenu?: boolean
selected?: boolean
children?: React.ReactNode
}) {
const {t: l} = useLingui()
const groupOwner = useMaybeProfileShadow(convo.primaryMember)
const {isWithinLeftPanel} = useIsWithinSplitView()
const moderation = useMemo(
() =>
groupOwner
? // TODO(phase4): drop toLex once useMaybeProfileShadow emits #/lexicons views
moderateProfile(bsky.toLex(groupOwner), moderationOpts)
: undefined,
[groupOwner, moderationOpts],
)
const chatName = convo.details.name
return (
<BaseChatItem
convo={convo}
avatar={
<AvatarBubbles
profiles={convo.members}
size={isWithinLeftPanel ? 48 : 52}
/>
}
title={chatName}
accessibilityHint={l`Go to the group chat named "${chatName}"`}
primaryProfile={groupOwner}
primaryProfileModeration={moderation}
isBlockedAccount={false}
isDeletedAccount={false}
requestInfo={
convo.details.unreadJoinRequestCount
? convo.details.unreadJoinRequestCount > JOIN_REQUESTS_THRESHOLD
? l({
message: `${JOIN_REQUESTS_THRESHOLD}+ new join requests`,
context:
'Displayed when there are more than 20 requests to join a group chat',
})
: plural(convo.details.unreadJoinRequestCount, {
one: '# new join request',
other: '# new join requests',
})
: undefined
}
showProfileBadges={false}
selected={selected}
showMenu={showMenu}>
{children}
</BaseChatItem>
)
}
function BaseChatItem({
convo,
avatar,
title,
subtitle,
requestInfo,
accessibilityHint,
isDeletedAccount,
isBlockedAccount,
primaryProfile,
primaryProfileModeration,
showMenu,
selected,
showProfileBadges,
postAlerts,
children,
}: {
convo: ConvoWithDetails
avatar: React.ReactNode
title: string
subtitle?: string
requestInfo?: string
accessibilityHint: string
isDeletedAccount: boolean
isBlockedAccount: boolean
primaryProfile?: Shadow<bsky.profile.AnyProfileView>
primaryProfileModeration?: ModerationDecision
showMenu?: boolean
selected?: boolean
showProfileBadges: boolean
postAlerts?: React.ReactNode
children?: React.ReactNode
}) {
const ax = useAnalytics()
const t = useTheme()
const {t: l, i18n} = useLingui()
const {currentAccount} = useSession()
const menuControl = useMenuControl()
const leaveConvoControl = useDialogControl()
const {mutate: markAsRead} = useMarkAsReadMutation()
const {gtMobile} = useBreakpoints()
const {isWithinLeftPanel} = useIsWithinSplitView()
const playHaptic = useHaptics()
const queryClient = useQueryClient()
const hasUnread =
!selected &&
!isDeletedAccount &&
(convo.view.unreadCount > 0 ||
(convo.kind === 'group' &&
(convo.details.unreadJoinRequestCount ?? 0) > 0)) &&
(convo.kind !== 'group' || convo.details.lockStatus === 'unlocked')
const blockInfo = useMemo(() => {
if (!primaryProfileModeration) return {listBlocks: [], userBlock: undefined}
const modui = primaryProfileModeration.ui('profileView')
const blocks = modui.alerts.filter(alert => alert.type === 'blocking')
const listBlocks = blocks.filter(alert => alert.source.type === 'list')
const userBlock = blocks.find(alert => alert.source.type === 'user')
return {
listBlocks,
userBlock,
}
}, [primaryProfileModeration])
const isDimStyle =
convo.view.muted ||
isBlockedAccount ||
isDeletedAccount ||
(convo.kind === 'group' && convo.details.lockStatus !== 'unlocked')
const {lastMessage, LastMessageIcon, lastMessageSentAt} = useMemo(() => {
let lastMessage = l`No messages yet`
let LastMessageIcon: React.ComponentType<SVGIconProps> | null = null
let lastMessageSentAt: string | null = null
// Deleted message
if (
bsky.isType(
chat.bsky.convo.defs.deletedMessageView,
convo.view.lastMessage,
)
) {
lastMessageSentAt = convo.view.lastMessage.sentAt
lastMessage = isDeletedAccount
? l`Conversation deleted`
: l`Message deleted`
}
// Message
if (bsky.isType(chat.bsky.convo.defs.messageView, convo.view.lastMessage)) {
const info = getMessageInfo({
convo: convo.view,
currentAccountDid: currentAccount?.did,
primaryProfile,
i18n,
})
if (info) {
lastMessage = info.isBlockedMessage
? l`This message is hidden`
: (info.message ?? lastMessage)
lastMessageSentAt = info.sentAt
}
}
// Reaction
if (
bsky.isType(
chat.bsky.convo.defs.messageAndReactionView,
convo.view.lastReaction,
)
) {
const info = getReactionInfo({
convo: convo.view,
currentAccountDid: currentAccount?.did,
primaryProfile,
i18n,
})
if (
info &&
!info.isBlocked &&
(!lastMessageSentAt ||
new Date(lastMessageSentAt) < new Date(info.createdAt))
) {
lastMessage = info.message
lastMessageSentAt = info.createdAt
}
}
// System message
if (
bsky.isType(
chat.bsky.convo.defs.systemMessageView,
convo.view.lastMessage,
)
) {
const info = getSystemMessageInfo(
convo.view.lastMessage.data,
new Map(convo.view.members.map(m => [m.did, m])),
{short: true},
)
if (info) {
lastMessage = i18n._(info.message)
LastMessageIcon = info.Icon
lastMessageSentAt = convo.view.lastMessage.sentAt
}
}
// Chat locked - override message
if (convo.kind === 'group' && convo.details.lockStatus !== 'unlocked') {
lastMessage = l`This chat is locked`
LastMessageIcon = LockIcon
}
return {
lastMessage,
LastMessageIcon,
lastMessageSentAt,
}
}, [l, convo, currentAccount?.did, isDeletedAccount, primaryProfile, i18n])
const [showActions, setShowActions] = useState(false)
const onMouseEnter = useCallback(() => {
setShowActions(true)
}, [])
const onMouseLeave = useCallback(() => {
setShowActions(false)
}, [])
const onFocus = useCallback<React.FocusEventHandler>(e => {
if (e.nativeEvent.relatedTarget == null) return
setShowActions(true)
}, [])
const onPress = useCallback(
(e: GestureResponderEvent) => {
for (const member of convo.view.members) {
unstableCacheProfileView(queryClient, member)
}
/*
* `convo.view` comes from the still-old-typed `#/components/dms/util`
* (migrates in a later task) while the convo cache is now keyed on the
* lexicon ConvoView. TODO(phase4): drop toLex once dms/util migrates.
*/
precacheConvoQuery(
queryClient,
bsky.toLex<chat.bsky.convo.defs.ConvoView>(convo.view),
)
void decrementBadgeCount(convo.view.unreadCount)
if (isDeletedAccount) {
e.preventDefault()
menuControl.open()
return false
} else {
ax.metric('chat:open', {logContext: 'ChatsList'})
}
},
[ax, isDeletedAccount, menuControl, queryClient, convo],
)
const onLongPress = useCallback(() => {
playHaptic()
menuControl.open()
}, [playHaptic, menuControl])
const markReadAction = {
threshold: 120,
color: t.palette.primary_500,
icon: EnvelopeOpenIcon,
action: () => {
markAsRead({
convoId: convo.view.id,
})
},
}
const deleteAction = {
threshold: 225,
color: t.palette.negative_500,
icon: TrashIcon,
action: () => {
leaveConvoControl.open()
},
}
const isGroupConvo = convo.kind === 'group'
const actions = hasUnread
? {
leftFirst: markReadAction,
leftSecond: deleteAction,
}
: {
leftFirst: deleteAction,
}
const avatarSize = isWithinLeftPanel ? 48 : 52
return (
<ChatListItemPortal.Provider>
<GestureActionView actions={actions}>
<View
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
// @ts-expect-error web only
onFocus={onFocus}
onBlur={onMouseLeave}
style={[a.relative, t.atoms.bg, isWithinLeftPanel && a.mx_sm]}>
<View
style={[
a.z_10,
a.absolute,
{top: tokens.space.md, left: tokens.space.lg},
isGroupConvo && a.pointer_events_none,
]}>
{avatar}
</View>
<Link
to={`/messages/${convo.view.id}`}
// In split view, this list stays mounted alongside the open convo,
// so push would stack duplicate routes on repeated clicks.
action={isWithinLeftPanel ? 'navigate' : 'push'}
label={title}
accessibilityHint={accessibilityHint}
accessibilityActions={
showMenu && IS_NATIVE
? [
{
name: 'magicTap',
label: l`Open conversation options`,
},
{
name: 'longpress',
label: l`Open conversation options`,
},
]
: undefined
}
onPressIn={() =>
// see onPress: old-typed dms/util view into the new-typed cache
precacheConvoQuery(
queryClient,
bsky.toLex<chat.bsky.convo.defs.ConvoView>(convo.view),
)
}
onPress={onPress}
onLongPress={showMenu && IS_NATIVE ? onLongPress : undefined}
onAccessibilityAction={showMenu ? onLongPress : undefined}>
{({hovered, pressed, focused}) => (
<View
style={[
a.flex_row,
isDeletedAccount || isGroupConvo
? a.align_center
: a.align_start,
a.flex_1,
a.px_lg,
a.py_md,
a.gap_md,
isWithinLeftPanel && [a.rounded_sm, a.mt_2xs],
{
backgroundColor: hasUnread
? t.palette.primary_25
: t.palette.contrast_0,
},
(hovered || pressed || focused) && t.atoms.bg_contrast_25,
selected && t.atoms.bg_contrast_50,
]}>
{/* Avatar goes here */}
<View style={{width: avatarSize, height: avatarSize}} />
<View
style={[a.flex_1, a.justify_center, web({paddingRight: 40})]}>
<View
style={[a.w_full, a.flex_row, a.align_center, a.pb_2xs]}>
<View style={[a.flex_shrink]}>
<Text
emoji
numberOfLines={1}
style={[
a.text_md,
t.atoms.text,
a.font_semi_bold,
{lineHeight: 21},
isDimStyle && t.atoms.text_contrast_medium,
]}>
{title}
</Text>
</View>
{showProfileBadges && primaryProfile && (
<ProfileBadges
profile={primaryProfile}
size="sm"
style={[a.pl_xs, a.self_center]}
/>
)}
{lastMessageSentAt && (
<View style={[a.pl_xs]}>
<TimeElapsed timestamp={lastMessageSentAt}>
{({timeElapsed}) => (
<Text
style={[
a.text_sm,
{lineHeight: 21},
t.atoms.text_contrast_medium,
web({whiteSpace: 'preserve nowrap'}),
]}>
{timeElapsed}
</Text>
)}
</TimeElapsed>
</View>
)}
{(convo.view.muted || isBlockedAccount) && (
<Text
style={[
a.text_sm,
{lineHeight: 21},
t.atoms.text_contrast_medium,
web({whiteSpace: 'preserve nowrap'}),
]}>
{' '}
<BellStrokeIcon
size="xs"
style={[t.atoms.text_contrast_medium]}
/>
</Text>
)}
{hasUnread && (
<View
style={[
a.rounded_full,
{
backgroundColor: isDimStyle
? t.palette.contrast_200
: t.palette.primary_500,
height: 8,
width: 8,
marginLeft: 6,
},
web({whiteSpace: 'preserve nowrap'}),
]}
/>
)}
</View>
{subtitle && (
<Text
numberOfLines={1}
style={[a.text_sm, t.atoms.text_contrast_medium, a.pb_xs]}
emoji>
{subtitle}
</Text>
)}
{postAlerts}
{requestInfo && (
<Text
numberOfLines={1}
style={[
hasUnread ? a.font_medium : t.atoms.text_contrast_high,
isDimStyle && t.atoms.text_contrast_medium,
a.pb_2xs,
]}
emoji>
{requestInfo}
</Text>
)}
<View style={[a.flex_row, a.align_center]}>
{LastMessageIcon && (
<LastMessageIcon
size="xs"
style={[
a.mr_2xs,
hasUnread
? t.atoms.text_contrast_high
: t.atoms.text_contrast_medium,
]}
/>
)}
<Text
emoji
numberOfLines={2}
style={[
hasUnread ? a.font_medium : t.atoms.text_contrast_high,
isDimStyle && t.atoms.text_contrast_medium,
]}>
{lastMessage}
</Text>
</View>
{children}
</View>
</View>
)}
</Link>
<ChatListItemPortal.Outlet />
{/* TODO: Allow showing menu for groups where the owner has left! */}
{showMenu && primaryProfile && (
<ConvoMenu
convo={convo}
profile={primaryProfile}
control={menuControl}
currentScreen="list"
showMarkAsRead={convo.view.unreadCount > 0}
hideTrigger={IS_NATIVE}
blockInfo={blockInfo}
style={[
a.absolute,
a.h_full,
a.self_end,
a.justify_center,
{
right: tokens.space.lg,
opacity:
!gtMobile || showActions || menuControl.isOpen ? 1 : 0,
},
]}
/>
)}
<LeaveConvoPrompt
control={leaveConvoControl}
convoId={convo.view.id}
currentScreen="list"
/>
</View>
</GestureActionView>
</ChatListItemPortal.Provider>
)
}