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 ( {children} ) } case 'group': { return ( {children} ) } default: { return null } } } function DirectChatItem({ convo, moderationOpts, showMenu, selected, children, }: { convo: Extract 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 ( } 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 : ( ) }> {children} ) } function GroupChatItem({ convo, moderationOpts, showMenu, selected, children, }: { convo: Extract 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 ( } 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} ) } 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 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 | 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(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(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 ( {avatar} // see onPress: old-typed dms/util view into the new-typed cache precacheConvoQuery( queryClient, bsky.toLex(convo.view), ) } onPress={onPress} onLongPress={showMenu && IS_NATIVE ? onLongPress : undefined} onAccessibilityAction={showMenu ? onLongPress : undefined}> {({hovered, pressed, focused}) => ( {/* Avatar goes here */} {title} {showProfileBadges && primaryProfile && ( )} {lastMessageSentAt && ( {({timeElapsed}) => ( {timeElapsed} )} )} {(convo.view.muted || isBlockedAccount) && ( {' '} )} {hasUnread && ( )} {subtitle && ( {subtitle} )} {postAlerts} {requestInfo && ( {requestInfo} )} {LastMessageIcon && ( )} {lastMessage} {children} )} {/* TODO: Allow showing menu for groups where the owner has left! */} {showMenu && primaryProfile && ( 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, }, ]} /> )} ) }