Bluesky app fork with some witchin' additions 💫

ContextMenu - return item to the right location if keyboard hides (#9963)

authored by samuel.fm and committed by

GitHub 7c9f05a2 d4357b2c

+93 -32
+7 -7
src/components/ContextMenu/context.tsx
··· 1 - import React from 'react' 2 3 import { 4 type ContextType, ··· 6 type MenuContextType, 7 } from '#/components/ContextMenu/types' 8 9 - export const Context = React.createContext<ContextType | null>(null) 10 Context.displayName = 'ContextMenuContext' 11 12 - export const MenuContext = React.createContext<MenuContextType | null>(null) 13 MenuContext.displayName = 'ContextMenuMenuContext' 14 15 - export const ItemContext = React.createContext<ItemContextType | null>(null) 16 ItemContext.displayName = 'ContextMenuItemContext' 17 18 export function useContextMenuContext() { 19 - const context = React.useContext(Context) 20 21 if (!context) { 22 throw new Error( ··· 28 } 29 30 export function useContextMenuMenuContext() { 31 - const context = React.useContext(MenuContext) 32 33 if (!context) { 34 throw new Error( ··· 40 } 41 42 export function useContextMenuItemContext() { 43 - const context = React.useContext(ItemContext) 44 45 if (!context) { 46 throw new Error(
··· 1 + import {createContext, useContext} from 'react' 2 3 import { 4 type ContextType, ··· 6 type MenuContextType, 7 } from '#/components/ContextMenu/types' 8 9 + export const Context = createContext<ContextType | null>(null) 10 Context.displayName = 'ContextMenuContext' 11 12 + export const MenuContext = createContext<MenuContextType | null>(null) 13 MenuContext.displayName = 'ContextMenuMenuContext' 14 15 + export const ItemContext = createContext<ItemContextType | null>(null) 16 ItemContext.displayName = 'ContextMenuItemContext' 17 18 export function useContextMenuContext() { 19 + const context = useContext(Context) 20 21 if (!context) { 22 throw new Error( ··· 28 } 29 30 export function useContextMenuMenuContext() { 31 + const context = useContext(MenuContext) 32 33 if (!context) { 34 throw new Error( ··· 40 } 41 42 export function useContextMenuItemContext() { 43 + const context = useContext(ItemContext) 44 45 if (!context) { 46 throw new Error(
+85 -25
src/components/ContextMenu/index.tsx
··· 23 type GestureUpdateEvent, 24 type PanGestureHandlerEventPayload, 25 } from 'react-native-gesture-handler' 26 import Animated, { 27 clamp, 28 interpolate, ··· 35 type WithSpringConfig, 36 } from 'react-native-reanimated' 37 import { 38 useSafeAreaFrame, 39 useSafeAreaInsets, 40 } from 'react-native-safe-area-context' ··· 81 const {Provider: PortalProvider, Outlet, Portal} = createPortalGroup() 82 83 const SPRING_IN: WithSpringConfig = { 84 - mass: IS_IOS ? 1.25 : 0.75, 85 - damping: 50, 86 - stiffness: 1100, 87 restDisplacementThreshold: 0.01, 88 } 89 ··· 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) 115 const isFocused = useIsFocused() ··· 142 ({ 143 isOpen: !!measurement && isFocused, 144 measurement, 145 animationSV, 146 translationSV, 147 mode, ··· 149 setMeasurement(evt) 150 setMode(mode) 151 animationSV.set(withSpring(1, SPRING_IN)) 152 }, 153 close: () => { 154 animationSV.set( ··· 156 if (finished) { 157 hoverablesSV.set({}) 158 translationSV.set(0) 159 runOnJS(onCompletedClose)() 160 } 161 }), ··· 194 }) satisfies ContextType, 195 [ 196 measurement, 197 setMeasurement, 198 onCompletedClose, 199 isFocused, ··· 225 export function Trigger({children, label, contentLabel, style}: TriggerProps) { 226 const context = useContextMenuContext() 227 const playHaptic = useHaptics() 228 - const {top: topInset} = useSafeAreaInsets() 229 const ref = useRef<View>(null) 230 const isFocused = useIsFocused() 231 const [image, setImage] = useState<string | null>(null) ··· 237 const open = useNonReactiveCallback( 238 async (mode: 'full' | 'auxiliary-only') => { 239 playHaptic() 240 - Keyboard.dismiss() 241 const [measurement, capture] = await Promise.all([ 242 - new Promise<Measurement>(resolve => { 243 - ref.current?.measureInWindow((x, y, width, height) => 244 - resolve({ 245 - x, 246 - y: 247 - y + 248 - platform({ 249 - default: 0, 250 - android: topInset, // not included in measurement 251 - }), 252 - width, 253 - height, 254 - }), 255 - ) 256 - }), 257 captureRef(ref, {result: 'data-uri'}).catch(err => { 258 logger.error(err instanceof Error ? err : String(err), { 259 message: 'Failed to capture image of context menu trigger', ··· 262 return '<failed capture>' 263 }), 264 ]) 265 setImage(capture) 266 - setPendingMeasurement({measurement, mode}) 267 }, 268 ) 269 270 const doubleTapGesture = useMemo(() => { 271 return Gesture.Tap() 272 .numberOfTaps(2) 273 .hitSlop(HITSLOP_10) 274 - .onEnd(() => open('auxiliary-only')) 275 .runOnJS(true) 276 }, [open]) 277 ··· 360 animation={animationSV} 361 image={image} 362 measurement={measurement} 363 onDisplay={() => { 364 if (pendingMeasurement) { 365 context.open( ··· 384 animation, 385 image, 386 measurement, 387 onDisplay, 388 label, 389 }: { ··· 391 animation: SharedValue<number> 392 image: string 393 measurement: Measurement 394 onDisplay: () => void 395 label: string 396 }) { 397 const {_} = useLingui() 398 399 - const animatedStyles = useAnimatedStyle(() => ({ 400 - transform: [{translateY: translation.get() * animation.get()}], 401 - })) 402 403 const handleError = useCallback( 404 (evt: ImageErrorEventData) => { ··· 872 style={[t.atoms.border_contrast_low, a.flex_1, {borderTopWidth: 3}]} 873 /> 874 ) 875 } 876 877 function getHoveredHoverable(
··· 23 type GestureUpdateEvent, 24 type PanGestureHandlerEventPayload, 25 } from 'react-native-gesture-handler' 26 + import {KeyboardEvents} from 'react-native-keyboard-controller' 27 import Animated, { 28 clamp, 29 interpolate, ··· 36 type WithSpringConfig, 37 } from 'react-native-reanimated' 38 import { 39 + type EdgeInsets, 40 useSafeAreaFrame, 41 useSafeAreaInsets, 42 } from 'react-native-safe-area-context' ··· 83 const {Provider: PortalProvider, Outlet, Portal} = createPortalGroup() 84 85 const SPRING_IN: WithSpringConfig = { 86 + mass: 0.75, 87 + damping: 300, 88 + stiffness: 1200, 89 restDisplacementThreshold: 0.01, 90 } 91 ··· 112 const playHaptic = useHaptics() 113 const [mode, setMode] = useState<'full' | 'auxiliary-only'>('full') 114 const [measurement, setMeasurement] = useState<Measurement | null>(null) 115 + const returnLocationSV = useSharedValue<{x: number; y: number} | null>(null) 116 const animationSV = useSharedValue(0) 117 const translationSV = useSharedValue(0) 118 const isFocused = useIsFocused() ··· 145 ({ 146 isOpen: !!measurement && isFocused, 147 measurement, 148 + returnLocationSV, 149 animationSV, 150 translationSV, 151 mode, ··· 153 setMeasurement(evt) 154 setMode(mode) 155 animationSV.set(withSpring(1, SPRING_IN)) 156 + // reset return location 157 + returnLocationSV.set(null) 158 }, 159 close: () => { 160 animationSV.set( ··· 162 if (finished) { 163 hoverablesSV.set({}) 164 translationSV.set(0) 165 + // note: return location has to be reset on open, 166 + // rather than on close, otherwise there's a flicker 167 + // where the reanimated update is faster than the react render 168 runOnJS(onCompletedClose)() 169 } 170 }), ··· 203 }) satisfies ContextType, 204 [ 205 measurement, 206 + returnLocationSV, 207 setMeasurement, 208 onCompletedClose, 209 isFocused, ··· 235 export function Trigger({children, label, contentLabel, style}: TriggerProps) { 236 const context = useContextMenuContext() 237 const playHaptic = useHaptics() 238 + const insets = useSafeAreaInsets() 239 const ref = useRef<View>(null) 240 const isFocused = useIsFocused() 241 const [image, setImage] = useState<string | null>(null) ··· 247 const open = useNonReactiveCallback( 248 async (mode: 'full' | 'auxiliary-only') => { 249 playHaptic() 250 const [measurement, capture] = await Promise.all([ 251 + measureView(ref.current, insets), 252 captureRef(ref, {result: 'data-uri'}).catch(err => { 253 logger.error(err instanceof Error ? err : String(err), { 254 message: 'Failed to capture image of context menu trigger', ··· 257 return '<failed capture>' 258 }), 259 ]) 260 + Keyboard.dismiss() 261 setImage(capture) 262 + if (measurement) { 263 + setPendingMeasurement({measurement, mode}) 264 + } 265 }, 266 ) 267 268 + // after keyboard hides, the position might change - set a return location 269 + useEffect(() => { 270 + if (context.isOpen && context.measurement) { 271 + const hide = KeyboardEvents.addListener('keyboardDidHide', () => { 272 + measureView(ref.current, insets) 273 + .then(newMeasurement => { 274 + if (!newMeasurement || !context.measurement) return 275 + if ( 276 + newMeasurement.x !== context.measurement.x || 277 + newMeasurement.y !== context.measurement.y 278 + ) { 279 + context.returnLocationSV.set({ 280 + x: newMeasurement.x, 281 + y: newMeasurement.y, 282 + }) 283 + } 284 + }) 285 + .catch(() => {}) 286 + }) 287 + 288 + return () => { 289 + hide.remove() 290 + } 291 + } 292 + }, [context, insets]) 293 + 294 const doubleTapGesture = useMemo(() => { 295 return Gesture.Tap() 296 .numberOfTaps(2) 297 .hitSlop(HITSLOP_10) 298 + .onEnd(() => void open('auxiliary-only')) 299 .runOnJS(true) 300 }, [open]) 301 ··· 384 animation={animationSV} 385 image={image} 386 measurement={measurement} 387 + returnLocation={context.returnLocationSV} 388 onDisplay={() => { 389 if (pendingMeasurement) { 390 context.open( ··· 409 animation, 410 image, 411 measurement, 412 + returnLocation, 413 onDisplay, 414 label, 415 }: { ··· 417 animation: SharedValue<number> 418 image: string 419 measurement: Measurement 420 + returnLocation: SharedValue<{x: number; y: number} | null> 421 onDisplay: () => void 422 label: string 423 }) { 424 const {_} = useLingui() 425 426 + const animatedStyles = useAnimatedStyle(() => { 427 + const anim = animation.get() 428 + const ret = returnLocation.get() 429 + const returnOffsetX = ret 430 + ? interpolate(anim, [0, 1], [ret.x - measurement.x, 0]) 431 + : 0 432 + const returnOffsetY = ret 433 + ? interpolate(anim, [0, 1], [ret.y - measurement.y, 0]) 434 + : 0 435 + 436 + return { 437 + transform: [ 438 + {translateX: returnOffsetX}, 439 + {translateY: translation.get() * anim + returnOffsetY}, 440 + ], 441 + } 442 + }) 443 444 const handleError = useCallback( 445 (evt: ImageErrorEventData) => { ··· 913 style={[t.atoms.border_contrast_low, a.flex_1, {borderTopWidth: 3}]} 914 /> 915 ) 916 + } 917 + 918 + function measureView(view: View | null, insets: EdgeInsets) { 919 + if (!view) return Promise.resolve(null) 920 + return new Promise<Measurement>(resolve => { 921 + view?.measureInWindow((x, y, width, height) => 922 + resolve({ 923 + x, 924 + y: 925 + y + 926 + platform({ 927 + default: 0, 928 + android: insets.top, // not included in measurement 929 + }), 930 + width, 931 + height, 932 + }), 933 + ) 934 + }) 935 } 936 937 function getHoveredHoverable(
+1
src/components/ContextMenu/types.ts
··· 49 translationSV: SharedValue<number> 50 mode: 'full' | 'auxiliary-only' 51 open: (evt: Measurement, mode: 'full' | 'auxiliary-only') => void 52 close: () => void 53 registerHoverable: ( 54 id: string,
··· 49 translationSV: SharedValue<number> 50 mode: 'full' | 'auxiliary-only' 51 open: (evt: Measurement, mode: 'full' | 'auxiliary-only') => void 52 + returnLocationSV: SharedValue<{x: number; y: number} | null> 53 close: () => void 54 registerHoverable: ( 55 id: string,