Update chat presentation (#10197)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
This commit is contained in:
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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
@@ -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 && (
|
|
||||||
<>
|
|
||||||
{' '}
|
|
||||||
·{' '}
|
|
||||||
<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 && (
|
||||||
<>
|
<>
|
||||||
{' '}
|
{' '}
|
||||||
·{' '}
|
|
||||||
<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}
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
·{' '}
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_xs,
|
||||||
|
{
|
||||||
|
color: t.palette.negative_400,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
{_(msg`Failed to send`)}
|
||||||
|
</Text>
|
||||||
|
{item.retry && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
·{' '}
|
||||||
|
<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} ·{' '}
|
{description}
|
||||||
{item.retry && (
|
{item.retry && (
|
||||||
<InlineLinkText
|
<>
|
||||||
to="#"
|
·{' '}
|
||||||
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} ·{' '}
|
||||||
|
{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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user