Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

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

Lazily measure lightbox thumbnails (#10270)

authored by

Samuel Newman and committed by
GitHub
3358e194 6e3c9c3a

+138 -95
+5 -24
src/components/Post/Embed/ImageEmbed.tsx
··· 1 1 import {InteractionManager, View} from 'react-native' 2 - import { 3 - type AnimatedRef, 4 - measure, 5 - type MeasuredDimensions, 6 - runOnJS, 7 - runOnUI, 8 - } from 'react-native-reanimated' 2 + import {type AnimatedRef} from 'react-native-reanimated' 9 3 import {Image} from 'expo-image' 10 4 11 5 import {useLightboxControls} from '#/state/lightbox' ··· 37 31 alt: img.alt, 38 32 dimensions: img.aspectRatio ?? null, 39 33 })) 40 - const _openLightbox = ( 34 + const onPress = ( 41 35 index: number, 42 - thumbRects: (MeasuredDimensions | null)[], 36 + refs: AnimatedRef<any>[], 43 37 fetchedDims: (Dimensions | null)[], 44 38 ) => { 45 39 openLightbox({ 46 40 images: items.map((item, i) => ({ 47 41 ...item, 48 - thumbRect: thumbRects[i] ?? null, 42 + thumbRect: null, 43 + thumbRef: refs[i] ?? null, 49 44 thumbDimensions: fetchedDims[i] ?? null, 50 45 type: 'image', 51 46 })), 52 47 index, 53 48 }) 54 - } 55 - const onPress = ( 56 - index: number, 57 - refs: AnimatedRef<any>[], 58 - fetchedDims: (Dimensions | null)[], 59 - ) => { 60 - runOnUI(() => { 61 - 'worklet' 62 - const rects: (MeasuredDimensions | null)[] = [] 63 - for (const r of refs) { 64 - rects.push(measure(r)) 65 - } 66 - runOnJS(_openLightbox)(index, rects, fetchedDims) 67 - })() 68 49 } 69 50 const onPressIn = (_: number) => { 70 51 InteractionManager.runAfterInteractions(() => {
+6 -16
src/screens/Profile/Header/Shell.tsx
··· 1 1 import {memo, useCallback, useEffect, useMemo} from 'react' 2 2 import {Pressable, View} from 'react-native' 3 3 import Animated, { 4 - measure, 5 - type MeasuredDimensions, 6 - runOnJS, 7 - runOnUI, 4 + type AnimatedRef, 8 5 useAnimatedRef, 9 6 } from 'react-native-reanimated' 10 7 import {useSafeAreaInsets} from 'react-native-safe-area-context' ··· 76 73 const _openLightbox = useCallback( 77 74 ( 78 75 uri: string, 79 - thumbRect: MeasuredDimensions | null, 76 + thumbRef: AnimatedRef<any>, 80 77 type: 'circle-avi' | 'rect-avi' | 'image' = 'circle-avi', 81 78 ) => { 82 79 openLightbox({ ··· 84 81 { 85 82 uri, 86 83 thumbUri: uri, 87 - thumbRect, 84 + thumbRect: null, 85 + thumbRef, 88 86 dimensions: 89 87 type === 'circle-avi' || type === 'rect-avi' 90 88 ? { ··· 130 128 const avatar = profile.avatar 131 129 const type = profile.associated?.labeler ? 'rect-avi' : 'circle-avi' 132 130 if (avatar && !(modui.blur && modui.noOverride)) { 133 - runOnUI(() => { 134 - 'worklet' 135 - const rect = measure(aviRef) 136 - runOnJS(_openLightbox)(avatar, rect, type) 137 - })() 131 + _openLightbox(avatar, aviRef, type) 138 132 } 139 133 } 140 134 }, [ ··· 152 146 const modui = moderation.ui('banner') 153 147 const banner = profile.banner 154 148 if (banner && !(modui.blur && modui.noOverride)) { 155 - runOnUI(() => { 156 - 'worklet' 157 - const rect = measure(bannerRef) 158 - runOnJS(_openLightbox)(banner, rect, 'image') 159 - })() 149 + _openLightbox(banner, bannerRef, 'image') 160 150 } 161 151 }, [profile.banner, moderation, _openLightbox, bannerRef]) 162 152
+39 -8
src/state/lightbox.tsx
··· 1 1 import {createContext, useContext, useEffect, useMemo, useState} from 'react' 2 + import { 3 + measure, 4 + type MeasuredDimensions, 5 + runOnJS, 6 + runOnUI, 7 + } from 'react-native-reanimated' 2 8 import {nanoid} from 'nanoid/non-secure' 3 9 4 10 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' ··· 39 45 } 40 46 }, [activeLightbox, disableScope, enableScope]) 41 47 48 + const doOpen = useNonReactiveCallback((lightbox: Omit<Lightbox, 'id'>) => { 49 + setActiveLightbox(prevLightbox => { 50 + if (prevLightbox) { 51 + // Ignore duplicate open requests. If it's already open, 52 + // the user has to explicitly close the previous one first. 53 + return prevLightbox 54 + } else { 55 + return {...lightbox, id: nanoid()} 56 + } 57 + }) 58 + }) 59 + 42 60 const openLightbox = useNonReactiveCallback( 43 61 (lightbox: Omit<Lightbox, 'id'>) => { 44 - setActiveLightbox(prevLightbox => { 45 - if (prevLightbox) { 46 - // Ignore duplicate open requests. If it's already open, 47 - // the user has to explicitly close the previous one first. 48 - return prevLightbox 49 - } else { 50 - return {...lightbox, id: nanoid()} 62 + const thumbRef = lightbox.images[lightbox.index]?.thumbRef 63 + if (thumbRef) { 64 + // Measure the tapped image on the UI thread, then open with 65 + // the rect baked in so it's available from the first render. 66 + // Only the rect (plain data) goes through runOnJS — AnimatedRef 67 + // objects can't survive serialization across threads. 68 + const openWithRect = (rect: MeasuredDimensions | null) => { 69 + doOpen({ 70 + ...lightbox, 71 + images: lightbox.images.map((img, i) => 72 + i === lightbox.index ? {...img, thumbRect: rect} : img, 73 + ), 74 + }) 51 75 } 52 - }) 76 + runOnUI(() => { 77 + 'worklet' 78 + const rect = measure(thumbRef) 79 + runOnJS(openWithRect)(rect) 80 + })() 81 + } else { 82 + doOpen(lightbox) 83 + } 53 84 }, 54 85 ) 55 86
+5 -1
src/view/com/lightbox/ImageViewing/@types/index.ts
··· 7 7 */ 8 8 9 9 import {type TransformsStyle} from 'react-native' 10 - import {type MeasuredDimensions} from 'react-native-reanimated' 10 + import { 11 + type AnimatedRef, 12 + type MeasuredDimensions, 13 + } from 'react-native-reanimated' 11 14 12 15 export type Dimensions = { 13 16 width: number ··· 25 28 thumbUri: string 26 29 thumbDimensions: Dimensions | null 27 30 thumbRect: MeasuredDimensions | null 31 + thumbRef?: AnimatedRef<any> | null 28 32 alt?: string 29 33 type: 'image' | 'circle-avi' | 'rect-avi' 30 34 }
+73 -20
src/view/com/lightbox/ImageViewing/index.tsx
··· 23 23 cancelAnimation, 24 24 interpolate, 25 25 measure, 26 + type MeasuredDimensions, 26 27 ReduceMotion, 27 28 runOnJS, 29 + runOnUI, 28 30 type SharedValue, 29 31 useAnimatedReaction, 30 32 useAnimatedRef, ··· 73 75 } 74 76 75 77 function canAnimate(lightbox: Lightbox): boolean { 76 - return ( 77 - !PlatformInfo.getIsReducedMotionEnabled() && 78 - lightbox.images.every( 79 - img => img.thumbRect && (img.dimensions || img.thumbDimensions), 80 - ) 81 - ) 78 + if (PlatformInfo.getIsReducedMotionEnabled()) { 79 + return false 80 + } 81 + const img = lightbox.images[lightbox.index] 82 + return !!img.thumbRect && !!(img.dimensions || img.thumbDimensions) 82 83 } 83 84 84 85 export default function ImageViewRoot({ ··· 99 100 'portrait', 100 101 ) 101 102 const openProgress = useSharedValue(0) 103 + const thumbRects = useSharedValue<Record<number, MeasuredDimensions | null>>( 104 + {}, 105 + ) 102 106 103 107 if (!activeLightbox && nextLightbox) { 104 108 setActiveLightbox(nextLightbox) ··· 109 113 return 110 114 } 111 115 116 + const initial: Record<number, MeasuredDimensions | null> = {} 117 + nextLightbox.images.forEach((img, i) => { 118 + initial[i] = img.thumbRect ?? null 119 + }) 120 + thumbRects.set(initial) 121 + 112 122 const isAnimated = canAnimate(nextLightbox) 113 123 114 124 // https://github.com/software-mansion/react-native-reanimated/issues/6677 ··· 125 135 ) 126 136 }) 127 137 } 128 - }, [nextLightbox, openProgress]) 138 + }, [nextLightbox, openProgress, thumbRects]) 139 + 140 + const onFullyClosed = useCallback(() => { 141 + setActiveLightbox(null) 142 + runOnUI(() => { 143 + 'worklet' 144 + thumbRects.set({}) 145 + })() 146 + }, [thumbRects]) 129 147 130 148 useAnimatedReaction( 131 149 () => openProgress.get() === 0, 132 150 (isGone, wasGone) => { 133 151 if (isGone && !wasGone) { 134 - runOnJS(setActiveLightbox)(null) 152 + runOnJS(onFullyClosed)() 135 153 } 136 154 }, 137 155 ) ··· 184 202 onFlyAway={onFlyAway} 185 203 safeAreaRef={ref} 186 204 openProgress={openProgress} 205 + thumbRects={thumbRects} 187 206 /> 188 207 )} 189 208 </Animated.View> ··· 200 219 onFlyAway, 201 220 safeAreaRef, 202 221 openProgress, 222 + thumbRects, 203 223 }: { 204 224 lightbox: Lightbox 205 225 orientation: 'portrait' | 'landscape' ··· 209 229 onFlyAway: () => void 210 230 safeAreaRef: AnimatedRef<View> 211 231 openProgress: SharedValue<number> 232 + thumbRects: SharedValue<Record<number, MeasuredDimensions | null>> 212 233 }) { 213 234 const {images, index: initialImageIndex} = lightbox 214 235 const isAnimated = useMemo(() => canAnimate(lightbox), [lightbox]) ··· 216 237 const [isDragging, setIsDragging] = useState(false) 217 238 const [imageIndex, setImageIndex] = useState(initialImageIndex) 218 239 const [showControls, setShowControls] = useState(true) 219 - const [isAltExpanded, setAltExpanded] = useState(false) 240 + const [isAltExpanded, setIsAltExpanded] = useState(false) 220 241 const dismissSwipeTranslateY = useSharedValue(0) 221 242 const isFlyingAway = useSharedValue(false) 222 243 ··· 287 308 } 288 309 }) 289 310 311 + const handleRequestClose = useCallback(() => { 312 + const activeRef = images[imageIndex]?.thumbRef 313 + if (isAnimated && activeRef) { 314 + runOnUI(() => { 315 + 'worklet' 316 + const rect = measure(activeRef) 317 + thumbRects.modify(rects => { 318 + 'worklet' 319 + rects[imageIndex] = rect 320 + return rects 321 + }) 322 + runOnJS(onRequestClose)() 323 + })() 324 + } else { 325 + onRequestClose() 326 + } 327 + }, [isAnimated, images, imageIndex, thumbRects, onRequestClose]) 328 + 290 329 const onTap = useCallback(() => { 291 330 setShowControls(show => !show) 292 331 }, []) ··· 355 394 onTap={onTap} 356 395 onZoom={onZoom} 357 396 imageSrc={imageSrc} 358 - onRequestClose={onRequestClose} 397 + onRequestClose={handleRequestClose} 359 398 isScrollViewBeingDragged={isDragging} 360 399 showControls={showControls} 361 400 safeAreaRef={safeAreaRef} ··· 364 403 isActive={i === imageIndex} 365 404 dismissSwipeTranslateY={dismissSwipeTranslateY} 366 405 openProgress={openProgress} 406 + thumbRects={thumbRects} 407 + imageIndex={i} 367 408 /> 368 409 </View> 369 410 ))} ··· 372 413 <Animated.View 373 414 style={animatedHeaderStyle} 374 415 renderToHardwareTextureAndroid> 375 - <ImageDefaultHeader onRequestClose={onRequestClose} /> 416 + <ImageDefaultHeader onRequestClose={handleRequestClose} /> 376 417 </Animated.View> 377 418 <Animated.View 378 419 style={animatedFooterStyle} ··· 381 422 images={images} 382 423 index={imageIndex} 383 424 isAltExpanded={isAltExpanded} 384 - toggleAltExpanded={() => setAltExpanded(e => !e)} 425 + toggleAltExpanded={() => setIsAltExpanded(e => !e)} 385 426 onPressSave={onPressSave} 386 427 onPressShare={onPressShare} 387 428 /> ··· 404 445 safeAreaRef, 405 446 openProgress, 406 447 dismissSwipeTranslateY, 448 + thumbRects, 449 + imageIndex, 407 450 }: { 408 451 imageSrc: ImageSource 409 452 onRequestClose: () => void ··· 417 460 safeAreaRef: AnimatedRef<View> 418 461 openProgress: SharedValue<number> 419 462 dismissSwipeTranslateY: SharedValue<number> 463 + thumbRects: SharedValue<Record<number, MeasuredDimensions | null>> 464 + imageIndex: number 420 465 }) { 421 466 const [fetchedDims, setFetchedDims] = useState<Dimensions | null>(null) 422 467 const dims = fetchedDims ?? imageSrc.dimensions ?? imageSrc.thumbDimensions ··· 449 494 return safeArea 450 495 }, [safeAreaRef, heightDelayedForJSThreadOnly, widthDelayedForJSThreadOnly]) 451 496 452 - const {thumbRect} = imageSrc 497 + const {thumbRect: thumbRectJS} = imageSrc 453 498 const transforms = useDerivedValue(() => { 454 499 'worklet' 455 500 const safeArea = measureSafeArea() ··· 467 512 } 468 513 } 469 514 470 - if (isActive && thumbRect && imageAspect && openProgressValue < 1) { 471 - return interpolateTransform( 472 - openProgressValue, 473 - thumbRect, 474 - safeArea, 475 - imageAspect, 476 - ) 515 + if (isActive && imageAspect && openProgressValue < 1) { 516 + let thumbRect 517 + if (_WORKLET) { 518 + thumbRect = thumbRects.get()[imageIndex] 519 + } else { 520 + thumbRect = thumbRectJS 521 + } 522 + if (thumbRect) { 523 + return interpolateTransform( 524 + openProgressValue, 525 + thumbRect, 526 + safeArea, 527 + imageAspect, 528 + ) 529 + } 477 530 } 478 531 return { 479 532 isHidden: false,
+10 -26
src/view/com/profile/ProfileSubpageHeader.tsx
··· 1 1 import {useCallback} from 'react' 2 2 import {Pressable, View} from 'react-native' 3 - import Animated, { 4 - measure, 5 - type MeasuredDimensions, 6 - runOnJS, 7 - runOnUI, 8 - useAnimatedRef, 9 - } from 'react-native-reanimated' 3 + import Animated, {useAnimatedRef} from 'react-native-reanimated' 10 4 import {type AppBskyGraphDefs} from '@atproto/api' 11 5 import {msg} from '@lingui/core/macro' 12 6 import {useLingui} from '@lingui/react' ··· 60 54 const canGoBack = navigation.canGoBack() 61 55 const aviRef = useAnimatedRef() 62 56 63 - const _openLightbox = useCallback( 64 - (uri: string, thumbRect: MeasuredDimensions | null) => { 57 + const onPressAvi = useCallback(() => { 58 + if ( 59 + avatar // TODO && !(view.moderation.avatar.blur && view.moderation.avatar.noOverride) 60 + ) { 65 61 openLightbox({ 66 62 images: [ 67 63 { 68 - uri, 69 - thumbUri: uri, 70 - thumbRect, 64 + uri: avatar, 65 + thumbUri: avatar, 66 + thumbRect: null, 67 + thumbRef: aviRef, 71 68 dimensions: { 72 69 // It's fine if it's actually smaller but we know it's 1:1. 73 70 height: 1000, ··· 79 76 ], 80 77 index: 0, 81 78 }) 82 - }, 83 - [openLightbox], 84 - ) 85 - 86 - const onPressAvi = useCallback(() => { 87 - if ( 88 - avatar // TODO && !(view.moderation.avatar.blur && view.moderation.avatar.noOverride) 89 - ) { 90 - runOnUI(() => { 91 - 'worklet' 92 - const rect = measure(aviRef) 93 - runOnJS(_openLightbox)(avatar, rect) 94 - })() 95 79 } 96 - }, [_openLightbox, avatar, aviRef]) 80 + }, [openLightbox, avatar, aviRef]) 97 81 98 82 return ( 99 83 <>