Update chat presentation (#10197)

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
This commit is contained in:
DS Boyce
2026-04-10 10:49:42 -07:00
committed by Samuel Newman
parent 549891a3d6
commit 921ecf927b
14 changed files with 1062 additions and 341 deletions
+3 -4
View File
@@ -1,7 +1,6 @@
import {View} from 'react-native' import {View} from 'react-native'
import {type ChatBskyConvoDefs} from '@atproto/api' import {type ChatBskyConvoDefs} from '@atproto/api'
import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
import {MessageContextMenu} from '#/components/dms/MessageContextMenu' import {MessageContextMenu} from '#/components/dms/MessageContextMenu'
@@ -15,7 +14,7 @@ export function ActionsWrapper({
isFromSelf: boolean isFromSelf: boolean
children: React.ReactNode children: React.ReactNode
}) { }) {
const {_} = useLingui() const {t: l} = useLingui()
return ( return (
<MessageContextMenu message={message}> <MessageContextMenu message={message}>
@@ -32,7 +31,7 @@ export function ActionsWrapper({
]} ]}
accessible={true} accessible={true}
accessibilityActions={[ accessibilityActions={[
{name: 'activate', label: _(msg`Open message options`)}, {name: 'activate', label: l`Open message options`},
]} ]}
onAccessibilityAction={() => trigger.control.open('full')}> onAccessibilityAction={() => trigger.control.open('full')}>
{children} {children}
+6 -12
View File
@@ -1,8 +1,6 @@
import {memo} from 'react' import {memo} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {msg} from '@lingui/core/macro' import {Trans, useLingui} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {subDays} from 'date-fns' import {subDays} from 'date-fns'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
@@ -29,7 +27,7 @@ const longDateFormatterWithYear = new Intl.DateTimeFormat(undefined, {
}) })
let DateDivider = ({date: dateStr}: {date: string}): React.ReactNode => { let DateDivider = ({date: dateStr}: {date: string}): React.ReactNode => {
const {_} = useLingui() const {t: l} = useLingui()
const t = useTheme() const t = useTheme()
let date: string let date: string
@@ -42,9 +40,9 @@ let DateDivider = ({date: dateStr}: {date: string}): React.ReactNode => {
const oneWeekAgo = subDays(today, 7) const oneWeekAgo = subDays(today, 7)
if (localDateString(today) === localDateString(timestamp)) { if (localDateString(today) === localDateString(timestamp)) {
date = _(msg`Today`) date = l`Today`
} else if (localDateString(yesterday) === localDateString(timestamp)) { } else if (localDateString(yesterday) === localDateString(timestamp)) {
date = _(msg`Yesterday`) date = l`Yesterday`
} else { } else {
if (timestamp < oneWeekAgo) { if (timestamp < oneWeekAgo) {
if (timestamp.getFullYear() === today.getFullYear()) { if (timestamp.getFullYear() === today.getFullYear()) {
@@ -58,7 +56,7 @@ let DateDivider = ({date: dateStr}: {date: string}): React.ReactNode => {
} }
return ( return (
<View style={[a.w_full, a.my_lg]}> <View style={[a.w_full, a.my_sm]}>
<Text <Text
style={[ style={[
a.text_xs, a.text_xs,
@@ -68,11 +66,7 @@ let DateDivider = ({date: dateStr}: {date: string}): React.ReactNode => {
a.px_md, a.px_md,
]}> ]}>
<Trans> <Trans>
<Text {date} at {time}
style={[a.text_xs, t.atoms.text_contrast_medium, a.font_semi_bold]}>
{date}
</Text>{' '}
at {time}
</Trans> </Trans>
</Text> </Text>
</View> </View>
@@ -0,0 +1,82 @@
import {memo} from 'react'
import {View} from 'react-native'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {subDays} from 'date-fns'
import {atoms as a, useTheme} from '#/alf'
import {Text} from '../Typography'
import {localDateString} from './util'
const timeFormatter = new Intl.DateTimeFormat(undefined, {
hour: 'numeric',
minute: 'numeric',
})
const weekdayFormatter = new Intl.DateTimeFormat(undefined, {
weekday: 'long',
})
const longDateFormatter = new Intl.DateTimeFormat(undefined, {
weekday: 'short',
month: 'long',
day: 'numeric',
})
const longDateFormatterWithYear = new Intl.DateTimeFormat(undefined, {
weekday: 'short',
month: 'long',
day: 'numeric',
year: 'numeric',
})
let DateDivider = ({date: dateStr}: {date: string}): React.ReactNode => {
const {_} = useLingui()
const t = useTheme()
let date: string
const time = timeFormatter.format(new Date(dateStr))
const timestamp = new Date(dateStr)
const today = new Date()
const yesterday = subDays(today, 1)
const oneWeekAgo = subDays(today, 7)
if (localDateString(today) === localDateString(timestamp)) {
date = _(msg`Today`)
} else if (localDateString(yesterday) === localDateString(timestamp)) {
date = _(msg`Yesterday`)
} else {
if (timestamp < oneWeekAgo) {
if (timestamp.getFullYear() === today.getFullYear()) {
date = longDateFormatter.format(timestamp)
} else {
date = longDateFormatterWithYear.format(timestamp)
}
} else {
date = weekdayFormatter.format(timestamp)
}
}
return (
<View style={[a.w_full, a.my_lg]}>
<Text
style={[
a.text_xs,
a.text_center,
t.atoms.bg,
t.atoms.text_contrast_medium,
a.px_md,
]}>
<Trans>
<Text
style={[a.text_xs, t.atoms.text_contrast_medium, a.font_semi_bold]}>
{date}
</Text>{' '}
at {time}
</Trans>
</Text>
</View>
)
}
DateDivider = memo(DateDivider)
export {DateDivider}
+31 -40
View File
@@ -2,8 +2,7 @@ import {memo, useCallback} from 'react'
import {LayoutAnimation, Platform} from 'react-native' import {LayoutAnimation, Platform} from 'react-native'
import * as Clipboard from 'expo-clipboard' import * as Clipboard from 'expo-clipboard'
import {type ChatBskyConvoDefs, RichText} from '@atproto/api' import {type ChatBskyConvoDefs, RichText} from '@atproto/api'
import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query' import {useQueryClient} from '@tanstack/react-query'
import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate' import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate'
@@ -15,10 +14,10 @@ import {useSession} from '#/state/session'
import * as ContextMenu from '#/components/ContextMenu' import * as ContextMenu from '#/components/ContextMenu'
import {type TriggerProps} from '#/components/ContextMenu/types' import {type TriggerProps} from '#/components/ContextMenu/types'
import {AfterReportDialog} from '#/components/dms/AfterReportDialog' import {AfterReportDialog} from '#/components/dms/AfterReportDialog'
import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble' import {BubbleQuestion_Stroke2_Corner0_Rounded as TranslateIcon} from '#/components/icons/Bubble'
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning' import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
import {ReportDialog} from '#/components/moderation/ReportDialog' import {ReportDialog} from '#/components/moderation/ReportDialog'
import * as Prompt from '#/components/Prompt' import * as Prompt from '#/components/Prompt'
import {usePromptControl} from '#/components/Prompt' import {usePromptControl} from '#/components/Prompt'
@@ -35,7 +34,7 @@ export let MessageContextMenu = ({
message: ChatBskyConvoDefs.MessageView message: ChatBskyConvoDefs.MessageView
children: TriggerProps['children'] children: TriggerProps['children']
}): React.ReactNode => { }): React.ReactNode => {
const {_} = useLingui() const {t: l} = useLingui()
const ax = useAnalytics() const ax = useAnalytics()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const queryClient = useQueryClient() const queryClient = useQueryClient()
@@ -58,10 +57,10 @@ export let MessageContextMenu = ({
) )
void Clipboard.setStringAsync(str) void Clipboard.setStringAsync(str)
Toast.show(_(msg`Copied to clipboard`), { Toast.show(l`Copied to clipboard`, {
type: 'success', type: 'success',
}) })
}, [_, message.text, message.facets]) }, [l, message.text, message.facets])
const onPressTranslateMessage = useCallback(() => { const onPressTranslateMessage = useCallback(() => {
void translate(message.text, langPrefs.primaryLanguage) void translate(message.text, langPrefs.primaryLanguage)
@@ -79,11 +78,9 @@ export let MessageContextMenu = ({
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
convo convo
.deleteMessage(message.id) .deleteMessage(message.id)
.then(() => .then(() => Toast.show(l({message: 'Message deleted', context: 'toast'})))
Toast.show(_(msg({message: 'Message deleted', context: 'toast'}))), .catch(() => Toast.show(l`Failed to delete message`))
) }, [l, convo, message.id])
.catch(() => Toast.show(_(msg`Failed to delete message`)))
}, [_, convo, message.id])
const onEmojiSelect = useCallback( const onEmojiSelect = useCallback(
(emoji: string) => { (emoji: string) => {
@@ -96,17 +93,17 @@ export let MessageContextMenu = ({
) { ) {
convo convo
.removeReaction(message.id, emoji) .removeReaction(message.id, emoji)
.catch(() => Toast.show(_(msg`Failed to remove emoji reaction`))) .catch(() => Toast.show(l`Failed to remove emoji reaction`))
} else { } else {
if (hasReachedReactionLimit(message, currentAccount?.did)) return if (hasReachedReactionLimit(message, currentAccount?.did)) return
convo.addReaction(message.id, emoji).catch(() => convo.addReaction(message.id, emoji).catch(() =>
Toast.show(_(msg`Failed to add emoji reaction`), { Toast.show(l`Failed to add emoji reaction`, {
type: 'error', type: 'error',
}), }),
) )
} }
}, },
[_, convo, message, currentAccount?.did], [l, convo, message, currentAccount?.did],
) )
const sender = convo.convo.members.find( const sender = convo.convo.members.find(
@@ -126,12 +123,10 @@ export let MessageContextMenu = ({
)} )}
<ContextMenu.Trigger <ContextMenu.Trigger
label={_(msg`Message options`)} label={l`Message options`}
contentLabel={_( contentLabel={l`Message from @${
msg`Message from @${ sender?.handle ?? 'unknown' // should always be defined
sender?.handle ?? 'unknown' // should always be defined }: ${message.text}`}>
}: ${message.text}`,
)}>
{children} {children}
</ContextMenu.Trigger> </ContextMenu.Trigger>
@@ -140,17 +135,17 @@ export let MessageContextMenu = ({
<> <>
<ContextMenu.Item <ContextMenu.Item
testID="messageDropdownTranslateBtn" testID="messageDropdownTranslateBtn"
label={_(msg`Translate`)} label={l`Translate`}
onPress={onPressTranslateMessage}> onPress={onPressTranslateMessage}>
<ContextMenu.ItemText>{_(msg`Translate`)}</ContextMenu.ItemText> <ContextMenu.ItemText>{l`Translate`}</ContextMenu.ItemText>
<ContextMenu.ItemIcon icon={Translate} position="right" /> <ContextMenu.ItemIcon icon={TranslateIcon} position="right" />
</ContextMenu.Item> </ContextMenu.Item>
<ContextMenu.Item <ContextMenu.Item
testID="messageDropdownCopyBtn" testID="messageDropdownCopyBtn"
label={_(msg`Copy message text`)} label={l`Copy message text`}
onPress={onCopyMessage}> onPress={onCopyMessage}>
<ContextMenu.ItemText> <ContextMenu.ItemText>
{_(msg`Copy message text`)} {l`Copy message text`}
</ContextMenu.ItemText> </ContextMenu.ItemText>
<ContextMenu.ItemIcon icon={ClipboardIcon} position="right" /> <ContextMenu.ItemIcon icon={ClipboardIcon} position="right" />
</ContextMenu.Item> </ContextMenu.Item>
@@ -159,23 +154,22 @@ export let MessageContextMenu = ({
)} )}
<ContextMenu.Item <ContextMenu.Item
testID="messageDropdownDeleteBtn" testID="messageDropdownDeleteBtn"
label={_(msg`Delete message for me`)} label={l`Delete message for me`}
onPress={() => deleteControl.open()}> onPress={() => deleteControl.open()}>
<ContextMenu.ItemText>{_(msg`Delete for me`)}</ContextMenu.ItemText> <ContextMenu.ItemText>{l`Delete for me`}</ContextMenu.ItemText>
<ContextMenu.ItemIcon icon={Trash} position="right" /> <ContextMenu.ItemIcon icon={TrashIcon} position="right" />
</ContextMenu.Item> </ContextMenu.Item>
{!isFromSelf && ( {!isFromSelf && (
<ContextMenu.Item <ContextMenu.Item
testID="messageDropdownReportBtn" testID="messageDropdownReportBtn"
label={_(msg`Report message`)} label={l`Report message`}
onPress={() => reportControl.open()}> onPress={() => reportControl.open()}>
<ContextMenu.ItemText>{_(msg`Report`)}</ContextMenu.ItemText> <ContextMenu.ItemText>{l`Report`}</ContextMenu.ItemText>
<ContextMenu.ItemIcon icon={Warning} position="right" /> <ContextMenu.ItemIcon icon={WarningIcon} position="right" />
</ContextMenu.Item> </ContextMenu.Item>
)} )}
</ContextMenu.Outer> </ContextMenu.Outer>
</ContextMenu.Root> </ContextMenu.Root>
<ReportDialog <ReportDialog
control={reportControl} control={reportControl}
subject={{ subject={{
@@ -198,14 +192,11 @@ export let MessageContextMenu = ({
message, message,
}} }}
/> />
<Prompt.Basic <Prompt.Basic
control={deleteControl} control={deleteControl}
title={_(msg`Delete message`)} title={l`Delete message`}
description={_( description={l`Are you sure you want to delete this message? The message will be deleted for you, but not for the other participants.`}
msg`Are you sure you want to delete this message? The message will be deleted for you, but not for the other participant.`, confirmButtonCta={l`Delete`}
)}
confirmButtonCta={_(msg`Delete`)}
confirmButtonColor="negative" confirmButtonColor="negative"
onConfirm={onDelete} onConfirm={onDelete}
/> />
+346 -191
View File
@@ -6,6 +6,8 @@ import {
View, View,
} from 'react-native' } from 'react-native'
import Animated, { import Animated, {
FadeIn,
FadeOut,
LayoutAnimationConfig, LayoutAnimationConfig,
LinearTransition, LinearTransition,
ZoomIn, ZoomIn,
@@ -16,217 +18,386 @@ import {
ChatBskyConvoDefs, ChatBskyConvoDefs,
RichText as RichTextAPI, RichText as RichTextAPI,
} from '@atproto/api' } from '@atproto/api'
import {type I18n} from '@lingui/core' import {plural} from '@lingui/core/macro'
import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {useConvoActive} from '#/state/messages/convo' import {useConvoActive} from '#/state/messages/convo'
import {type ConvoItem} from '#/state/messages/convo/types' import {type ConvoItem} from '#/state/messages/convo/types'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {TimeElapsed} from '#/view/com/util/TimeElapsed'
import {atoms as a, native, useTheme} from '#/alf' import {atoms as a, native, useTheme} from '#/alf'
import {isOnlyEmoji} from '#/alf/typography' import {isOnlyEmoji} from '#/alf/typography'
import {ActionsWrapper} from '#/components/dms/ActionsWrapper' import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
import {InlineLinkText} from '#/components/Link' import {InlineLinkText} from '#/components/Link'
import * as ProfileCard from '#/components/ProfileCard'
import {RichText} from '#/components/RichText' import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {IS_NATIVE} from '#/env' import type * as bsky from '#/types/bsky'
import {DateDivider} from './DateDivider' import {DateDivider} from './DateDivider'
import {MessageItemEmbed} from './MessageItemEmbed' 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
const CLUSTERED_MESSAGE_THRESHOLD_MS = 5 * 60 * 1000
const MESSAGE_GAP_THRESHOLD_MS = 60 * 60 * 1000
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 = ({ let MessageItem = ({
item, item,
isGroupChat = false,
profile,
}: { }: {
item: ConvoItem & {type: 'message' | 'pending-message'} item: ConvoItem & {type: 'message' | 'pending-message'}
isGroupChat?: boolean
profile?: bsky.profile.AnyProfileView
}): React.ReactNode => { }): React.ReactNode => {
const t = useTheme() const t = useTheme()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const {_} = useLingui() const {t: l} = useLingui()
const {convo} = useConvoActive() const {convo} = useConvoActive()
const moderationOpts = useModerationOpts()
const {message, nextMessage, prevMessage} = item const {message, nextMessage, prevMessage} = item
const isPending = item.type === 'pending-message' const isPending = item.type === 'pending-message'
const displayName = sanitizeDisplayName(
profile?.displayName || sanitizeHandle(profile?.handle ?? ''),
)
const isFromSelf = message.sender?.did === currentAccount?.did const isFromSelf = message.sender?.did === currentAccount?.did
const prevIsMessage = ChatBskyConvoDefs.isMessageView(prevMessage)
const nextIsMessage = ChatBskyConvoDefs.isMessageView(nextMessage) const nextIsMessage = ChatBskyConvoDefs.isMessageView(nextMessage)
const isNextFromSelf = const isPrevFromSameSender =
nextIsMessage && nextMessage.sender?.did === currentAccount?.did 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(() => { const isLastInCluster = useMemo(
if (!prevMessage) return true () =>
isWithinCluster({
isPending,
adjacentMessage: nextMessage,
isFromSameSender: isNextFromSameSender,
currentSentAt: message.sentAt,
direction: 'next',
}),
[isPending, nextMessage, isNextFromSameSender, message.sentAt],
)
const thisDate = new Date(message.sentAt) const hasLargeGapFromPrev =
const prevDate = new Date(prevMessage.sentAt) !ChatBskyConvoDefs.isMessageView(prevMessage) ||
new Date(message.sentAt).getTime() -
new Date(prevMessage.sentAt).getTime() >
MESSAGE_GAP_THRESHOLD_MS
return localDateString(thisDate) !== localDateString(prevDate) const showDateDivider = hasLargeGapFromPrev
}, [message, prevMessage])
const isLastMessageOfDay = useMemo(() => { const isInCluster = !(isFirstInCluster && isLastInCluster)
if (!nextMessage || !nextIsMessage) return true const isInMiddleOfCluster =
isInCluster && !isFirstInCluster && !isLastInCluster
const thisDate = new Date(message.sentAt) const hasReactions = message.reactions && message.reactions.length > 0
const prevDate = new Date(nextMessage.sentAt) const squaredBottomCorner =
!hasReactions && isInCluster && (isInMiddleOfCluster || isFirstInCluster)
const squaredTopCorner =
isInCluster && (isInMiddleOfCluster || isLastInCluster)
return localDateString(thisDate) !== localDateString(prevDate) const pendingColor = t.palette.primary_300
}, [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 rt = useMemo(() => { const rt = useMemo(() => {
return new RichTextAPI({text: message.text, facets: message.facets}) return new RichTextAPI({text: message.text, facets: message.facets})
}, [message.text, 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,
{
value: string
senders: ChatBskyConvoDefs.ReactionViewSender[]
count: number
}
>()
for (const react of reactions) {
if (!react) continue
const existing = grouped.get(react.value)
if (existing) {
existing.senders.push(react.sender)
existing.count++
} else {
grouped.set(react.value, {
value: react.value,
senders: [react.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 = ( const appliedReactions = (
<LayoutAnimationConfig skipEntering skipExiting> <LayoutAnimationConfig skipEntering skipExiting>
{message.reactions && message.reactions.length > 0 && ( {hasReactions ? (
<View <View
style={[isFromSelf ? a.align_end : a.align_start, a.px_sm, a.pb_2xs]}> style={[
isFromSelf ? a.align_end : a.align_start,
a.px_sm,
a.pb_2xs,
!isFromSelf && isGroupChat && {paddingLeft: AVATAR_SIZE},
]}>
<View <View
accessible={true}
accessibilityLabel={reactionsLabel}
accessibilityHint={l`Double tap or long press the message to add a reaction`}
style={[ style={[
a.flex_row, a.flex_row,
a.gap_2xs, a.gap_2xs,
a.py_xs, a.py_xs,
a.px_xs, a.px_xs,
a.justify_center,
isFromSelf ? a.justify_end : a.justify_start, isFromSelf ? a.justify_end : a.justify_start,
a.flex_wrap, a.flex_wrap,
a.pb_xs, a.rounded_lg,
t.atoms.bg_contrast_25,
a.border, a.border,
t.atoms.border_contrast_low, t.atoms.border_contrast_low,
a.rounded_lg, t.atoms.bg_contrast_25,
t.atoms.shadow_sm, t.atoms.shadow_sm,
{ {
// vibe coded number transform: [{translateY: -8}],
transform: [{translateY: -11}],
}, },
]}> ]}>
{message.reactions.map((reaction, _i, reactions) => { {groupedReactions.map(group => (
let label <Animated.View
if (reaction.sender.did === currentAccount?.did) { entering={native(ZoomIn.springify(200).delay(400))}
label = _(msg`You reacted ${reaction.value}`) exiting={
} else { groupedReactions.length > 1 && native(ZoomOut.delay(200))
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}`)
} }
} layout={native(LinearTransition.delay(300))}
return ( key={group.value}
<Animated.View style={[a.p_2xs]}>
entering={native(ZoomIn.springify(200).delay(400))} <Text emoji style={[a.text_sm]}>
exiting={reactions.length > 1 && native(ZoomOut.delay(200))} {group.value}
layout={native(LinearTransition.delay(300))} </Text>
key={reaction.sender.did + reaction.value} </Animated.View>
style={[a.p_2xs]} ))}
accessible={true} {groupedReactions.length !== reactions.length &&
accessibilityLabel={label} reactions.length > 1 ? (
accessibilityHint={_( <View style={[a.p_2xs, a.justify_center]}>
msg`Double tap or long press the message to add a reaction`, <Text
)}> style={[
<Text emoji style={[a.text_sm]}> a.text_xs,
{reaction.value} t.atoms.text_contrast_medium,
</Text> {includeFontPadding: false},
</Animated.View> ]}>
) {reactions.length}
})} </Text>
</View>
) : null}
</View> </View>
</View> </View>
)} ) : null}
</LayoutAnimationConfig> </LayoutAnimationConfig>
) )
return ( return (
<> <>
{isNewDay && <DateDivider date={message.sentAt} />} {showDateDivider && (
<Animated.View entering={native(FadeIn)} exiting={native(FadeOut)}>
<DateDivider date={message.sentAt} />
</Animated.View>
)}
<View <View
style={[ style={[
isFromSelf ? a.mr_md : a.ml_md, isFromSelf ? a.mr_sm : a.ml_sm,
nextIsMessage && !isNextFromSameSender && a.mb_md, isFirstInCluster && !showDateDivider && a.mt_sm,
]}> ]}>
<ActionsWrapper isFromSelf={isFromSelf} message={message}> <View style={[a.relative]}>
{AppBskyEmbedRecord.isView(message.embed) && ( {isGroupChat && !isFromSelf && isLastInCluster ? (
<MessageItemEmbed embed={message.embed} /> <View style={[a.absolute, a.bottom_0]}>{avatar}</View>
)} ) : null}
{rt.text.length > 0 && ( <View
<View style={[
style={ a.flex_grow,
!isOnlyEmoji(message.text) && [ !isFromSelf &&
a.py_sm, isGroupChat && {
a.my_2xs, paddingLeft: AVATAR_SIZE,
a.rounded_md, },
]}>
{isGroupChat &&
!isFromSelf &&
isFirstInCluster &&
!isOnlyEmoji(message.text) ? (
<Text
style={[
a.text_xs,
t.atoms.text_contrast_medium,
a.pt_xs,
a.pb_2xs,
{ {
paddingLeft: 14, paddingLeft: DISPLAY_NAME_INSET,
paddingRight: 14,
backgroundColor: isFromSelf
? isPending
? pendingColor
: t.palette.primary_500
: t.palette.contrast_50,
borderRadius: 17,
}, },
isFromSelf ? a.self_end : a.self_start, ]}>
isFromSelf {displayName}
? {borderBottomRightRadius: needsTail ? 2 : 17} </Text>
: {borderBottomLeftRadius: needsTail ? 2 : 17}, ) : null}
] <ActionsWrapper isFromSelf={isFromSelf} message={message}>
}> {rt.text.length > 0 && (
<RichText <View
value={rt} style={[
style={[a.text_md, isFromSelf && {color: t.palette.white}]} !isFromSelf && a.ml_sm,
interactiveStyle={a.underline} ...(isOnlyEmoji(message.text)
enableTags ? []
emojiMultiplier={3} : [
shouldProxyLinks={true} a.rounded_md,
/> a.rounded_xl,
</View> a.py_sm,
)} a.px_md,
{
{IS_NATIVE && appliedReactions} marginTop: isFirstInCluster
</ActionsWrapper> ? 0
: CLUSTERED_MESSAGE_GAP,
{!IS_NATIVE && appliedReactions} backgroundColor: isFromSelf
? isPending
{isLastInGroup && ( ? 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}
/>
)}
</ActionsWrapper>
</View>
</View>
{appliedReactions}
{isLastInCluster && (
<MessageItemMetadata <MessageItemMetadata
item={item} item={item}
style={isFromSelf ? a.text_right : a.text_left} style={[isFromSelf ? a.text_right : a.text_left]}
/> />
)} )}
</View> </View>
@@ -244,8 +415,7 @@ let MessageItemMetadata = ({
style: StyleProp<TextStyle> style: StyleProp<TextStyle>
}): React.ReactNode => { }): React.ReactNode => {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {t: l} = useLingui()
const {message} = item
const handleRetry = useCallback( const handleRetry = useCallback(
(e: GestureResponderEvent) => { (e: GestureResponderEvent) => {
@@ -258,75 +428,60 @@ let MessageItemMetadata = ({
[item], [item],
) )
const relativeTimestamp = useCallback( const errorColor = t.palette.negative_400
(i18n: I18n, timestamp: string) => {
const date = new Date(timestamp)
const now = new Date()
const time = i18n.date(date, { switch (item.type) {
hour: 'numeric', case 'pending-message':
minute: 'numeric', return item.failed ? (
}) <Text
style={[
const diff = now.getTime() - date.getTime() a.text_xs,
a.my_2xs,
// if under 30 seconds {
if (diff < 1000 * 30) { color: errorColor,
return _(msg`Now`) },
} style,
]}>
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 && (
<>
{' '}
&middot;{' '}
<Text <Text
style={[ style={[
a.text_xs, a.text_xs,
{ {
color: t.palette.negative_400, color: errorColor,
}, },
]}> ]}>
{_(msg`Failed to send`)} {l`Message failed to send.`}
</Text> </Text>
{item.retry && ( {item.retry && (
<> <>
{' '} {' '}
&middot;{' '}
<InlineLinkText <InlineLinkText
label={_(msg`Click to retry failed message`)} label={l`Click to retry failed message`}
to="#" to="#"
onPress={handleRetry} onPress={handleRetry}
style={[a.text_xs]}> style={[
{_(msg`Retry`)} a.text_xs,
{
color: errorColor,
},
]}>
{l`Tap to retry`}
</InlineLinkText> </InlineLinkText>
.
</> </>
)} )}
</> </Text>
)} ) : (
</Text> <Text
) style={[
a.text_xs,
a.my_2xs,
style,
t.atoms.text_contrast_high,
]}>{l`Sending…`}</Text>
)
default:
return null
}
} }
MessageItemMetadata = memo(MessageItemMetadata) MessageItemMetadata = memo(MessageItemMetadata)
export {MessageItemMetadata} export {MessageItemMetadata}
+39 -3
View File
@@ -2,14 +2,24 @@ import {memo} from 'react'
import {useWindowDimensions, View} from 'react-native' import {useWindowDimensions, View} from 'react-native'
import {type $Typed, type AppBskyEmbedRecord} from '@atproto/api' import {type $Typed, type AppBskyEmbedRecord} from '@atproto/api'
import {atoms as a, native, tokens, useTheme, web} from '#/alf' import {atoms as a, native, useTheme, web} from '#/alf'
import {Embed, PostEmbedViewContext} from '#/components/Post/Embed' import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
import {MessageContextProvider} from './MessageContext' import {MessageContextProvider} from './MessageContext'
const CLUSTERED_MESSAGE_GAP = 2
const BORDER_RADIUS = 20
const SQUARED_BORDER_RADIUS = 4
let MessageItemEmbed = ({ let MessageItemEmbed = ({
embed, embed,
isFromSelf,
squaredTopCorner,
squaredBottomCorner,
}: { }: {
embed: $Typed<AppBskyEmbedRecord.View> embed: $Typed<AppBskyEmbedRecord.View>
isFromSelf: boolean
squaredTopCorner: boolean
squaredBottomCorner: boolean
}): React.ReactNode => { }): React.ReactNode => {
const t = useTheme() const t = useTheme()
const screen = useWindowDimensions() const screen = useWindowDimensions()
@@ -18,7 +28,7 @@ let MessageItemEmbed = ({
<MessageContextProvider> <MessageContextProvider>
<View <View
style={[ style={[
a.my_xs, isFromSelf ? a.mr_sm : a.ml_sm,
t.atoms.bg, t.atoms.bg,
a.rounded_md, a.rounded_md,
native({ native({
@@ -30,12 +40,38 @@ let MessageItemEmbed = ({
minWidth: 280, minWidth: 280,
maxWidth: 360, maxWidth: 360,
}), }),
{
marginTop: CLUSTERED_MESSAGE_GAP,
},
]}> ]}>
<View style={{marginTop: tokens.space.sm * -1}}> <View style={{marginTop: -8}}>
<Embed <Embed
embed={embed} embed={embed}
allowNestedQuotes allowNestedQuotes
viewContext={PostEmbedViewContext.Feed} viewContext={PostEmbedViewContext.Feed}
style={[
a.rounded_xl,
a.border_0,
isFromSelf
? {
backgroundColor: t.palette.primary_50,
borderBottomRightRadius: squaredBottomCorner
? SQUARED_BORDER_RADIUS
: BORDER_RADIUS,
borderTopRightRadius: squaredTopCorner
? SQUARED_BORDER_RADIUS
: BORDER_RADIUS,
}
: {
backgroundColor: t.palette.contrast_50,
borderBottomLeftRadius: squaredBottomCorner
? SQUARED_BORDER_RADIUS
: BORDER_RADIUS,
borderTopLeftRadius: squaredTopCorner
? SQUARED_BORDER_RADIUS
: BORDER_RADIUS,
},
]}
/> />
</View> </View>
</View> </View>
@@ -0,0 +1,49 @@
import {memo} from 'react'
import {useWindowDimensions, View} from 'react-native'
import {type $Typed, type AppBskyEmbedRecord} from '@atproto/api'
import {atoms as a, native, tokens, useTheme, web} from '#/alf'
import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
import {MessageContextProvider} from './MessageContext'
/**
* @deprecated
*/
let MessageItemEmbed = ({
embed,
}: {
embed: $Typed<AppBskyEmbedRecord.View>
}): React.ReactNode => {
const t = useTheme()
const screen = useWindowDimensions()
return (
<MessageContextProvider>
<View
style={[
a.my_xs,
t.atoms.bg,
a.rounded_md,
native({
flexBasis: 0,
width: Math.min(screen.width, 600) / 1.4,
}),
web({
width: '100%',
minWidth: 280,
maxWidth: 360,
}),
]}>
<View style={{marginTop: tokens.space.sm * -1}}>
<Embed
embed={embed}
allowNestedQuotes
viewContext={PostEmbedViewContext.Feed}
/>
</View>
</View>
</MessageContextProvider>
)
}
MessageItemEmbed = memo(MessageItemEmbed)
export {MessageItemEmbed}
@@ -0,0 +1,335 @@
import {memo, useCallback, useMemo} from 'react'
import {
type GestureResponderEvent,
type StyleProp,
type TextStyle,
View,
} from 'react-native'
import Animated, {
LayoutAnimationConfig,
LinearTransition,
ZoomIn,
ZoomOut,
} from 'react-native-reanimated'
import {
AppBskyEmbedRecord,
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 {sanitizeDisplayName} from '#/lib/strings/display-names'
import {useConvoActive} from '#/state/messages/convo'
import {type ConvoItem} from '#/state/messages/convo/types'
import {useSession} from '#/state/session'
import {TimeElapsed} from '#/view/com/util/TimeElapsed'
import {atoms as a, native, useTheme} from '#/alf'
import {isOnlyEmoji} from '#/alf/typography'
import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
import {InlineLinkText} from '#/components/Link'
import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography'
import {IS_NATIVE} from '#/env'
import {DateDivider as DateDividerDeprecated} from './DateDivider_DEPRECATED'
import {MessageItemEmbed as MessageItemEmbedDeprecated} from './MessageItemEmbed_DEPRECATED'
import {localDateString} from './util'
/**
* @deprecated
*/
let MessageItem = ({
item,
}: {
item: ConvoItem & {type: 'message' | 'pending-message'}
}): React.ReactNode => {
const t = useTheme()
const {currentAccount} = useSession()
const {_} = useLingui()
const {convo} = useConvoActive()
const {message, nextMessage, prevMessage} = item
const isPending = item.type === 'pending-message'
const isFromSelf = message.sender?.did === currentAccount?.did
const nextIsMessage = ChatBskyConvoDefs.isMessageView(nextMessage)
const isNextFromSelf =
nextIsMessage && nextMessage.sender?.did === currentAccount?.did
const isNextFromSameSender = isNextFromSelf === isFromSelf
const isNewDay = useMemo(() => {
if (!prevMessage) return true
const thisDate = new Date(message.sentAt)
const prevDate = new Date(prevMessage.sentAt)
return localDateString(thisDate) !== localDateString(prevDate)
}, [message, prevMessage])
const isLastMessageOfDay = useMemo(() => {
if (!nextMessage || !nextIsMessage) return true
const thisDate = new Date(message.sentAt)
const prevDate = new Date(nextMessage.sentAt)
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 rt = useMemo(() => {
return new RichTextAPI({text: message.text, facets: message.facets})
}, [message.text, message.facets])
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]}>
<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}],
},
]}>
{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}`)
}
}
return (
<Animated.View
entering={native(ZoomIn.springify(200).delay(400))}
exiting={reactions.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`,
)}>
<Text emoji style={[a.text_sm]}>
{reaction.value}
</Text>
</Animated.View>
)
})}
</View>
</View>
)}
</LayoutAnimationConfig>
)
return (
<>
{isNewDay && <DateDividerDeprecated date={message.sentAt} />}
<View
style={[
isFromSelf ? a.mr_md : a.ml_md,
nextIsMessage && !isNextFromSameSender && a.mb_md,
]}>
<ActionsWrapper isFromSelf={isFromSelf} message={message}>
{AppBskyEmbedRecord.isView(message.embed) && (
<MessageItemEmbedDeprecated 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>
)}
{IS_NATIVE && appliedReactions}
</ActionsWrapper>
{!IS_NATIVE && appliedReactions}
{isLastInGroup && (
<MessageItemMetadata
item={item}
style={isFromSelf ? a.text_right : a.text_left}
/>
)}
</View>
</>
)
}
MessageItem = memo(MessageItem)
export {MessageItem}
let MessageItemMetadata = ({
item,
style,
}: {
item: ConvoItem & {type: 'message' | 'pending-message'}
style: StyleProp<TextStyle>
}): React.ReactNode => {
const t = useTheme()
const {_} = useLingui()
const {message} = item
const handleRetry = useCallback(
(e: GestureResponderEvent) => {
if (item.type === 'pending-message' && item.retry) {
e.preventDefault()
item.retry()
return false
}
},
[item],
)
const relativeTimestamp = useCallback(
(i18n: I18n, timestamp: string) => {
const date = new Date(timestamp)
const now = new Date()
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 && (
<>
{' '}
&middot;{' '}
<Text
style={[
a.text_xs,
{
color: t.palette.negative_400,
},
]}>
{_(msg`Failed to send`)}
</Text>
{item.retry && (
<>
{' '}
&middot;{' '}
<InlineLinkText
label={_(msg`Click to retry failed message`)}
to="#"
onPress={handleRetry}
style={[a.text_xs]}>
{_(msg`Retry`)}
</InlineLinkText>
</>
)}
</>
)}
</Text>
)
}
MessageItemMetadata = memo(MessageItemMetadata)
export {MessageItemMetadata}
@@ -15,8 +15,7 @@ import Animated, {
} from 'react-native-reanimated' } from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context' import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {GlassContainer} from 'expo-glass-effect' import {GlassContainer} from 'expo-glass-effect'
import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
import {countGraphemes} from 'unicode-segmenter/grapheme' import {countGraphemes} from 'unicode-segmenter/grapheme'
import {HITSLOP_10, MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants' import {HITSLOP_10, MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants'
@@ -47,13 +46,13 @@ export function MessageInput({
children, children,
}: { }: {
textInputId?: string textInputId?: string
onSendMessage: (message: string) => void onSendMessage: (message: string) => Promise<void> | void
hasEmbed: boolean hasEmbed: boolean
setEmbed: (embedUrl: string | undefined) => void setEmbed: (embedUrl: string | undefined) => void
children?: React.ReactNode children?: React.ReactNode
openEmojiPicker?: (pos: EmojiPickerPosition) => void openEmojiPicker?: (pos: EmojiPickerPosition) => void
}) { }) {
const {_} = useLingui() const {t: l} = useLingui()
const t = useTheme() const t = useTheme()
const playHaptic = useHaptics() const playHaptic = useHaptics()
const {getDraft, clearDraft} = useMessageDraft() const {getDraft, clearDraft} = useMessageDraft()
@@ -82,13 +81,13 @@ export function MessageInput({
return return
} }
if (countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) { if (countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) {
Toast.show(_(msg`Message is too long`), { Toast.show(l`Message is too long`, {
type: 'error', type: 'error',
}) })
return return
} }
clearDraft() clearDraft()
onSendMessage(message) void onSendMessage(message)
playHaptic() playHaptic()
setEmbed(undefined) setEmbed(undefined)
setMessage('') setMessage('')
@@ -111,7 +110,7 @@ export function MessageInput({
playHaptic, playHaptic,
setEmbed, setEmbed,
inputRef, inputRef,
_, l,
]) ])
useFocusedInputHandler( useFocusedInputHandler(
@@ -169,9 +168,9 @@ export function MessageInput({
fallbackStyle={[t.atoms.bg_contrast_50]}> fallbackStyle={[t.atoms.bg_contrast_50]}>
<AnimatedTextInput <AnimatedTextInput
nativeID={textInputId} nativeID={textInputId}
accessibilityLabel={_(msg`Message input field`)} accessibilityLabel={l`Message input field`}
accessibilityHint={_(msg`Type your message here`)} accessibilityHint={l`Type your message here`}
placeholder={_(msg`Message`)} placeholder={l`Message`}
placeholderTextColor={t.palette.contrast_500} placeholderTextColor={t.palette.contrast_500}
value={message} value={message}
onChange={evt => { onChange={evt => {
@@ -225,7 +224,7 @@ export function MessageInput({
}}> }}>
<Pressable <Pressable
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={_(msg`Send message`)} accessibilityLabel={l`Send message`}
accessibilityHint="" accessibilityHint=""
hitSlop={HITSLOP_10} hitSlop={HITSLOP_10}
style={[ style={[
@@ -1,7 +1,6 @@
import {useCallback, useEffect, useRef, useState} from 'react' import {useCallback, useEffect, useRef, useState} from 'react'
import {Pressable, View} from 'react-native' import {Pressable, View} from 'react-native'
import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
import {flushSync} from 'react-dom' import {flushSync} from 'react-dom'
import TextareaAutosize from 'react-textarea-autosize' import TextareaAutosize from 'react-textarea-autosize'
import {countGraphemes} from 'unicode-segmenter/grapheme' import {countGraphemes} from 'unicode-segmenter/grapheme'
@@ -40,7 +39,7 @@ export function MessageInput({
openEmojiPicker?: (pos: EmojiPickerPosition) => void openEmojiPicker?: (pos: EmojiPickerPosition) => void
}) { }) {
const {isMobile} = useWebMediaQueries() const {isMobile} = useWebMediaQueries()
const {_} = useLingui() const {t: l} = useLingui()
const t = useTheme() const t = useTheme()
const {getDraft, clearDraft} = useMessageDraft() const {getDraft, clearDraft} = useMessageDraft()
const [message, setMessage] = useState(getDraft) const [message, setMessage] = useState(getDraft)
@@ -57,7 +56,7 @@ export function MessageInput({
return return
} }
if (countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) { if (countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) {
Toast.show(_(msg`Message is too long`), { Toast.show(l`Message is too long`, {
type: 'error', type: 'error',
}) })
return return
@@ -66,7 +65,7 @@ export function MessageInput({
onSendMessage(message) onSendMessage(message)
setMessage('') setMessage('')
setEmbed(undefined) setEmbed(undefined)
}, [message, onSendMessage, _, clearDraft, hasEmbed, setEmbed]) }, [message, onSendMessage, l, clearDraft, hasEmbed, setEmbed])
const onKeyDown = useCallback( const onKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => { (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
@@ -177,7 +176,7 @@ export function MessageInput({
width: 30, width: 30,
}, },
]} ]}
label={_(msg`Open emoji picker`)}> label={l`Open emoji picker`}>
{state => ( {state => (
<View <View
style={[ style={[
@@ -210,7 +209,7 @@ export function MessageInput({
}, },
])} ])}
maxRows={12} maxRows={12}
placeholder={_(msg`Write a message`)} placeholder={l`Message`}
defaultValue="" defaultValue=""
value={message} value={message}
dirName="ltr" dirName="ltr"
@@ -231,7 +230,7 @@ export function MessageInput({
/> />
<Pressable <Pressable
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={_(msg`Send message`)} accessibilityLabel={l`Send message`}
accessibilityHint="" accessibilityHint=""
style={[ style={[
a.rounded_full, a.rounded_full,
@@ -1,34 +1,33 @@
import {useMemo} from 'react' import {useMemo} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
import {type ConvoItem, ConvoItemError} from '#/state/messages/convo/types' import {type ConvoItem, ConvoItemError} from '#/state/messages/convo/types'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {InlineLinkText} from '#/components/Link' import {createStaticClick, InlineLinkText} from '#/components/Link'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
export function MessageListError({item}: {item: ConvoItem & {type: 'error'}}) { export function MessageListError({item}: {item: ConvoItem & {type: 'error'}}) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {t: l} = useLingui()
const {description, help, cta} = useMemo(() => { const {description, help, cta} = useMemo(() => {
return { return {
[ConvoItemError.FirehoseFailed]: { [ConvoItemError.FirehoseFailed]: {
description: _(msg`This chat was disconnected`), description: l`This chat was disconnected`,
help: _(msg`Press to attempt reconnection`), help: l`Press to attempt reconnection`,
cta: _(msg`Reconnect`), cta: l`Reconnect`,
}, },
[ConvoItemError.HistoryFailed]: { [ConvoItemError.HistoryFailed]: {
description: _(msg`Failed to load past messages`), description: l`Failed to load past messages`,
help: _(msg`Press to retry`), help: l`Press to retry`,
cta: _(msg`Retry`), cta: l`Retry`,
}, },
}[item.code] }[item.code]
}, [_, item.code]) }, [l, item.code])
return ( return (
<View style={[a.py_md, a.w_full, a.flex_row, a.justify_center]}> <View style={[a.my_md, a.w_full, a.flex_row, a.justify_center]}>
<View <View
style={[ style={[
a.flex_1, a.flex_1,
@@ -41,18 +40,18 @@ export function MessageListError({item}: {item: ConvoItem & {type: 'error'}}) {
<CircleInfo size="sm" fill={t.palette.negative_400} /> <CircleInfo size="sm" fill={t.palette.negative_400} />
<Text style={[a.leading_snug, t.atoms.text_contrast_medium]}> <Text style={[a.leading_snug, t.atoms.text_contrast_medium]}>
{description} &middot;{' '} {description}
{item.retry && ( {item.retry && (
<InlineLinkText <>
to="#" &middot;{' '}
label={help} <InlineLinkText
onPress={e => { label={help}
e.preventDefault() {...createStaticClick(() => {
item.retry?.() item.retry?.()
return false })}>
}}> {cta}
{cta} </InlineLinkText>
</InlineLinkText> </>
)} )}
</Text> </Text>
</View> </View>
@@ -0,0 +1,64 @@
import {useMemo} from 'react'
import {View} from 'react-native'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {type ConvoItem, ConvoItemError} from '#/state/messages/convo/types'
import {atoms as a, useTheme} from '#/alf'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {InlineLinkText} from '#/components/Link'
import {Text} from '#/components/Typography'
/**
* @deprecated
*/
export function MessageListError({item}: {item: ConvoItem & {type: 'error'}}) {
const t = useTheme()
const {_} = useLingui()
const {description, help, cta} = useMemo(() => {
return {
[ConvoItemError.FirehoseFailed]: {
description: _(msg`This chat was disconnected`),
help: _(msg`Press to attempt reconnection`),
cta: _(msg`Reconnect`),
},
[ConvoItemError.HistoryFailed]: {
description: _(msg`Failed to load past messages`),
help: _(msg`Press to retry`),
cta: _(msg`Retry`),
},
}[item.code]
}, [_, item.code])
return (
<View style={[a.py_md, a.w_full, a.flex_row, a.justify_center]}>
<View
style={[
a.flex_1,
a.flex_row,
a.align_center,
a.justify_center,
a.gap_sm,
{maxWidth: 400},
]}>
<CircleInfo size="sm" fill={t.palette.negative_400} />
<Text style={[a.leading_snug, t.atoms.text_contrast_medium]}>
{description} &middot;{' '}
{item.retry && (
<InlineLinkText
to="#"
label={help}
onPress={e => {
e.preventDefault()
item.retry?.()
return false
}}>
{cta}
</InlineLinkText>
)}
</Text>
</View>
</View>
)
}
@@ -5,7 +5,7 @@ import {
type KeyboardChatScrollViewProps, type KeyboardChatScrollViewProps,
KeyboardGestureArea, KeyboardGestureArea,
} from 'react-native-keyboard-controller' } from 'react-native-keyboard-controller'
import Animated, { import {
runOnJS, runOnJS,
type ScrollEvent, type ScrollEvent,
type SharedValue, type SharedValue,
@@ -51,9 +51,11 @@ import {ChatDisabled} from '#/screens/Messages/components/ChatDisabled'
import {MessageComposer} from '#/screens/Messages/components/MessageComposer' import {MessageComposer} from '#/screens/Messages/components/MessageComposer'
import {MessageInput} from '#/screens/Messages/components/MessageInput' import {MessageInput} from '#/screens/Messages/components/MessageInput'
import {MessageListError} from '#/screens/Messages/components/MessageListError' import {MessageListError} from '#/screens/Messages/components/MessageListError'
import {MessageListError as MessageListErrorDeprecated} from '#/screens/Messages/components/MessageListError_DEPRECATED'
import {atoms as a, platform, tokens, useTheme, web} from '#/alf' import {atoms as a, platform, tokens, useTheme, web} from '#/alf'
import {ChatEmptyPill} from '#/components/dms/ChatEmptyPill' import {ChatEmptyPill} from '#/components/dms/ChatEmptyPill'
import {MessageItem} from '#/components/dms/MessageItem' import {MessageItem} from '#/components/dms/MessageItem'
import {MessageItem as MessageItemDeprecated} from '#/components/dms/MessageItem_DEPRECATED'
import {NewMessagesPill} from '#/components/dms/NewMessagesPill' import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
@@ -77,18 +79,6 @@ function MaybeLoader({isLoading}: {isLoading: boolean}) {
) )
} }
function renderItem({item}: {item: ConvoItem}) {
if (item.type === 'message' || item.type === 'pending-message') {
return <MessageItem item={item} />
} else if (item.type === 'deleted-message') {
return <Text>Deleted message</Text>
} else if (item.type === 'error') {
return <MessageListError item={item} />
}
return null
}
function keyExtractor(item: ConvoItem) { function keyExtractor(item: ConvoItem) {
return item.key return item.key
} }
@@ -117,6 +107,8 @@ export function MessagesList({
const {embedUri, setEmbed} = useMessageEmbed() const {embedUri, setEmbed} = useMessageEmbed()
const t = useTheme() const t = useTheme()
const isGroupChatEnabled = ax.features.enabled(ax.features.GroupChatsEnable)
const textInputId = 'chat-input-' + useId() const textInputId = 'chat-input-' + useId()
const flatListRef = useAnimatedRef<ListMethods>() const flatListRef = useAnimatedRef<ListMethods>()
@@ -369,6 +361,32 @@ export function MessagesList({
setEmojiPickerState({isOpen: true, pos}) setEmojiPickerState({isOpen: true, pos})
}, []) }, [])
const renderItem = ({item}: {item: ConvoItem}) => {
if (item.type === 'message' || item.type === 'pending-message') {
return isGroupChatEnabled ? (
<MessageItem
item={item}
profile={convoState.convo.members.find(
member => member.did === item.message.sender.did,
)}
isGroupChat={convoState.convo.kind === 'group'}
/>
) : (
<MessageItemDeprecated item={item} />
)
} else if (item.type === 'deleted-message') {
return <Text>Deleted message</Text>
} else if (item.type === 'error') {
return isGroupChatEnabled ? (
<MessageListError item={item} />
) : (
<MessageListErrorDeprecated item={item} />
)
}
return null
}
const renderScrollComponent = useCallback( const renderScrollComponent = useCallback(
(props: ScrollViewProps) => ( (props: ScrollViewProps) => (
<ChatScrollComponent {...props} inputHeight={inputHeightUI} /> <ChatScrollComponent {...props} inputHeight={inputHeightUI} />
@@ -411,9 +429,18 @@ export function MessagesList({
} }
// native only (prop is not supported on web) // native only (prop is not supported on web)
renderScrollComponent={renderScrollComponent} renderScrollComponent={renderScrollComponent}
// pushes up the content under the input on web (renderScrollComponent handles it on native) contentContainerStyle={{
paddingBottom: platform({
// ios is slightly larger as the input has no top padding
ios: tokens.space.lg,
android: tokens.space.md,
web: 0, // web uses ListFooterComponent instead for scroll reasons
}),
}}
// adds extra space underneath the absolutely positioned input on web
// as renderScrollComponent isn't available here (luckily we don't need the fancy behaviour)
ListFooterComponent={web( ListFooterComponent={web(
<WebInputSpacer inputHeight={inputHeightJS} />, <View style={{height: tokens.space.md + inputHeightJS}} />,
)} )}
style={web({ style={web({
scrollbarWidth: 'thin', scrollbarWidth: 'thin',
@@ -518,12 +545,6 @@ function ChatScrollComponent({
) )
} }
function WebInputSpacer({inputHeight}: {inputHeight: number}) {
if (!IS_WEB) return null
return <Animated.View style={{height: inputHeight}} />
}
type FooterState = 'loading' | 'new-chat' | 'request' | 'standard' type FooterState = 'loading' | 'new-chat' | 'request' | 'standard'
function getFooterState( function getFooterState(
+26 -28
View File
@@ -222,7 +222,7 @@ export class Convo {
switch (action.event) { switch (action.event) {
case ConvoDispatchEvent.Init: { case ConvoDispatchEvent.Init: {
this.status = ConvoStatus.Initializing this.status = ConvoStatus.Initializing
this.setup() void this.setup()
this.setupFirehose() this.setupFirehose()
this.requestPollInterval(ACTIVE_POLL_INTERVAL) this.requestPollInterval(ACTIVE_POLL_INTERVAL)
break break
@@ -234,12 +234,12 @@ export class Convo {
switch (action.event) { switch (action.event) {
case ConvoDispatchEvent.Ready: { case ConvoDispatchEvent.Ready: {
this.status = ConvoStatus.Ready this.status = ConvoStatus.Ready
this.fetchMessageHistory() void this.fetchMessageHistory()
break break
} }
case ConvoDispatchEvent.Background: { case ConvoDispatchEvent.Background: {
this.status = ConvoStatus.Backgrounded this.status = ConvoStatus.Backgrounded
this.fetchMessageHistory() void this.fetchMessageHistory()
this.requestPollInterval(BACKGROUND_POLL_INTERVAL) this.requestPollInterval(BACKGROUND_POLL_INTERVAL)
break break
} }
@@ -258,7 +258,7 @@ export class Convo {
} }
case ConvoDispatchEvent.Disable: { case ConvoDispatchEvent.Disable: {
this.status = ConvoStatus.Disabled this.status = ConvoStatus.Disabled
this.fetchMessageHistory() // finish init void this.fetchMessageHistory() // finish init
this.cleanupFirehoseConnection?.() this.cleanupFirehoseConnection?.()
this.withdrawRequestedPollInterval() this.withdrawRequestedPollInterval()
break break
@@ -269,7 +269,7 @@ export class Convo {
case ConvoStatus.Ready: { case ConvoStatus.Ready: {
switch (action.event) { switch (action.event) {
case ConvoDispatchEvent.Resume: { case ConvoDispatchEvent.Resume: {
this.refreshConvo() void this.refreshConvo()
this.requestPollInterval(ACTIVE_POLL_INTERVAL) this.requestPollInterval(ACTIVE_POLL_INTERVAL)
break break
} }
@@ -308,11 +308,11 @@ export class Convo {
} else { } else {
if (this.convo) { if (this.convo) {
this.status = ConvoStatus.Ready this.status = ConvoStatus.Ready
this.refreshConvo() void this.refreshConvo()
this.maybeRecoverFromNetworkError() this.maybeRecoverFromNetworkError()
} else { } else {
this.status = ConvoStatus.Initializing this.status = ConvoStatus.Initializing
this.setup() void this.setup()
} }
this.requestPollInterval(ACTIVE_POLL_INTERVAL) this.requestPollInterval(ACTIVE_POLL_INTERVAL)
} }
@@ -435,7 +435,7 @@ export class Convo {
this.firehoseError = undefined this.firehoseError = undefined
this.commit() this.commit()
} else { } else {
this.batchRetryPendingMessages() void this.batchRetryPendingMessages()
} }
if (this.fetchMessageHistoryError) { if (this.fetchMessageHistoryError) {
@@ -487,7 +487,8 @@ export class Convo {
} else { } else {
this.dispatch({event: ConvoDispatchEvent.Ready}) this.dispatch({event: ConvoDispatchEvent.Ready})
} }
} catch (e: any) { } catch (err) {
const e = err as Error
if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) { if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) {
logger.error('setup failed', { logger.error('setup failed', {
safeMessage: e.message, safeMessage: e.message,
@@ -557,11 +558,7 @@ export class Convo {
async fetchConvo() { async fetchConvo() {
if (this.pendingFetchConvo) return this.pendingFetchConvo if (this.pendingFetchConvo) return this.pendingFetchConvo
this.pendingFetchConvo = new Promise<{ this.pendingFetchConvo = (async () => {
convo: ChatBskyConvoDefs.ConvoView
sender: ChatBskyActorDefs.ProfileViewBasic | undefined
recipients: ChatBskyActorDefs.ProfileViewBasic[]
}>(async (resolve, reject) => {
try { try {
const response = await networkRetry(2, () => { const response = await networkRetry(2, () => {
return this.agent.api.chat.bsky.convo.getConvo( return this.agent.api.chat.bsky.convo.getConvo(
@@ -574,17 +571,15 @@ export class Convo {
const convo = response.data.convo const convo = response.data.convo
resolve({ return {
convo, convo,
sender: convo.members.find(m => m.did === this.senderUserDid), sender: convo.members.find(m => m.did === this.senderUserDid),
recipients: convo.members.filter(m => m.did !== this.senderUserDid), recipients: convo.members.filter(m => m.did !== this.senderUserDid),
}) }
} catch (e) {
reject(e)
} finally { } finally {
this.pendingFetchConvo = undefined this.pendingFetchConvo = undefined
} }
}) })()
return this.pendingFetchConvo return this.pendingFetchConvo
} }
@@ -596,7 +591,8 @@ export class Convo {
this.convo = convo || this.convo this.convo = convo || this.convo
this.sender = sender || this.sender this.sender = sender || this.sender
this.recipients = recipients || this.recipients this.recipients = recipients || this.recipients
} catch (e: any) { } catch (err) {
const e = err as Error
if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) { if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) {
logger.error(`failed to refresh convo`, { logger.error(`failed to refresh convo`, {
safeMessage: e.message, safeMessage: e.message,
@@ -664,7 +660,8 @@ export class Convo {
this.pastMessages.set(message.id, message) this.pastMessages.set(message.id, message)
} }
} }
} catch (e: any) { } catch (err) {
const e = err as Error
if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) { if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) {
logger.error('failed to fetch message history', { logger.error('failed to fetch message history', {
safeMessage: e.message, safeMessage: e.message,
@@ -673,7 +670,7 @@ export class Convo {
this.fetchMessageHistoryError = { this.fetchMessageHistoryError = {
retry: () => { retry: () => {
this.fetchMessageHistory() void this.fetchMessageHistory()
}, },
} }
} finally { } finally {
@@ -716,7 +713,7 @@ export class Convo {
onFirehoseConnect() { onFirehoseConnect() {
this.firehoseError = undefined this.firehoseError = undefined
this.batchRetryPendingMessages() void this.batchRetryPendingMessages()
this.commit() this.commit()
} }
@@ -761,8 +758,8 @@ export class Convo {
/** /**
* If this message is already in new messages, it was added by our * If this message is already in new messages, it was added by our
* sending logic, and is based on client-ordering. When we receive * sending logic, and is based on client-ordering. When we receive
* the "commited" event from the log, we should replace this * the "committed" event from the log, we should replace this
* reference and re-insert in order to respect the order we receied * reference and re-insert in order to respect the order we received
* from the log. * from the log.
*/ */
if (this.newMessages.has(ev.message.id)) { if (this.newMessages.has(ev.message.id)) {
@@ -836,7 +833,7 @@ export class Convo {
this.commit() this.commit()
if (!this.isProcessingPendingMessages && !this.pendingMessageFailure) { if (!this.isProcessingPendingMessages && !this.pendingMessageFailure) {
this.processPendingMessages() void this.processPendingMessages()
} }
} }
@@ -912,7 +909,7 @@ export class Convo {
} }
} }
private handleSendMessageFailure(e: any) { private handleSendMessageFailure(e: Error | XRPCError) {
if (e instanceof XRPCError) { if (e instanceof XRPCError) {
if (NETWORK_FAILURE_STATUSES.includes(e.status)) { if (NETWORK_FAILURE_STATUSES.includes(e.status)) {
this.pendingMessageFailure = 'recoverable' this.pendingMessageFailure = 'recoverable'
@@ -1026,7 +1023,8 @@ export class Convo {
{encoding: 'application/json', headers: DM_SERVICE_HEADERS}, {encoding: 'application/json', headers: DM_SERVICE_HEADERS},
) )
}) })
} catch (e: any) { } catch (err) {
const e = err as Error
if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) { if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) {
logger.error(`failed to delete message`, { logger.error(`failed to delete message`, {
safeMessage: e.message, safeMessage: e.message,