import {useCallback, useMemo, useState} from 'react' import {type GestureResponderEvent, View} from 'react-native' import { ChatBskyConvoDefs, moderateProfile, type ModerationDecision, type ModerationOpts, } from '@atproto/api' 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, useProfileShadow} from '#/state/cache/profile-shadow' import {useModerationOpts} from '#/state/preferences/moderation-opts' import { precacheConvoQuery, useMarkAsReadMutation, } from '#/state/queries/messages/conversation' 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 BellStroke} from '#/components/icons/Bell2' import {Envelope_Open_Stroke2_Corner0_Rounded as EnvelopeOpen} from '#/components/icons/EnveopeOpen' import {Trash_Stroke2_Corner0_Rounded} 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 type * as bsky from '#/types/bsky' export const ChatListItemPortal = createPortalGroup() export function ChatListItem({ convo: convoView, showMenu = true, children, }: { convo: ChatBskyConvoDefs.ConvoView showMenu?: 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, children, }: { convo: Extract moderationOpts: ModerationOpts showMenu?: boolean children?: React.ReactNode }) { const {t: l} = useLingui() const profile = useProfileShadow(convo.primaryMember) const moderation = useMemo( () => moderateProfile(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} isDeletedAccount={isDeletedAccount} isBlockedAccount={moderation.blocked} showProfileBadges postAlerts={ }> {children} ) } function GroupChatItem({ convo, moderationOpts, showMenu, children, }: { convo: Extract moderationOpts: ModerationOpts showMenu?: boolean children?: React.ReactNode }) { const {t: l} = useLingui() const groupOwner = useProfileShadow(convo.primaryMember) const moderation = useMemo( () => moderateProfile(groupOwner, moderationOpts), [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} showProfileBadges={false} showMenu={showMenu}> {children} ) } function BaseChatItem({ convo, avatar, title, subtitle, accessibilityHint, isDeletedAccount, isBlockedAccount, primaryProfile, primaryProfileModeration, showMenu, showProfileBadges, postAlerts, children, }: { convo: ChatBskyConvoDefs.ConvoView avatar: React.ReactNode title: string subtitle?: string accessibilityHint: string isDeletedAccount: boolean isBlockedAccount: boolean primaryProfile: Shadow primaryProfileModeration: ModerationDecision showMenu?: 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 playHaptic = useHaptics() const queryClient = useQueryClient() const hasUnread = convo.unreadCount > 0 && !isDeletedAccount const blockInfo = useMemo(() => { 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.muted || isBlockedAccount || isDeletedAccount const {lastMessage, lastMessageSentAt, latestReportableMessage} = useMemo(() => { let lastMessage = l`No messages yet` let lastMessageSentAt: string | null = null let latestReportableMessage: ChatBskyConvoDefs.MessageView | undefined // Deleted message if (ChatBskyConvoDefs.isDeletedMessageView(convo.lastMessage)) { lastMessageSentAt = convo.lastMessage.sentAt lastMessage = isDeletedAccount ? l`Conversation deleted` : l`Message deleted` } // Message if (ChatBskyConvoDefs.isMessageView(convo.lastMessage)) { const info = getMessageInfo({ convo, currentAccountDid: currentAccount?.did, i18n, }) if (info) { lastMessage = info.message ?? lastMessage lastMessageSentAt = info.sentAt latestReportableMessage = info.reportableMessage } } // Reaction if (ChatBskyConvoDefs.isMessageAndReactionView(convo.lastReaction)) { const info = getReactionInfo({ convo, currentAccountDid: currentAccount?.did, i18n, }) if ( info && (!lastMessageSentAt || new Date(lastMessageSentAt) < new Date(info.createdAt)) ) { lastMessage = info.message lastMessageSentAt = info.createdAt } } // System message if (ChatBskyConvoDefs.isSystemMessageView(convo.lastMessage)) { const info = getSystemMessageInfo(convo.lastMessage.data, convo.members) if (info) { lastMessage = i18n._(info.message) lastMessageSentAt = convo.lastMessage.sentAt } } return { lastMessage, lastMessageSentAt, latestReportableMessage, } }, [l, convo, currentAccount?.did, isDeletedAccount, 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.members) { unstableCacheProfileView(queryClient, member) } precacheConvoQuery(queryClient, convo) void decrementBadgeCount(convo.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: EnvelopeOpen, action: () => { markAsRead({ convoId: convo.id, }) }, } const deleteAction = { threshold: 225, color: t.palette.negative_500, icon: Trash_Stroke2_Corner0_Rounded, action: () => { leaveConvoControl.open() }, } const actions = hasUnread ? { leftFirst: markReadAction, leftSecond: deleteAction, } : { leftFirst: deleteAction, } return ( {avatar} precacheConvoQuery(queryClient, convo)} onPress={onPress} onLongPress={showMenu && IS_NATIVE ? onLongPress : undefined} onAccessibilityAction={showMenu ? onLongPress : undefined}> {({hovered, pressed, focused}) => ( {/* Avatar goes here */} {title} {showProfileBadges && ( )} {lastMessageSentAt && ( {({timeElapsed}) => ( · {timeElapsed} )} )} {(convo.muted || isBlockedAccount) && ( {' '} ·{' '} )} {subtitle && ( {subtitle} )} {lastMessage} {postAlerts} {children} {hasUnread && ( )} )} {showMenu && ( 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, }, ]} latestReportableMessage={latestReportableMessage} /> )} ) }