import {memo, useCallback, useMemo, useState} from 'react' import { type GestureResponderEvent, Pressable, type StyleProp, type TextStyle, View, } from 'react-native' import Animated, { FadeIn, FadeOut, LayoutAnimationConfig, LinearTransition, useSharedValue, 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 {HITSLOP_10} from '#/lib/constants' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' import {useConvoActive} from '#/state/messages/convo' import {type ConvoItem} from '#/state/messages/convo/types' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useSession} from '#/state/session' import {DraggableScrollView} from '#/view/com/pager/DraggableScrollView' import {UserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, native, useTheme, web} from '#/alf' import {isOnlyEmoji} from '#/alf/typography' import * as Dialog from '#/components/Dialog' import {useDialogControl} from '#/components/Dialog' import {ActionsWrapper} from '#/components/dms/ActionsWrapper' import {InlineLinkText} 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 {MessageItemEmbed} from './MessageItemEmbed' const AVATAR_SIZE = 28 const CLUSTERED_MESSAGE_GAP = 2 const BORDER_RADIUS = 18 const SQUARED_BORDER_RADIUS = 4 const DISPLAY_NAME_INSET = 22 // 42px avatar + 2 * 8px my_sm margins const ROW_HEIGHT = 58 const CLUSTERED_MESSAGE_THRESHOLD_MS = 5 * 60 * 1000 const MESSAGE_GAP_THRESHOLD_MS = 60 * 60 * 1000 type Reaction = { key: string value: string senders: ChatBskyConvoDefs.ReactionViewSender[] count: number } function isWithinCluster({ 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 t = useTheme() const {currentAccount} = useSession() const {t: l} = useLingui() const {convo} = useConvoActive() const moderationOpts = useModerationOpts() const reactionsControl = useDialogControl() const {message, nextMessage, prevMessage} = item const isPending = item.type === 'pending-message' const displayName = sanitizeDisplayName( profile?.displayName || sanitizeHandle(profile?.handle ?? ''), ) const isFromSelf = message.sender?.did === currentAccount?.did const prevIsMessage = ChatBskyConvoDefs.isMessageView(prevMessage) const nextIsMessage = ChatBskyConvoDefs.isMessageView(nextMessage) const isPrevFromSameSender = prevIsMessage && prevMessage.sender?.did === message.sender?.did const isNextFromSameSender = nextIsMessage && nextMessage.sender?.did === message.sender?.did const isFirstInCluster = useMemo( () => isWithinCluster({ isPending, adjacentMessage: prevMessage, isFromSameSender: isPrevFromSameSender, currentSentAt: message.sentAt, direction: 'prev', }), [isPending, prevMessage, isPrevFromSameSender, message.sentAt], ) const isLastInCluster = useMemo( () => isWithinCluster({ isPending, adjacentMessage: nextMessage, isFromSameSender: isNextFromSameSender, currentSentAt: message.sentAt, direction: 'next', }), [isPending, nextMessage, isNextFromSameSender, message.sentAt], ) const hasLargeGapFromPrev = !ChatBskyConvoDefs.isMessageView(prevMessage) || new Date(message.sentAt).getTime() - new Date(prevMessage.sentAt).getTime() > MESSAGE_GAP_THRESHOLD_MS const showDateDivider = hasLargeGapFromPrev const isInCluster = !(isFirstInCluster && isLastInCluster) const isInMiddleOfCluster = isInCluster && !isFirstInCluster && !isLastInCluster const hasReactions = message.reactions && message.reactions.length > 0 const squaredBottomCorner = !hasReactions && isInCluster && (isInMiddleOfCluster || isFirstInCluster) const squaredTopCorner = isInCluster && (isInMiddleOfCluster || isLastInCluster) const pendingColor = t.palette.primary_300 const rt = useMemo(() => { return new RichTextAPI({text: message.text, facets: message.facets}) }, [message.text, message.facets]) const hasEmbedAndText = AppBskyEmbedRecord.isView(message.embed) && rt.text.length > 0 const avatar = 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 sender = convo.members.find(member => member.did === senderDid) if (sender) { return l`${sanitizeDisplayName( sender.displayName || sender.handle, )} 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 ? ( <> isGroupChat ? reactionsControl.open() : undefined }> {groupedReactions.map(group => ( 1 && native(ZoomOut.delay(200)) } layout={native(LinearTransition.delay(300))} key={group.value} style={[a.p_2xs]}> {group.value} ))} {groupedReactions.length !== reactions.length && reactions.length > 1 ? ( {reactions.length} ) : null} ) : null} ) return ( <> {showDateDivider && ( )} {isGroupChat && !isFromSelf && isLastInCluster ? ( {avatar} ) : null} {isGroupChat && !isFromSelf && isFirstInCluster && !isOnlyEmoji(message.text) ? ( {displayName} ) : null} {rt.text.length > 0 && ( )} {AppBskyEmbedRecord.isView(message.embed) && ( )} {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 = useCallback( (e: GestureResponderEvent) => { if (item.type === 'pending-message' && item.retry) { e.preventDefault() item.retry() return false } }, [item], ) const errorColor = t.palette.negative_400 switch (item.type) { case 'pending-message': return item.failed ? ( {l`Message failed to send.`} {item.retry && ( <> {' '} {l`Tap to retry`} . )} ) : ( {l`Sending…`} ) default: return null } } MessageItemMetadata = memo(MessageItemMetadata) export {MessageItemMetadata} function ReactionsDialog({ control, members, reactions, groupedReactions, }: { control: Dialog.DialogControlProps members: bsky.profile.AnyProfileView[] reactions?: ChatBskyConvoDefs.ReactionView[] groupedReactions?: Reaction[] }) { const t = useTheme() const {t: l} = useLingui() const [selected, setSelected] = useState('all') const handleFilter = (value: string) => { setSelected(value) } const filteredMembers = selected === 'all' ? members : members.filter(m => reactions?.some(r => r.sender.did === m.did && r.value === selected), ) const minHeight = members.length * ROW_HEIGHT return ( setSelected('all')} nativeOptions={{preventExpansion: true, minHeight}}> Reactions {filteredMembers.map(profile => { const displayName = sanitizeDisplayName( profile?.displayName || sanitizeHandle(profile?.handle ?? ''), ) const handle = sanitizeHandle(profile?.handle ?? '', '@') const reaction = reactions?.find( ({sender}) => sender.did === profile.did, ) const rt = reaction ? new RichTextAPI({text: reaction.value}) : undefined return rt ? ( {displayName} {handle} ) : null })} ) } function ReactionTabs({ groupedReactions, selected, totalReactions, onFilter, }: { groupedReactions?: Reaction[] selected: string totalReactions: number onFilter: (value: string) => void }) { const t = useTheme() const {t: l} = useLingui() const contentSize = useSharedValue(0) const scrollX = useSharedValue(0) const handlePress = (value: string) => { onFilter(value) } const tabs = [ { key: 'all', value: l`All`, senders: [], count: totalReactions, } as Reaction, ...(groupedReactions ?? []), ] return ( { scrollX.set(Math.round(e.nativeEvent.contentOffset.x)) }}> { contentSize.set(e.nativeEvent.layout.width) }}> {tabs?.map((reaction, index) => ( ))} ) } function ReactionTab({ index, reaction, selected, total, onPress, }: { index: number reaction: Reaction selected: string total: number onPress: (value: string) => void }) { const t = useTheme() const {t: l} = useLingui() return ( onPress(reaction.key)}> {l`${reaction.value} ${reaction.count}`} ) }