Bluesky app fork with some witchin' additions 💫 witchsky.app
bluesky fork client
117
fork

Configure Feed

Select the types of activity you want to include in your feed.

Reveal chat timestamp on tap (#10262)

authored by

DS Boyce and committed by
GitHub
e10c05d7 a9e170b6

+178 -50
+20 -1
src/components/ContextMenu/index.tsx
··· 235 235 return <Context.Provider value={context}>{children}</Context.Provider> 236 236 } 237 237 238 - export function Trigger({children, label, contentLabel, style}: TriggerProps) { 238 + export function Trigger({ 239 + children, 240 + label, 241 + contentLabel, 242 + style, 243 + onTap, 244 + }: TriggerProps) { 239 245 const context = useContextMenuContext() 240 246 const playHaptic = useHaptics() 241 247 const insets = useSafeAreaInsets() ··· 294 300 } 295 301 }, [context, insets]) 296 302 303 + const tapGesture = useMemo(() => { 304 + const gesture = Gesture.Tap() 305 + .numberOfTaps(1) 306 + .cancelsTouchesInView(false) 307 + .runOnJS(true) 308 + if (onTap) { 309 + gesture.onEnd(() => void onTap()) 310 + } 311 + return gesture 312 + }, [onTap]) 313 + 297 314 const doubleTapGesture = useMemo(() => { 298 315 return Gesture.Tap() 299 316 .numberOfTaps(2) ··· 346 363 }) 347 364 }, [open, hoverablesSV, onTouchUpMenuItem, hoveredItemSV, translationSV]) 348 365 366 + // Order matters here: doubleTapGesture must come before tapGesture. 349 367 const composedGestures = Gesture.Exclusive( 350 368 doubleTapGesture, 369 + tapGesture, 351 370 pressAndHoldGesture, 352 371 ) 353 372
+8
src/components/ContextMenu/types.ts
··· 84 84 hint?: string 85 85 role?: AccessibilityRole 86 86 style?: StyleProp<ViewStyle> 87 + /** 88 + * Callback for single taps. Composed with the double-tap and 89 + * press-and-hold gestures via `Gesture.Exclusive`, so a double tap 90 + * does not also fire this handler. 91 + * 92 + * @platform ios, android 93 + */ 94 + onTap?: () => void 87 95 } 88 96 export type TriggerChildProps = 89 97 | {
+4 -1
src/components/dms/ActionsWrapper.tsx
··· 9 9 message, 10 10 isFromSelf, 11 11 children, 12 + onTap, 12 13 }: { 13 14 message: ChatBskyConvoDefs.MessageView 15 + hasReactions?: boolean 14 16 isFromSelf: boolean 15 17 children: React.ReactNode 18 + onTap?: () => void 16 19 }) { 17 20 const {t: l} = useLingui() 18 21 19 22 return ( 20 - <MessageContextMenu message={message}> 23 + <MessageContextMenu message={message} onTap={onTap}> 21 24 {trigger => 22 25 // will always be true, since this file is platform split 23 26 trigger.IS_NATIVE && (
+15 -8
src/components/dms/ActionsWrapper.web.tsx
··· 1 1 import {useCallback, useRef, useState} from 'react' 2 2 import {Pressable, View} from 'react-native' 3 3 import {type ChatBskyConvoDefs} from '@atproto/api' 4 - import {msg} from '@lingui/core/macro' 5 - import {useLingui} from '@lingui/react' 4 + import {useLingui} from '@lingui/react/macro' 6 5 7 6 import {useConvoActive} from '#/state/messages/convo' 8 7 import {useSession} from '#/state/session' ··· 16 15 17 16 export function ActionsWrapper({ 18 17 message, 18 + hasReactions, 19 19 isFromSelf, 20 20 children, 21 + onTap, 21 22 }: { 22 23 message: ChatBskyConvoDefs.MessageView 24 + hasReactions?: boolean 23 25 isFromSelf: boolean 24 26 children: React.ReactNode 27 + onTap?: () => void 25 28 }) { 26 29 const viewRef = useRef(null) 27 30 const t = useTheme() 28 - const {_} = useLingui() 31 + const {t: l} = useLingui() 29 32 const convo = useConvoActive() 30 33 const {currentAccount} = useSession() 31 34 ··· 57 60 ) { 58 61 convo 59 62 .removeReaction(message.id, emoji) 60 - .catch(() => Toast.show(_(msg`Failed to remove emoji reaction`))) 63 + .catch(() => Toast.show(l`Failed to remove emoji reaction`)) 61 64 } else { 62 65 if (hasReachedReactionLimit(message, currentAccount?.did)) return 63 66 convo.addReaction(message.id, emoji).catch(() => 64 - Toast.show(_(msg`Failed to add emoji reaction`), { 67 + Toast.show(l`Failed to add emoji reaction`, { 65 68 type: 'error', 66 69 }), 67 70 ) 68 71 } 69 72 }, 70 - [_, convo, message, currentAccount?.did], 73 + [l, convo, message, currentAccount?.did], 71 74 ) 72 75 73 76 return ( ··· 87 90 isFromSelf 88 91 ? [a.mr_xs, {marginLeft: 'auto'}, a.flex_row_reverse] 89 92 : [a.ml_xs, {marginRight: 'auto'}], 93 + hasReactions ? [a.mb_2xl] : undefined, 90 94 ]}> 91 95 <EmojiReactionPicker message={message} onEmojiSelect={onEmojiSelect}> 92 96 {({props, state, IS_NATIVE, control}) => { ··· 133 137 }} 134 138 </MessageContextMenu> 135 139 </View> 136 - <View 140 + <Pressable 141 + accessibilityRole="button" 142 + accessibilityHint={l`Click to view the date and time`} 143 + onPress={onTap} 137 144 style={[{maxWidth: '80%'}, isFromSelf ? a.align_end : a.align_start]}> 138 145 {children} 139 - </View> 146 + </Pressable> 140 147 </View> 141 148 ) 142 149 }
+1 -1
src/components/dms/DateDivider.tsx
··· 27 27 }) 28 28 29 29 let DateDivider = ({date: dateStr}: {date: string}): React.ReactNode => { 30 - const {t: l} = useLingui() 31 30 const t = useTheme() 31 + const {t: l} = useLingui() 32 32 33 33 let date: string 34 34 const time = timeFormatter.format(new Date(dateStr))
+44
src/components/dms/DateDividerToggle.tsx
··· 1 + import {createContext, useCallback, useContext, useState} from 'react' 2 + 3 + type DateDividerToggleContextType = { 4 + isDividerToggled: (id: string) => boolean 5 + toggleDivider: (id: string) => void 6 + } 7 + 8 + const DateDividerToggleContext = createContext<DateDividerToggleContextType>({ 9 + isDividerToggled: () => false, 10 + toggleDivider: () => {}, 11 + }) 12 + 13 + export function DateDividerToggleProvider({ 14 + children, 15 + }: { 16 + children: React.ReactNode 17 + }) { 18 + const [toggledIds, setToggledIds] = useState(new Set<string>()) 19 + 20 + const toggleDivider = useCallback((id: string) => { 21 + setToggledIds(prev => { 22 + const next = new Set(prev) 23 + if (next.has(id)) next.delete(id) 24 + else next.add(id) 25 + return next 26 + }) 27 + }, []) 28 + 29 + const isDividerToggled = useCallback( 30 + (id: string) => toggledIds.has(id), 31 + [toggledIds], 32 + ) 33 + 34 + return ( 35 + <DateDividerToggleContext.Provider 36 + value={{isDividerToggled, toggleDivider}}> 37 + {children} 38 + </DateDividerToggleContext.Provider> 39 + ) 40 + } 41 + 42 + export function useDateDividerToggle() { 43 + return useContext(DateDividerToggleContext) 44 + }
+4 -1
src/components/dms/MessageContextMenu.tsx
··· 31 31 export let MessageContextMenu = ({ 32 32 message, 33 33 children, 34 + onTap, 34 35 }: { 35 36 message: ChatBskyConvoDefs.MessageView 36 37 children: TriggerProps['children'] 38 + onTap?: () => void 37 39 }): React.ReactNode => { 38 40 const {t: l} = useLingui() 39 41 const ax = useAnalytics() ··· 130 132 label={l`Message options`} 131 133 contentLabel={l`Message from @${ 132 134 sender?.handle ?? 'unknown' // should always be defined 133 - }: ${message.text}`}> 135 + }: ${message.text}`} 136 + onTap={onTap}> 134 137 {children} 135 138 </ContextMenu.Trigger> 136 139
+79 -36
src/components/dms/MessageItem.tsx
··· 1 - import {memo, useCallback, useMemo, useState} from 'react' 1 + import {memo, useCallback, useEffect, useMemo, useState} from 'react' 2 2 import { 3 3 type GestureResponderEvent, 4 + LayoutAnimation, 4 5 Pressable, 5 6 type StyleProp, 6 7 type TextStyle, ··· 11 12 FadeOut, 12 13 LayoutAnimationConfig, 13 14 LinearTransition, 15 + useAnimatedStyle, 14 16 useSharedValue, 17 + withTiming, 15 18 ZoomIn, 16 19 ZoomOut, 17 20 } from 'react-native-reanimated' ··· 43 46 import {Text} from '#/components/Typography' 44 47 import type * as bsky from '#/types/bsky' 45 48 import {DateDivider} from './DateDivider' 49 + import {useDateDividerToggle} from './DateDividerToggle' 46 50 import {MessageItemEmbed} from './MessageItemEmbed' 47 51 48 52 const AVATAR_SIZE = 28 ··· 158 162 new Date(prevMessage.sentAt).getTime() > 159 163 MESSAGE_GAP_THRESHOLD_MS 160 164 165 + const {isDividerToggled, toggleDivider} = useDateDividerToggle() 166 + const isDateDividerToggled = isDividerToggled(message.id) 167 + const isNextDateDividerToggled = 168 + nextMessage != null && isDividerToggled(nextMessage.id) 161 169 const showDateDivider = hasLargeGapFromPrev 162 170 163 - const isInCluster = !(isFirstInCluster && isLastInCluster) 171 + const effectiveFirstInCluster = isFirstInCluster || isDateDividerToggled 172 + const effectiveLastInCluster = isLastInCluster || isNextDateDividerToggled 173 + const isInCluster = !(effectiveFirstInCluster && effectiveLastInCluster) 164 174 const isInMiddleOfCluster = 165 - isInCluster && !isFirstInCluster && !isLastInCluster 175 + isInCluster && !effectiveFirstInCluster && !effectiveLastInCluster 166 176 167 177 const hasReactions = message.reactions && message.reactions.length > 0 168 178 const squaredBottomCorner = 169 - !hasReactions && isInCluster && (isInMiddleOfCluster || isFirstInCluster) 179 + !hasReactions && 180 + isInCluster && 181 + (isInMiddleOfCluster || effectiveFirstInCluster) 170 182 const squaredTopCorner = 171 - isInCluster && (isInMiddleOfCluster || isLastInCluster) 183 + isInCluster && (isInMiddleOfCluster || effectiveLastInCluster) 172 184 173 185 const pendingColor = t.palette.primary_300 174 186 ··· 179 191 const hasEmbedAndText = 180 192 AppBskyEmbedRecord.isView(message.embed) && rt.text.length > 0 181 193 194 + const targetBottomRadius = 195 + squaredBottomCorner || hasEmbedAndText 196 + ? SQUARED_BORDER_RADIUS 197 + : BORDER_RADIUS 198 + const targetTopRadius = squaredTopCorner 199 + ? SQUARED_BORDER_RADIUS 200 + : BORDER_RADIUS 201 + 202 + const bottomRadiusSV = useSharedValue(targetBottomRadius) 203 + const topRadiusSV = useSharedValue(targetTopRadius) 204 + 205 + const showDisplayName = 206 + isGroupChat && 207 + !isFromSelf && 208 + effectiveFirstInCluster && 209 + !isDateDividerToggled && 210 + !isOnlyEmoji(message.text) 211 + const showAvatar = isGroupChat && !isFromSelf && isLastInCluster 212 + 213 + useEffect(() => { 214 + bottomRadiusSV.set(withTiming(targetBottomRadius, {duration: 300})) 215 + }, [targetBottomRadius, bottomRadiusSV]) 216 + 217 + useEffect(() => { 218 + topRadiusSV.set(withTiming(targetTopRadius, {duration: 300})) 219 + }, [targetTopRadius, topRadiusSV]) 220 + 221 + const borderRadiusStyle = useAnimatedStyle(() => 222 + isFromSelf 223 + ? { 224 + borderBottomRightRadius: bottomRadiusSV.get(), 225 + borderTopRightRadius: topRadiusSV.get(), 226 + } 227 + : { 228 + borderBottomLeftRadius: bottomRadiusSV.get(), 229 + borderTopLeftRadius: topRadiusSV.get(), 230 + }, 231 + ) 232 + 182 233 const avatar = profile ? ( 183 234 <ProfileCard.Avatar 184 235 profile={profile} ··· 322 373 323 374 return ( 324 375 <> 325 - {showDateDivider && ( 376 + {(showDateDivider || isDateDividerToggled) && ( 326 377 <Animated.View entering={native(FadeIn)} exiting={native(FadeOut)}> 327 378 <DateDivider date={message.sentAt} /> 328 379 </Animated.View> ··· 330 381 <View 331 382 style={[ 332 383 isFromSelf ? a.mr_sm : a.ml_sm, 333 - isFirstInCluster && !showDateDivider && a.mt_sm, 384 + effectiveFirstInCluster && 385 + !(showDateDivider || isDateDividerToggled) && 386 + a.mt_sm, 334 387 ]}> 335 388 <View style={[a.relative]}> 336 - {isGroupChat && !isFromSelf && isLastInCluster ? ( 389 + {showAvatar ? ( 337 390 <View style={[a.absolute, {bottom: hasReactions ? 10 : 0}]}> 338 391 {avatar} 339 392 </View> ··· 346 399 paddingLeft: AVATAR_SIZE, 347 400 }, 348 401 ]}> 349 - {isGroupChat && 350 - !isFromSelf && 351 - isFirstInCluster && 352 - !isOnlyEmoji(message.text) ? ( 402 + {showDisplayName ? ( 353 403 <Text 354 404 style={[ 355 405 a.text_xs, ··· 363 413 {displayName} 364 414 </Text> 365 415 ) : null} 366 - <ActionsWrapper isFromSelf={isFromSelf} message={message}> 416 + <ActionsWrapper 417 + hasReactions={hasReactions} 418 + isFromSelf={isFromSelf} 419 + message={message} 420 + onTap={() => { 421 + if (!hasLargeGapFromPrev) { 422 + LayoutAnimation.configureNext( 423 + LayoutAnimation.Presets.easeInEaseOut, 424 + ) 425 + toggleDivider(message.id) 426 + } 427 + }}> 367 428 {rt.text.length > 0 && ( 368 - <View 429 + <Animated.View 369 430 accessibilityHint={l`Double tap or long press the message to add a reaction`} 370 431 style={[ 371 432 !isFromSelf && a.ml_sm, ··· 377 438 a.py_sm, 378 439 a.px_md, 379 440 { 380 - marginTop: isFirstInCluster 441 + marginTop: effectiveFirstInCluster 381 442 ? 0 382 443 : CLUSTERED_MESSAGE_GAP, 383 444 backgroundColor: isFromSelf ··· 387 448 : t.palette.contrast_50, 388 449 }, 389 450 isFromSelf ? a.self_end : a.self_start, 390 - isFromSelf 391 - ? { 392 - borderBottomRightRadius: 393 - squaredBottomCorner || hasEmbedAndText 394 - ? SQUARED_BORDER_RADIUS 395 - : BORDER_RADIUS, 396 - borderTopRightRadius: squaredTopCorner 397 - ? SQUARED_BORDER_RADIUS 398 - : BORDER_RADIUS, 399 - } 400 - : { 401 - borderBottomLeftRadius: 402 - squaredBottomCorner || hasEmbedAndText 403 - ? SQUARED_BORDER_RADIUS 404 - : BORDER_RADIUS, 405 - borderTopLeftRadius: squaredTopCorner 406 - ? SQUARED_BORDER_RADIUS 407 - : BORDER_RADIUS, 408 - }, 451 + borderRadiusStyle, 409 452 ]), 410 453 ]}> 411 454 <RichText ··· 416 459 emojiMultiplier={3} 417 460 shouldProxyLinks={true} 418 461 /> 419 - </View> 462 + </Animated.View> 420 463 )} 421 464 {AppBskyEmbedRecord.isView(message.embed) && ( 422 465 <MessageItemEmbed ··· 430 473 </ActionsWrapper> 431 474 </View> 432 475 </View> 433 - {isLastInCluster && ( 476 + {effectiveLastInCluster && ( 434 477 <MessageItemMetadata 435 478 item={item} 436 479 style={[isFromSelf ? a.text_right : a.text_left]}
+3 -2
src/screens/Messages/components/MessagesList.tsx
··· 53 53 import {MessageListError} from '#/screens/Messages/components/MessageListError' 54 54 import {atoms as a, platform, tokens, useTheme, web} from '#/alf' 55 55 import {ChatEmptyPill} from '#/components/dms/ChatEmptyPill' 56 + import {DateDividerToggleProvider} from '#/components/dms/DateDividerToggle' 56 57 import {MessageItem} from '#/components/dms/MessageItem' 57 58 import {NewMessagesPill} from '#/components/dms/NewMessagesPill' 58 59 import {Loader} from '#/components/Loader' ··· 417 418 ) 418 419 419 420 return ( 420 - <> 421 + <DateDividerToggleProvider> 421 422 <KeyboardGestureArea 422 423 interpolator="ios" 423 424 // HACKFIX: https://github.com/kirillzyusko/react-native-keyboard-controller/issues/1419 ··· 528 529 )} 529 530 530 531 {newMessagesPill.show && <NewMessagesPill onPress={scrollToEndOnPress} />} 531 - </> 532 + </DateDividerToggleProvider> 532 533 ) 533 534 } 534 535