Reveal chat timestamp on tap (#10262)

This commit is contained in:
DS Boyce
2026-04-16 10:17:01 -07:00
committed by GitHub
parent a9e170b6d0
commit e10c05d735
9 changed files with 178 additions and 50 deletions
+20 -1
View File
@@ -235,7 +235,13 @@ export function Root({children}: {children: React.ReactNode}) {
return <Context.Provider value={context}>{children}</Context.Provider>
}
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,
)
+8
View File
@@ -84,6 +84,14 @@ export type TriggerProps = {
hint?: string
role?: AccessibilityRole
style?: StyleProp<ViewStyle>
/**
* 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 =
| {
+4 -1
View File
@@ -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 (
<MessageContextMenu message={message}>
<MessageContextMenu message={message} onTap={onTap}>
{trigger =>
// will always be true, since this file is platform split
trigger.IS_NATIVE && (
+15 -8
View File
@@ -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,
]}>
<EmojiReactionPicker message={message} onEmojiSelect={onEmojiSelect}>
{({props, state, IS_NATIVE, control}) => {
@@ -133,10 +137,13 @@ export function ActionsWrapper({
}}
</MessageContextMenu>
</View>
<View
<Pressable
accessibilityRole="button"
accessibilityHint={l`Click to view the date and time`}
onPress={onTap}
style={[{maxWidth: '80%'}, isFromSelf ? a.align_end : a.align_start]}>
{children}
</View>
</Pressable>
</View>
)
}
+1 -1
View File
@@ -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))
+44
View File
@@ -0,0 +1,44 @@
import {createContext, useCallback, useContext, useState} from 'react'
type DateDividerToggleContextType = {
isDividerToggled: (id: string) => boolean
toggleDivider: (id: string) => void
}
const DateDividerToggleContext = createContext<DateDividerToggleContextType>({
isDividerToggled: () => false,
toggleDivider: () => {},
})
export function DateDividerToggleProvider({
children,
}: {
children: React.ReactNode
}) {
const [toggledIds, setToggledIds] = useState(new Set<string>())
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 (
<DateDividerToggleContext.Provider
value={{isDividerToggled, toggleDivider}}>
{children}
</DateDividerToggleContext.Provider>
)
}
export function useDateDividerToggle() {
return useContext(DateDividerToggleContext)
}
+4 -1
View File
@@ -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}
</ContextMenu.Trigger>
+79 -36
View File
@@ -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 ? (
<ProfileCard.Avatar
profile={profile}
@@ -322,7 +373,7 @@ let MessageItem = ({
return (
<>
{showDateDivider && (
{(showDateDivider || isDateDividerToggled) && (
<Animated.View entering={native(FadeIn)} exiting={native(FadeOut)}>
<DateDivider date={message.sentAt} />
</Animated.View>
@@ -330,10 +381,12 @@ let MessageItem = ({
<View
style={[
isFromSelf ? a.mr_sm : a.ml_sm,
isFirstInCluster && !showDateDivider && a.mt_sm,
effectiveFirstInCluster &&
!(showDateDivider || isDateDividerToggled) &&
a.mt_sm,
]}>
<View style={[a.relative]}>
{isGroupChat && !isFromSelf && isLastInCluster ? (
{showAvatar ? (
<View style={[a.absolute, {bottom: hasReactions ? 10 : 0}]}>
{avatar}
</View>
@@ -346,10 +399,7 @@ let MessageItem = ({
paddingLeft: AVATAR_SIZE,
},
]}>
{isGroupChat &&
!isFromSelf &&
isFirstInCluster &&
!isOnlyEmoji(message.text) ? (
{showDisplayName ? (
<Text
style={[
a.text_xs,
@@ -363,9 +413,20 @@ let MessageItem = ({
{displayName}
</Text>
) : null}
<ActionsWrapper isFromSelf={isFromSelf} message={message}>
<ActionsWrapper
hasReactions={hasReactions}
isFromSelf={isFromSelf}
message={message}
onTap={() => {
if (!hasLargeGapFromPrev) {
LayoutAnimation.configureNext(
LayoutAnimation.Presets.easeInEaseOut,
)
toggleDivider(message.id)
}
}}>
{rt.text.length > 0 && (
<View
<Animated.View
accessibilityHint={l`Double tap or long press the message to add a reaction`}
style={[
!isFromSelf && a.ml_sm,
@@ -377,7 +438,7 @@ let MessageItem = ({
a.py_sm,
a.px_md,
{
marginTop: isFirstInCluster
marginTop: effectiveFirstInCluster
? 0
: CLUSTERED_MESSAGE_GAP,
backgroundColor: isFromSelf
@@ -387,25 +448,7 @@ let MessageItem = ({
: 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,
},
borderRadiusStyle,
]),
]}>
<RichText
@@ -416,7 +459,7 @@ let MessageItem = ({
emojiMultiplier={3}
shouldProxyLinks={true}
/>
</View>
</Animated.View>
)}
{AppBskyEmbedRecord.isView(message.embed) && (
<MessageItemEmbed
@@ -430,7 +473,7 @@ let MessageItem = ({
</ActionsWrapper>
</View>
</View>
{isLastInCluster && (
{effectiveLastInCluster && (
<MessageItemMetadata
item={item}
style={[isFromSelf ? a.text_right : a.text_left]}
@@ -53,6 +53,7 @@ import {MessageInput} from '#/screens/Messages/components/MessageInput'
import {MessageListError} from '#/screens/Messages/components/MessageListError'
import {atoms as a, platform, tokens, useTheme, web} from '#/alf'
import {ChatEmptyPill} from '#/components/dms/ChatEmptyPill'
import {DateDividerToggleProvider} from '#/components/dms/DateDividerToggle'
import {MessageItem} from '#/components/dms/MessageItem'
import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
import {Loader} from '#/components/Loader'
@@ -417,7 +418,7 @@ export function MessagesList({
)
return (
<>
<DateDividerToggleProvider>
<KeyboardGestureArea
interpolator="ios"
// HACKFIX: https://github.com/kirillzyusko/react-native-keyboard-controller/issues/1419
@@ -528,7 +529,7 @@ export function MessagesList({
)}
{newMessagesPill.show && <NewMessagesPill onPress={scrollToEndOnPress} />}
</>
</DateDividerToggleProvider>
)
}