Groupchats feature branch (#10181)
Co-authored-by: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+589
-207
@@ -1,13 +1,17 @@
|
||||
import {memo, useCallback, useMemo} from 'react'
|
||||
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'
|
||||
@@ -16,217 +20,420 @@ import {
|
||||
ChatBskyConvoDefs,
|
||||
RichText as RichTextAPI,
|
||||
} from '@atproto/api'
|
||||
import {type I18n} from '@lingui/core'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
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 {TimeElapsed} from '#/view/com/util/TimeElapsed'
|
||||
import {atoms as a, native, useTheme} from '#/alf'
|
||||
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 {IS_NATIVE} from '#/env'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
import {DateDivider} from './DateDivider'
|
||||
import {MessageItemEmbed} from './MessageItemEmbed'
|
||||
import {localDateString} 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
|
||||
|
||||
// 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 {_} = useLingui()
|
||||
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 isNextFromSelf =
|
||||
nextIsMessage && nextMessage.sender?.did === currentAccount?.did
|
||||
const isPrevFromSameSender =
|
||||
prevIsMessage && prevMessage.sender?.did === message.sender?.did
|
||||
const isNextFromSameSender =
|
||||
nextIsMessage && nextMessage.sender?.did === message.sender?.did
|
||||
|
||||
const isNextFromSameSender = isNextFromSelf === isFromSelf
|
||||
const isFirstInCluster = useMemo(
|
||||
() =>
|
||||
isWithinCluster({
|
||||
isPending,
|
||||
adjacentMessage: prevMessage,
|
||||
isFromSameSender: isPrevFromSameSender,
|
||||
currentSentAt: message.sentAt,
|
||||
direction: 'prev',
|
||||
}),
|
||||
[isPending, prevMessage, isPrevFromSameSender, message.sentAt],
|
||||
)
|
||||
|
||||
const isNewDay = useMemo(() => {
|
||||
if (!prevMessage) return true
|
||||
const isLastInCluster = useMemo(
|
||||
() =>
|
||||
isWithinCluster({
|
||||
isPending,
|
||||
adjacentMessage: nextMessage,
|
||||
isFromSameSender: isNextFromSameSender,
|
||||
currentSentAt: message.sentAt,
|
||||
direction: 'next',
|
||||
}),
|
||||
[isPending, nextMessage, isNextFromSameSender, message.sentAt],
|
||||
)
|
||||
|
||||
const thisDate = new Date(message.sentAt)
|
||||
const prevDate = new Date(prevMessage.sentAt)
|
||||
const hasLargeGapFromPrev =
|
||||
!ChatBskyConvoDefs.isMessageView(prevMessage) ||
|
||||
new Date(message.sentAt).getTime() -
|
||||
new Date(prevMessage.sentAt).getTime() >
|
||||
MESSAGE_GAP_THRESHOLD_MS
|
||||
|
||||
return localDateString(thisDate) !== localDateString(prevDate)
|
||||
}, [message, prevMessage])
|
||||
const showDateDivider = hasLargeGapFromPrev
|
||||
|
||||
const isLastMessageOfDay = useMemo(() => {
|
||||
if (!nextMessage || !nextIsMessage) return true
|
||||
const isInCluster = !(isFirstInCluster && isLastInCluster)
|
||||
const isInMiddleOfCluster =
|
||||
isInCluster && !isFirstInCluster && !isLastInCluster
|
||||
|
||||
const thisDate = new Date(message.sentAt)
|
||||
const prevDate = new Date(nextMessage.sentAt)
|
||||
const hasReactions = message.reactions && message.reactions.length > 0
|
||||
const squaredBottomCorner =
|
||||
!hasReactions && isInCluster && (isInMiddleOfCluster || isFirstInCluster)
|
||||
const squaredTopCorner =
|
||||
isInCluster && (isInMiddleOfCluster || isLastInCluster)
|
||||
|
||||
return localDateString(thisDate) !== localDateString(prevDate)
|
||||
}, [message.sentAt, nextIsMessage, nextMessage])
|
||||
|
||||
const needsTail = isLastMessageOfDay || !isNextFromSameSender
|
||||
|
||||
const isLastInGroup = useMemo(() => {
|
||||
// if this message is pending, it means the next message is pending too
|
||||
if (isPending && nextMessage) {
|
||||
return false
|
||||
}
|
||||
|
||||
// or, if there's a 5 minute gap between this message and the next
|
||||
if (ChatBskyConvoDefs.isMessageView(nextMessage)) {
|
||||
const thisDate = new Date(message.sentAt)
|
||||
const nextDate = new Date(nextMessage.sentAt)
|
||||
|
||||
const diff = nextDate.getTime() - thisDate.getTime()
|
||||
|
||||
// 5 minutes
|
||||
return diff > 5 * 60 * 1000
|
||||
}
|
||||
|
||||
return true
|
||||
}, [message, nextMessage, isPending])
|
||||
|
||||
const pendingColor = t.palette.primary_200
|
||||
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 ? (
|
||||
<ProfileCard.Avatar
|
||||
profile={profile}
|
||||
size={AVATAR_SIZE}
|
||||
moderationOpts={moderationOpts!}
|
||||
disabledPreview
|
||||
/>
|
||||
) : (
|
||||
<ProfileCard.AvatarPlaceholder size={AVATAR_SIZE} />
|
||||
)
|
||||
|
||||
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 = (
|
||||
<LayoutAnimationConfig skipEntering skipExiting>
|
||||
{message.reactions && message.reactions.length > 0 && (
|
||||
<View
|
||||
style={[isFromSelf ? a.align_end : a.align_start, a.px_sm, a.pb_2xs]}>
|
||||
{hasReactions ? (
|
||||
<>
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.gap_2xs,
|
||||
a.py_xs,
|
||||
a.px_xs,
|
||||
a.justify_center,
|
||||
isFromSelf ? a.justify_end : a.justify_start,
|
||||
a.flex_wrap,
|
||||
a.pb_xs,
|
||||
t.atoms.bg_contrast_25,
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
a.rounded_lg,
|
||||
t.atoms.shadow_sm,
|
||||
{
|
||||
// vibe coded number
|
||||
transform: [{translateY: -11}],
|
||||
},
|
||||
isFromSelf ? a.align_end : a.align_start,
|
||||
a.px_sm,
|
||||
a.pb_2xs,
|
||||
]}>
|
||||
{message.reactions.map((reaction, _i, reactions) => {
|
||||
let label
|
||||
if (reaction.sender.did === currentAccount?.did) {
|
||||
label = _(msg`You reacted ${reaction.value}`)
|
||||
} else {
|
||||
const senderDid = reaction.sender.did
|
||||
const sender = convo.members.find(
|
||||
member => member.did === senderDid,
|
||||
)
|
||||
if (sender) {
|
||||
label = _(
|
||||
msg`${sanitizeDisplayName(
|
||||
sender.displayName || sender.handle,
|
||||
)} reacted ${reaction.value}`,
|
||||
)
|
||||
} else {
|
||||
label = _(msg`Someone reacted ${reaction.value}`)
|
||||
}
|
||||
<Pressable
|
||||
accessible={true}
|
||||
accessibilityLabel={reactionsLabel}
|
||||
accessibilityHint={
|
||||
isGroupChat ? l`Tap to view reactions` : undefined
|
||||
}
|
||||
return (
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.gap_2xs,
|
||||
a.py_xs,
|
||||
a.px_xs,
|
||||
isFromSelf ? a.justify_end : a.justify_start,
|
||||
a.flex_wrap,
|
||||
a.rounded_lg,
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
t.atoms.bg_contrast_25,
|
||||
t.atoms.shadow_sm,
|
||||
{
|
||||
transform: [{translateY: -8}],
|
||||
},
|
||||
]}
|
||||
onPress={() =>
|
||||
isGroupChat ? reactionsControl.open() : undefined
|
||||
}>
|
||||
{groupedReactions.map(group => (
|
||||
<Animated.View
|
||||
entering={native(ZoomIn.springify(200).delay(400))}
|
||||
exiting={reactions.length > 1 && native(ZoomOut.delay(200))}
|
||||
exiting={
|
||||
groupedReactions.length > 1 && native(ZoomOut.delay(200))
|
||||
}
|
||||
layout={native(LinearTransition.delay(300))}
|
||||
key={reaction.sender.did + reaction.value}
|
||||
style={[a.p_2xs]}
|
||||
accessible={true}
|
||||
accessibilityLabel={label}
|
||||
accessibilityHint={_(
|
||||
msg`Double tap or long press the message to add a reaction`,
|
||||
)}>
|
||||
key={group.value}
|
||||
style={[a.p_2xs]}>
|
||||
<Text emoji style={[a.text_sm]}>
|
||||
{reaction.value}
|
||||
{group.value}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
)
|
||||
})}
|
||||
))}
|
||||
{groupedReactions.length !== reactions.length &&
|
||||
reactions.length > 1 ? (
|
||||
<View style={[a.p_2xs, a.justify_center]}>
|
||||
<Text
|
||||
style={[
|
||||
a.text_xs,
|
||||
t.atoms.text_contrast_medium,
|
||||
{includeFontPadding: false},
|
||||
]}>
|
||||
{reactions.length}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
<ReactionsDialog
|
||||
control={reactionsControl}
|
||||
members={convo.members}
|
||||
reactions={message.reactions}
|
||||
groupedReactions={groupedReactions}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</LayoutAnimationConfig>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{isNewDay && <DateDivider date={message.sentAt} />}
|
||||
{showDateDivider && (
|
||||
<Animated.View entering={native(FadeIn)} exiting={native(FadeOut)}>
|
||||
<DateDivider date={message.sentAt} />
|
||||
</Animated.View>
|
||||
)}
|
||||
<View
|
||||
style={[
|
||||
isFromSelf ? a.mr_md : a.ml_md,
|
||||
nextIsMessage && !isNextFromSameSender && a.mb_md,
|
||||
isFromSelf ? a.mr_sm : a.ml_sm,
|
||||
isFirstInCluster && !showDateDivider && a.mt_sm,
|
||||
]}>
|
||||
<ActionsWrapper isFromSelf={isFromSelf} message={message}>
|
||||
{AppBskyEmbedRecord.isView(message.embed) && (
|
||||
<MessageItemEmbed embed={message.embed} />
|
||||
)}
|
||||
{rt.text.length > 0 && (
|
||||
<View
|
||||
style={
|
||||
!isOnlyEmoji(message.text) && [
|
||||
a.py_sm,
|
||||
a.my_2xs,
|
||||
a.rounded_md,
|
||||
{
|
||||
paddingLeft: 14,
|
||||
paddingRight: 14,
|
||||
backgroundColor: isFromSelf
|
||||
? isPending
|
||||
? pendingColor
|
||||
: t.palette.primary_500
|
||||
: t.palette.contrast_50,
|
||||
borderRadius: 17,
|
||||
},
|
||||
isFromSelf ? a.self_end : a.self_start,
|
||||
isFromSelf
|
||||
? {borderBottomRightRadius: needsTail ? 2 : 17}
|
||||
: {borderBottomLeftRadius: needsTail ? 2 : 17},
|
||||
]
|
||||
}>
|
||||
<RichText
|
||||
value={rt}
|
||||
style={[a.text_md, isFromSelf && {color: t.palette.white}]}
|
||||
interactiveStyle={a.underline}
|
||||
enableTags
|
||||
emojiMultiplier={3}
|
||||
shouldProxyLinks={true}
|
||||
/>
|
||||
<View style={[a.relative]}>
|
||||
{isGroupChat && !isFromSelf && isLastInCluster ? (
|
||||
<View style={[a.absolute, {bottom: hasReactions ? 10 : 0}]}>
|
||||
{avatar}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{IS_NATIVE && appliedReactions}
|
||||
</ActionsWrapper>
|
||||
|
||||
{!IS_NATIVE && appliedReactions}
|
||||
|
||||
{isLastInGroup && (
|
||||
) : null}
|
||||
<View
|
||||
style={[
|
||||
a.flex_grow,
|
||||
!isFromSelf &&
|
||||
isGroupChat && {
|
||||
paddingLeft: AVATAR_SIZE,
|
||||
},
|
||||
]}>
|
||||
{isGroupChat &&
|
||||
!isFromSelf &&
|
||||
isFirstInCluster &&
|
||||
!isOnlyEmoji(message.text) ? (
|
||||
<Text
|
||||
style={[
|
||||
a.text_xs,
|
||||
t.atoms.text_contrast_medium,
|
||||
a.pt_xs,
|
||||
a.pb_2xs,
|
||||
{
|
||||
paddingLeft: DISPLAY_NAME_INSET,
|
||||
},
|
||||
]}>
|
||||
{displayName}
|
||||
</Text>
|
||||
) : null}
|
||||
<ActionsWrapper isFromSelf={isFromSelf} message={message}>
|
||||
{rt.text.length > 0 && (
|
||||
<View
|
||||
accessibilityHint={l`Double tap or long press the message to add a reaction`}
|
||||
style={[
|
||||
!isFromSelf && a.ml_sm,
|
||||
...(isOnlyEmoji(message.text)
|
||||
? []
|
||||
: [
|
||||
a.rounded_md,
|
||||
a.rounded_xl,
|
||||
a.py_sm,
|
||||
a.px_md,
|
||||
{
|
||||
marginTop: isFirstInCluster
|
||||
? 0
|
||||
: CLUSTERED_MESSAGE_GAP,
|
||||
backgroundColor: isFromSelf
|
||||
? isPending
|
||||
? pendingColor
|
||||
: t.palette.primary_500
|
||||
: t.palette.contrast_50,
|
||||
},
|
||||
isFromSelf ? a.self_end : a.self_start,
|
||||
isFromSelf
|
||||
? {
|
||||
borderBottomRightRadius:
|
||||
squaredBottomCorner || hasEmbedAndText
|
||||
? SQUARED_BORDER_RADIUS
|
||||
: BORDER_RADIUS,
|
||||
borderTopRightRadius: squaredTopCorner
|
||||
? SQUARED_BORDER_RADIUS
|
||||
: BORDER_RADIUS,
|
||||
}
|
||||
: {
|
||||
borderBottomLeftRadius:
|
||||
squaredBottomCorner || hasEmbedAndText
|
||||
? SQUARED_BORDER_RADIUS
|
||||
: BORDER_RADIUS,
|
||||
borderTopLeftRadius: squaredTopCorner
|
||||
? SQUARED_BORDER_RADIUS
|
||||
: BORDER_RADIUS,
|
||||
},
|
||||
]),
|
||||
]}>
|
||||
<RichText
|
||||
value={rt}
|
||||
style={[a.text_md, isFromSelf && {color: t.palette.white}]}
|
||||
interactiveStyle={a.underline}
|
||||
enableTags
|
||||
emojiMultiplier={3}
|
||||
shouldProxyLinks={true}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
{AppBskyEmbedRecord.isView(message.embed) && (
|
||||
<MessageItemEmbed
|
||||
embed={message.embed}
|
||||
isFromSelf={isFromSelf}
|
||||
squaredBottomCorner={squaredBottomCorner}
|
||||
squaredTopCorner={squaredTopCorner || hasEmbedAndText}
|
||||
/>
|
||||
)}
|
||||
{appliedReactions}
|
||||
</ActionsWrapper>
|
||||
</View>
|
||||
</View>
|
||||
{isLastInCluster && (
|
||||
<MessageItemMetadata
|
||||
item={item}
|
||||
style={isFromSelf ? a.text_right : a.text_left}
|
||||
style={[isFromSelf ? a.text_right : a.text_left]}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
@@ -244,8 +451,7 @@ let MessageItemMetadata = ({
|
||||
style: StyleProp<TextStyle>
|
||||
}): React.ReactNode => {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {message} = item
|
||||
const {t: l} = useLingui()
|
||||
|
||||
const handleRetry = useCallback(
|
||||
(e: GestureResponderEvent) => {
|
||||
@@ -258,75 +464,251 @@ let MessageItemMetadata = ({
|
||||
[item],
|
||||
)
|
||||
|
||||
const relativeTimestamp = useCallback(
|
||||
(i18n: I18n, timestamp: string) => {
|
||||
const date = new Date(timestamp)
|
||||
const now = new Date()
|
||||
const errorColor = t.palette.negative_400
|
||||
|
||||
const time = i18n.date(date, {
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
})
|
||||
|
||||
const diff = now.getTime() - date.getTime()
|
||||
|
||||
// if under 30 seconds
|
||||
if (diff < 1000 * 30) {
|
||||
return _(msg`Now`)
|
||||
}
|
||||
|
||||
return time
|
||||
},
|
||||
[_],
|
||||
)
|
||||
|
||||
return (
|
||||
<Text
|
||||
style={[
|
||||
a.text_xs,
|
||||
a.mt_2xs,
|
||||
a.mb_lg,
|
||||
t.atoms.text_contrast_medium,
|
||||
style,
|
||||
]}>
|
||||
<TimeElapsed timestamp={message.sentAt} timeToString={relativeTimestamp}>
|
||||
{({timeElapsed}) => (
|
||||
<Text style={[a.text_xs, t.atoms.text_contrast_medium]}>
|
||||
{timeElapsed}
|
||||
</Text>
|
||||
)}
|
||||
</TimeElapsed>
|
||||
|
||||
{item.type === 'pending-message' && item.failed && (
|
||||
<>
|
||||
{' '}
|
||||
·{' '}
|
||||
<Text
|
||||
style={[
|
||||
a.text_xs,
|
||||
{
|
||||
color: t.palette.negative_400,
|
||||
},
|
||||
]}>
|
||||
{_(msg`Failed to send`)}
|
||||
switch (item.type) {
|
||||
case 'pending-message':
|
||||
return item.failed ? (
|
||||
<Text style={[a.text_xs, a.my_2xs, {color: errorColor}, style]}>
|
||||
<Text style={[a.text_xs, {color: errorColor}]}>
|
||||
<Trans>Message failed to send.</Trans>
|
||||
</Text>
|
||||
{item.retry && (
|
||||
<>
|
||||
{' '}
|
||||
·{' '}
|
||||
<InlineLinkText
|
||||
label={_(msg`Click to retry failed message`)}
|
||||
label={l`Click to retry failed message`}
|
||||
to="#"
|
||||
onPress={handleRetry}
|
||||
style={[a.text_xs]}>
|
||||
{_(msg`Retry`)}
|
||||
style={[a.text_xs, {color: errorColor}]}>
|
||||
<Trans>Tap to retry</Trans>
|
||||
</InlineLinkText>
|
||||
.
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
)
|
||||
</Text>
|
||||
) : null
|
||||
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 (
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
onClose={() => setSelected('all')}
|
||||
nativeOptions={{preventExpansion: true, minHeight}}>
|
||||
<Dialog.Handle />
|
||||
<View style={[a.px_2xl, a.pt_3xl, t.atoms.bg]}>
|
||||
<Text style={[a.font_bold, a.text_2xl, a.mb_sm]}>
|
||||
<Trans>Reactions</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
<ReactionTabs
|
||||
groupedReactions={groupedReactions}
|
||||
selected={selected}
|
||||
totalReactions={reactions?.length ?? 0}
|
||||
onFilter={handleFilter}
|
||||
/>
|
||||
<Dialog.ScrollableInner
|
||||
label={l`Reactions`}
|
||||
contentContainerStyle={[a.pt_0]}
|
||||
style={[web({maxWidth: 400})]}>
|
||||
{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 ? (
|
||||
<View
|
||||
key={profile.did}
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.gap_sm,
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
a.my_sm,
|
||||
]}>
|
||||
<View style={[a.flex_row, a.gap_sm]}>
|
||||
<UserAvatar
|
||||
avatar={profile.avatar}
|
||||
size={42}
|
||||
type="user"
|
||||
hideLiveBadge
|
||||
/>
|
||||
<View>
|
||||
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
|
||||
{displayName}
|
||||
</Text>
|
||||
<Text style={[a.text_xs, t.atoms.text_contrast_medium]}>
|
||||
{handle}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View>
|
||||
<RichText
|
||||
value={rt}
|
||||
style={[a.text_md]}
|
||||
interactiveStyle={a.underline}
|
||||
enableTags
|
||||
emojiMultiplier={2}
|
||||
shouldProxyLinks={true}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
) : null
|
||||
})}
|
||||
</Dialog.ScrollableInner>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<View accessibilityRole="list" style={[t.atoms.bg]}>
|
||||
<DraggableScrollView
|
||||
horizontal={true}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
onScroll={e => {
|
||||
scrollX.set(Math.round(e.nativeEvent.contentOffset.x))
|
||||
}}>
|
||||
<Animated.View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.flex_grow,
|
||||
a.gap_sm,
|
||||
a.align_center,
|
||||
a.justify_start,
|
||||
]}
|
||||
onLayout={e => {
|
||||
contentSize.set(e.nativeEvent.layout.width)
|
||||
}}>
|
||||
{tabs?.map((reaction, index) => (
|
||||
<ReactionTab
|
||||
key={reaction.value}
|
||||
index={index}
|
||||
reaction={reaction}
|
||||
selected={selected}
|
||||
total={tabs.length}
|
||||
onPress={handlePress}
|
||||
/>
|
||||
))}
|
||||
</Animated.View>
|
||||
</DraggableScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityHint={
|
||||
reaction.key === 'all'
|
||||
? l`Tap to show all reactions `
|
||||
: l`Tap to show ${reaction.value} reactions`
|
||||
}
|
||||
hitSlop={HITSLOP_10}
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.border,
|
||||
a.justify_center,
|
||||
a.rounded_lg,
|
||||
a.px_md,
|
||||
a.py_sm,
|
||||
a.mb_sm,
|
||||
t.atoms.border_contrast_low,
|
||||
selected === reaction.key ? t.atoms.bg_contrast_50 : t.atoms.bg,
|
||||
index === 0 ? a.ml_2xl : index === total - 1 ? a.mr_2xl : null,
|
||||
]}
|
||||
onPress={() => onPress(reaction.key)}>
|
||||
<Text emoji style={[a.text_sm]}>
|
||||
{l`${reaction.value} ${reaction.count}`}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user