import React, {useCallback, useMemo, useState} from 'react' import {type GestureResponderEvent, View} from 'react-native' import { AppBskyEmbedRecord, ChatBskyConvoDefs, moderateProfile, type ModerationOpts, } from '@atproto/api' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' import {GestureActionView} from '#/lib/custom-animations/GestureActionView' import {useHaptics} from '#/lib/haptics' import {decrementBadgeCount} from '#/lib/notifications/notifications' import {logEvent} from '#/lib/statsig/statsig' import {sanitizeDisplayName} from '#/lib/strings/display-names' import { postUriToRelativePath, toBskyAppUrl, toShortUrl, } from '#/lib/strings/url-helpers' import {isNative} from '#/platform/detection' import {useProfileShadow} from '#/state/cache/profile-shadow' import {useModerationOpts} from '#/state/preferences/moderation-opts' import { precacheConvoQuery, useMarkAsReadMutation, } from '#/state/queries/messages/conversation' import {precacheProfile} 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 {useDialogControl} from '#/components/Dialog' import {ConvoMenu} from '#/components/dms/ConvoMenu' import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt' 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 {Text} from '#/components/Typography' import type * as bsky from '#/types/bsky' export let ChatListItem = ({ convo, showMenu = true, children, }: { convo: ChatBskyConvoDefs.ConvoView showMenu?: boolean children?: React.ReactNode }): React.ReactNode => { const {currentAccount} = useSession() const moderationOpts = useModerationOpts() const otherUser = convo.members.find( member => member.did !== currentAccount?.did, ) if (!otherUser || !moderationOpts) { return null } return ( {children} ) } ChatListItem = React.memo(ChatListItem) function ChatListItemReady({ convo, profile: profileUnshadowed, moderationOpts, showMenu, children, }: { convo: ChatBskyConvoDefs.ConvoView profile: bsky.profile.AnyProfileView moderationOpts: ModerationOpts showMenu?: boolean children?: React.ReactNode }) { const t = useTheme() const {_} = useLingui() const {currentAccount} = useSession() const menuControl = useMenuControl() const leaveConvoControl = useDialogControl() const {gtMobile} = useBreakpoints() const profile = useProfileShadow(profileUnshadowed) const {mutate: markAsRead} = useMarkAsReadMutation() const moderation = React.useMemo( () => moderateProfile(profile, moderationOpts), [profile, moderationOpts], ) const playHaptic = useHaptics() const queryClient = useQueryClient() const isUnread = convo.unreadCount > 0 const blockInfo = useMemo(() => { const modui = moderation.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, } }, [moderation]) const isDeletedAccount = profile.handle === 'missing.invalid' const displayName = isDeletedAccount ? _(msg`Deleted Account`) : sanitizeDisplayName( profile.displayName || profile.handle, moderation.ui('displayName'), ) const isDimStyle = convo.muted || moderation.blocked || isDeletedAccount const {lastMessage, lastMessageSentAt, latestReportableMessage} = useMemo(() => { // eslint-disable-next-line @typescript-eslint/no-shadow let lastMessage = _(msg`No messages yet`) // eslint-disable-next-line @typescript-eslint/no-shadow let lastMessageSentAt: string | null = null // eslint-disable-next-line @typescript-eslint/no-shadow let latestReportableMessage: ChatBskyConvoDefs.MessageView | undefined if (ChatBskyConvoDefs.isMessageView(convo.lastMessage)) { const isFromMe = convo.lastMessage.sender?.did === currentAccount?.did if (!isFromMe) { latestReportableMessage = convo.lastMessage } if (convo.lastMessage.text) { if (isFromMe) { lastMessage = _(msg`You: ${convo.lastMessage.text}`) } else { lastMessage = convo.lastMessage.text } } else if (convo.lastMessage.embed) { const defaultEmbeddedContentMessage = _( msg`(contains embedded content)`, ) if (AppBskyEmbedRecord.isView(convo.lastMessage.embed)) { const embed = convo.lastMessage.embed if (AppBskyEmbedRecord.isViewRecord(embed.record)) { const record = embed.record const path = postUriToRelativePath(record.uri, { handle: record.author.handle, }) const href = path ? toBskyAppUrl(path) : undefined const short = href ? toShortUrl(href) : defaultEmbeddedContentMessage if (isFromMe) { lastMessage = _(msg`You: ${short}`) } else { lastMessage = short } } } else { if (isFromMe) { lastMessage = _(msg`You: ${defaultEmbeddedContentMessage}`) } else { lastMessage = defaultEmbeddedContentMessage } } } lastMessageSentAt = convo.lastMessage.sentAt } if (ChatBskyConvoDefs.isDeletedMessageView(convo.lastMessage)) { lastMessage = isDeletedAccount ? _(msg`Conversation deleted`) : _(msg`Message deleted`) } if (ChatBskyConvoDefs.isMessageAndReactionView(convo.lastMessage)) { const isFromMe = convo.lastMessage.reaction.sender.did === currentAccount?.did const lastMessageText = convo.lastMessage.message.text const fallbackMessage = _( msg({ message: 'a message', comment: `If last message does not contain text, fall back to "{user} reacted to {a message}"`, }), ) if (isFromMe) { lastMessage = _( msg`You reacted ${convo.lastMessage.reaction.value} to ${ lastMessageText ? `"${convo.lastMessage.message.text}"` : fallbackMessage }`, ) } else { const senderDid = convo.lastMessage.reaction.sender.did const sender = convo.members.find(member => member.did === senderDid) if (sender) { lastMessage = _( msg`${sanitizeDisplayName( sender.displayName || sender.handle, )} reacted ${convo.lastMessage.reaction.value} to ${ lastMessageText ? `"${convo.lastMessage.message.text}"` : fallbackMessage }`, ) } else { lastMessage = _( msg`Someone reacted ${convo.lastMessage.reaction.value} to ${ lastMessageText ? `"${convo.lastMessage.message.text}"` : fallbackMessage }`, ) } } } return { lastMessage, lastMessageSentAt, latestReportableMessage, } }, [ _, convo.lastMessage, currentAccount?.did, isDeletedAccount, convo.members, ]) 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) => { precacheProfile(queryClient, profile) precacheConvoQuery(queryClient, convo) decrementBadgeCount(convo.unreadCount) if (isDeletedAccount) { e.preventDefault() menuControl.open() return false } else { logEvent('chat:open', {logContext: 'ChatsList'}) } }, [isDeletedAccount, menuControl, queryClient, profile, 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 = isUnread ? { leftFirst: markReadAction, leftSecond: deleteAction, } : { leftFirst: deleteAction, } const hasUnread = convo.unreadCount > 0 && !isDeletedAccount return ( {({hovered, pressed, focused}) => ( {/* Avatar goes here */} {displayName} {lastMessageSentAt && ( {({timeElapsed}) => ( {' '} · {timeElapsed} )} )} {(convo.muted || moderation.blocked) && ( {' '} ·{' '} )} {!isDeletedAccount && ( @{profile.handle} )} {lastMessage} {children} {hasUnread && ( )} )} {showMenu && ( 0} hideTrigger={isNative} 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} /> )} ) }