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 && (
+
}
- >
+
)
}