diff --git a/src/components/dms/ActionsWrapper.tsx b/src/components/dms/ActionsWrapper.tsx index c1f54e2394..3ed704f99d 100644 --- a/src/components/dms/ActionsWrapper.tsx +++ b/src/components/dms/ActionsWrapper.tsx @@ -1,7 +1,6 @@ import {View} from 'react-native' import {type ChatBskyConvoDefs} from '@atproto/api' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' +import {useLingui} from '@lingui/react/macro' import {atoms as a} from '#/alf' import {MessageContextMenu} from '#/components/dms/MessageContextMenu' @@ -15,7 +14,7 @@ export function ActionsWrapper({ isFromSelf: boolean children: React.ReactNode }) { - const {_} = useLingui() + const {t: l} = useLingui() return ( @@ -32,7 +31,7 @@ export function ActionsWrapper({ ]} accessible={true} accessibilityActions={[ - {name: 'activate', label: _(msg`Open message options`)}, + {name: 'activate', label: l`Open message options`}, ]} onAccessibilityAction={() => trigger.control.open('full')}> {children} diff --git a/src/components/dms/DateDivider.tsx b/src/components/dms/DateDivider.tsx index dfc2d53da5..0a54de39fc 100644 --- a/src/components/dms/DateDivider.tsx +++ b/src/components/dms/DateDivider.tsx @@ -1,8 +1,6 @@ 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 {Trans, useLingui} from '@lingui/react/macro' import {subDays} from 'date-fns' 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 => { - const {_} = useLingui() + const {t: l} = useLingui() const t = useTheme() let date: string @@ -42,9 +40,9 @@ let DateDivider = ({date: dateStr}: {date: string}): React.ReactNode => { const oneWeekAgo = subDays(today, 7) if (localDateString(today) === localDateString(timestamp)) { - date = _(msg`Today`) + date = l`Today` } else if (localDateString(yesterday) === localDateString(timestamp)) { - date = _(msg`Yesterday`) + date = l`Yesterday` } else { if (timestamp < oneWeekAgo) { if (timestamp.getFullYear() === today.getFullYear()) { @@ -58,7 +56,7 @@ let DateDivider = ({date: dateStr}: {date: string}): React.ReactNode => { } return ( - + { a.px_md, ]}> - - {date} - {' '} - at {time} + {date} at {time} diff --git a/src/components/dms/DateDivider_DEPRECATED.tsx b/src/components/dms/DateDivider_DEPRECATED.tsx new file mode 100644 index 0000000000..dfc2d53da5 --- /dev/null +++ b/src/components/dms/DateDivider_DEPRECATED.tsx @@ -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 ( + + + + + {date} + {' '} + at {time} + + + + ) +} +DateDivider = memo(DateDivider) +export {DateDivider} diff --git a/src/components/dms/MessageContextMenu.tsx b/src/components/dms/MessageContextMenu.tsx index dda99c77e2..5cae1adfc8 100644 --- a/src/components/dms/MessageContextMenu.tsx +++ b/src/components/dms/MessageContextMenu.tsx @@ -2,8 +2,7 @@ import {memo, useCallback} from 'react' import {LayoutAnimation, Platform} from 'react-native' import * as Clipboard from 'expo-clipboard' import {type ChatBskyConvoDefs, RichText} from '@atproto/api' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' +import {useLingui} from '@lingui/react/macro' import {useQueryClient} from '@tanstack/react-query' import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate' @@ -15,10 +14,10 @@ import {useSession} from '#/state/session' import * as ContextMenu from '#/components/ContextMenu' import {type TriggerProps} from '#/components/ContextMenu/types' 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 {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' -import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning' +import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' +import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning' import {ReportDialog} from '#/components/moderation/ReportDialog' import * as Prompt from '#/components/Prompt' import {usePromptControl} from '#/components/Prompt' @@ -35,7 +34,7 @@ export let MessageContextMenu = ({ message: ChatBskyConvoDefs.MessageView children: TriggerProps['children'] }): React.ReactNode => { - const {_} = useLingui() + const {t: l} = useLingui() const ax = useAnalytics() const {currentAccount} = useSession() const queryClient = useQueryClient() @@ -58,10 +57,10 @@ export let MessageContextMenu = ({ ) void Clipboard.setStringAsync(str) - Toast.show(_(msg`Copied to clipboard`), { + Toast.show(l`Copied to clipboard`, { type: 'success', }) - }, [_, message.text, message.facets]) + }, [l, message.text, message.facets]) const onPressTranslateMessage = useCallback(() => { void translate(message.text, langPrefs.primaryLanguage) @@ -79,11 +78,9 @@ export let MessageContextMenu = ({ LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) convo .deleteMessage(message.id) - .then(() => - Toast.show(_(msg({message: 'Message deleted', context: 'toast'}))), - ) - .catch(() => Toast.show(_(msg`Failed to delete message`))) - }, [_, convo, message.id]) + .then(() => Toast.show(l({message: 'Message deleted', context: 'toast'}))) + .catch(() => Toast.show(l`Failed to delete message`)) + }, [l, convo, message.id]) const onEmojiSelect = useCallback( (emoji: string) => { @@ -96,17 +93,17 @@ export let MessageContextMenu = ({ ) { convo .removeReaction(message.id, emoji) - .catch(() => Toast.show(_(msg`Failed to remove emoji reaction`))) + .catch(() => Toast.show(l`Failed to remove emoji reaction`)) } else { if (hasReachedReactionLimit(message, currentAccount?.did)) return convo.addReaction(message.id, emoji).catch(() => - Toast.show(_(msg`Failed to add emoji reaction`), { + Toast.show(l`Failed to add emoji reaction`, { type: 'error', }), ) } }, - [_, convo, message, currentAccount?.did], + [l, convo, message, currentAccount?.did], ) const sender = convo.convo.members.find( @@ -126,12 +123,10 @@ export let MessageContextMenu = ({ )} + label={l`Message options`} + contentLabel={l`Message from @${ + sender?.handle ?? 'unknown' // should always be defined + }: ${message.text}`}> {children} @@ -140,17 +135,17 @@ export let MessageContextMenu = ({ <> - {_(msg`Translate`)} - + {l`Translate`} + - {_(msg`Copy message text`)} + {l`Copy message text`} @@ -159,23 +154,22 @@ export let MessageContextMenu = ({ )} deleteControl.open()}> - {_(msg`Delete for me`)} - + {l`Delete for me`} + {!isFromSelf && ( reportControl.open()}> - {_(msg`Report`)} - + {l`Report`} + )} - - diff --git a/src/components/dms/MessageItem.tsx b/src/components/dms/MessageItem.tsx index 386b85d7f9..384ce1d5cb 100644 --- a/src/components/dms/MessageItem.tsx +++ b/src/components/dms/MessageItem.tsx @@ -6,6 +6,8 @@ import { View, } from 'react-native' import Animated, { + FadeIn, + FadeOut, LayoutAnimationConfig, LinearTransition, ZoomIn, @@ -16,217 +18,386 @@ import { ChatBskyConvoDefs, RichText as RichTextAPI, } from '@atproto/api' -import {type I18n} from '@lingui/core' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' +import {plural} from '@lingui/core/macro' +import {useLingui} from '@lingui/react/macro' import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {sanitizeHandle} from '#/lib/strings/handles' import {useConvoActive} from '#/state/messages/convo' import {type ConvoItem} from '#/state/messages/convo/types' +import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useSession} from '#/state/session' -import {TimeElapsed} from '#/view/com/util/TimeElapsed' import {atoms as a, native, useTheme} from '#/alf' import {isOnlyEmoji} from '#/alf/typography' import {ActionsWrapper} from '#/components/dms/ActionsWrapper' import {InlineLinkText} from '#/components/Link' +import * as ProfileCard from '#/components/ProfileCard' import {RichText} from '#/components/RichText' import {Text} from '#/components/Typography' -import {IS_NATIVE} from '#/env' +import type * as bsky from '#/types/bsky' import {DateDivider} from './DateDivider' import {MessageItemEmbed} from './MessageItemEmbed' -import {localDateString} from './util' + +const AVATAR_SIZE = 28 +const CLUSTERED_MESSAGE_GAP = 2 +const BORDER_RADIUS = 18 +const SQUARED_BORDER_RADIUS = 4 +const DISPLAY_NAME_INSET = 22 + +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 = ({ item, + isGroupChat = false, + profile, }: { item: ConvoItem & {type: 'message' | 'pending-message'} + isGroupChat?: boolean + profile?: bsky.profile.AnyProfileView }): React.ReactNode => { const t = useTheme() const {currentAccount} = useSession() - const {_} = useLingui() + const {t: l} = useLingui() const {convo} = useConvoActive() + const moderationOpts = useModerationOpts() const {message, nextMessage, prevMessage} = item const isPending = item.type === 'pending-message' + const displayName = sanitizeDisplayName( + profile?.displayName || sanitizeHandle(profile?.handle ?? ''), + ) + const isFromSelf = message.sender?.did === currentAccount?.did + const prevIsMessage = ChatBskyConvoDefs.isMessageView(prevMessage) const nextIsMessage = ChatBskyConvoDefs.isMessageView(nextMessage) - const isNextFromSelf = - nextIsMessage && nextMessage.sender?.did === currentAccount?.did + const isPrevFromSameSender = + prevIsMessage && prevMessage.sender?.did === message.sender?.did + const isNextFromSameSender = + nextIsMessage && nextMessage.sender?.did === message.sender?.did - const isNextFromSameSender = isNextFromSelf === isFromSelf + const isFirstInCluster = useMemo( + () => + isWithinCluster({ + isPending, + adjacentMessage: prevMessage, + isFromSameSender: isPrevFromSameSender, + currentSentAt: message.sentAt, + direction: 'prev', + }), + [isPending, prevMessage, isPrevFromSameSender, message.sentAt], + ) - const isNewDay = useMemo(() => { - if (!prevMessage) return true + const isLastInCluster = useMemo( + () => + isWithinCluster({ + isPending, + adjacentMessage: nextMessage, + isFromSameSender: isNextFromSameSender, + currentSentAt: message.sentAt, + direction: 'next', + }), + [isPending, nextMessage, isNextFromSameSender, message.sentAt], + ) - const thisDate = new Date(message.sentAt) - const prevDate = new Date(prevMessage.sentAt) + const hasLargeGapFromPrev = + !ChatBskyConvoDefs.isMessageView(prevMessage) || + new Date(message.sentAt).getTime() - + new Date(prevMessage.sentAt).getTime() > + MESSAGE_GAP_THRESHOLD_MS - return localDateString(thisDate) !== localDateString(prevDate) - }, [message, prevMessage]) + const showDateDivider = hasLargeGapFromPrev - const isLastMessageOfDay = useMemo(() => { - if (!nextMessage || !nextIsMessage) return true + const isInCluster = !(isFirstInCluster && isLastInCluster) + const isInMiddleOfCluster = + isInCluster && !isFirstInCluster && !isLastInCluster - const thisDate = new Date(message.sentAt) - const prevDate = new Date(nextMessage.sentAt) + const hasReactions = message.reactions && message.reactions.length > 0 + const squaredBottomCorner = + !hasReactions && isInCluster && (isInMiddleOfCluster || isFirstInCluster) + const squaredTopCorner = + isInCluster && (isInMiddleOfCluster || isLastInCluster) - return localDateString(thisDate) !== localDateString(prevDate) - }, [message.sentAt, nextIsMessage, nextMessage]) - - const needsTail = isLastMessageOfDay || !isNextFromSameSender - - const isLastInGroup = useMemo(() => { - // if this message is pending, it means the next message is pending too - if (isPending && nextMessage) { - return false - } - - // or, if there's a 5 minute gap between this message and the next - if (ChatBskyConvoDefs.isMessageView(nextMessage)) { - const thisDate = new Date(message.sentAt) - const nextDate = new Date(nextMessage.sentAt) - - const diff = nextDate.getTime() - thisDate.getTime() - - // 5 minutes - return diff > 5 * 60 * 1000 - } - - return true - }, [message, nextMessage, isPending]) - - const pendingColor = t.palette.primary_200 + const pendingColor = t.palette.primary_300 const rt = useMemo(() => { return new RichTextAPI({text: message.text, facets: message.facets}) }, [message.text, message.facets]) + const hasEmbedAndText = + AppBskyEmbedRecord.isView(message.embed) && rt.text.length > 0 + + const avatar = profile ? ( + + ) : ( + + ) + + 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 = ( - {message.reactions && message.reactions.length > 0 && ( + {hasReactions ? ( + style={[ + isFromSelf ? a.align_end : a.align_start, + a.px_sm, + a.pb_2xs, + !isFromSelf && isGroupChat && {paddingLeft: AVATAR_SIZE}, + ]}> - {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}`) + {groupedReactions.map(group => ( + 1 && native(ZoomOut.delay(200)) } - } - return ( - 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`, - )}> - - {reaction.value} - - - ) - })} + layout={native(LinearTransition.delay(300))} + key={group.value} + style={[a.p_2xs]}> + + {group.value} + + + ))} + {groupedReactions.length !== reactions.length && + reactions.length > 1 ? ( + + + {reactions.length} + + + ) : null} - )} + ) : null} ) return ( <> - {isNewDay && } + {showDateDivider && ( + + + + )} - - {AppBskyEmbedRecord.isView(message.embed) && ( - - )} - {rt.text.length > 0 && ( - + {isGroupChat && !isFromSelf && isLastInCluster ? ( + {avatar} + ) : null} + + {isGroupChat && + !isFromSelf && + isFirstInCluster && + !isOnlyEmoji(message.text) ? ( + - - - )} - - {IS_NATIVE && appliedReactions} - - - {!IS_NATIVE && appliedReactions} - - {isLastInGroup && ( + ]}> + {displayName} + + ) : null} + + {rt.text.length > 0 && ( + + + + )} + {AppBskyEmbedRecord.isView(message.embed) && ( + + )} + + + + {appliedReactions} + {isLastInCluster && ( )} @@ -244,8 +415,7 @@ let MessageItemMetadata = ({ style: StyleProp }): React.ReactNode => { const t = useTheme() - const {_} = useLingui() - const {message} = item + const {t: l} = useLingui() const handleRetry = useCallback( (e: GestureResponderEvent) => { @@ -258,75 +428,60 @@ let MessageItemMetadata = ({ [item], ) - const relativeTimestamp = useCallback( - (i18n: I18n, timestamp: string) => { - const date = new Date(timestamp) - const now = new Date() + const errorColor = t.palette.negative_400 - const time = i18n.date(date, { - hour: 'numeric', - minute: 'numeric', - }) - - const diff = now.getTime() - date.getTime() - - // if under 30 seconds - if (diff < 1000 * 30) { - return _(msg`Now`) - } - - return time - }, - [_], - ) - - return ( - - - {({timeElapsed}) => ( - - {timeElapsed} - - )} - - - {item.type === 'pending-message' && item.failed && ( - <> - {' '} - ·{' '} + switch (item.type) { + case 'pending-message': + return item.failed ? ( + - {_(msg`Failed to send`)} + {l`Message failed to send.`} {item.retry && ( <> {' '} - ·{' '} - {_(msg`Retry`)} + style={[ + a.text_xs, + { + color: errorColor, + }, + ]}> + {l`Tap to retry`} + . )} - - )} - - ) + + ) : ( + {l`Sending…`} + ) + default: + return null + } } MessageItemMetadata = memo(MessageItemMetadata) export {MessageItemMetadata} diff --git a/src/components/dms/MessageItemEmbed.tsx b/src/components/dms/MessageItemEmbed.tsx index 67f07dd4fc..ba48b6e123 100644 --- a/src/components/dms/MessageItemEmbed.tsx +++ b/src/components/dms/MessageItemEmbed.tsx @@ -2,14 +2,24 @@ 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 {atoms as a, native, useTheme, web} from '#/alf' import {Embed, PostEmbedViewContext} from '#/components/Post/Embed' import {MessageContextProvider} from './MessageContext' +const CLUSTERED_MESSAGE_GAP = 2 +const BORDER_RADIUS = 20 +const SQUARED_BORDER_RADIUS = 4 + let MessageItemEmbed = ({ embed, + isFromSelf, + squaredTopCorner, + squaredBottomCorner, }: { embed: $Typed + isFromSelf: boolean + squaredTopCorner: boolean + squaredBottomCorner: boolean }): React.ReactNode => { const t = useTheme() const screen = useWindowDimensions() @@ -18,7 +28,7 @@ let MessageItemEmbed = ({ - + diff --git a/src/components/dms/MessageItemEmbed_DEPRECATED.tsx b/src/components/dms/MessageItemEmbed_DEPRECATED.tsx new file mode 100644 index 0000000000..a531c990c0 --- /dev/null +++ b/src/components/dms/MessageItemEmbed_DEPRECATED.tsx @@ -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 +}): React.ReactNode => { + const t = useTheme() + const screen = useWindowDimensions() + + return ( + + + + + + + + ) +} +MessageItemEmbed = memo(MessageItemEmbed) +export {MessageItemEmbed} diff --git a/src/components/dms/MessageItem_DEPRECATED.tsx b/src/components/dms/MessageItem_DEPRECATED.tsx new file mode 100644 index 0000000000..efec8eb92c --- /dev/null +++ b/src/components/dms/MessageItem_DEPRECATED.tsx @@ -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 = ( + + {message.reactions && message.reactions.length > 0 && ( + + + {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 ( + 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`, + )}> + + {reaction.value} + + + ) + })} + + + )} + + ) + + return ( + <> + {isNewDay && } + + + {AppBskyEmbedRecord.isView(message.embed) && ( + + )} + {rt.text.length > 0 && ( + + + + )} + + {IS_NATIVE && appliedReactions} + + + {!IS_NATIVE && appliedReactions} + + {isLastInGroup && ( + + )} + + + ) +} +MessageItem = memo(MessageItem) +export {MessageItem} + +let MessageItemMetadata = ({ + item, + style, +}: { + item: ConvoItem & {type: 'message' | 'pending-message'} + style: StyleProp +}): 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 ( + + + {({timeElapsed}) => ( + + {timeElapsed} + + )} + + + {item.type === 'pending-message' && item.failed && ( + <> + {' '} + ·{' '} + + {_(msg`Failed to send`)} + + {item.retry && ( + <> + {' '} + ·{' '} + + {_(msg`Retry`)} + + + )} + + )} + + ) +} +MessageItemMetadata = memo(MessageItemMetadata) +export {MessageItemMetadata} diff --git a/src/screens/Messages/components/MessageInput.tsx b/src/screens/Messages/components/MessageInput.tsx index 643c0c86a6..e2bf735713 100644 --- a/src/screens/Messages/components/MessageInput.tsx +++ b/src/screens/Messages/components/MessageInput.tsx @@ -15,8 +15,7 @@ import Animated, { } from 'react-native-reanimated' import {useSafeAreaInsets} from 'react-native-safe-area-context' import {GlassContainer} from 'expo-glass-effect' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' +import {useLingui} from '@lingui/react/macro' import {countGraphemes} from 'unicode-segmenter/grapheme' import {HITSLOP_10, MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants' @@ -47,13 +46,13 @@ export function MessageInput({ children, }: { textInputId?: string - onSendMessage: (message: string) => void + onSendMessage: (message: string) => Promise | void hasEmbed: boolean setEmbed: (embedUrl: string | undefined) => void children?: React.ReactNode openEmojiPicker?: (pos: EmojiPickerPosition) => void }) { - const {_} = useLingui() + const {t: l} = useLingui() const t = useTheme() const playHaptic = useHaptics() const {getDraft, clearDraft} = useMessageDraft() @@ -82,13 +81,13 @@ export function MessageInput({ return } if (countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) { - Toast.show(_(msg`Message is too long`), { + Toast.show(l`Message is too long`, { type: 'error', }) return } clearDraft() - onSendMessage(message) + void onSendMessage(message) playHaptic() setEmbed(undefined) setMessage('') @@ -111,7 +110,7 @@ export function MessageInput({ playHaptic, setEmbed, inputRef, - _, + l, ]) useFocusedInputHandler( @@ -169,9 +168,9 @@ export function MessageInput({ fallbackStyle={[t.atoms.bg_contrast_50]}> { @@ -225,7 +224,7 @@ export function MessageInput({ }}> void }) { const {isMobile} = useWebMediaQueries() - const {_} = useLingui() + const {t: l} = useLingui() const t = useTheme() const {getDraft, clearDraft} = useMessageDraft() const [message, setMessage] = useState(getDraft) @@ -57,7 +56,7 @@ export function MessageInput({ return } if (countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) { - Toast.show(_(msg`Message is too long`), { + Toast.show(l`Message is too long`, { type: 'error', }) return @@ -66,7 +65,7 @@ export function MessageInput({ onSendMessage(message) setMessage('') setEmbed(undefined) - }, [message, onSendMessage, _, clearDraft, hasEmbed, setEmbed]) + }, [message, onSendMessage, l, clearDraft, hasEmbed, setEmbed]) const onKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -177,7 +176,7 @@ export function MessageInput({ width: 30, }, ]} - label={_(msg`Open emoji picker`)}> + label={l`Open emoji picker`}> {state => ( { return { [ConvoItemError.FirehoseFailed]: { - description: _(msg`This chat was disconnected`), - help: _(msg`Press to attempt reconnection`), - cta: _(msg`Reconnect`), + description: l`This chat was disconnected`, + help: l`Press to attempt reconnection`, + cta: l`Reconnect`, }, [ConvoItemError.HistoryFailed]: { - description: _(msg`Failed to load past messages`), - help: _(msg`Press to retry`), - cta: _(msg`Retry`), + description: l`Failed to load past messages`, + help: l`Press to retry`, + cta: l`Retry`, }, }[item.code] - }, [_, item.code]) + }, [l, item.code]) return ( - + - {description} ·{' '} + {description} {item.retry && ( - { - e.preventDefault() - item.retry?.() - return false - }}> - {cta} - + <> + ·{' '} + { + item.retry?.() + })}> + {cta} + + )} diff --git a/src/screens/Messages/components/MessageListError_DEPRECATED.tsx b/src/screens/Messages/components/MessageListError_DEPRECATED.tsx new file mode 100644 index 0000000000..0e84fb51d4 --- /dev/null +++ b/src/screens/Messages/components/MessageListError_DEPRECATED.tsx @@ -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 ( + + + + + + {description} ·{' '} + {item.retry && ( + { + e.preventDefault() + item.retry?.() + return false + }}> + {cta} + + )} + + + + ) +} diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx index eda3c593d9..617582d824 100644 --- a/src/screens/Messages/components/MessagesList.tsx +++ b/src/screens/Messages/components/MessagesList.tsx @@ -5,7 +5,7 @@ import { type KeyboardChatScrollViewProps, KeyboardGestureArea, } from 'react-native-keyboard-controller' -import Animated, { +import { runOnJS, type ScrollEvent, type SharedValue, @@ -51,9 +51,11 @@ import {ChatDisabled} from '#/screens/Messages/components/ChatDisabled' import {MessageComposer} from '#/screens/Messages/components/MessageComposer' import {MessageInput} from '#/screens/Messages/components/MessageInput' 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 {ChatEmptyPill} from '#/components/dms/ChatEmptyPill' import {MessageItem} from '#/components/dms/MessageItem' +import {MessageItem as MessageItemDeprecated} from '#/components/dms/MessageItem_DEPRECATED' import {NewMessagesPill} from '#/components/dms/NewMessagesPill' import {Loader} from '#/components/Loader' 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 - } else if (item.type === 'deleted-message') { - return Deleted message - } else if (item.type === 'error') { - return - } - - return null -} - function keyExtractor(item: ConvoItem) { return item.key } @@ -117,6 +107,8 @@ export function MessagesList({ const {embedUri, setEmbed} = useMessageEmbed() const t = useTheme() + const isGroupChatEnabled = ax.features.enabled(ax.features.GroupChatsEnable) + const textInputId = 'chat-input-' + useId() const flatListRef = useAnimatedRef() @@ -369,6 +361,32 @@ export function MessagesList({ setEmojiPickerState({isOpen: true, pos}) }, []) + const renderItem = ({item}: {item: ConvoItem}) => { + if (item.type === 'message' || item.type === 'pending-message') { + return isGroupChatEnabled ? ( + member.did === item.message.sender.did, + )} + isGroupChat={convoState.convo.kind === 'group'} + /> + ) : ( + + ) + } else if (item.type === 'deleted-message') { + return Deleted message + } else if (item.type === 'error') { + return isGroupChatEnabled ? ( + + ) : ( + + ) + } + + return null + } + const renderScrollComponent = useCallback( (props: ScrollViewProps) => ( @@ -411,9 +429,18 @@ export function MessagesList({ } // native only (prop is not supported on web) 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( - , + , )} style={web({ scrollbarWidth: 'thin', @@ -518,12 +545,6 @@ function ChatScrollComponent({ ) } -function WebInputSpacer({inputHeight}: {inputHeight: number}) { - if (!IS_WEB) return null - - return -} - type FooterState = 'loading' | 'new-chat' | 'request' | 'standard' function getFooterState( diff --git a/src/state/messages/convo/agent.ts b/src/state/messages/convo/agent.ts index b6c8ee2f16..93726833f1 100644 --- a/src/state/messages/convo/agent.ts +++ b/src/state/messages/convo/agent.ts @@ -222,7 +222,7 @@ export class Convo { switch (action.event) { case ConvoDispatchEvent.Init: { this.status = ConvoStatus.Initializing - this.setup() + void this.setup() this.setupFirehose() this.requestPollInterval(ACTIVE_POLL_INTERVAL) break @@ -234,12 +234,12 @@ export class Convo { switch (action.event) { case ConvoDispatchEvent.Ready: { this.status = ConvoStatus.Ready - this.fetchMessageHistory() + void this.fetchMessageHistory() break } case ConvoDispatchEvent.Background: { this.status = ConvoStatus.Backgrounded - this.fetchMessageHistory() + void this.fetchMessageHistory() this.requestPollInterval(BACKGROUND_POLL_INTERVAL) break } @@ -258,7 +258,7 @@ export class Convo { } case ConvoDispatchEvent.Disable: { this.status = ConvoStatus.Disabled - this.fetchMessageHistory() // finish init + void this.fetchMessageHistory() // finish init this.cleanupFirehoseConnection?.() this.withdrawRequestedPollInterval() break @@ -269,7 +269,7 @@ export class Convo { case ConvoStatus.Ready: { switch (action.event) { case ConvoDispatchEvent.Resume: { - this.refreshConvo() + void this.refreshConvo() this.requestPollInterval(ACTIVE_POLL_INTERVAL) break } @@ -308,11 +308,11 @@ export class Convo { } else { if (this.convo) { this.status = ConvoStatus.Ready - this.refreshConvo() + void this.refreshConvo() this.maybeRecoverFromNetworkError() } else { this.status = ConvoStatus.Initializing - this.setup() + void this.setup() } this.requestPollInterval(ACTIVE_POLL_INTERVAL) } @@ -435,7 +435,7 @@ export class Convo { this.firehoseError = undefined this.commit() } else { - this.batchRetryPendingMessages() + void this.batchRetryPendingMessages() } if (this.fetchMessageHistoryError) { @@ -487,7 +487,8 @@ export class Convo { } else { this.dispatch({event: ConvoDispatchEvent.Ready}) } - } catch (e: any) { + } catch (err) { + const e = err as Error if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) { logger.error('setup failed', { safeMessage: e.message, @@ -557,11 +558,7 @@ export class Convo { async fetchConvo() { if (this.pendingFetchConvo) return this.pendingFetchConvo - this.pendingFetchConvo = new Promise<{ - convo: ChatBskyConvoDefs.ConvoView - sender: ChatBskyActorDefs.ProfileViewBasic | undefined - recipients: ChatBskyActorDefs.ProfileViewBasic[] - }>(async (resolve, reject) => { + this.pendingFetchConvo = (async () => { try { const response = await networkRetry(2, () => { return this.agent.api.chat.bsky.convo.getConvo( @@ -574,17 +571,15 @@ export class Convo { const convo = response.data.convo - resolve({ + return { convo, sender: convo.members.find(m => m.did === this.senderUserDid), recipients: convo.members.filter(m => m.did !== this.senderUserDid), - }) - } catch (e) { - reject(e) + } } finally { this.pendingFetchConvo = undefined } - }) + })() return this.pendingFetchConvo } @@ -596,7 +591,8 @@ export class Convo { this.convo = convo || this.convo this.sender = sender || this.sender this.recipients = recipients || this.recipients - } catch (e: any) { + } catch (err) { + const e = err as Error if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) { logger.error(`failed to refresh convo`, { safeMessage: e.message, @@ -664,7 +660,8 @@ export class Convo { this.pastMessages.set(message.id, message) } } - } catch (e: any) { + } catch (err) { + const e = err as Error if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) { logger.error('failed to fetch message history', { safeMessage: e.message, @@ -673,7 +670,7 @@ export class Convo { this.fetchMessageHistoryError = { retry: () => { - this.fetchMessageHistory() + void this.fetchMessageHistory() }, } } finally { @@ -716,7 +713,7 @@ export class Convo { onFirehoseConnect() { this.firehoseError = undefined - this.batchRetryPendingMessages() + void this.batchRetryPendingMessages() this.commit() } @@ -761,8 +758,8 @@ export class Convo { /** * If this message is already in new messages, it was added by our * sending logic, and is based on client-ordering. When we receive - * the "commited" event from the log, we should replace this - * reference and re-insert in order to respect the order we receied + * the "committed" event from the log, we should replace this + * reference and re-insert in order to respect the order we received * from the log. */ if (this.newMessages.has(ev.message.id)) { @@ -836,7 +833,7 @@ export class Convo { this.commit() 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 (NETWORK_FAILURE_STATUSES.includes(e.status)) { this.pendingMessageFailure = 'recoverable' @@ -1026,7 +1023,8 @@ export class Convo { {encoding: 'application/json', headers: DM_SERVICE_HEADERS}, ) }) - } catch (e: any) { + } catch (err) { + const e = err as Error if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) { logger.error(`failed to delete message`, { safeMessage: e.message,