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' 1 + import {createContext, useContext} from 'react' 2 2 3 3 import { 4 4 type ContextType, ··· 6 6 type MenuContextType, 7 7 } from '#/components/ContextMenu/types' 8 8 9 - export const Context = React.createContext<ContextType | null>(null) 9 + export const Context = createContext<ContextType | null>(null) 10 10 Context.displayName = 'ContextMenuContext' 11 11 12 - export const MenuContext = React.createContext<MenuContextType | null>(null) 12 + export const MenuContext = createContext<MenuContextType | null>(null) 13 13 MenuContext.displayName = 'ContextMenuMenuContext' 14 14 15 - export const ItemContext = React.createContext<ItemContextType | null>(null) 15 + export const ItemContext = createContext<ItemContextType | null>(null) 16 16 ItemContext.displayName = 'ContextMenuItemContext' 17 17 18 18 export function useContextMenuContext() { 19 - const context = React.useContext(Context) 19 + const context = useContext(Context) 20 20 21 21 if (!context) { 22 22 throw new Error( ··· 28 28 } 29 29 30 30 export function useContextMenuMenuContext() { 31 - const context = React.useContext(MenuContext) 31 + const context = useContext(MenuContext) 32 32 33 33 if (!context) { 34 34 throw new Error( ··· 40 40 } 41 41 42 42 export function useContextMenuItemContext() { 43 - const context = React.useContext(ItemContext) 43 + const context = useContext(ItemContext) 44 44 45 45 if (!context) { 46 46 throw new Error(
+85 -25
src/components/ContextMenu/index.tsx
··· 23 23 type GestureUpdateEvent, 24 24 type PanGestureHandlerEventPayload, 25 25 } from 'react-native-gesture-handler' 26 + import {KeyboardEvents} from 'react-native-keyboard-controller' 26 27 import Animated, { 27 28 clamp, 28 29 interpolate, ··· 35 36 type WithSpringConfig, 36 37 } from 'react-native-reanimated' 37 38 import { 39 + type EdgeInsets, 38 40 useSafeAreaFrame, 39 41 useSafeAreaInsets, 40 42 } from 'react-native-safe-area-context' ··· 81 83 const {Provider: PortalProvider, Outlet, Portal} = createPortalGroup() 82 84 83 85 const SPRING_IN: WithSpringConfig = { 84 - mass: IS_IOS ? 1.25 : 0.75, 85 - damping: 50, 86 - stiffness: 1100, 86 + mass: 0.75, 87 + damping: 300, 88 + stiffness: 1200, 87 89 restDisplacementThreshold: 0.01, 88 90 } 89 91 ··· 110 112 const playHaptic = useHaptics() 111 113 const [mode, setMode] = useState<'full' | 'auxiliary-only'>('full') 112 114 const [measurement, setMeasurement] = useState<Measurement | null>(null) 115 + const returnLocationSV = useSharedValue<{x: number; y: number} | null>(null) 113 116 const animationSV = useSharedValue(0) 114 117 const translationSV = useSharedValue(0) 115 118 const isFocused = useIsFocused() ··· 142 145 ({ 143 146 isOpen: !!measurement && isFocused, 144 147 measurement, 148 + returnLocationSV, 145 149 animationSV, 146 150 translationSV, 147 151 mode, ··· 149 153 setMeasurement(evt) 150 154 setMode(mode) 151 155 animationSV.set(withSpring(1, SPRING_IN)) 156 + // reset return location 157 + returnLocationSV.set(null) 152 158 }, 153 159 close: () => { 154 160 animationSV.set( ··· 156 162 if (finished) { 157 163 hoverablesSV.set({}) 158 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 159 168 runOnJS(onCompletedClose)() 160 169 } 161 170 }), ··· 194 203 }) satisfies ContextType, 195 204 [ 196 205 measurement, 206 + returnLocationSV, 197 207 setMeasurement, 198 208 onCompletedClose, 199 209 isFocused, ··· 225 235 export function Trigger({children, label, contentLabel, style}: TriggerProps) { 226 236 const context = useContextMenuContext() 227 237 const playHaptic = useHaptics() 228 - const {top: topInset} = useSafeAreaInsets() 238 + const insets = useSafeAreaInsets() 229 239 const ref = useRef<View>(null) 230 240 const isFocused = useIsFocused() 231 241 const [image, setImage] = useState<string | null>(null) ··· 237 247 const open = useNonReactiveCallback( 238 248 async (mode: 'full' | 'auxiliary-only') => { 239 249 playHaptic() 240 - Keyboard.dismiss() 241 250 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 - }), 251 + measureView(ref.current, insets), 257 252 captureRef(ref, {result: 'data-uri'}).catch(err => { 258 253 logger.error(err instanceof Error ? err : String(err), { 259 254 message: 'Failed to capture image of context menu trigger', ··· 262 257 return '<failed capture>' 263 258 }), 264 259 ]) 260 + Keyboard.dismiss() 265 261 setImage(capture) 266 - setPendingMeasurement({measurement, mode}) 262 + if (measurement) { 263 + setPendingMeasurement({measurement, mode}) 264 + } 267 265 }, 268 266 ) 269 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 + 270 294 const doubleTapGesture = useMemo(() => { 271 295 return Gesture.Tap() 272 296 .numberOfTaps(2) 273 297 .hitSlop(HITSLOP_10) 274 - .onEnd(() => open('auxiliary-only')) 298 + .onEnd(() => void open('auxiliary-only')) 275 299 .runOnJS(true) 276 300 }, [open]) 277 301 ··· 360 384 animation={animationSV} 361 385 image={image} 362 386 measurement={measurement} 387 + returnLocation={context.returnLocationSV} 363 388 onDisplay={() => { 364 389 if (pendingMeasurement) { 365 390 context.open( ··· 384 409 animation, 385 410 image, 386 411 measurement, 412 + returnLocation, 387 413 onDisplay, 388 414 label, 389 415 }: { ··· 391 417 animation: SharedValue<number> 392 418 image: string 393 419 measurement: Measurement 420 + returnLocation: SharedValue<{x: number; y: number} | null> 394 421 onDisplay: () => void 395 422 label: string 396 423 }) { 397 424 const {_} = useLingui() 398 425 399 - const animatedStyles = useAnimatedStyle(() => ({ 400 - transform: [{translateY: translation.get() * animation.get()}], 401 - })) 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 + }) 402 443 403 444 const handleError = useCallback( 404 445 (evt: ImageErrorEventData) => { ··· 872 913 style={[t.atoms.border_contrast_low, a.flex_1, {borderTopWidth: 3}]} 873 914 /> 874 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 + }) 875 935 } 876 936 877 937 function getHoveredHoverable(
+1
src/components/ContextMenu/types.ts
··· 49 49 translationSV: SharedValue<number> 50 50 mode: 'full' | 'auxiliary-only' 51 51 open: (evt: Measurement, mode: 'full' | 'auxiliary-only') => void 52 + returnLocationSV: SharedValue<{x: number; y: number} | null> 52 53 close: () => void 53 54 registerHoverable: ( 54 55 id: string,