import {memo, useEffect, useMemo, useRef} from 'react' import { type GestureResponderEvent, LayoutAnimation, Pressable, type StyleProp, type TextStyle, View, type ViewStyle, } from 'react-native' import Animated, { FadeIn, FadeOut, LayoutAnimationConfig, LinearTransition, useAnimatedStyle, useSharedValue, withTiming, ZoomIn, ZoomOut, } from 'react-native-reanimated' import { AppBskyEmbedRecord, 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 {useConvoActive} from '#/state/messages/convo' import {type ConvoItem} from '#/state/messages/convo/types' import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons' 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 type * as bsky from '#/types/bsky' import {DateDivider} from './DateDivider' import {useDateDividerToggle} from './DateDividerToggle' import {MessageItemEmbed} from './MessageItemEmbed' import {ReactionsDialog} from './ReactionsDialog' const AVATAR_SIZE = 28 const CLUSTERED_MESSAGE_GAP = 2 const SQUARED_BORDER_RADIUS = 4 const DISPLAY_NAME_INSET = 22 const CLUSTERED_MESSAGE_THRESHOLD_MS = 5 * 60 * 1000 const MESSAGE_GAP_THRESHOLD_MS = 60 * 60 * 1000 const TAP_AND_DRAG_DELAY_MS = 100 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 (isPending && adjacentMessage) return false 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() return diff > CLUSTERED_MESSAGE_THRESHOLD_MS } return true } let MessageItem = ({ item, isGroupChat = false, profile, }: { item: ConvoItem & {type: 'message' | 'pending-message'} isGroupChat?: boolean profile?: bsky.profile.AnyProfileView }): React.ReactNode => { const enableSquareButtons = useEnableSquareButtons() const t = useTheme() const {currentAccount} = useSession() const {t: l} = useLingui() const {convo} = useConvoActive() const moderationOpts = useModerationOpts() const queryClient = useQueryClient() const reactionsControl = useDialogControl() const reactionTapRef = useRef(false) const {message, nextMessage, prevMessage} = item 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 {isDividerToggled, toggleDivider} = useDateDividerToggle() const isDateDividerToggled = isDividerToggled(message.id) const isNextDateDividerToggled = nextMessage != null && isDividerToggled(nextMessage.id) const effectiveFirstInCluster = isFirstInCluster || isDateDividerToggled const effectiveLastInCluster = isLastInCluster || isNextDateDividerToggled const isInCluster = !(effectiveFirstInCluster && effectiveLastInCluster) const isInMiddleOfCluster = isInCluster && !effectiveFirstInCluster && !effectiveLastInCluster const hasReactions = message.reactions && message.reactions.length > 0 const prevHasReactions = prevIsMessage && prevMessage.reactions && prevMessage.reactions.length > 0 const squaredBottomCorner = !hasReactions && isInCluster && (isInMiddleOfCluster || effectiveFirstInCluster) const squaredTopCorner = !prevHasReactions && isInCluster && (isInMiddleOfCluster || effectiveLastInCluster) const pendingColor = t.palette.primary_300 const borderRadius = enableSquareButtons ? 4 : 18 const rt = new RichTextAPI({text: message.text, facets: message.facets}) const hasEmbedAndText = AppBskyEmbedRecord.isView(message.embed) && rt.text.length > 0 const targetBottomRadius = squaredBottomCorner || hasEmbedAndText ? SQUARED_BORDER_RADIUS : borderRadius const targetTopRadius = squaredTopCorner ? SQUARED_BORDER_RADIUS : borderRadius 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 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 = convo.members.find( member => member.did === 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, convo.members, l]) const appliedReactions = ( {hasReactions ? ( { // Don't toggle the date divider when tapping a reaction. reactionTapRef.current = true }} onPressOut={() => { // Include a delay here to account for tap-and-drag before release. setTimeout(() => { reactionTapRef.current = false }, TAP_AND_DRAG_DELAY_MS) }} onPress={isGroupChat ? reactionsControl.open : undefined}> {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({ ios: isFromSelf ? a.mr_md : isGroupChat ? a.ml_md : a.ml_sm, android: isFromSelf ? a.mr_sm : isGroupChat ? a.ml_sm : undefined, web: isFromSelf ? a.mr_sm : isGroupChat ? a.ml_sm : undefined, }) return ( <> {(hasLargeGapFromPrev || isDateDividerToggled) && ( )} {showAvatar ? ( {avatar} ) : null} {displayName && showDisplayName ? ( {displayName} ) : null} { if (reactionTapRef.current) return if (!hasLargeGapFromPrev) { LayoutAnimation.configureNext( LayoutAnimation.Presets.easeInEaseOut, ) toggleDivider(message.id) } }}> {rt.text.length > 0 && ( )} {AppBskyEmbedRecord.isView(message.embed) && ( )} {appliedReactions} {effectiveLastInCluster && ( )} ) } 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}