import {memo, useEffect, useMemo} from 'react' import { type GestureResponderEvent, Pressable, type StyleProp, type TextStyle, View, type ViewStyle, } from 'react-native' import Animated, { LayoutAnimationConfig, LinearTransition, useAnimatedStyle, useSharedValue, withTiming, ZoomIn, ZoomOut, } from 'react-native-reanimated' import { AppBskyEmbedRecord, type ChatBskyActorDefs, ChatBskyConvoDefs, RichText as RichTextAPI, } from '@atproto/api' import {plural} from '@lingui/core/macro' import {Trans, useLingui} from '@lingui/react/macro' import {useQueryClient} from '@tanstack/react-query' import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' import {makeProfileLink} from '#/lib/routes/links' import {type ConvoItem} from '#/state/messages/convo/types' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache' import {useSession} from '#/state/session' import {atoms as a, native, platform, useTheme} from '#/alf' import {isOnlyEmoji} from '#/alf/typography' import {useDialogControl} from '#/components/Dialog' import {ActionsWrapper} from '#/components/dms/ActionsWrapper' import {InlineLinkText, Link} from '#/components/Link' import * as ProfileCard from '#/components/ProfileCard' import {RichText} from '#/components/RichText' import {Text} from '#/components/Typography' import {DateDivider} from './DateDivider' import {MessageItemEmbed} from './MessageItemEmbed' import {ReactionsDialog} from './ReactionsDialog' import {CLUSTERED_MESSAGE_THRESHOLD_MS, MESSAGE_GAP_THRESHOLD_MS} from './util' const AVATAR_SIZE = 28 const CLUSTERED_MESSAGE_GAP = 2 const BORDER_RADIUS = 18 const SQUARED_BORDER_RADIUS = 4 const DISPLAY_NAME_INSET = 22 function isWithinClusterBoundary({ isPending, adjacentMessage, isFromSameSender, currentSentAt, direction, }: { isPending: boolean adjacentMessage: | ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.DeletedMessageView | null isFromSameSender: boolean currentSentAt: string direction: 'prev' | 'next' }): boolean { if (!isFromSameSender) return true if (ChatBskyConvoDefs.isMessageView(adjacentMessage)) { const thisDate = new Date(currentSentAt) const adjDate = new Date(adjacentMessage.sentAt) const diff = direction === 'next' ? adjDate.getTime() - thisDate.getTime() : thisDate.getTime() - adjDate.getTime() const isOutsideThreshold = diff > CLUSTERED_MESSAGE_THRESHOLD_MS // For pending messages, still check the time threshold if (isPending) return isOutsideThreshold return isOutsideThreshold } return true } let MessageItem = ({ item, isGroupChat = false, prevMessage, nextMessage, relatedProfiles, }: { item: ConvoItem & {type: 'message' | 'pending-message'} isGroupChat?: boolean prevMessage: | ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.DeletedMessageView | null nextMessage: | ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.DeletedMessageView | null relatedProfiles: Map }): React.ReactNode => { const t = useTheme() const {currentAccount} = useSession() const {t: l} = useLingui() const moderationOpts = useModerationOpts() const queryClient = useQueryClient() const {message} = item const profile = relatedProfiles.get(message.sender.did) const reactionsControl = useDialogControl() const isPending = item.type === 'pending-message' const displayName = profile ? createSanitizedDisplayName(profile) : null const isFromSelf = message.sender?.did != null && message.sender.did === currentAccount?.did const prevIsMessage = ChatBskyConvoDefs.isMessageView(prevMessage) const nextIsMessage = ChatBskyConvoDefs.isMessageView(nextMessage) const isPrevFromSameSender = prevIsMessage && prevMessage.sender?.did === message.sender?.did && message.sender?.did != null const isNextFromSameSender = nextIsMessage && nextMessage.sender?.did === message.sender?.did && message.sender?.did != null const isFirstInCluster = isWithinClusterBoundary({ isPending, adjacentMessage: prevMessage, isFromSameSender: isPrevFromSameSender, currentSentAt: message.sentAt, direction: 'prev', }) const isLastInCluster = isWithinClusterBoundary({ isPending, adjacentMessage: nextMessage, isFromSameSender: isNextFromSameSender, currentSentAt: message.sentAt, direction: 'next', }) const hasLargeGapFromPrev = !ChatBskyConvoDefs.isMessageView(prevMessage) || new Date(message.sentAt).getTime() - new Date(prevMessage.sentAt).getTime() > MESSAGE_GAP_THRESHOLD_MS const isInCluster = !(isFirstInCluster && isLastInCluster) const isInMiddleOfCluster = isInCluster && !isFirstInCluster && !isLastInCluster const hasReactions = message.reactions && message.reactions.length > 0 const prevHasReactions = prevIsMessage && prevMessage.reactions && prevMessage.reactions.length > 0 const isNextEmojiOnly = nextIsMessage && isOnlyEmoji(nextMessage.text) const isPrevEmojiOnly = prevIsMessage && isOnlyEmoji(prevMessage.text) const squaredBottomCorner = !hasReactions && !isNextEmojiOnly && isInCluster && (isInMiddleOfCluster || isFirstInCluster) const squaredTopCorner = !prevHasReactions && !isPrevEmojiOnly && isInCluster && (isInMiddleOfCluster || isLastInCluster) const pendingColor = t.palette.primary_300 const rt = new RichTextAPI({text: message.text, facets: message.facets}) const hasEmbedAndText = AppBskyEmbedRecord.isView(message.embed) && rt.text.length > 0 const targetBottomRadius = squaredBottomCorner ? SQUARED_BORDER_RADIUS : BORDER_RADIUS const targetTopRadius = squaredTopCorner || hasEmbedAndText ? SQUARED_BORDER_RADIUS : BORDER_RADIUS const bottomRadiusSV = useSharedValue(targetBottomRadius) const topRadiusSV = useSharedValue(targetTopRadius) const showDisplayName = isGroupChat && !isFromSelf && isFirstInCluster && !isOnlyEmoji(message.text) const showAvatar = isGroupChat && !isFromSelf && isLastInCluster useEffect(() => { bottomRadiusSV.set(withTiming(targetBottomRadius, {duration: 300})) }, [targetBottomRadius, bottomRadiusSV]) useEffect(() => { topRadiusSV.set(withTiming(targetTopRadius, {duration: 300})) }, [targetTopRadius, topRadiusSV]) const borderRadiusStyle = useAnimatedStyle(() => isFromSelf ? { borderBottomRightRadius: bottomRadiusSV.get(), borderTopRightRadius: topRadiusSV.get(), } : { borderBottomLeftRadius: bottomRadiusSV.get(), borderTopLeftRadius: topRadiusSV.get(), }, ) const avatar = profile && moderationOpts ? ( unstableCacheProfileView(queryClient, profile)}> ) : ( ) const groupedReactions = useMemo(() => { const reactions = message.reactions ?? [] const grouped = new Map< string, { key: string value: string senders: ChatBskyConvoDefs.ReactionViewSender[] count: number } >() for (const reaction of reactions) { if (!reaction) continue const existing = grouped.get(reaction.value) if (existing) { existing.senders.push(reaction.sender) existing.count++ } else { grouped.set(reaction.value, { key: reaction.value, value: reaction.value, senders: [reaction.sender], count: 1, }) } } return Array.from(grouped.values()) }, [message.reactions]) const reactions = useMemo(() => message.reactions ?? [], [message.reactions]) const hasSelfReacted = reactions.some( r => r.sender.did === currentAccount?.did, ) const reactionsLabel = useMemo(() => { if (reactions.length === 0) return '' if (reactions.length === 1) { const reaction = reactions[0] const sender = reaction.sender if (sender.did === currentAccount?.did) { return l`You reacted ${reaction.value}` } else { const senderDid = reaction.sender.did const memberSender = relatedProfiles.get(senderDid) if (memberSender) { return l`${createSanitizedDisplayName(memberSender)} reacted ${reaction.value}` } return l`Someone reacted ${reaction.value}` } } return l`${plural(reactions.length, { one: '# person', other: '# people', })} reacted – ${groupedReactions.map(g => g.value).join(' ')}` }, [reactions, groupedReactions, currentAccount?.did, relatedProfiles, l]) const appliedReactions = ( {hasReactions ? ( {groupedReactions.map(group => ( 1 ? native(ZoomOut.delay(200)) : undefined } layout={native(LinearTransition.delay(300))} key={group.value} style={[a.py_2xs]}> {group.value} ))} {groupedReactions.length !== reactions.length && reactions.length > 1 ? ( {reactions.length} ) : null} ) : null} ) const messageInset = platform({ android: a.mx_sm, ios: a.mx_md, web: a.mx_lg, }) return ( <> {hasLargeGapFromPrev && } {showAvatar ? ( {avatar} ) : null} {displayName && showDisplayName ? ( {displayName} ) : null} {AppBskyEmbedRecord.isView(message.embed) && ( )} {rt.text.length > 0 && ( )} {appliedReactions} {isLastInCluster && ( )} ) } MessageItem = memo(MessageItem) export {MessageItem} let MessageItemMetadata = ({ item, style, }: { item: ConvoItem & {type: 'message' | 'pending-message'} style: StyleProp }): React.ReactNode => { const t = useTheme() const {t: l} = useLingui() const handleRetry = (e: GestureResponderEvent) => { if (item.type === 'pending-message' && item.retry) { e.preventDefault() item.retry() return false } } const errorColor = t.palette.negative_400 switch (item.type) { case 'pending-message': return item.failed ? ( Message failed to send. {item.retry && ( <> {' '} Tap to retry . )} ) : null default: return null } } MessageItemMetadata = memo(MessageItemMetadata) export {MessageItemMetadata}