An ATproto social media client -- with an independent Appview.

[DMs] Emoji reaction picker (#8023)

authored by samuel.fm and committed by

GitHub 55a40c24 ac2c2a9a

+697 -197
+1
package.json
··· 178 "react-native-compressor": "1.10.3", 179 "react-native-date-picker": "^5.0.7", 180 "react-native-drawer-layout": "^4.1.1", 181 "react-native-gesture-handler": "2.20.2", 182 "react-native-get-random-values": "~1.11.0", 183 "react-native-image-crop-picker": "^0.41.6",
··· 178 "react-native-compressor": "1.10.3", 179 "react-native-date-picker": "^5.0.7", 180 "react-native-drawer-layout": "^4.1.1", 181 + "react-native-emoji-popup": "^0.1.2", 182 "react-native-gesture-handler": "2.20.2", 183 "react-native-get-random-values": "~1.11.0", 184 "react-native-image-crop-picker": "^0.41.6",
+45 -9
src/components/ContextMenu/Backdrop.ios.tsx
··· 2 import Animated, { 3 Extrapolation, 4 interpolate, 5 - SharedValue, 6 useAnimatedProps, 7 } from 'react-native-reanimated' 8 import {BlurView} from 'expo-blur' 9 import {msg} from '@lingui/macro' 10 import {useLingui} from '@lingui/react' 11 12 - import {atoms as a} from '#/alf' 13 14 const AnimatedBlurView = Animated.createAnimatedComponent(BlurView) 15 16 - export function Backdrop({ 17 - animation, 18 - intensity = 50, 19 - onPress, 20 - }: { 21 animation: SharedValue<number> 22 intensity?: number 23 onPress?: () => void 24 - }) { 25 const {_} = useLingui() 26 27 const animatedProps = useAnimatedProps(() => ({ ··· 37 <AnimatedBlurView 38 animatedProps={animatedProps} 39 style={[a.absolute, a.inset_0]} 40 - tint="systemThinMaterialDark"> 41 <Pressable 42 style={a.flex_1} 43 accessibilityLabel={_(msg`Close menu`)} ··· 47 </AnimatedBlurView> 48 ) 49 }
··· 2 import Animated, { 3 Extrapolation, 4 interpolate, 5 + type SharedValue, 6 useAnimatedProps, 7 + useAnimatedStyle, 8 } from 'react-native-reanimated' 9 import {BlurView} from 'expo-blur' 10 import {msg} from '@lingui/macro' 11 import {useLingui} from '@lingui/react' 12 13 + import {atoms as a, useTheme} from '#/alf' 14 + import {useContextMenuContext} from './context' 15 16 const AnimatedBlurView = Animated.createAnimatedComponent(BlurView) 17 18 + type Props = { 19 animation: SharedValue<number> 20 intensity?: number 21 onPress?: () => void 22 + } 23 + 24 + export function Backdrop(props: Props) { 25 + const {mode} = useContextMenuContext() 26 + switch (mode) { 27 + case 'full': 28 + return <BlurredBackdrop {...props} /> 29 + case 'auxiliary-only': 30 + return <OpacityBackdrop {...props} /> 31 + } 32 + } 33 + 34 + function BlurredBackdrop({animation, intensity = 50, onPress}: Props) { 35 const {_} = useLingui() 36 37 const animatedProps = useAnimatedProps(() => ({ ··· 47 <AnimatedBlurView 48 animatedProps={animatedProps} 49 style={[a.absolute, a.inset_0]} 50 + tint="systemMaterialDark"> 51 <Pressable 52 style={a.flex_1} 53 accessibilityLabel={_(msg`Close menu`)} ··· 57 </AnimatedBlurView> 58 ) 59 } 60 + 61 + function OpacityBackdrop({animation, onPress}: Props) { 62 + const t = useTheme() 63 + const {_} = useLingui() 64 + 65 + const animatedStyle = useAnimatedStyle(() => ({ 66 + opacity: interpolate( 67 + animation.get(), 68 + [0, 1], 69 + [0, 0.05], 70 + Extrapolation.CLAMP, 71 + ), 72 + })) 73 + 74 + return ( 75 + <Animated.View 76 + style={[a.absolute, a.inset_0, t.atoms.bg_contrast_975, animatedStyle]}> 77 + <Pressable 78 + style={a.flex_1} 79 + accessibilityLabel={_(msg`Close menu`)} 80 + accessibilityHint={_(msg`Tap to close context menu`)} 81 + onPress={onPress} 82 + /> 83 + </Animated.View> 84 + ) 85 + }
+7 -1
src/components/ContextMenu/Backdrop.tsx
··· 9 import {useLingui} from '@lingui/react' 10 11 import {atoms as a, useTheme} from '#/alf' 12 13 export function Backdrop({ 14 animation, ··· 21 }) { 22 const t = useTheme() 23 const {_} = useLingui() 24 25 const animatedStyle = useAnimatedStyle(() => ({ 26 opacity: interpolate( 27 animation.get(), 28 [0, 1], 29 - [0, intensity / 100], 30 Extrapolation.CLAMP, 31 ), 32 }))
··· 9 import {useLingui} from '@lingui/react' 10 11 import {atoms as a, useTheme} from '#/alf' 12 + import {useContextMenuContext} from './context' 13 14 export function Backdrop({ 15 animation, ··· 22 }) { 23 const t = useTheme() 24 const {_} = useLingui() 25 + const {mode} = useContextMenuContext() 26 + 27 + const reduced = mode === 'auxiliary-only' 28 + 29 + const target = reduced ? 0.05 : intensity / 100 30 31 const animatedStyle = useAnimatedStyle(() => ({ 32 opacity: interpolate( 33 animation.get(), 34 [0, 1], 35 + [0, target], 36 Extrapolation.CLAMP, 37 ), 38 }))
+268 -144
src/components/ContextMenu/index.tsx
··· 9 import { 10 BackHandler, 11 Keyboard, 12 - LayoutChangeEvent, 13 Pressable, 14 - StyleProp, 15 useWindowDimensions, 16 View, 17 - ViewStyle, 18 } from 'react-native' 19 import { 20 Gesture, 21 GestureDetector, 22 - GestureStateChangeEvent, 23 - GestureUpdateEvent, 24 - PanGestureHandlerEventPayload, 25 } from 'react-native-gesture-handler' 26 import Animated, { 27 clamp, 28 interpolate, 29 runOnJS, 30 - SharedValue, 31 useAnimatedReaction, 32 useAnimatedStyle, 33 useSharedValue, 34 withSpring, 35 - WithSpringConfig, 36 } from 'react-native-reanimated' 37 import { 38 useSafeAreaFrame, 39 useSafeAreaInsets, 40 } from 'react-native-safe-area-context' 41 import {captureRef} from 'react-native-view-shot' 42 - import {Image, ImageErrorEventData} from 'expo-image' 43 import {msg} from '@lingui/macro' 44 import {useLingui} from '@lingui/react' 45 import {useIsFocused} from '@react-navigation/native' ··· 60 useContextMenuMenuContext, 61 } from '#/components/ContextMenu/context' 62 import { 63 - ContextType, 64 - ItemIconProps, 65 - ItemProps, 66 - ItemTextProps, 67 - Measurement, 68 - TriggerProps, 69 } from '#/components/ContextMenu/types' 70 import {useInteractionState} from '#/components/hooks/useInteractionState' 71 import {createPortalGroup} from '#/components/Portal' ··· 79 80 const {Provider: PortalProvider, Outlet, Portal} = createPortalGroup() 81 82 - const SPRING: WithSpringConfig = { 83 mass: isIOS ? 1.25 : 0.75, 84 damping: 150, 85 stiffness: 1000, ··· 100 101 export function Root({children}: {children: React.ReactNode}) { 102 const playHaptic = useHaptics() 103 const [measurement, setMeasurement] = useState<Measurement | null>(null) 104 const animationSV = useSharedValue(0) 105 const translationSV = useSharedValue(0) ··· 134 measurement, 135 animationSV, 136 translationSV, 137 - open: (evt: Measurement) => { 138 setMeasurement(evt) 139 - animationSV.set(withSpring(1, SPRING)) 140 }, 141 close: () => { 142 animationSV.set( 143 - withSpring(0, SPRING, finished => { 144 if (finished) { 145 hoverablesSV.set({}) 146 translationSV.set(0) ··· 192 hoveredMenuItem, 193 setHoveredMenuItem, 194 playHaptic, 195 ], 196 ) 197 ··· 216 const ref = useRef<View>(null) 217 const isFocused = useIsFocused() 218 const [image, setImage] = useState<string | null>(null) 219 - const [pendingMeasurement, setPendingMeasurement] = 220 - useState<Measurement | null>(null) 221 222 - const open = useNonReactiveCallback(async () => { 223 - playHaptic() 224 - Keyboard.dismiss() 225 - const [measurement, capture] = await Promise.all([ 226 - new Promise<Measurement>(resolve => { 227 - ref.current?.measureInWindow((x, y, width, height) => 228 - resolve({ 229 - x, 230 - y: 231 - y + 232 - platform({ 233 - default: 0, 234 - android: topInset, // not included in measurement 235 - }), 236 - width, 237 - height, 238 - }), 239 - ) 240 - }), 241 - captureRef(ref, {result: 'data-uri'}).catch(err => { 242 - logger.error(err instanceof Error ? err : String(err), { 243 - message: 'Failed to capture image of context menu trigger', 244 - }) 245 - // will cause the image to fail to load, but it will get handled gracefully 246 - return '<failed capture>' 247 - }), 248 - ]) 249 - setImage(capture) 250 - setPendingMeasurement(measurement) 251 - }) 252 253 const doubleTapGesture = useMemo(() => { 254 return Gesture.Tap() 255 .numberOfTaps(2) 256 .hitSlop(HITSLOP_10) 257 - .onEnd(open) 258 .runOnJS(true) 259 }, [open]) 260 ··· 283 .averageTouches(true) 284 .onStart(() => { 285 'worklet' 286 - runOnJS(open)() 287 }) 288 .onUpdate(evt => { 289 'worklet' 290 const item = getHoveredHoverable(evt, hoverablesSV, translationSV) 291 hoveredItemSV.set(item) 292 }) 293 - .onEnd(evt => { 294 'worklet' 295 - const item = getHoveredHoverable(evt, hoverablesSV, translationSV) 296 - hoveredItemSV.set(null) 297 if (item) { 298 runOnJS(onTouchUpMenuItem)(item) 299 } ··· 305 pressAndHoldGesture, 306 ) 307 308 - const measurement = context.measurement || pendingMeasurement 309 310 return ( 311 <> ··· 343 measurement={measurement} 344 onDisplay={() => { 345 if (pendingMeasurement) { 346 - context.open(pendingMeasurement) 347 setPendingMeasurement(null) 348 } 349 }} ··· 416 ) 417 } 418 419 - const MENU_WIDTH = 230 420 421 export function Outer({ 422 children, ··· 491 <Context.Provider value={context}> 492 <MenuContext.Provider value={menuContext}> 493 <Backdrop animation={animationSV} onPress={context.close} /> 494 - {/* containing element - stays the same size, so we measure it 495 - to determine if a translation is necessary. also has the positioning */} 496 - <Animated.View 497 - onLayout={onLayout} 498 - style={[ 499 - a.absolute, 500 - a.z_10, 501 - a.mt_xs, 502 - { 503 - width: MENU_WIDTH, 504 - top: context.measurement.y + context.measurement.height, 505 - }, 506 - align === 'left' 507 - ? {left: context.measurement.x} 508 - : { 509 - right: 510 - screenWidth - 511 - context.measurement.x - 512 - context.measurement.width, 513 - }, 514 - animatedContainerStyle, 515 - ]}> 516 - {/* scaling element - has the scale/fade animation on it */} 517 <Animated.View 518 style={[ 519 - a.rounded_md, 520 - a.shadow_md, 521 - t.atoms.bg_contrast_25, 522 - a.w_full, 523 - // @ts-ignore react-native-web expects string, and this file is platform-split -sfn 524 - // note: above @ts-ignore cannot be a @ts-expect-error because this does not cause an error 525 - // in the typecheck CI - presumably because of RNW overriding the types 526 { 527 - transformOrigin: 528 - // "top right" doesn't seem to work on android, so set explicity in pixels 529 - align === 'left' ? [0, 0, 0] : [MENU_WIDTH, 0, 0], 530 }, 531 - animatedStyle, 532 - style, 533 ]}> 534 - {/* innermost element - needs an overflow: hidden for children, but we also need a shadow, 535 - so put the shadow on the scaling element and the overflow on the innermost element */} 536 - <View 537 style={[ 538 - a.flex_1, 539 a.rounded_md, 540 - a.overflow_hidden, 541 - a.border, 542 - t.atoms.border_contrast_low, 543 ]}> 544 - {flattenReactChildren(children).map((child, i) => { 545 - return React.isValidElement(child) && 546 - (child.type === Item || child.type === Divider) ? ( 547 - <React.Fragment key={i}> 548 - {i > 0 ? ( 549 - <View 550 - style={[a.border_b, t.atoms.border_contrast_low]} 551 - /> 552 - ) : null} 553 - {React.cloneElement(child, { 554 - // @ts-expect-error not typed 555 - style: { 556 - borderRadius: 0, 557 - borderWidth: 0, 558 - }, 559 - })} 560 - </React.Fragment> 561 - ) : null 562 - })} 563 - </View> 564 </Animated.View> 565 - </Animated.View> 566 </MenuContext.Provider> 567 </Context.Provider> 568 </Portal> 569 ) 570 } 571 572 - export function Item({children, label, style, onPress, ...rest}: ItemProps) { 573 const t = useTheme() 574 const context = useContextMenuContext() 575 const playHaptic = useHaptics() ··· 590 591 const layout = evt.nativeEvent.layout 592 593 registerHoverable( 594 id, 595 { 596 width: layout.width, 597 height: layout.height, 598 - y: measurement.y + measurement.height + tokens.space.xs + layout.y, 599 - x: 600 - align === 'left' 601 - ? measurement.x 602 - : measurement.x + measurement.width - layout.width, 603 }, 604 () => { 605 close() ··· 607 }, 608 ) 609 }, 610 - [id, measurement, registerHoverable, close, onPress, align], 611 ) 612 613 const itemContext = useMemo( ··· 637 rest.onPressOut?.(e) 638 }} 639 style={[ 640 - a.flex_row, 641 - a.align_center, 642 - a.gap_sm, 643 - a.py_sm, 644 - a.px_md, 645 - a.rounded_md, 646 - a.border, 647 - t.atoms.bg_contrast_25, 648 - t.atoms.border_contrast_low, 649 - {minHeight: 40}, 650 style, 651 - (focused || pressed || context.hoveredMenuItem === id) && 652 - !rest.disabled && [t.atoms.bg_contrast_50], 653 ]}> 654 <ItemContext.Provider value={itemContext}> 655 - {children} 656 </ItemContext.Provider> 657 </Pressable> 658 ) ··· 667 ellipsizeMode="middle" 668 style={[ 669 a.flex_1, 670 - a.text_sm, 671 a.font_bold, 672 t.atoms.text_contrast_high, 673 {paddingTop: 3}, ··· 684 const {disabled} = useContextMenuItemContext() 685 return ( 686 <Comp 687 - size="md" 688 fill={ 689 disabled 690 ? t.atoms.text_contrast_low.color
··· 9 import { 10 BackHandler, 11 Keyboard, 12 + type LayoutChangeEvent, 13 Pressable, 14 + type StyleProp, 15 useWindowDimensions, 16 View, 17 + type ViewStyle, 18 } from 'react-native' 19 import { 20 Gesture, 21 GestureDetector, 22 + type GestureStateChangeEvent, 23 + type GestureUpdateEvent, 24 + type PanGestureHandlerEventPayload, 25 } from 'react-native-gesture-handler' 26 import Animated, { 27 clamp, 28 interpolate, 29 runOnJS, 30 + type SharedValue, 31 useAnimatedReaction, 32 useAnimatedStyle, 33 useSharedValue, 34 withSpring, 35 + type WithSpringConfig, 36 } from 'react-native-reanimated' 37 import { 38 useSafeAreaFrame, 39 useSafeAreaInsets, 40 } from 'react-native-safe-area-context' 41 import {captureRef} from 'react-native-view-shot' 42 + import {Image, type ImageErrorEventData} from 'expo-image' 43 import {msg} from '@lingui/macro' 44 import {useLingui} from '@lingui/react' 45 import {useIsFocused} from '@react-navigation/native' ··· 60 useContextMenuMenuContext, 61 } from '#/components/ContextMenu/context' 62 import { 63 + type AuxiliaryViewProps, 64 + type ContextType, 65 + type ItemIconProps, 66 + type ItemProps, 67 + type ItemTextProps, 68 + type Measurement, 69 + type TriggerProps, 70 } from '#/components/ContextMenu/types' 71 import {useInteractionState} from '#/components/hooks/useInteractionState' 72 import {createPortalGroup} from '#/components/Portal' ··· 80 81 const {Provider: PortalProvider, Outlet, Portal} = createPortalGroup() 82 83 + const SPRING_IN: WithSpringConfig = { 84 + mass: isIOS ? 1.25 : 0.75, 85 + damping: 50, 86 + stiffness: 1100, 87 + restDisplacementThreshold: 0.01, 88 + } 89 + 90 + const SPRING_OUT: WithSpringConfig = { 91 mass: isIOS ? 1.25 : 0.75, 92 damping: 150, 93 stiffness: 1000, ··· 108 109 export function Root({children}: {children: React.ReactNode}) { 110 const playHaptic = useHaptics() 111 + const [mode, setMode] = useState<'full' | 'auxiliary-only'>('full') 112 const [measurement, setMeasurement] = useState<Measurement | null>(null) 113 const animationSV = useSharedValue(0) 114 const translationSV = useSharedValue(0) ··· 143 measurement, 144 animationSV, 145 translationSV, 146 + mode, 147 + open: (evt: Measurement, mode: 'full' | 'auxiliary-only') => { 148 setMeasurement(evt) 149 + setMode(mode) 150 + animationSV.set(withSpring(1, SPRING_IN)) 151 }, 152 close: () => { 153 animationSV.set( 154 + withSpring(0, SPRING_OUT, finished => { 155 if (finished) { 156 hoverablesSV.set({}) 157 translationSV.set(0) ··· 203 hoveredMenuItem, 204 setHoveredMenuItem, 205 playHaptic, 206 + mode, 207 ], 208 ) 209 ··· 228 const ref = useRef<View>(null) 229 const isFocused = useIsFocused() 230 const [image, setImage] = useState<string | null>(null) 231 + const [pendingMeasurement, setPendingMeasurement] = useState<{ 232 + measurement: Measurement 233 + mode: 'full' | 'auxiliary-only' 234 + } | null>(null) 235 236 + const open = useNonReactiveCallback( 237 + async (mode: 'full' | 'auxiliary-only') => { 238 + playHaptic() 239 + Keyboard.dismiss() 240 + const [measurement, capture] = await Promise.all([ 241 + new Promise<Measurement>(resolve => { 242 + ref.current?.measureInWindow((x, y, width, height) => 243 + resolve({ 244 + x, 245 + y: 246 + y + 247 + platform({ 248 + default: 0, 249 + android: topInset, // not included in measurement 250 + }), 251 + width, 252 + height, 253 + }), 254 + ) 255 + }), 256 + captureRef(ref, {result: 'data-uri'}).catch(err => { 257 + logger.error(err instanceof Error ? err : String(err), { 258 + message: 'Failed to capture image of context menu trigger', 259 + }) 260 + // will cause the image to fail to load, but it will get handled gracefully 261 + return '<failed capture>' 262 + }), 263 + ]) 264 + setImage(capture) 265 + setPendingMeasurement({measurement, mode}) 266 + }, 267 + ) 268 269 const doubleTapGesture = useMemo(() => { 270 return Gesture.Tap() 271 .numberOfTaps(2) 272 .hitSlop(HITSLOP_10) 273 + .onEnd(() => open('auxiliary-only')) 274 .runOnJS(true) 275 }, [open]) 276 ··· 299 .averageTouches(true) 300 .onStart(() => { 301 'worklet' 302 + runOnJS(open)('full') 303 }) 304 .onUpdate(evt => { 305 'worklet' 306 const item = getHoveredHoverable(evt, hoverablesSV, translationSV) 307 hoveredItemSV.set(item) 308 }) 309 + .onEnd(() => { 310 'worklet' 311 + // don't recalculate hovered item - if they haven't moved their finger from 312 + // the initial press, it's jarring to then select the item underneath 313 + // as the menu may have slid into place beneath their finger 314 + const item = hoveredItemSV.get() 315 if (item) { 316 runOnJS(onTouchUpMenuItem)(item) 317 } ··· 323 pressAndHoldGesture, 324 ) 325 326 + const measurement = context.measurement || pendingMeasurement?.measurement 327 328 return ( 329 <> ··· 361 measurement={measurement} 362 onDisplay={() => { 363 if (pendingMeasurement) { 364 + context.open( 365 + pendingMeasurement.measurement, 366 + pendingMeasurement.mode, 367 + ) 368 setPendingMeasurement(null) 369 } 370 }} ··· 437 ) 438 } 439 440 + export function AuxiliaryView({children, align = 'left'}: AuxiliaryViewProps) { 441 + const context = useContextMenuContext() 442 + const {width: screenWidth} = useWindowDimensions() 443 + const {top: topInset} = useSafeAreaInsets() 444 + const ensureOnScreenTranslationSV = useSharedValue(0) 445 + 446 + const {isOpen, mode, measurement, translationSV, animationSV} = context 447 + 448 + const animatedStyle = useAnimatedStyle(() => { 449 + return { 450 + opacity: clamp(animationSV.get(), 0, 1), 451 + transform: [ 452 + { 453 + translateY: 454 + (ensureOnScreenTranslationSV.get() || translationSV.get()) * 455 + animationSV.get(), 456 + }, 457 + {scale: interpolate(animationSV.get(), [0, 1], [0.2, 1])}, 458 + ], 459 + } 460 + }) 461 + 462 + const menuContext = useMemo(() => ({align}), [align]) 463 + 464 + const onLayout = useCallback(() => { 465 + if (!measurement) return 466 + 467 + let translation = 0 468 + 469 + // vibes based, just assuming it'll fit within this space. revisit if we use 470 + // AuxiliaryView for something tall 471 + const TOP_INSET = topInset + 80 472 + 473 + const distanceMessageFromTop = measurement.y - TOP_INSET 474 + if (distanceMessageFromTop < 0) { 475 + translation = -distanceMessageFromTop 476 + } 477 + 478 + // normally, the context menu is responsible for measuring itself and moving everything into the right place 479 + // however, in auxiliary-only mode, that doesn't happen, so we need to do it ourselves here 480 + if (mode === 'auxiliary-only') { 481 + translationSV.set(translation) 482 + ensureOnScreenTranslationSV.set(0) 483 + } 484 + // however, we also need to make sure that for super tall triggers, we don't go off the screen 485 + // so we have an additional cap on the standard transform every other element has 486 + // note: this breaks the press-and-hold gesture for the reaction items. unfortunately I think 487 + // we'll just have to live with it for now, fixing it would be possible but be a large complexity 488 + // increase for an edge case 489 + else { 490 + ensureOnScreenTranslationSV.set(translation) 491 + } 492 + }, [mode, measurement, translationSV, topInset, ensureOnScreenTranslationSV]) 493 + 494 + if (!isOpen || !measurement) return null 495 + 496 + return ( 497 + <Portal> 498 + <Context.Provider value={context}> 499 + <MenuContext.Provider value={menuContext}> 500 + <Animated.View 501 + onLayout={onLayout} 502 + style={[ 503 + a.absolute, 504 + { 505 + top: measurement.y, 506 + transformOrigin: 507 + align === 'left' ? 'bottom left' : 'bottom right', 508 + }, 509 + align === 'left' 510 + ? {left: measurement.x} 511 + : {right: screenWidth - measurement.x - measurement.width}, 512 + animatedStyle, 513 + a.z_20, 514 + ]}> 515 + {children} 516 + </Animated.View> 517 + </MenuContext.Provider> 518 + </Context.Provider> 519 + </Portal> 520 + ) 521 + } 522 + 523 + const MENU_WIDTH = 240 524 525 export function Outer({ 526 children, ··· 595 <Context.Provider value={context}> 596 <MenuContext.Provider value={menuContext}> 597 <Backdrop animation={animationSV} onPress={context.close} /> 598 + {context.mode === 'full' && ( 599 + /* containing element - stays the same size, so we measure it 600 + to determine if a translation is necessary. also has the positioning */ 601 <Animated.View 602 + onLayout={onLayout} 603 style={[ 604 + a.absolute, 605 + a.z_10, 606 + a.mt_xs, 607 { 608 + width: MENU_WIDTH, 609 + top: context.measurement.y + context.measurement.height, 610 }, 611 + align === 'left' 612 + ? {left: context.measurement.x} 613 + : { 614 + right: 615 + screenWidth - 616 + context.measurement.x - 617 + context.measurement.width, 618 + }, 619 + animatedContainerStyle, 620 ]}> 621 + {/* scaling element - has the scale/fade animation on it */} 622 + <Animated.View 623 style={[ 624 a.rounded_md, 625 + a.shadow_md, 626 + t.atoms.bg_contrast_25, 627 + a.w_full, 628 + // @ts-ignore react-native-web expects string, and this file is platform-split -sfn 629 + // note: above @ts-ignore cannot be a @ts-expect-error because this does not cause an error 630 + // in the typecheck CI - presumably because of RNW overriding the types 631 + { 632 + transformOrigin: 633 + // "top right" doesn't seem to work on android, so set explicitly in pixels 634 + align === 'left' ? [0, 0, 0] : [MENU_WIDTH, 0, 0], 635 + }, 636 + animatedStyle, 637 + style, 638 ]}> 639 + {/* innermost element - needs an overflow: hidden for children, but we also need a shadow, 640 + so put the shadow on the scaling element and the overflow on the innermost element */} 641 + <View 642 + style={[ 643 + a.flex_1, 644 + a.rounded_md, 645 + a.overflow_hidden, 646 + a.border, 647 + t.atoms.border_contrast_low, 648 + ]}> 649 + {flattenReactChildren(children).map((child, i) => { 650 + return React.isValidElement(child) && 651 + (child.type === Item || child.type === Divider) ? ( 652 + <React.Fragment key={i}> 653 + {i > 0 ? ( 654 + <View 655 + style={[a.border_b, t.atoms.border_contrast_low]} 656 + /> 657 + ) : null} 658 + {React.cloneElement(child, { 659 + // @ts-expect-error not typed 660 + style: { 661 + borderRadius: 0, 662 + borderWidth: 0, 663 + }, 664 + })} 665 + </React.Fragment> 666 + ) : null 667 + })} 668 + </View> 669 + </Animated.View> 670 </Animated.View> 671 + )} 672 </MenuContext.Provider> 673 </Context.Provider> 674 </Portal> 675 ) 676 } 677 678 + export function Item({ 679 + children, 680 + label, 681 + unstyled, 682 + style, 683 + onPress, 684 + position, 685 + ...rest 686 + }: ItemProps) { 687 const t = useTheme() 688 const context = useContextMenuContext() 689 const playHaptic = useHaptics() ··· 704 705 const layout = evt.nativeEvent.layout 706 707 + const yOffset = position 708 + ? position.y 709 + : measurement.y + measurement.height + tokens.space.xs 710 + const xOffset = position 711 + ? position.x 712 + : align === 'left' 713 + ? measurement.x 714 + : measurement.x + measurement.width - layout.width 715 + 716 registerHoverable( 717 id, 718 { 719 width: layout.width, 720 height: layout.height, 721 + y: yOffset + layout.y, 722 + x: xOffset + layout.x, 723 }, 724 () => { 725 close() ··· 727 }, 728 ) 729 }, 730 + [id, measurement, registerHoverable, close, onPress, align, position], 731 ) 732 733 const itemContext = useMemo( ··· 757 rest.onPressOut?.(e) 758 }} 759 style={[ 760 + !unstyled && [ 761 + a.flex_row, 762 + a.align_center, 763 + a.gap_sm, 764 + a.px_md, 765 + a.rounded_md, 766 + a.border, 767 + t.atoms.bg_contrast_25, 768 + t.atoms.border_contrast_low, 769 + {minHeight: 44, paddingVertical: 10}, 770 + (focused || pressed || context.hoveredMenuItem === id) && 771 + !rest.disabled && 772 + t.atoms.bg_contrast_50, 773 + ], 774 style, 775 ]}> 776 <ItemContext.Provider value={itemContext}> 777 + {typeof children === 'function' 778 + ? children(focused || pressed || context.hoveredMenuItem === id) 779 + : children} 780 </ItemContext.Provider> 781 </Pressable> 782 ) ··· 791 ellipsizeMode="middle" 792 style={[ 793 a.flex_1, 794 + a.text_md, 795 a.font_bold, 796 t.atoms.text_contrast_high, 797 {paddingTop: 3}, ··· 808 const {disabled} = useContextMenuItemContext() 809 return ( 810 <Comp 811 + size="lg" 812 fill={ 813 disabled 814 ? t.atoms.text_contrast_low.color
+7
src/components/ContextMenu/index.web.tsx
··· 1 export * from '#/components/Menu' 2 3 export function Provider({children}: {children: React.ReactNode}) { 4 return children 5 }
··· 1 + import {type AuxiliaryViewProps} from './types' 2 + 3 export * from '#/components/Menu' 4 5 export function Provider({children}: {children: React.ReactNode}) { 6 return children 7 } 8 + 9 + // native only 10 + export function AuxiliaryView({}: AuxiliaryViewProps) { 11 + return null 12 + }
+27 -13
src/components/ContextMenu/types.ts
··· 1 - import React from 'react' 2 import { 3 - AccessibilityRole, 4 - GestureResponderEvent, 5 - StyleProp, 6 - ViewStyle, 7 } from 'react-native' 8 - import {SharedValue} from 'react-native-reanimated' 9 10 - import * as Dialog from '#/components/Dialog' 11 import { 12 - ItemProps as MenuItemProps, 13 - RadixPassThroughTriggerProps, 14 } from '#/components/Menu/types' 15 16 export type { ··· 19 ItemTextProps, 20 } from '#/components/Menu/types' 21 22 - // Same as Menu.ItemProps, but onPress is not guaranteed to get an event 23 - export type ItemProps = Omit<MenuItemProps, 'onPress'> & { 24 onPress: (evt?: GestureResponderEvent) => void 25 } 26 27 export type Measurement = { ··· 38 animationSV: SharedValue<number> 39 /* Translation in Y axis to ensure everything's onscreen */ 40 translationSV: SharedValue<number> 41 - open: (evt: Measurement) => void 42 close: () => void 43 registerHoverable: ( 44 id: string, ··· 76 export type TriggerChildProps = 77 | { 78 isNative: true 79 - control: {isOpen: boolean; open: () => void} 80 state: { 81 hovered: false 82 focused: false
··· 1 import { 2 + type AccessibilityRole, 3 + type GestureResponderEvent, 4 + type StyleProp, 5 + type ViewStyle, 6 } from 'react-native' 7 + import {type SharedValue} from 'react-native-reanimated' 8 + import type React from 'react' 9 10 + import type * as Dialog from '#/components/Dialog' 11 import { 12 + type ItemProps as MenuItemProps, 13 + type RadixPassThroughTriggerProps, 14 } from '#/components/Menu/types' 15 16 export type { ··· 19 ItemTextProps, 20 } from '#/components/Menu/types' 21 22 + export type AuxiliaryViewProps = { 23 + children?: React.ReactNode 24 + align?: 'left' | 'right' 25 + } 26 + 27 + export type ItemProps = Omit<MenuItemProps, 'onPress' | 'children'> & { 28 + // remove default styles (i.e. for emoji reactions) 29 + unstyled?: boolean 30 onPress: (evt?: GestureResponderEvent) => void 31 + children?: React.ReactNode | ((hovered: boolean) => React.ReactNode) 32 + // absolute position of the parent element. if undefined, assumed to 33 + // be in the context menu. use this if using AuxiliaryView 34 + position?: Measurement 35 } 36 37 export type Measurement = { ··· 48 animationSV: SharedValue<number> 49 /* Translation in Y axis to ensure everything's onscreen */ 50 translationSV: SharedValue<number> 51 + mode: 'full' | 'auxiliary-only' 52 + open: (evt: Measurement, mode: 'full' | 'auxiliary-only') => void 53 close: () => void 54 registerHoverable: ( 55 id: string, ··· 87 export type TriggerChildProps = 88 | { 89 isNative: true 90 + control: { 91 + isOpen: boolean 92 + open: (mode: 'full' | 'auxiliary-only') => void 93 + } 94 state: { 95 hovered: false 96 focused: false
+2
src/components/Menu/index.tsx
··· 30 useDialogControl as useMenuControl, 31 } from '#/components/Dialog' 32 33 export function Root({ 34 children, 35 control,
··· 30 useDialogControl as useMenuControl, 31 } from '#/components/Dialog' 32 33 + export {useMenuContext} 34 + 35 export function Root({ 36 children, 37 control,
+2
src/components/Menu/index.web.tsx
··· 26 import {Portal} from '#/components/Portal' 27 import {Text} from '#/components/Typography' 28 29 export function useMenuControl(): Dialog.DialogControlProps { 30 const id = React.useId() 31 const [isOpen, setIsOpen] = React.useState(false)
··· 26 import {Portal} from '#/components/Portal' 27 import {Text} from '#/components/Typography' 28 29 + export {useMenuContext} 30 + 31 export function useMenuControl(): Dialog.DialogControlProps { 32 const id = React.useId() 33 const [isOpen, setIsOpen] = React.useState(false)
+1 -23
src/components/dms/ActionsWrapper.tsx
··· 23 // will always be true, since this file is platform split 24 trigger.isNative && ( 25 <View style={[a.flex_1, a.relative]}> 26 - {/* {isNative && ( 27 - <View 28 - style={[ 29 - a.rounded_full, 30 - a.absolute, 31 - {bottom: '100%'}, 32 - isFromSelf ? a.right_0 : a.left_0, 33 - t.atoms.bg, 34 - a.flex_row, 35 - a.shadow_lg, 36 - a.py_xs, 37 - a.px_md, 38 - a.gap_md, 39 - a.mb_xs, 40 - ]}> 41 - {['👍', '😆', '❤️', '👀', '😢'].map(emoji => ( 42 - <Text key={emoji} style={[a.text_center, {fontSize: 32}]}> 43 - {emoji} 44 - </Text> 45 - ))} 46 - </View> 47 - )} */} 48 <View 49 style={[ 50 {maxWidth: '80%'}, ··· 56 accessibilityActions={[ 57 {name: 'activate', label: _(msg`Open message options`)}, 58 ]} 59 - onAccessibilityAction={trigger.control.open}> 60 {children} 61 </View> 62 </View>
··· 23 // will always be true, since this file is platform split 24 trigger.isNative && ( 25 <View style={[a.flex_1, a.relative]}> 26 <View 27 style={[ 28 {maxWidth: '80%'}, ··· 34 accessibilityActions={[ 35 {name: 'activate', label: _(msg`Open message options`)}, 36 ]} 37 + onAccessibilityAction={() => trigger.control.open('full')}> 38 {children} 39 </View> 40 </View>
+35 -5
src/components/dms/ActionsWrapper.web.tsx
··· 4 5 import {atoms as a, useTheme} from '#/alf' 6 import {MessageContextMenu} from '#/components/dms/MessageContextMenu' 7 - import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '../icons/DotGrid' 8 9 export function ActionsWrapper({ 10 message, ··· 47 <View 48 style={[ 49 a.justify_center, 50 isFromSelf 51 - ? [a.mr_xl, {marginLeft: 'auto'}] 52 - : [a.ml_xl, {marginRight: 'auto'}], 53 ]}> 54 <MessageContextMenu message={message}> 55 {({props, state, isNative, control}) => { 56 // always false, file is platform split ··· 61 {...props} 62 style={[ 63 {opacity: showMenuTrigger}, 64 - a.p_sm, 65 a.rounded_full, 66 (state.hovered || state.pressed) && t.atoms.bg_contrast_25, 67 ]}> 68 - <DotsHorizontalIcon size="md" style={t.atoms.text} /> 69 </Pressable> 70 ) 71 }}
··· 4 5 import {atoms as a, useTheme} from '#/alf' 6 import {MessageContextMenu} from '#/components/dms/MessageContextMenu' 7 + import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid' 8 + import {EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji' 9 + import {EmojiReactionPicker} from './EmojiReactionPicker' 10 11 export function ActionsWrapper({ 12 message, ··· 49 <View 50 style={[ 51 a.justify_center, 52 + a.flex_row, 53 + a.align_center, 54 + a.gap_xs, 55 isFromSelf 56 + ? [a.mr_md, {marginLeft: 'auto'}] 57 + : [a.ml_md, {marginRight: 'auto'}], 58 ]}> 59 + <EmojiReactionPicker message={message}> 60 + {({props, state, isNative, control}) => { 61 + // always false, file is platform split 62 + if (isNative) return null 63 + const showMenuTrigger = showActions || control.isOpen ? 1 : 0 64 + return ( 65 + <Pressable 66 + {...props} 67 + style={[ 68 + {opacity: showMenuTrigger}, 69 + a.p_xs, 70 + a.rounded_full, 71 + (state.hovered || state.pressed) && t.atoms.bg_contrast_25, 72 + ]}> 73 + <EmojiSmileIcon 74 + size="md" 75 + style={t.atoms.text_contrast_medium} 76 + /> 77 + </Pressable> 78 + ) 79 + }} 80 + </EmojiReactionPicker> 81 <MessageContextMenu message={message}> 82 {({props, state, isNative, control}) => { 83 // always false, file is platform split ··· 88 {...props} 89 style={[ 90 {opacity: showMenuTrigger}, 91 + a.p_xs, 92 a.rounded_full, 93 (state.hovered || state.pressed) && t.atoms.bg_contrast_25, 94 ]}> 95 + <DotsHorizontalIcon 96 + size="md" 97 + style={t.atoms.text_contrast_medium} 98 + /> 99 </Pressable> 100 ) 101 }}
+82
src/components/dms/EmojiPopup.android.tsx
···
··· 1 + import {useState} from 'react' 2 + import {Modal, Pressable, View} from 'react-native' 3 + // @ts-expect-error internal component, not supposed to be used directly 4 + // waiting on more customisability: https://github.com/okwasniewski/react-native-emoji-popup/issues/1#issuecomment-2737463753 5 + import EmojiPopupView from 'react-native-emoji-popup/src/EmojiPopupViewNativeComponent' 6 + import {Trans} from '@lingui/macro' 7 + import {useLingui} from '@lingui/react' 8 + 9 + import {atoms as a, useTheme} from '#/alf' 10 + import {Button, ButtonIcon} from '#/components/Button' 11 + import {TimesLarge_Stroke2_Corner0_Rounded} from '#/components/icons/Times' 12 + import {Text} from '#/components/Typography' 13 + 14 + export function EmojiPopup({ 15 + children, 16 + onEmojiSelected, 17 + }: { 18 + children: React.ReactNode 19 + onEmojiSelected: (emoji: string) => void 20 + }) { 21 + const [modalVisible, setModalVisible] = useState(false) 22 + const {_} = useLingui() 23 + const t = useTheme() 24 + 25 + return ( 26 + <> 27 + <Pressable 28 + accessibilityLabel={_('Open full emoji list')} 29 + accessibilityHint="" 30 + accessibilityRole="button" 31 + onPress={() => setModalVisible(true)}> 32 + {children} 33 + </Pressable> 34 + 35 + <Modal 36 + animationType="slide" 37 + transparent={true} 38 + visible={modalVisible} 39 + onRequestClose={() => setModalVisible(false)}> 40 + <View style={[a.flex_1, {backgroundColor: t.palette.white}]}> 41 + <View 42 + style={[ 43 + t.atoms.bg, 44 + a.pl_lg, 45 + a.pr_md, 46 + a.py_sm, 47 + a.w_full, 48 + a.align_center, 49 + a.flex_row, 50 + a.justify_between, 51 + a.border_b, 52 + t.atoms.border_contrast_low, 53 + ]}> 54 + <Text style={[a.font_bold, a.text_md]}> 55 + <Trans>Add Reaction</Trans> 56 + </Text> 57 + <Button 58 + label={_('Close')} 59 + onPress={() => setModalVisible(false)} 60 + size="small" 61 + variant="ghost" 62 + color="secondary" 63 + shape="round"> 64 + <ButtonIcon icon={TimesLarge_Stroke2_Corner0_Rounded} /> 65 + </Button> 66 + </View> 67 + <EmojiPopupView 68 + onEmojiSelected={({ 69 + nativeEvent: {emoji}, 70 + }: { 71 + nativeEvent: {emoji: string} 72 + }) => { 73 + setModalVisible(false) 74 + onEmojiSelected(emoji) 75 + }} 76 + style={[a.flex_1, a.w_full]} 77 + /> 78 + </View> 79 + </Modal> 80 + </> 81 + ) 82 + }
+1
src/components/dms/EmojiPopup.tsx
···
··· 1 + export {EmojiPopup} from 'react-native-emoji-popup'
+118
src/components/dms/EmojiReactionPicker.tsx
···
··· 1 + import {useMemo, useState} from 'react' 2 + import {Alert, useWindowDimensions, View} from 'react-native' 3 + import {type ChatBskyConvoDefs} from '@atproto/api' 4 + import {msg} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 6 + 7 + import {useSession} from '#/state/session' 8 + import {atoms as a, tokens, useTheme} from '#/alf' 9 + import * as ContextMenu from '#/components/ContextMenu' 10 + import { 11 + useContextMenuContext, 12 + useContextMenuMenuContext, 13 + } from '#/components/ContextMenu/context' 14 + import { 15 + EmojiHeartEyes_Stroke2_Corner0_Rounded as EmojiHeartEyesIcon, 16 + EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmileIcon, 17 + } from '#/components/icons/Emoji' 18 + import {type TriggerProps} from '#/components/Menu/types' 19 + import {Text} from '#/components/Typography' 20 + import {EmojiPopup} from './EmojiPopup' 21 + 22 + export function EmojiReactionPicker({ 23 + message, 24 + }: { 25 + message: ChatBskyConvoDefs.MessageView 26 + children?: TriggerProps['children'] 27 + }) { 28 + const {_} = useLingui() 29 + const {currentAccount} = useSession() 30 + const t = useTheme() 31 + const isFromSelf = message.sender?.did === currentAccount?.did 32 + const {measurement, close} = useContextMenuContext() 33 + const {align} = useContextMenuMenuContext() 34 + const [layout, setLayout] = useState({width: 0, height: 0}) 35 + const {width: screenWidth} = useWindowDimensions() 36 + 37 + // 1 in 100 chance of showing heart eyes icon 38 + const EmojiIcon = useMemo(() => { 39 + return Math.random() < 0.01 ? EmojiHeartEyesIcon : EmojiSmileIcon 40 + }, []) 41 + 42 + const handleEmojiSelect = (emoji: string) => { 43 + Alert.alert(emoji) 44 + } 45 + 46 + const position = useMemo(() => { 47 + return { 48 + x: align === 'left' ? 12 : screenWidth - layout.width - 12, 49 + y: (measurement?.y ?? 0) - tokens.space.xs - layout.height, 50 + height: layout.height, 51 + width: layout.width, 52 + } 53 + }, [measurement, align, screenWidth, layout]) 54 + 55 + return ( 56 + <View 57 + onLayout={evt => setLayout(evt.nativeEvent.layout)} 58 + style={[ 59 + a.rounded_full, 60 + a.absolute, 61 + {bottom: '100%'}, 62 + isFromSelf ? a.right_0 : a.left_0, 63 + t.scheme === 'light' ? t.atoms.bg : t.atoms.bg_contrast_25, 64 + a.flex_row, 65 + a.p_xs, 66 + a.gap_xs, 67 + a.mb_xs, 68 + a.z_20, 69 + a.border, 70 + t.atoms.border_contrast_low, 71 + a.shadow_md, 72 + ]}> 73 + {['👍', '😆', '❤️', '👀', '😢'].map(emoji => ( 74 + <ContextMenu.Item 75 + position={position} 76 + label={_(msg`React with ${emoji}`)} 77 + key={emoji} 78 + onPress={() => handleEmojiSelect(emoji)} 79 + unstyled> 80 + {hovered => ( 81 + <View 82 + style={[ 83 + a.rounded_full, 84 + hovered && {backgroundColor: t.palette.primary_500}, 85 + {height: 40, width: 40}, 86 + a.justify_center, 87 + a.align_center, 88 + ]}> 89 + <Text style={[a.text_center, {fontSize: 30}]} emoji> 90 + {emoji} 91 + </Text> 92 + </View> 93 + )} 94 + </ContextMenu.Item> 95 + ))} 96 + <EmojiPopup 97 + onEmojiSelected={emoji => { 98 + close() 99 + handleEmojiSelect(emoji) 100 + }}> 101 + <View 102 + style={[ 103 + a.rounded_full, 104 + t.scheme === 'light' 105 + ? t.atoms.bg_contrast_25 106 + : t.atoms.bg_contrast_50, 107 + {height: 40, width: 40}, 108 + a.justify_center, 109 + a.align_center, 110 + a.border, 111 + t.atoms.border_contrast_low, 112 + ]}> 113 + <EmojiIcon size="xl" fill={t.palette.contrast_400} /> 114 + </View> 115 + </EmojiPopup> 116 + </View> 117 + ) 118 + }
+86
src/components/dms/EmojiReactionPicker.web.tsx
···
··· 1 + import {useState} from 'react' 2 + import {View} from 'react-native' 3 + import {ChatBskyConvoDefs} from '@atproto/api' 4 + import EmojiPicker from '@emoji-mart/react' 5 + import {msg} from '@lingui/macro' 6 + import {useLingui} from '@lingui/react' 7 + 8 + import {Emoji} from '#/view/com/composer/text-input/web/EmojiPicker.web' 9 + import {PressableWithHover} from '#/view/com/util/PressableWithHover' 10 + import {atoms as a} from '#/alf' 11 + import {useTheme} from '#/alf' 12 + import {DotGrid_Stroke2_Corner0_Rounded as DotGridIcon} from '#/components/icons/DotGrid' 13 + import * as Menu from '#/components/Menu' 14 + import {TriggerProps} from '#/components/Menu/types' 15 + import {Text} from '#/components/Typography' 16 + 17 + export function EmojiReactionPicker({ 18 + children, 19 + }: { 20 + message: ChatBskyConvoDefs.MessageView 21 + children?: TriggerProps['children'] 22 + }) { 23 + if (!children) 24 + throw new Error('EmojiReactionPicker requires the children prop on web') 25 + 26 + const {_} = useLingui() 27 + 28 + return ( 29 + <Menu.Root> 30 + <Menu.Trigger label={_(msg`Add emoji reaction`)}>{children}</Menu.Trigger> 31 + <Menu.Outer> 32 + <MenuInner /> 33 + </Menu.Outer> 34 + </Menu.Root> 35 + ) 36 + } 37 + 38 + function MenuInner() { 39 + const t = useTheme() 40 + const {control} = Menu.useMenuContext() 41 + 42 + const [expanded, setExpanded] = useState(false) 43 + 44 + const handleEmojiPickerResponse = (emoji: Emoji) => { 45 + handleEmojiSelect(emoji.native) 46 + } 47 + 48 + const handleEmojiSelect = (emoji: string) => { 49 + control.close() 50 + window.alert(emoji) 51 + } 52 + 53 + return expanded ? ( 54 + <EmojiPicker onEmojiSelect={handleEmojiPickerResponse} autoFocus={true} /> 55 + ) : ( 56 + <View style={[a.flex_row, a.gap_xs]}> 57 + {['👍', '😆', '❤️', '👀', '😢'].map(emoji => ( 58 + <PressableWithHover 59 + key={emoji} 60 + onPress={() => handleEmojiSelect(emoji)} 61 + hoverStyle={{backgroundColor: t.palette.primary_100}} 62 + style={[ 63 + a.rounded_xs, 64 + {height: 40, width: 40}, 65 + a.justify_center, 66 + a.align_center, 67 + ]}> 68 + <Text style={[a.text_center, {fontSize: 30}]} emoji> 69 + {emoji} 70 + </Text> 71 + </PressableWithHover> 72 + ))} 73 + <PressableWithHover 74 + onPress={() => setExpanded(true)} 75 + hoverStyle={{backgroundColor: t.palette.primary_100}} 76 + style={[ 77 + a.rounded_xs, 78 + {height: 40, width: 40}, 79 + a.justify_center, 80 + a.align_center, 81 + ]}> 82 + <DotGridIcon size="lg" style={t.atoms.text_contrast_medium} /> 83 + </PressableWithHover> 84 + </View> 85 + ) 86 + }
+10 -2
src/components/dms/MessageContextMenu.tsx
··· 1 import React from 'react' 2 import {LayoutAnimation} from 'react-native' 3 import * as Clipboard from 'expo-clipboard' 4 - import {ChatBskyConvoDefs, RichText} from '@atproto/api' 5 import {msg} from '@lingui/macro' 6 import {useLingui} from '@lingui/react' 7 8 import {useOpenLink} from '#/lib/hooks/useOpenLink' 9 import {richTextToString} from '#/lib/strings/rich-text-helpers' 10 import {getTranslatorLink} from '#/locale/helpers' 11 import {useConvoActive} from '#/state/messages/convo' 12 import {useLanguagePrefs} from '#/state/preferences' 13 import {useSession} from '#/state/session' 14 import * as Toast from '#/view/com/util/Toast' 15 import * as ContextMenu from '#/components/ContextMenu' 16 - import {TriggerProps} from '#/components/ContextMenu/types' 17 import {ReportDialog} from '#/components/dms/ReportDialog' 18 import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble' 19 import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' ··· 21 import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning' 22 import * as Prompt from '#/components/Prompt' 23 import {usePromptControl} from '#/components/Prompt' 24 25 export let MessageContextMenu = ({ 26 message, ··· 77 return ( 78 <> 79 <ContextMenu.Root> 80 <ContextMenu.Trigger 81 label={_(msg`Message options`)} 82 contentLabel={_(
··· 1 import React from 'react' 2 import {LayoutAnimation} from 'react-native' 3 import * as Clipboard from 'expo-clipboard' 4 + import {type ChatBskyConvoDefs, RichText} from '@atproto/api' 5 import {msg} from '@lingui/macro' 6 import {useLingui} from '@lingui/react' 7 8 import {useOpenLink} from '#/lib/hooks/useOpenLink' 9 import {richTextToString} from '#/lib/strings/rich-text-helpers' 10 import {getTranslatorLink} from '#/locale/helpers' 11 + import {isNative} from '#/platform/detection' 12 import {useConvoActive} from '#/state/messages/convo' 13 import {useLanguagePrefs} from '#/state/preferences' 14 import {useSession} from '#/state/session' 15 import * as Toast from '#/view/com/util/Toast' 16 import * as ContextMenu from '#/components/ContextMenu' 17 + import {type TriggerProps} from '#/components/ContextMenu/types' 18 import {ReportDialog} from '#/components/dms/ReportDialog' 19 import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble' 20 import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' ··· 22 import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning' 23 import * as Prompt from '#/components/Prompt' 24 import {usePromptControl} from '#/components/Prompt' 25 + import {EmojiReactionPicker} from './EmojiReactionPicker' 26 27 export let MessageContextMenu = ({ 28 message, ··· 79 return ( 80 <> 81 <ContextMenu.Root> 82 + {isNative && ( 83 + <ContextMenu.AuxiliaryView align={isFromSelf ? 'right' : 'left'}> 84 + <EmojiReactionPicker message={message} /> 85 + </ContextMenu.AuxiliaryView> 86 + )} 87 + 88 <ContextMenu.Trigger 89 label={_(msg`Message options`)} 90 contentLabel={_(
+5
yarn.lock
··· 16733 dependencies: 16734 use-latest-callback "^0.2.1" 16735 16736 react-native-gesture-handler@2.20.2: 16737 version "2.20.2" 16738 resolved "https://registry.yarnpkg.com/react-native-gesture-handler/-/react-native-gesture-handler-2.20.2.tgz#73844c8e9c417459c2f2981bc4d8f66ba8a5ee66"
··· 16733 dependencies: 16734 use-latest-callback "^0.2.1" 16735 16736 + react-native-emoji-popup@^0.1.2: 16737 + version "0.1.2" 16738 + resolved "https://registry.yarnpkg.com/react-native-emoji-popup/-/react-native-emoji-popup-0.1.2.tgz#7cd3874ba0496031e6f3e24de77e0df895168ce6" 16739 + integrity sha512-YxuAwubxe6VLNfTyMlpw9g2WQVUIuJb4flWVZjfR7r6fmVvXw4Sxo6ZD6m/fG9AQP3pHkZptzNUr4gdF23m3ZQ== 16740 + 16741 react-native-gesture-handler@2.20.2: 16742 version "2.20.2" 16743 resolved "https://registry.yarnpkg.com/react-native-gesture-handler/-/react-native-gesture-handler-2.20.2.tgz#73844c8e9c417459c2f2981bc4d8f66ba8a5ee66"