From e10c05d735adcfab3baae7cb82ee4b4127871189 Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Thu, 16 Apr 2026 10:17:01 -0700 Subject: [PATCH] Reveal chat timestamp on tap (#10262) --- src/components/ContextMenu/index.tsx | 21 +++- src/components/ContextMenu/types.ts | 8 ++ src/components/dms/ActionsWrapper.tsx | 5 +- src/components/dms/ActionsWrapper.web.tsx | 23 ++-- src/components/dms/DateDivider.tsx | 2 +- src/components/dms/DateDividerToggle.tsx | 44 +++++++ src/components/dms/MessageContextMenu.tsx | 5 +- src/components/dms/MessageItem.tsx | 115 ++++++++++++------ .../Messages/components/MessagesList.tsx | 5 +- 9 files changed, 178 insertions(+), 50 deletions(-) create mode 100644 src/components/dms/DateDividerToggle.tsx diff --git a/src/components/ContextMenu/index.tsx b/src/components/ContextMenu/index.tsx index cce4332dc4..ea4badde82 100644 --- a/src/components/ContextMenu/index.tsx +++ b/src/components/ContextMenu/index.tsx @@ -235,7 +235,13 @@ export function Root({children}: {children: React.ReactNode}) { return {children} } -export function Trigger({children, label, contentLabel, style}: TriggerProps) { +export function Trigger({ + children, + label, + contentLabel, + style, + onTap, +}: TriggerProps) { const context = useContextMenuContext() const playHaptic = useHaptics() const insets = useSafeAreaInsets() @@ -294,6 +300,17 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) { } }, [context, insets]) + const tapGesture = useMemo(() => { + const gesture = Gesture.Tap() + .numberOfTaps(1) + .cancelsTouchesInView(false) + .runOnJS(true) + if (onTap) { + gesture.onEnd(() => void onTap()) + } + return gesture + }, [onTap]) + const doubleTapGesture = useMemo(() => { return Gesture.Tap() .numberOfTaps(2) @@ -346,8 +363,10 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) { }) }, [open, hoverablesSV, onTouchUpMenuItem, hoveredItemSV, translationSV]) + // Order matters here: doubleTapGesture must come before tapGesture. const composedGestures = Gesture.Exclusive( doubleTapGesture, + tapGesture, pressAndHoldGesture, ) diff --git a/src/components/ContextMenu/types.ts b/src/components/ContextMenu/types.ts index 260d95e85c..e2f1522d1e 100644 --- a/src/components/ContextMenu/types.ts +++ b/src/components/ContextMenu/types.ts @@ -84,6 +84,14 @@ export type TriggerProps = { hint?: string role?: AccessibilityRole style?: StyleProp + /** + * Callback for single taps. Composed with the double-tap and + * press-and-hold gestures via `Gesture.Exclusive`, so a double tap + * does not also fire this handler. + * + * @platform ios, android + */ + onTap?: () => void } export type TriggerChildProps = | { diff --git a/src/components/dms/ActionsWrapper.tsx b/src/components/dms/ActionsWrapper.tsx index 3ed704f99d..1e4b40206e 100644 --- a/src/components/dms/ActionsWrapper.tsx +++ b/src/components/dms/ActionsWrapper.tsx @@ -9,15 +9,18 @@ export function ActionsWrapper({ message, isFromSelf, children, + onTap, }: { message: ChatBskyConvoDefs.MessageView + hasReactions?: boolean isFromSelf: boolean children: React.ReactNode + onTap?: () => void }) { const {t: l} = useLingui() return ( - + {trigger => // will always be true, since this file is platform split trigger.IS_NATIVE && ( diff --git a/src/components/dms/ActionsWrapper.web.tsx b/src/components/dms/ActionsWrapper.web.tsx index beb6577e0f..05df7b0324 100644 --- a/src/components/dms/ActionsWrapper.web.tsx +++ b/src/components/dms/ActionsWrapper.web.tsx @@ -1,8 +1,7 @@ import {useCallback, useRef, useState} from 'react' import {Pressable, 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 {useConvoActive} from '#/state/messages/convo' import {useSession} from '#/state/session' @@ -16,16 +15,20 @@ import {hasReachedReactionLimit} from './util' export function ActionsWrapper({ message, + hasReactions, isFromSelf, children, + onTap, }: { message: ChatBskyConvoDefs.MessageView + hasReactions?: boolean isFromSelf: boolean children: React.ReactNode + onTap?: () => void }) { const viewRef = useRef(null) const t = useTheme() - const {_} = useLingui() + const {t: l} = useLingui() const convo = useConvoActive() const {currentAccount} = useSession() @@ -57,17 +60,17 @@ export function ActionsWrapper({ ) { 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], ) return ( @@ -87,6 +90,7 @@ export function ActionsWrapper({ isFromSelf ? [a.mr_xs, {marginLeft: 'auto'}, a.flex_row_reverse] : [a.ml_xs, {marginRight: 'auto'}], + hasReactions ? [a.mb_2xl] : undefined, ]}> {({props, state, IS_NATIVE, control}) => { @@ -133,10 +137,13 @@ export function ActionsWrapper({ }} - {children} - + ) } diff --git a/src/components/dms/DateDivider.tsx b/src/components/dms/DateDivider.tsx index 0a54de39fc..21724ba3ea 100644 --- a/src/components/dms/DateDivider.tsx +++ b/src/components/dms/DateDivider.tsx @@ -27,8 +27,8 @@ const longDateFormatterWithYear = new Intl.DateTimeFormat(undefined, { }) let DateDivider = ({date: dateStr}: {date: string}): React.ReactNode => { - const {t: l} = useLingui() const t = useTheme() + const {t: l} = useLingui() let date: string const time = timeFormatter.format(new Date(dateStr)) diff --git a/src/components/dms/DateDividerToggle.tsx b/src/components/dms/DateDividerToggle.tsx new file mode 100644 index 0000000000..97489f8401 --- /dev/null +++ b/src/components/dms/DateDividerToggle.tsx @@ -0,0 +1,44 @@ +import {createContext, useCallback, useContext, useState} from 'react' + +type DateDividerToggleContextType = { + isDividerToggled: (id: string) => boolean + toggleDivider: (id: string) => void +} + +const DateDividerToggleContext = createContext({ + isDividerToggled: () => false, + toggleDivider: () => {}, +}) + +export function DateDividerToggleProvider({ + children, +}: { + children: React.ReactNode +}) { + const [toggledIds, setToggledIds] = useState(new Set()) + + const toggleDivider = useCallback((id: string) => { + setToggledIds(prev => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + }, []) + + const isDividerToggled = useCallback( + (id: string) => toggledIds.has(id), + [toggledIds], + ) + + return ( + + {children} + + ) +} + +export function useDateDividerToggle() { + return useContext(DateDividerToggleContext) +} diff --git a/src/components/dms/MessageContextMenu.tsx b/src/components/dms/MessageContextMenu.tsx index 2460aa585d..3a923133f1 100644 --- a/src/components/dms/MessageContextMenu.tsx +++ b/src/components/dms/MessageContextMenu.tsx @@ -31,9 +31,11 @@ import {hasReachedReactionLimit} from './util' export let MessageContextMenu = ({ message, children, + onTap, }: { message: ChatBskyConvoDefs.MessageView children: TriggerProps['children'] + onTap?: () => void }): React.ReactNode => { const {t: l} = useLingui() const ax = useAnalytics() @@ -130,7 +132,8 @@ export let MessageContextMenu = ({ label={l`Message options`} contentLabel={l`Message from @${ sender?.handle ?? 'unknown' // should always be defined - }: ${message.text}`}> + }: ${message.text}`} + onTap={onTap}> {children} diff --git a/src/components/dms/MessageItem.tsx b/src/components/dms/MessageItem.tsx index adfc3e67b1..7a000c8dc5 100644 --- a/src/components/dms/MessageItem.tsx +++ b/src/components/dms/MessageItem.tsx @@ -1,6 +1,7 @@ -import {memo, useCallback, useMemo, useState} from 'react' +import {memo, useCallback, useEffect, useMemo, useState} from 'react' import { type GestureResponderEvent, + LayoutAnimation, Pressable, type StyleProp, type TextStyle, @@ -11,7 +12,9 @@ import Animated, { FadeOut, LayoutAnimationConfig, LinearTransition, + useAnimatedStyle, useSharedValue, + withTiming, ZoomIn, ZoomOut, } from 'react-native-reanimated' @@ -43,6 +46,7 @@ import {RichText} from '#/components/RichText' import {Text} from '#/components/Typography' import type * as bsky from '#/types/bsky' import {DateDivider} from './DateDivider' +import {useDateDividerToggle} from './DateDividerToggle' import {MessageItemEmbed} from './MessageItemEmbed' const AVATAR_SIZE = 28 @@ -158,17 +162,25 @@ let MessageItem = ({ new Date(prevMessage.sentAt).getTime() > MESSAGE_GAP_THRESHOLD_MS + const {isDividerToggled, toggleDivider} = useDateDividerToggle() + const isDateDividerToggled = isDividerToggled(message.id) + const isNextDateDividerToggled = + nextMessage != null && isDividerToggled(nextMessage.id) const showDateDivider = hasLargeGapFromPrev - const isInCluster = !(isFirstInCluster && isLastInCluster) + const effectiveFirstInCluster = isFirstInCluster || isDateDividerToggled + const effectiveLastInCluster = isLastInCluster || isNextDateDividerToggled + const isInCluster = !(effectiveFirstInCluster && effectiveLastInCluster) const isInMiddleOfCluster = - isInCluster && !isFirstInCluster && !isLastInCluster + isInCluster && !effectiveFirstInCluster && !effectiveLastInCluster const hasReactions = message.reactions && message.reactions.length > 0 const squaredBottomCorner = - !hasReactions && isInCluster && (isInMiddleOfCluster || isFirstInCluster) + !hasReactions && + isInCluster && + (isInMiddleOfCluster || effectiveFirstInCluster) const squaredTopCorner = - isInCluster && (isInMiddleOfCluster || isLastInCluster) + isInCluster && (isInMiddleOfCluster || effectiveLastInCluster) const pendingColor = t.palette.primary_300 @@ -179,6 +191,45 @@ let MessageItem = ({ const hasEmbedAndText = AppBskyEmbedRecord.isView(message.embed) && rt.text.length > 0 + const targetBottomRadius = + squaredBottomCorner || hasEmbedAndText + ? SQUARED_BORDER_RADIUS + : BORDER_RADIUS + const targetTopRadius = squaredTopCorner + ? SQUARED_BORDER_RADIUS + : BORDER_RADIUS + + const bottomRadiusSV = useSharedValue(targetBottomRadius) + const topRadiusSV = useSharedValue(targetTopRadius) + + const showDisplayName = + isGroupChat && + !isFromSelf && + effectiveFirstInCluster && + !isDateDividerToggled && + !isOnlyEmoji(message.text) + const showAvatar = isGroupChat && !isFromSelf && isLastInCluster + + useEffect(() => { + bottomRadiusSV.set(withTiming(targetBottomRadius, {duration: 300})) + }, [targetBottomRadius, bottomRadiusSV]) + + useEffect(() => { + topRadiusSV.set(withTiming(targetTopRadius, {duration: 300})) + }, [targetTopRadius, topRadiusSV]) + + const borderRadiusStyle = useAnimatedStyle(() => + isFromSelf + ? { + borderBottomRightRadius: bottomRadiusSV.get(), + borderTopRightRadius: topRadiusSV.get(), + } + : { + borderBottomLeftRadius: bottomRadiusSV.get(), + borderTopLeftRadius: topRadiusSV.get(), + }, + ) + const avatar = profile ? ( - {showDateDivider && ( + {(showDateDivider || isDateDividerToggled) && ( @@ -330,10 +381,12 @@ let MessageItem = ({ - {isGroupChat && !isFromSelf && isLastInCluster ? ( + {showAvatar ? ( {avatar} @@ -346,10 +399,7 @@ let MessageItem = ({ paddingLeft: AVATAR_SIZE, }, ]}> - {isGroupChat && - !isFromSelf && - isFirstInCluster && - !isOnlyEmoji(message.text) ? ( + {showDisplayName ? ( ) : null} - + { + if (!hasLargeGapFromPrev) { + LayoutAnimation.configureNext( + LayoutAnimation.Presets.easeInEaseOut, + ) + toggleDivider(message.id) + } + }}> {rt.text.length > 0 && ( - - + )} {AppBskyEmbedRecord.isView(message.embed) && ( - {isLastInCluster && ( + {effectiveLastInCluster && ( + } - + ) }