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 178 "react-native-compressor": "1.10.3", 179 179 "react-native-date-picker": "^5.0.7", 180 180 "react-native-drawer-layout": "^4.1.1", 181 + "react-native-emoji-popup": "^0.1.2", 181 182 "react-native-gesture-handler": "2.20.2", 182 183 "react-native-get-random-values": "~1.11.0", 183 184 "react-native-image-crop-picker": "^0.41.6",
+45 -9
src/components/ContextMenu/Backdrop.ios.tsx
··· 2 2 import Animated, { 3 3 Extrapolation, 4 4 interpolate, 5 - SharedValue, 5 + type SharedValue, 6 6 useAnimatedProps, 7 + useAnimatedStyle, 7 8 } from 'react-native-reanimated' 8 9 import {BlurView} from 'expo-blur' 9 10 import {msg} from '@lingui/macro' 10 11 import {useLingui} from '@lingui/react' 11 12 12 - import {atoms as a} from '#/alf' 13 + import {atoms as a, useTheme} from '#/alf' 14 + import {useContextMenuContext} from './context' 13 15 14 16 const AnimatedBlurView = Animated.createAnimatedComponent(BlurView) 15 17 16 - export function Backdrop({ 17 - animation, 18 - intensity = 50, 19 - onPress, 20 - }: { 18 + type Props = { 21 19 animation: SharedValue<number> 22 20 intensity?: number 23 21 onPress?: () => void 24 - }) { 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) { 25 35 const {_} = useLingui() 26 36 27 37 const animatedProps = useAnimatedProps(() => ({ ··· 37 47 <AnimatedBlurView 38 48 animatedProps={animatedProps} 39 49 style={[a.absolute, a.inset_0]} 40 - tint="systemThinMaterialDark"> 50 + tint="systemMaterialDark"> 41 51 <Pressable 42 52 style={a.flex_1} 43 53 accessibilityLabel={_(msg`Close menu`)} ··· 47 57 </AnimatedBlurView> 48 58 ) 49 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 9 import {useLingui} from '@lingui/react' 10 10 11 11 import {atoms as a, useTheme} from '#/alf' 12 + import {useContextMenuContext} from './context' 12 13 13 14 export function Backdrop({ 14 15 animation, ··· 21 22 }) { 22 23 const t = useTheme() 23 24 const {_} = useLingui() 25 + const {mode} = useContextMenuContext() 26 + 27 + const reduced = mode === 'auxiliary-only' 28 + 29 + const target = reduced ? 0.05 : intensity / 100 24 30 25 31 const animatedStyle = useAnimatedStyle(() => ({ 26 32 opacity: interpolate( 27 33 animation.get(), 28 34 [0, 1], 29 - [0, intensity / 100], 35 + [0, target], 30 36 Extrapolation.CLAMP, 31 37 ), 32 38 }))
+268 -144
src/components/ContextMenu/index.tsx
··· 9 9 import { 10 10 BackHandler, 11 11 Keyboard, 12 - LayoutChangeEvent, 12 + type LayoutChangeEvent, 13 13 Pressable, 14 - StyleProp, 14 + type StyleProp, 15 15 useWindowDimensions, 16 16 View, 17 - ViewStyle, 17 + type ViewStyle, 18 18 } from 'react-native' 19 19 import { 20 20 Gesture, 21 21 GestureDetector, 22 - GestureStateChangeEvent, 23 - GestureUpdateEvent, 24 - PanGestureHandlerEventPayload, 22 + type GestureStateChangeEvent, 23 + type GestureUpdateEvent, 24 + type PanGestureHandlerEventPayload, 25 25 } from 'react-native-gesture-handler' 26 26 import Animated, { 27 27 clamp, 28 28 interpolate, 29 29 runOnJS, 30 - SharedValue, 30 + type SharedValue, 31 31 useAnimatedReaction, 32 32 useAnimatedStyle, 33 33 useSharedValue, 34 34 withSpring, 35 - WithSpringConfig, 35 + type WithSpringConfig, 36 36 } from 'react-native-reanimated' 37 37 import { 38 38 useSafeAreaFrame, 39 39 useSafeAreaInsets, 40 40 } from 'react-native-safe-area-context' 41 41 import {captureRef} from 'react-native-view-shot' 42 - import {Image, ImageErrorEventData} from 'expo-image' 42 + import {Image, type ImageErrorEventData} from 'expo-image' 43 43 import {msg} from '@lingui/macro' 44 44 import {useLingui} from '@lingui/react' 45 45 import {useIsFocused} from '@react-navigation/native' ··· 60 60 useContextMenuMenuContext, 61 61 } from '#/components/ContextMenu/context' 62 62 import { 63 - ContextType, 64 - ItemIconProps, 65 - ItemProps, 66 - ItemTextProps, 67 - Measurement, 68 - TriggerProps, 63 + type AuxiliaryViewProps, 64 + type ContextType, 65 + type ItemIconProps, 66 + type ItemProps, 67 + type ItemTextProps, 68 + type Measurement, 69 + type TriggerProps, 69 70 } from '#/components/ContextMenu/types' 70 71 import {useInteractionState} from '#/components/hooks/useInteractionState' 71 72 import {createPortalGroup} from '#/components/Portal' ··· 79 80 80 81 const {Provider: PortalProvider, Outlet, Portal} = createPortalGroup() 81 82 82 - const SPRING: WithSpringConfig = { 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 = { 83 91 mass: isIOS ? 1.25 : 0.75, 84 92 damping: 150, 85 93 stiffness: 1000, ··· 100 108 101 109 export function Root({children}: {children: React.ReactNode}) { 102 110 const playHaptic = useHaptics() 111 + const [mode, setMode] = useState<'full' | 'auxiliary-only'>('full') 103 112 const [measurement, setMeasurement] = useState<Measurement | null>(null) 104 113 const animationSV = useSharedValue(0) 105 114 const translationSV = useSharedValue(0) ··· 134 143 measurement, 135 144 animationSV, 136 145 translationSV, 137 - open: (evt: Measurement) => { 146 + mode, 147 + open: (evt: Measurement, mode: 'full' | 'auxiliary-only') => { 138 148 setMeasurement(evt) 139 - animationSV.set(withSpring(1, SPRING)) 149 + setMode(mode) 150 + animationSV.set(withSpring(1, SPRING_IN)) 140 151 }, 141 152 close: () => { 142 153 animationSV.set( 143 - withSpring(0, SPRING, finished => { 154 + withSpring(0, SPRING_OUT, finished => { 144 155 if (finished) { 145 156 hoverablesSV.set({}) 146 157 translationSV.set(0) ··· 192 203 hoveredMenuItem, 193 204 setHoveredMenuItem, 194 205 playHaptic, 206 + mode, 195 207 ], 196 208 ) 197 209 ··· 216 228 const ref = useRef<View>(null) 217 229 const isFocused = useIsFocused() 218 230 const [image, setImage] = useState<string | null>(null) 219 - const [pendingMeasurement, setPendingMeasurement] = 220 - useState<Measurement | null>(null) 231 + const [pendingMeasurement, setPendingMeasurement] = useState<{ 232 + measurement: Measurement 233 + mode: 'full' | 'auxiliary-only' 234 + } | null>(null) 221 235 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 - }) 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 + ) 252 268 253 269 const doubleTapGesture = useMemo(() => { 254 270 return Gesture.Tap() 255 271 .numberOfTaps(2) 256 272 .hitSlop(HITSLOP_10) 257 - .onEnd(open) 273 + .onEnd(() => open('auxiliary-only')) 258 274 .runOnJS(true) 259 275 }, [open]) 260 276 ··· 283 299 .averageTouches(true) 284 300 .onStart(() => { 285 301 'worklet' 286 - runOnJS(open)() 302 + runOnJS(open)('full') 287 303 }) 288 304 .onUpdate(evt => { 289 305 'worklet' 290 306 const item = getHoveredHoverable(evt, hoverablesSV, translationSV) 291 307 hoveredItemSV.set(item) 292 308 }) 293 - .onEnd(evt => { 309 + .onEnd(() => { 294 310 'worklet' 295 - const item = getHoveredHoverable(evt, hoverablesSV, translationSV) 296 - hoveredItemSV.set(null) 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() 297 315 if (item) { 298 316 runOnJS(onTouchUpMenuItem)(item) 299 317 } ··· 305 323 pressAndHoldGesture, 306 324 ) 307 325 308 - const measurement = context.measurement || pendingMeasurement 326 + const measurement = context.measurement || pendingMeasurement?.measurement 309 327 310 328 return ( 311 329 <> ··· 343 361 measurement={measurement} 344 362 onDisplay={() => { 345 363 if (pendingMeasurement) { 346 - context.open(pendingMeasurement) 364 + context.open( 365 + pendingMeasurement.measurement, 366 + pendingMeasurement.mode, 367 + ) 347 368 setPendingMeasurement(null) 348 369 } 349 370 }} ··· 416 437 ) 417 438 } 418 439 419 - const MENU_WIDTH = 230 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 420 524 421 525 export function Outer({ 422 526 children, ··· 491 595 <Context.Provider value={context}> 492 596 <MenuContext.Provider value={menuContext}> 493 597 <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 */} 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 */ 517 601 <Animated.View 602 + onLayout={onLayout} 518 603 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 604 + a.absolute, 605 + a.z_10, 606 + a.mt_xs, 526 607 { 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], 608 + width: MENU_WIDTH, 609 + top: context.measurement.y + context.measurement.height, 530 610 }, 531 - animatedStyle, 532 - style, 611 + align === 'left' 612 + ? {left: context.measurement.x} 613 + : { 614 + right: 615 + screenWidth - 616 + context.measurement.x - 617 + context.measurement.width, 618 + }, 619 + animatedContainerStyle, 533 620 ]}> 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 621 + {/* scaling element - has the scale/fade animation on it */} 622 + <Animated.View 537 623 style={[ 538 - a.flex_1, 539 624 a.rounded_md, 540 - a.overflow_hidden, 541 - a.border, 542 - t.atoms.border_contrast_low, 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, 543 638 ]}> 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> 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> 564 670 </Animated.View> 565 - </Animated.View> 671 + )} 566 672 </MenuContext.Provider> 567 673 </Context.Provider> 568 674 </Portal> 569 675 ) 570 676 } 571 677 572 - export function Item({children, label, style, onPress, ...rest}: ItemProps) { 678 + export function Item({ 679 + children, 680 + label, 681 + unstyled, 682 + style, 683 + onPress, 684 + position, 685 + ...rest 686 + }: ItemProps) { 573 687 const t = useTheme() 574 688 const context = useContextMenuContext() 575 689 const playHaptic = useHaptics() ··· 590 704 591 705 const layout = evt.nativeEvent.layout 592 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 + 593 716 registerHoverable( 594 717 id, 595 718 { 596 719 width: layout.width, 597 720 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, 721 + y: yOffset + layout.y, 722 + x: xOffset + layout.x, 603 723 }, 604 724 () => { 605 725 close() ··· 607 727 }, 608 728 ) 609 729 }, 610 - [id, measurement, registerHoverable, close, onPress, align], 730 + [id, measurement, registerHoverable, close, onPress, align, position], 611 731 ) 612 732 613 733 const itemContext = useMemo( ··· 637 757 rest.onPressOut?.(e) 638 758 }} 639 759 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}, 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 + ], 650 774 style, 651 - (focused || pressed || context.hoveredMenuItem === id) && 652 - !rest.disabled && [t.atoms.bg_contrast_50], 653 775 ]}> 654 776 <ItemContext.Provider value={itemContext}> 655 - {children} 777 + {typeof children === 'function' 778 + ? children(focused || pressed || context.hoveredMenuItem === id) 779 + : children} 656 780 </ItemContext.Provider> 657 781 </Pressable> 658 782 ) ··· 667 791 ellipsizeMode="middle" 668 792 style={[ 669 793 a.flex_1, 670 - a.text_sm, 794 + a.text_md, 671 795 a.font_bold, 672 796 t.atoms.text_contrast_high, 673 797 {paddingTop: 3}, ··· 684 808 const {disabled} = useContextMenuItemContext() 685 809 return ( 686 810 <Comp 687 - size="md" 811 + size="lg" 688 812 fill={ 689 813 disabled 690 814 ? t.atoms.text_contrast_low.color
+7
src/components/ContextMenu/index.web.tsx
··· 1 + import {type AuxiliaryViewProps} from './types' 2 + 1 3 export * from '#/components/Menu' 2 4 3 5 export function Provider({children}: {children: React.ReactNode}) { 4 6 return children 5 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 1 import { 3 - AccessibilityRole, 4 - GestureResponderEvent, 5 - StyleProp, 6 - ViewStyle, 2 + type AccessibilityRole, 3 + type GestureResponderEvent, 4 + type StyleProp, 5 + type ViewStyle, 7 6 } from 'react-native' 8 - import {SharedValue} from 'react-native-reanimated' 7 + import {type SharedValue} from 'react-native-reanimated' 8 + import type React from 'react' 9 9 10 - import * as Dialog from '#/components/Dialog' 10 + import type * as Dialog from '#/components/Dialog' 11 11 import { 12 - ItemProps as MenuItemProps, 13 - RadixPassThroughTriggerProps, 12 + type ItemProps as MenuItemProps, 13 + type RadixPassThroughTriggerProps, 14 14 } from '#/components/Menu/types' 15 15 16 16 export type { ··· 19 19 ItemTextProps, 20 20 } from '#/components/Menu/types' 21 21 22 - // Same as Menu.ItemProps, but onPress is not guaranteed to get an event 23 - export type ItemProps = Omit<MenuItemProps, 'onPress'> & { 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 24 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 25 35 } 26 36 27 37 export type Measurement = { ··· 38 48 animationSV: SharedValue<number> 39 49 /* Translation in Y axis to ensure everything's onscreen */ 40 50 translationSV: SharedValue<number> 41 - open: (evt: Measurement) => void 51 + mode: 'full' | 'auxiliary-only' 52 + open: (evt: Measurement, mode: 'full' | 'auxiliary-only') => void 42 53 close: () => void 43 54 registerHoverable: ( 44 55 id: string, ··· 76 87 export type TriggerChildProps = 77 88 | { 78 89 isNative: true 79 - control: {isOpen: boolean; open: () => void} 90 + control: { 91 + isOpen: boolean 92 + open: (mode: 'full' | 'auxiliary-only') => void 93 + } 80 94 state: { 81 95 hovered: false 82 96 focused: false
+2
src/components/Menu/index.tsx
··· 30 30 useDialogControl as useMenuControl, 31 31 } from '#/components/Dialog' 32 32 33 + export {useMenuContext} 34 + 33 35 export function Root({ 34 36 children, 35 37 control,
+2
src/components/Menu/index.web.tsx
··· 26 26 import {Portal} from '#/components/Portal' 27 27 import {Text} from '#/components/Typography' 28 28 29 + export {useMenuContext} 30 + 29 31 export function useMenuControl(): Dialog.DialogControlProps { 30 32 const id = React.useId() 31 33 const [isOpen, setIsOpen] = React.useState(false)
+1 -23
src/components/dms/ActionsWrapper.tsx
··· 23 23 // will always be true, since this file is platform split 24 24 trigger.isNative && ( 25 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 26 <View 49 27 style={[ 50 28 {maxWidth: '80%'}, ··· 56 34 accessibilityActions={[ 57 35 {name: 'activate', label: _(msg`Open message options`)}, 58 36 ]} 59 - onAccessibilityAction={trigger.control.open}> 37 + onAccessibilityAction={() => trigger.control.open('full')}> 60 38 {children} 61 39 </View> 62 40 </View>
+35 -5
src/components/dms/ActionsWrapper.web.tsx
··· 4 4 5 5 import {atoms as a, useTheme} from '#/alf' 6 6 import {MessageContextMenu} from '#/components/dms/MessageContextMenu' 7 - import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '../icons/DotGrid' 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' 8 10 9 11 export function ActionsWrapper({ 10 12 message, ··· 47 49 <View 48 50 style={[ 49 51 a.justify_center, 52 + a.flex_row, 53 + a.align_center, 54 + a.gap_xs, 50 55 isFromSelf 51 - ? [a.mr_xl, {marginLeft: 'auto'}] 52 - : [a.ml_xl, {marginRight: 'auto'}], 56 + ? [a.mr_md, {marginLeft: 'auto'}] 57 + : [a.ml_md, {marginRight: 'auto'}], 53 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> 54 81 <MessageContextMenu message={message}> 55 82 {({props, state, isNative, control}) => { 56 83 // always false, file is platform split ··· 61 88 {...props} 62 89 style={[ 63 90 {opacity: showMenuTrigger}, 64 - a.p_sm, 91 + a.p_xs, 65 92 a.rounded_full, 66 93 (state.hovered || state.pressed) && t.atoms.bg_contrast_25, 67 94 ]}> 68 - <DotsHorizontalIcon size="md" style={t.atoms.text} /> 95 + <DotsHorizontalIcon 96 + size="md" 97 + style={t.atoms.text_contrast_medium} 98 + /> 69 99 </Pressable> 70 100 ) 71 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 1 import React from 'react' 2 2 import {LayoutAnimation} from 'react-native' 3 3 import * as Clipboard from 'expo-clipboard' 4 - import {ChatBskyConvoDefs, RichText} from '@atproto/api' 4 + import {type ChatBskyConvoDefs, RichText} from '@atproto/api' 5 5 import {msg} from '@lingui/macro' 6 6 import {useLingui} from '@lingui/react' 7 7 8 8 import {useOpenLink} from '#/lib/hooks/useOpenLink' 9 9 import {richTextToString} from '#/lib/strings/rich-text-helpers' 10 10 import {getTranslatorLink} from '#/locale/helpers' 11 + import {isNative} from '#/platform/detection' 11 12 import {useConvoActive} from '#/state/messages/convo' 12 13 import {useLanguagePrefs} from '#/state/preferences' 13 14 import {useSession} from '#/state/session' 14 15 import * as Toast from '#/view/com/util/Toast' 15 16 import * as ContextMenu from '#/components/ContextMenu' 16 - import {TriggerProps} from '#/components/ContextMenu/types' 17 + import {type TriggerProps} from '#/components/ContextMenu/types' 17 18 import {ReportDialog} from '#/components/dms/ReportDialog' 18 19 import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble' 19 20 import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' ··· 21 22 import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning' 22 23 import * as Prompt from '#/components/Prompt' 23 24 import {usePromptControl} from '#/components/Prompt' 25 + import {EmojiReactionPicker} from './EmojiReactionPicker' 24 26 25 27 export let MessageContextMenu = ({ 26 28 message, ··· 77 79 return ( 78 80 <> 79 81 <ContextMenu.Root> 82 + {isNative && ( 83 + <ContextMenu.AuxiliaryView align={isFromSelf ? 'right' : 'left'}> 84 + <EmojiReactionPicker message={message} /> 85 + </ContextMenu.AuxiliaryView> 86 + )} 87 + 80 88 <ContextMenu.Trigger 81 89 label={_(msg`Message options`)} 82 90 contentLabel={_(
+5
yarn.lock
··· 16733 16733 dependencies: 16734 16734 use-latest-callback "^0.2.1" 16735 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 + 16736 16741 react-native-gesture-handler@2.20.2: 16737 16742 version "2.20.2" 16738 16743 resolved "https://registry.yarnpkg.com/react-native-gesture-handler/-/react-native-gesture-handler-2.20.2.tgz#73844c8e9c417459c2f2981bc4d8f66ba8a5ee66"