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}
/>
)}
)
}