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> 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 context = useContextMenuContext()
const playHaptic = useHaptics() const playHaptic = useHaptics()
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
@@ -294,6 +300,17 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) {
} }
}, [context, insets]) }, [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(() => { const doubleTapGesture = useMemo(() => {
return Gesture.Tap() return Gesture.Tap()
.numberOfTaps(2) .numberOfTaps(2)
@@ -346,8 +363,10 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) {
}) })
}, [open, hoverablesSV, onTouchUpMenuItem, hoveredItemSV, translationSV]) }, [open, hoverablesSV, onTouchUpMenuItem, hoveredItemSV, translationSV])
// Order matters here: doubleTapGesture must come before tapGesture.
const composedGestures = Gesture.Exclusive( const composedGestures = Gesture.Exclusive(
doubleTapGesture, doubleTapGesture,
tapGesture,
pressAndHoldGesture, pressAndHoldGesture,
) )
+8
View File
@@ -84,6 +84,14 @@ export type TriggerProps = {
hint?: string hint?: string
role?: AccessibilityRole role?: AccessibilityRole
style?: StyleProp<ViewStyle> 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 = export type TriggerChildProps =
| { | {
+4 -1
View File
@@ -9,15 +9,18 @@ export function ActionsWrapper({
message, message,
isFromSelf, isFromSelf,
children, children,
onTap,
}: { }: {
message: ChatBskyConvoDefs.MessageView message: ChatBskyConvoDefs.MessageView
hasReactions?: boolean
isFromSelf: boolean isFromSelf: boolean
children: React.ReactNode children: React.ReactNode
onTap?: () => void
}) { }) {
const {t: l} = useLingui() const {t: l} = useLingui()
return ( return (
<MessageContextMenu message={message}> <MessageContextMenu message={message} onTap={onTap}>
{trigger => {trigger =>
// will always be true, since this file is platform split // will always be true, since this file is platform split
trigger.IS_NATIVE && ( trigger.IS_NATIVE && (
+15 -8
View File
@@ -1,8 +1,7 @@
import {useCallback, useRef, useState} from 'react' import {useCallback, useRef, useState} from 'react'
import {Pressable, View} from 'react-native' import {Pressable, 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 {useConvoActive} from '#/state/messages/convo' import {useConvoActive} from '#/state/messages/convo'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
@@ -16,16 +15,20 @@ import {hasReachedReactionLimit} from './util'
export function ActionsWrapper({ export function ActionsWrapper({
message, message,
hasReactions,
isFromSelf, isFromSelf,
children, children,
onTap,
}: { }: {
message: ChatBskyConvoDefs.MessageView message: ChatBskyConvoDefs.MessageView
hasReactions?: boolean
isFromSelf: boolean isFromSelf: boolean
children: React.ReactNode children: React.ReactNode
onTap?: () => void
}) { }) {
const viewRef = useRef(null) const viewRef = useRef(null)
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {t: l} = useLingui()
const convo = useConvoActive() const convo = useConvoActive()
const {currentAccount} = useSession() const {currentAccount} = useSession()
@@ -57,17 +60,17 @@ export function ActionsWrapper({
) { ) {
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],
) )
return ( return (
@@ -87,6 +90,7 @@ export function ActionsWrapper({
isFromSelf isFromSelf
? [a.mr_xs, {marginLeft: 'auto'}, a.flex_row_reverse] ? [a.mr_xs, {marginLeft: 'auto'}, a.flex_row_reverse]
: [a.ml_xs, {marginRight: 'auto'}], : [a.ml_xs, {marginRight: 'auto'}],
hasReactions ? [a.mb_2xl] : undefined,
]}> ]}>
<EmojiReactionPicker message={message} onEmojiSelect={onEmojiSelect}> <EmojiReactionPicker message={message} onEmojiSelect={onEmojiSelect}>
{({props, state, IS_NATIVE, control}) => { {({props, state, IS_NATIVE, control}) => {
@@ -133,10 +137,13 @@ export function ActionsWrapper({
}} }}
</MessageContextMenu> </MessageContextMenu>
</View> </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]}> style={[{maxWidth: '80%'}, isFromSelf ? a.align_end : a.align_start]}>
{children} {children}
</View> </Pressable>
</View> </View>
) )
} }
+1 -1
View File
@@ -27,8 +27,8 @@ const longDateFormatterWithYear = new Intl.DateTimeFormat(undefined, {
}) })
let DateDivider = ({date: dateStr}: {date: string}): React.ReactNode => { let DateDivider = ({date: dateStr}: {date: string}): React.ReactNode => {
const {t: l} = useLingui()
const t = useTheme() const t = useTheme()
const {t: l} = useLingui()
let date: string let date: string
const time = timeFormatter.format(new Date(dateStr)) 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 = ({ export let MessageContextMenu = ({
message, message,
children, children,
onTap,
}: { }: {
message: ChatBskyConvoDefs.MessageView message: ChatBskyConvoDefs.MessageView
children: TriggerProps['children'] children: TriggerProps['children']
onTap?: () => void
}): React.ReactNode => { }): React.ReactNode => {
const {t: l} = useLingui() const {t: l} = useLingui()
const ax = useAnalytics() const ax = useAnalytics()
@@ -130,7 +132,8 @@ export let MessageContextMenu = ({
label={l`Message options`} label={l`Message options`}
contentLabel={l`Message from @${ contentLabel={l`Message from @${
sender?.handle ?? 'unknown' // should always be defined sender?.handle ?? 'unknown' // should always be defined
}: ${message.text}`}> }: ${message.text}`}
onTap={onTap}>
{children} {children}
</ContextMenu.Trigger> </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 { import {
type GestureResponderEvent, type GestureResponderEvent,
LayoutAnimation,
Pressable, Pressable,
type StyleProp, type StyleProp,
type TextStyle, type TextStyle,
@@ -11,7 +12,9 @@ import Animated, {
FadeOut, FadeOut,
LayoutAnimationConfig, LayoutAnimationConfig,
LinearTransition, LinearTransition,
useAnimatedStyle,
useSharedValue, useSharedValue,
withTiming,
ZoomIn, ZoomIn,
ZoomOut, ZoomOut,
} from 'react-native-reanimated' } from 'react-native-reanimated'
@@ -43,6 +46,7 @@ import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
import {DateDivider} from './DateDivider' import {DateDivider} from './DateDivider'
import {useDateDividerToggle} from './DateDividerToggle'
import {MessageItemEmbed} from './MessageItemEmbed' import {MessageItemEmbed} from './MessageItemEmbed'
const AVATAR_SIZE = 28 const AVATAR_SIZE = 28
@@ -158,17 +162,25 @@ let MessageItem = ({
new Date(prevMessage.sentAt).getTime() > new Date(prevMessage.sentAt).getTime() >
MESSAGE_GAP_THRESHOLD_MS MESSAGE_GAP_THRESHOLD_MS
const {isDividerToggled, toggleDivider} = useDateDividerToggle()
const isDateDividerToggled = isDividerToggled(message.id)
const isNextDateDividerToggled =
nextMessage != null && isDividerToggled(nextMessage.id)
const showDateDivider = hasLargeGapFromPrev const showDateDivider = hasLargeGapFromPrev
const isInCluster = !(isFirstInCluster && isLastInCluster) const effectiveFirstInCluster = isFirstInCluster || isDateDividerToggled
const effectiveLastInCluster = isLastInCluster || isNextDateDividerToggled
const isInCluster = !(effectiveFirstInCluster && effectiveLastInCluster)
const isInMiddleOfCluster = const isInMiddleOfCluster =
isInCluster && !isFirstInCluster && !isLastInCluster isInCluster && !effectiveFirstInCluster && !effectiveLastInCluster
const hasReactions = message.reactions && message.reactions.length > 0 const hasReactions = message.reactions && message.reactions.length > 0
const squaredBottomCorner = const squaredBottomCorner =
!hasReactions && isInCluster && (isInMiddleOfCluster || isFirstInCluster) !hasReactions &&
isInCluster &&
(isInMiddleOfCluster || effectiveFirstInCluster)
const squaredTopCorner = const squaredTopCorner =
isInCluster && (isInMiddleOfCluster || isLastInCluster) isInCluster && (isInMiddleOfCluster || effectiveLastInCluster)
const pendingColor = t.palette.primary_300 const pendingColor = t.palette.primary_300
@@ -179,6 +191,45 @@ let MessageItem = ({
const hasEmbedAndText = const hasEmbedAndText =
AppBskyEmbedRecord.isView(message.embed) && rt.text.length > 0 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 ? ( const avatar = profile ? (
<ProfileCard.Avatar <ProfileCard.Avatar
profile={profile} profile={profile}
@@ -322,7 +373,7 @@ let MessageItem = ({
return ( return (
<> <>
{showDateDivider && ( {(showDateDivider || isDateDividerToggled) && (
<Animated.View entering={native(FadeIn)} exiting={native(FadeOut)}> <Animated.View entering={native(FadeIn)} exiting={native(FadeOut)}>
<DateDivider date={message.sentAt} /> <DateDivider date={message.sentAt} />
</Animated.View> </Animated.View>
@@ -330,10 +381,12 @@ let MessageItem = ({
<View <View
style={[ style={[
isFromSelf ? a.mr_sm : a.ml_sm, isFromSelf ? a.mr_sm : a.ml_sm,
isFirstInCluster && !showDateDivider && a.mt_sm, effectiveFirstInCluster &&
!(showDateDivider || isDateDividerToggled) &&
a.mt_sm,
]}> ]}>
<View style={[a.relative]}> <View style={[a.relative]}>
{isGroupChat && !isFromSelf && isLastInCluster ? ( {showAvatar ? (
<View style={[a.absolute, {bottom: hasReactions ? 10 : 0}]}> <View style={[a.absolute, {bottom: hasReactions ? 10 : 0}]}>
{avatar} {avatar}
</View> </View>
@@ -346,10 +399,7 @@ let MessageItem = ({
paddingLeft: AVATAR_SIZE, paddingLeft: AVATAR_SIZE,
}, },
]}> ]}>
{isGroupChat && {showDisplayName ? (
!isFromSelf &&
isFirstInCluster &&
!isOnlyEmoji(message.text) ? (
<Text <Text
style={[ style={[
a.text_xs, a.text_xs,
@@ -363,9 +413,20 @@ let MessageItem = ({
{displayName} {displayName}
</Text> </Text>
) : null} ) : 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 && ( {rt.text.length > 0 && (
<View <Animated.View
accessibilityHint={l`Double tap or long press the message to add a reaction`} accessibilityHint={l`Double tap or long press the message to add a reaction`}
style={[ style={[
!isFromSelf && a.ml_sm, !isFromSelf && a.ml_sm,
@@ -377,7 +438,7 @@ let MessageItem = ({
a.py_sm, a.py_sm,
a.px_md, a.px_md,
{ {
marginTop: isFirstInCluster marginTop: effectiveFirstInCluster
? 0 ? 0
: CLUSTERED_MESSAGE_GAP, : CLUSTERED_MESSAGE_GAP,
backgroundColor: isFromSelf backgroundColor: isFromSelf
@@ -387,25 +448,7 @@ let MessageItem = ({
: t.palette.contrast_50, : t.palette.contrast_50,
}, },
isFromSelf ? a.self_end : a.self_start, isFromSelf ? a.self_end : a.self_start,
isFromSelf borderRadiusStyle,
? {
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 <RichText
@@ -416,7 +459,7 @@ let MessageItem = ({
emojiMultiplier={3} emojiMultiplier={3}
shouldProxyLinks={true} shouldProxyLinks={true}
/> />
</View> </Animated.View>
)} )}
{AppBskyEmbedRecord.isView(message.embed) && ( {AppBskyEmbedRecord.isView(message.embed) && (
<MessageItemEmbed <MessageItemEmbed
@@ -430,7 +473,7 @@ let MessageItem = ({
</ActionsWrapper> </ActionsWrapper>
</View> </View>
</View> </View>
{isLastInCluster && ( {effectiveLastInCluster && (
<MessageItemMetadata <MessageItemMetadata
item={item} item={item}
style={[isFromSelf ? a.text_right : a.text_left]} 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 {MessageListError} from '#/screens/Messages/components/MessageListError'
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 {DateDividerToggleProvider} from '#/components/dms/DateDividerToggle'
import {MessageItem} from '#/components/dms/MessageItem' import {MessageItem} from '#/components/dms/MessageItem'
import {NewMessagesPill} from '#/components/dms/NewMessagesPill' import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
@@ -417,7 +418,7 @@ export function MessagesList({
) )
return ( return (
<> <DateDividerToggleProvider>
<KeyboardGestureArea <KeyboardGestureArea
interpolator="ios" interpolator="ios"
// HACKFIX: https://github.com/kirillzyusko/react-native-keyboard-controller/issues/1419 // HACKFIX: https://github.com/kirillzyusko/react-native-keyboard-controller/issues/1419
@@ -528,7 +529,7 @@ export function MessagesList({
)} )}
{newMessagesPill.show && <NewMessagesPill onPress={scrollToEndOnPress} />} {newMessagesPill.show && <NewMessagesPill onPress={scrollToEndOnPress} />}
</> </DateDividerToggleProvider>
) )
} }