import {memo, useEffect, useMemo} from 'react' import { type GestureResponderEvent, Pressable, type StyleProp, type TextStyle, View, type ViewStyle, } from 'react-native' import Animated, { type AnimatedStyle, FadeIn, FadeOut, interpolateColor, LayoutAnimationConfig, LinearTransition, useAnimatedStyle, useSharedValue, withDelay, withSequence, withTiming, ZoomIn, ZoomOut, } from 'react-native-reanimated' import { AppBskyEmbedRecord, type ChatBskyActorDefs, ChatBskyConvoDefs, ChatBskyEmbedJoinLink, moderateProfile, 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 {isBlockedOrBlocking} from '#/lib/moderation/blocked-and-muted' import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' import {sanitizeHandle} from '#/lib/strings/handles' import {useMaybeProfileShadow} from '#/state/cache/profile-shadow' import {type Shadow} from '#/state/cache/types' import {type ConvoItem} from '#/state/messages/convo/types' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useProfileBlockMutationQueue} from '#/state/queries/profile' import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache' import {useSession} from '#/state/session' import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, native, platform, tokens, useTheme, utils} from '#/alf' import {isOnlyEmoji} from '#/alf/typography' import {Button} from '#/components/Button' import {ActionsWrapper} from '#/components/dms/ActionsWrapper' import {useMessageDialogs} from '#/components/dms/MessageOverlays' import {useMessageReplies} from '#/components/dms/MessageReplies' import {useReplyPreviewText} from '#/components/dms/replyPreview' import {ArrowCornerDownRight_Stroke2_Corner3_Rounded as ArrowCornerDownRightIcon} from '#/components/icons/ArrowCornerDownRight' import {InlineLinkText} from '#/components/Link' import * as ProfileCard from '#/components/ProfileCard' import * as Prompt from '#/components/Prompt' import {RichText} from '#/components/RichText' import {Text} from '#/components/Typography' import {DateDivider} from './DateDivider' import {MessageItemEmbed} from './MessageItemEmbed' import {MessageItemInviteEmbed} from './MessageItemInviteEmbed' import {groupReactions} from './ReactionsDialog' import { CLUSTERED_MESSAGE_THRESHOLD_MS, filterBlockedReactions, MESSAGE_BUBBLE_MAX_WIDTH, MESSAGE_GAP_THRESHOLD_MS, } from './util' const AVATAR_SIZE = 28 const CLUSTERED_MESSAGE_GAP = 2 const BORDER_RADIUS = 20 const SQUARED_BORDER_RADIUS = 4 const DISPLAY_NAME_INSET = 20 export type MessageItemNeighbor = | ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.DeletedMessageView | null function messageIsReply(message: MessageItemNeighbor): boolean { return ( ChatBskyConvoDefs.isMessageView(message) && (ChatBskyConvoDefs.isMessageView(message.replyTo) || ChatBskyConvoDefs.isDeletedMessageView(message.replyTo) || ChatBskyConvoDefs.isMessageBeforeUserJoinedGroupView(message.replyTo)) ) } function isWithinClusterBoundary({ isPending, message, adjacentMessage, isFromSameSender, direction, }: { isPending: boolean message: ChatBskyConvoDefs.MessageView adjacentMessage: MessageItemNeighbor isFromSameSender: boolean direction: 'prev' | 'next' }): boolean { // A reply always starts its own cluster, breaking grouping with the message // above it. Looking back, that's a boundary if this message is a reply; // looking forward, it's a boundary if the next message is a reply. if (messageIsReply(direction === 'prev' ? message : adjacentMessage)) { return true } if (!isFromSameSender) return true if (ChatBskyConvoDefs.isMessageView(adjacentMessage)) { const currentSentAt = message.sentAt 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: MessageItemNeighbor nextMessage: MessageItemNeighbor 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 = useMaybeProfileShadow(relatedProfiles.get(message.sender.did)) const {openReactions} = useMessageDialogs() const {scrollToMessage, highlightedMessage} = useMessageReplies() // `replyTo` comes back hydrated as the referenced message, a deleted-message // tombstone, or a before-joined placeholder. Narrow away the open-union // fallback so we only render shapes we understand. const replyTo = ChatBskyConvoDefs.isMessageView(message.replyTo) || ChatBskyConvoDefs.isDeletedMessageView(message.replyTo) || ChatBskyConvoDefs.isMessageBeforeUserJoinedGroupView(message.replyTo) ? message.replyTo : undefined const replyToMessageId = replyTo && !ChatBskyConvoDefs.isMessageBeforeUserJoinedGroupView(replyTo) ? replyTo.id : undefined const onPressReplyTo = replyToMessageId ? () => scrollToMessage(replyToMessageId) : undefined 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, message, adjacentMessage: prevMessage, isFromSameSender: isPrevFromSameSender, direction: 'prev', }) const isLastInCluster = isWithinClusterBoundary({ isPending, message, adjacentMessage: nextMessage, isFromSameSender: isNextFromSameSender, 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 visibleReactions = useMemo( () => filterBlockedReactions(message.reactions, relatedProfiles), [message.reactions, relatedProfiles], ) const hasReactions = visibleReactions.length > 0 const prevHasReactions = prevIsMessage && filterBlockedReactions(prevMessage.reactions, relatedProfiles).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 bubbleColor = isFromSelf ? isPending ? pendingColor : t.palette.primary_500 : t.palette.contrast_50 const highlightColor = isFromSelf ? t.palette.primary_300 : t.palette.primary_100 const rt = new RichTextAPI({text: message.text, facets: message.facets}) const isEmojiOnly = isOnlyEmoji(message.text) const hasEmbed = AppBskyEmbedRecord.isView(message.embed) || ChatBskyEmbedJoinLink.isView(message.embed) const hasEmbedAndText = hasEmbed && 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 && !isEmojiOnly const showAvatar = isGroupChat && !isFromSelf && isLastInCluster /* * Emoji-only messages have no bubble background (see the `!isOnlyEmoji` gate * on the bubble styling below), so the corner-radius animation is invisible * overhead for them. Worse, on Android the resulting re-layout of the parent * Animated.View re-measures the enlarged emoji `` and some Android * device's text stack drops the trailing glyph on that second pass (while * keeping its reserved width). Set the radii directly for emoji-only messages * so nothing re-lays-out the glyph after its initial paint. */ useEffect(() => { bottomRadiusSV.set( isEmojiOnly ? targetBottomRadius : withTiming(targetBottomRadius, {duration: 300}), ) }, [targetBottomRadius, bottomRadiusSV, isEmojiOnly]) useEffect(() => { topRadiusSV.set( isEmojiOnly ? targetTopRadius : withTiming(targetTopRadius, {duration: 300}), ) }, [targetTopRadius, topRadiusSV, isEmojiOnly]) // Flash the message background when it's been scrolled to (e.g. by tapping a // reply that quotes it), so it's easy to spot. Keyed on the highlight `key` // so re-tapping the same message re-triggers the flash. const highlightSV = useSharedValue(0) const isHighlighted = highlightedMessage?.id === message.id const highlightKey = isHighlighted ? highlightedMessage.key : null useEffect(() => { if (highlightKey === null) return highlightSV.set( withSequence( withTiming(1, {duration: 150}), withDelay(400, withTiming(0, {duration: 450})), ), ) }, [highlightKey, highlightSV]) const highlightStyle = useAnimatedStyle(() => ({ backgroundColor: interpolateColor( highlightSV.get(), [0, 1], [bubbleColor, highlightColor], ), })) const borderRadiusStyle = useAnimatedStyle(() => isFromSelf ? { borderBottomRightRadius: bottomRadiusSV.get(), borderTopRightRadius: topRadiusSV.get(), } : { borderBottomLeftRadius: bottomRadiusSV.get(), borderTopLeftRadius: topRadiusSV.get(), }, ) const avatar = profile && moderationOpts ? ( unstableCacheProfileView(queryClient, profile)} moderation={moderateProfile(profile, moderationOpts).ui('avatar')} /> ) : ( ) const groupedReactions = useMemo( () => groupReactions(visibleReactions), [visibleReactions], ) const reactions = visibleReactions 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 ? ( openReactions(message) : undefined}> {groupedReactions.slice(0, 10).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 || groupedReactions.length > 10) && 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} {replyTo ? ( ) : displayName && showDisplayName ? ( {displayName} ) : null} {profile && isBlockedOrBlocking(profile) && isGroupChat ? ( ) : ( {AppBskyEmbedRecord.isView(message.embed) && ( )} {ChatBskyEmbedJoinLink.isView(message.embed) && ( )} {rt.text.length > 0 && ( {replyTo && !isEmojiOnly ? ( ) : null} )} {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} function BlockedPlaceholder({ profile, style, }: { profile: Shadow style?: AnimatedStyle }) { const {t: l} = useLingui() const t = useTheme() const control = Prompt.usePromptControl() const [_queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile) return ( <> {profile.viewer?.blocking ? ( You are blocking {sanitizeHandle(profile.handle, '@')} ) : ( {sanitizeHandle(profile.handle, '@')} is blocking you )} {profile.viewer?.blocking ? ( Messages from this person are hidden while you are blocking them. ) : ( Messages from this person are hidden while they are blocking you. )} {}} cta={l`Okay`} color="primary" /> {profile.viewer?.blocking && !profile.viewer.blockingByList && ( void queueUnblock()} cta={l`Unblock`} color="secondary" /> )} ) } /** * The "↪ X replied to Y" caption rendered above a reply message, in place of * the display name. `X` is the person sending the reply (self -> "you"), `Y` is * the original sender. Tapping it scrolls to the original (if loaded). * * Aligns with the sender's display name for others (left), or with the message * bubble for self (right). */ function ReplyCaption({ replyTo, isFromSelf, isGroupChat, replierDisplayName, relatedProfiles, onPress, }: { replyTo: | ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.DeletedMessageView | ChatBskyConvoDefs.MessageBeforeUserJoinedGroupView isFromSelf: boolean isGroupChat: boolean replierDisplayName: string | null relatedProfiles: Map onPress?: () => void }) { const t = useTheme() const {t: l} = useLingui() const {currentAccount} = useSession() let caption: string = '' if ( ChatBskyConvoDefs.isMessageView(replyTo) || ChatBskyConvoDefs.isDeletedMessageView(replyTo) ) { const originalSenderIsSelf = replyTo.sender.did === currentAccount?.did const originalProfile = relatedProfiles.get(replyTo.sender.did) const originalName = originalSenderIsSelf ? null : originalProfile ? createSanitizedDisplayName(originalProfile) : null caption = isFromSelf ? originalSenderIsSelf ? l`You replied to yourself` : originalName ? l`You replied to ${originalName}` : l`You replied` : originalSenderIsSelf ? l`${replierDisplayName} replied to you` : originalName ? l`${replierDisplayName} replied to ${originalName}` : l`${replierDisplayName} replied` } else { caption = l`Someone replied` } return ( ) } /** * The nested quote of the original message, rendered at the top of a reply * bubble. Tapping it scrolls to the original (if loaded). */ function ReplyQuote({ replyTo, isFromSelf, relatedProfiles, onPress, }: { replyTo: | ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.DeletedMessageView | ChatBskyConvoDefs.MessageBeforeUserJoinedGroupView isFromSelf: boolean relatedProfiles: Map onPress?: () => void }) { const t = useTheme() const {t: l} = useLingui() const getReplyPreviewText = useReplyPreviewText() const senderDid = ChatBskyConvoDefs.isMessageView(replyTo) || ChatBskyConvoDefs.isDeletedMessageView(replyTo) ? replyTo.sender.did : undefined const senderProfile = useMaybeProfileShadow( senderDid ? relatedProfiles.get(senderDid) : undefined, ) // Hide the quoted content if we block, or are blocked by, the original // sender - mirroring how the message bubble itself is hidden. const isBlocked = senderProfile ? isBlockedOrBlocking(senderProfile) : false const senderName = senderProfile && !isBlocked ? createSanitizedDisplayName(senderProfile) : null const tintColor = isFromSelf ? t.palette.white : t.atoms.text.color const subtleColor = isFromSelf ? t.palette.white : t.atoms.text_contrast_high.color const borderColor = isFromSelf ? utils.alpha(t.palette.white, 0.5) : t.atoms.border_contrast_high.borderColor let text: string let subtle = false if (isBlocked) { text = l({ message: '(blocked message hidden)', comment: 'A reply summary in chat', }) subtle = true } else if (ChatBskyConvoDefs.isMessageView(replyTo)) { ;({text, subtle} = getReplyPreviewText(replyTo)) } else if (ChatBskyConvoDefs.isMessageBeforeUserJoinedGroupView(replyTo)) { text = l({ message: `(message sent before you joined)`, comment: 'A reply summary in chat', }) subtle = true } else { text = l({message: '(deleted message)', comment: 'A reply summary in chat'}) subtle = true } return ( ) }