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}