Reveal chat timestamp on tap (#10262)
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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 =
|
||||
| {
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user