Bluesky app fork with some witchin' additions 💫

[Lightbox] Always rely on Expo Image cache (#6189)

* Inline useImageAspectRatio

* Switch AutoSizedImage to read dimensions from Expo Image cache

* Include thumbnail dimensions in image data

* Use dims from Expo Image cache in lightbox

* Fix wiring so all thumbnails get dimensions

* Fix type

* Oops

authored by danabra.mov and committed by

GitHub 42abd97f 2d73c5a2

+105 -146
-93
src/lib/media/image-sizes.ts
··· 1 - import {useEffect, useState} from 'react' 2 - import {Image} from 'react-native' 3 - 4 - import type {Dimensions} from '#/lib/media/types' 5 - 6 - type CacheStorageItem<T> = {key: string; value: T} 7 - const createCache = <T>(cacheSize: number) => ({ 8 - _storage: [] as CacheStorageItem<T>[], 9 - get(key: string) { 10 - const {value} = 11 - this._storage.find(({key: storageKey}) => storageKey === key) || {} 12 - return value 13 - }, 14 - set(key: string, value: T) { 15 - if (this._storage.length >= cacheSize) { 16 - this._storage.shift() 17 - } 18 - this._storage.push({key, value}) 19 - }, 20 - }) 21 - 22 - const sizes = createCache<Dimensions>(50) 23 - const activeRequests: Map<string, Promise<Dimensions>> = new Map() 24 - 25 - export function get(uri: string): Dimensions | undefined { 26 - return sizes.get(uri) 27 - } 28 - 29 - export function fetch(uri: string): Promise<Dimensions> { 30 - const dims = sizes.get(uri) 31 - if (dims) { 32 - return Promise.resolve(dims) 33 - } 34 - const activeRequest = activeRequests.get(uri) 35 - if (activeRequest) { 36 - return activeRequest 37 - } 38 - const prom = new Promise<Dimensions>((resolve, reject) => { 39 - Image.getSize( 40 - uri, 41 - (width: number, height: number) => { 42 - const size = {width, height} 43 - sizes.set(uri, size) 44 - resolve(size) 45 - }, 46 - (err: any) => { 47 - console.error('Failed to fetch image dimensions for', uri, err) 48 - reject(new Error('Could not fetch dimensions')) 49 - }, 50 - ) 51 - }).finally(() => { 52 - activeRequests.delete(uri) 53 - }) 54 - activeRequests.set(uri, prom) 55 - return prom 56 - } 57 - 58 - export function useImageDimensions({ 59 - src, 60 - knownDimensions, 61 - }: { 62 - src: string 63 - knownDimensions: Dimensions | null 64 - }): [number | undefined, Dimensions | undefined] { 65 - const [dims, setDims] = useState(() => knownDimensions ?? get(src)) 66 - const [prevSrc, setPrevSrc] = useState(src) 67 - if (src !== prevSrc) { 68 - setDims(knownDimensions ?? get(src)) 69 - setPrevSrc(src) 70 - } 71 - 72 - useEffect(() => { 73 - let aborted = false 74 - if (dims !== undefined) return 75 - fetch(src).then(newDims => { 76 - if (aborted) return 77 - setDims(newDims) 78 - }) 79 - return () => { 80 - aborted = true 81 - } 82 - }, [dims, setDims, src]) 83 - 84 - let aspectRatio: number | undefined 85 - if (dims) { 86 - aspectRatio = dims.width / dims.height 87 - if (Number.isNaN(aspectRatio)) { 88 - aspectRatio = undefined 89 - } 90 - } 91 - 92 - return [aspectRatio, dims] 93 - }
+1
src/screens/Profile/Header/Shell.tsx
··· 72 72 height: 1000, 73 73 width: 1000, 74 74 }, 75 + thumbDimensions: null, 75 76 type: 'circle-avi', 76 77 }, 77 78 ],
+2 -1
src/view/com/lightbox/ImageViewing/@types/index.ts
··· 21 21 22 22 export type ImageSource = { 23 23 uri: string 24 + dimensions: Dimensions | null 24 25 thumbUri: string 26 + thumbDimensions: Dimensions | null 25 27 thumbRect: MeasuredDimensions | null 26 28 alt?: string 27 - dimensions: Dimensions | null 28 29 type: 'image' | 'circle-avi' | 'rect-avi' 29 30 } 30 31
+8 -2
src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx
··· 41 41 onRequestClose: () => void 42 42 onTap: () => void 43 43 onZoom: (isZoomed: boolean) => void 44 + onLoad: (dims: ImageDimensions) => void 44 45 isScrollViewBeingDragged: boolean 45 46 showControls: boolean 46 47 measureSafeArea: () => { ··· 66 67 imageSrc, 67 68 onTap, 68 69 onZoom, 70 + onLoad, 69 71 isScrollViewBeingDragged, 70 72 measureSafeArea, 71 73 imageAspect, ··· 330 332 transform: scaleAndMoveTransform.concat(manipulationTransform), 331 333 width: screenSize.width, 332 334 maxHeight: screenSize.height, 333 - aspectRatio: imageAspect, 334 335 alignSelf: 'center', 336 + aspectRatio: imageAspect ?? 1 /* force onLoad */, 335 337 } 336 338 }) 337 339 ··· 349 351 return { 350 352 flex: 1, 351 353 transform: cropContentTransform, 354 + opacity: imageAspect === undefined ? 0 : 1, 352 355 } 353 356 }) 354 357 ··· 393 396 placeholderContentFit="cover" 394 397 placeholder={{uri: imageSrc.thumbUri}} 395 398 accessibilityLabel={imageSrc.alt} 396 - onLoad={() => setHasLoaded(false)} 399 + onLoad={e => { 400 + setHasLoaded(true) 401 + onLoad({width: e.source.width, height: e.source.height}) 402 + }} 397 403 style={{flex: 1, borderRadius}} 398 404 accessibilityHint="" 399 405 accessibilityIgnoresInvertColors
+10 -3
src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx
··· 38 38 onRequestClose: () => void 39 39 onTap: () => void 40 40 onZoom: (scaled: boolean) => void 41 + onLoad: (dims: ImageDimensions) => void 41 42 isScrollViewBeingDragged: boolean 42 43 showControls: boolean 43 44 measureSafeArea: () => { ··· 64 65 imageSrc, 65 66 onTap, 66 67 onZoom, 68 + onLoad, 67 69 showControls, 68 70 measureSafeArea, 69 71 imageAspect, ··· 162 164 transform: cropFrameTransform, 163 165 width: screenSize.width, 164 166 maxHeight: screenSize.height, 165 - aspectRatio: imageAspect, 166 167 alignSelf: 'center', 168 + aspectRatio: imageAspect ?? 1 /* force onLoad */, 169 + opacity: imageAspect === undefined ? 0 : 1, 167 170 } 168 171 }) 169 172 ··· 172 175 return { 173 176 transform: cropContentTransform, 174 177 width: '100%', 175 - aspectRatio: imageAspect, 178 + aspectRatio: imageAspect ?? 1 /* force onLoad */, 179 + opacity: imageAspect === undefined ? 0 : 1, 176 180 } 177 181 }) 178 182 ··· 224 228 accessibilityHint="" 225 229 enableLiveTextInteraction={showControls && !scaled} 226 230 accessibilityIgnoresInvertColors 227 - onLoad={() => setHasLoaded(true)} 231 + onLoad={e => { 232 + setHasLoaded(true) 233 + onLoad({width: e.source.width, height: e.source.height}) 234 + }} 228 235 /> 229 236 </Animated.View> 230 237 </Animated.View>
+2
src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx
··· 5 5 import {PanGesture} from 'react-native-gesture-handler' 6 6 import {SharedValue} from 'react-native-reanimated' 7 7 8 + import {Dimensions} from '#/lib/media/types' 8 9 import { 9 10 Dimensions as ImageDimensions, 10 11 ImageSource, ··· 16 17 onRequestClose: () => void 17 18 onTap: () => void 18 19 onZoom: (scaled: boolean) => void 20 + onLoad: (dims: Dimensions) => void 19 21 isScrollViewBeingDragged: boolean 20 22 showControls: boolean 21 23 measureSafeArea: () => {
+15 -7
src/view/com/lightbox/ImageViewing/index.tsx
··· 42 42 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 43 43 import {Trans} from '@lingui/macro' 44 44 45 - import {useImageDimensions} from '#/lib/media/image-sizes' 45 + import {Dimensions} from '#/lib/media/types' 46 46 import {colors, s} from '#/lib/styles' 47 47 import {isIOS} from '#/platform/detection' 48 48 import {Lightbox} from '#/state/lightbox' ··· 92 92 93 93 const canAnimate = 94 94 !PlatformInfo.getIsReducedMotionEnabled() && 95 - nextLightbox.images.every(img => img.dimensions && img.thumbRect) 95 + nextLightbox.images.every( 96 + img => img.thumbRect && (img.dimensions || img.thumbDimensions), 97 + ) 96 98 97 99 // https://github.com/software-mansion/react-native-reanimated/issues/6677 98 100 requestAnimationFrame(() => { ··· 345 347 openProgress: SharedValue<number> 346 348 dismissSwipeTranslateY: SharedValue<number> 347 349 }) { 348 - const [imageAspect, imageDimensions] = useImageDimensions({ 349 - src: imageSrc.uri, 350 - knownDimensions: imageSrc.dimensions, 351 - }) 350 + const [fetchedDims, setFetchedDims] = React.useState<Dimensions | null>(null) 351 + const dims = fetchedDims ?? imageSrc.dimensions ?? imageSrc.thumbDimensions 352 + let imageAspect: number | undefined 353 + if (dims) { 354 + imageAspect = dims.width / dims.height 355 + if (Number.isNaN(imageAspect)) { 356 + imageAspect = undefined 357 + } 358 + } 352 359 353 360 const safeFrameDelayedForJSThreadOnly = useSafeAreaFrame() 354 361 const safeInsetsDelayedForJSThreadOnly = useSafeAreaInsets() ··· 452 459 onTap={onTap} 453 460 onZoom={onZoom} 454 461 onRequestClose={onRequestClose} 462 + onLoad={setFetchedDims} 455 463 isScrollViewBeingDragged={isScrollViewBeingDragged} 456 464 showControls={showControls} 457 465 measureSafeArea={measureSafeArea} 458 466 imageAspect={imageAspect} 459 - imageDimensions={imageDimensions} 467 + imageDimensions={dims ?? undefined} 460 468 dismissSwipePan={dismissSwipePan} 461 469 transforms={transforms} 462 470 />
+1
src/view/com/profile/ProfileSubpageHeader.tsx
··· 87 87 height: 1000, 88 88 width: 1000, 89 89 }, 90 + thumbDimensions: null, 90 91 type: 'rect-avi', 91 92 }, 92 93 ],
+30 -37
src/view/com/util/images/AutoSizedImage.tsx
··· 6 6 import {msg} from '@lingui/macro' 7 7 import {useLingui} from '@lingui/react' 8 8 9 - import {useImageDimensions} from '#/lib/media/image-sizes' 10 - import {Dimensions} from '#/lib/media/types' 9 + import type {Dimensions} from '#/lib/media/types' 11 10 import {isNative} from '#/platform/detection' 12 11 import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' 13 12 import {atoms as a, useBreakpoints, useTheme} from '#/alf' 14 13 import {ArrowsDiagonalOut_Stroke2_Corner0_Rounded as Fullscreen} from '#/components/icons/ArrowsDiagonal' 15 14 import {MediaInsetBorder} from '#/components/MediaInsetBorder' 16 15 import {Text} from '#/components/Typography' 17 - 18 - function useImageAspectRatio({ 19 - src, 20 - knownDimensions, 21 - }: { 22 - src: string 23 - knownDimensions: Dimensions | null 24 - }) { 25 - const [raw] = useImageDimensions({src, knownDimensions}) 26 - let constrained: number | undefined 27 - let max: number | undefined 28 - let isCropped: boolean | undefined 29 - if (raw !== undefined) { 30 - const ratio = 1 / 2 // max of 1:2 ratio in feeds 31 - constrained = Math.max(raw, ratio) 32 - max = Math.max(raw, 0.25) // max of 1:4 in thread 33 - isCropped = raw < constrained 34 - } 35 - return { 36 - constrained, 37 - max, 38 - isCropped, 39 - } 40 - } 41 16 42 17 export function ConstrainedImage({ 43 18 aspectRatio, ··· 93 68 image: AppBskyEmbedImages.ViewImage 94 69 crop?: 'none' | 'square' | 'constrained' 95 70 hideBadge?: boolean 96 - onPress?: (containerRef: AnimatedRef<React.Component<{}, {}, any>>) => void 71 + onPress?: ( 72 + containerRef: AnimatedRef<React.Component<{}, {}, any>>, 73 + fetchedDims: Dimensions | null, 74 + ) => void 97 75 onLongPress?: () => void 98 76 onPressIn?: () => void 99 77 }) { 100 78 const t = useTheme() 101 79 const {_} = useLingui() 102 80 const largeAlt = useLargeAltBadgeEnabled() 103 - const { 104 - constrained, 105 - max, 106 - isCropped: rawIsCropped, 107 - } = useImageAspectRatio({ 108 - src: image.thumb, 109 - knownDimensions: image.aspectRatio ?? null, 110 - }) 111 81 const containerRef = useAnimatedRef() 112 82 83 + const [fetchedDims, setFetchedDims] = React.useState<Dimensions | null>(null) 84 + const dims = fetchedDims ?? image.aspectRatio 85 + let aspectRatio: number | undefined 86 + if (dims) { 87 + aspectRatio = dims.width / dims.height 88 + if (Number.isNaN(aspectRatio)) { 89 + aspectRatio = undefined 90 + } 91 + } 92 + 93 + let constrained: number | undefined 94 + let max: number | undefined 95 + let rawIsCropped: boolean | undefined 96 + if (aspectRatio !== undefined) { 97 + const ratio = 1 / 2 // max of 1:2 ratio in feeds 98 + constrained = Math.max(aspectRatio, ratio) 99 + max = Math.max(aspectRatio, 0.25) // max of 1:4 in thread 100 + rawIsCropped = aspectRatio < constrained 101 + } 102 + 113 103 const cropDisabled = crop === 'none' 114 104 const isCropped = rawIsCropped && !cropDisabled 115 105 const hasAlt = !!image.alt ··· 123 113 accessibilityIgnoresInvertColors 124 114 accessibilityLabel={image.alt} 125 115 accessibilityHint="" 116 + onLoad={e => { 117 + setFetchedDims({width: e.source.width, height: e.source.height}) 118 + }} 126 119 /> 127 120 <MediaInsetBorder /> 128 121 ··· 194 187 if (cropDisabled) { 195 188 return ( 196 189 <Pressable 197 - onPress={() => onPress?.(containerRef)} 190 + onPress={() => onPress?.(containerRef, fetchedDims)} 198 191 onLongPress={onLongPress} 199 192 onPressIn={onPressIn} 200 193 // alt here is what screen readers actually use ··· 216 209 fullBleed={crop === 'square'} 217 210 aspectRatio={constrained ?? 1}> 218 211 <Pressable 219 - onPress={() => onPress?.(containerRef)} 212 + onPress={() => onPress?.(containerRef, fetchedDims)} 220 213 onLongPress={onLongPress} 221 214 onPressIn={onPressIn} 222 215 // alt here is what screen readers actually use
+15 -1
src/view/com/util/images/Gallery.tsx
··· 6 6 import {msg} from '@lingui/macro' 7 7 import {useLingui} from '@lingui/react' 8 8 9 + import {Dimensions} from '#/lib/media/types' 9 10 import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' 10 11 import {PostEmbedViewContext} from '#/view/com/util/post-embeds/types' 11 12 import {atoms as a, useTheme} from '#/alf' ··· 20 21 onPress?: ( 21 22 index: number, 22 23 containerRefs: AnimatedRef<React.Component<{}, {}, any>>[], 24 + fetchedDims: (Dimensions | null)[], 23 25 ) => void 24 26 onLongPress?: EventFunction 25 27 onPressIn?: EventFunction ··· 27 29 viewContext?: PostEmbedViewContext 28 30 insetBorderStyle?: StyleProp<ViewStyle> 29 31 containerRefs: AnimatedRef<React.Component<{}, {}, any>>[] 32 + thumbDimsRef: React.MutableRefObject<(Dimensions | null)[]> 30 33 } 31 34 32 35 export function GalleryItem({ ··· 39 42 viewContext, 40 43 insetBorderStyle, 41 44 containerRefs, 45 + thumbDimsRef, 42 46 }: Props) { 43 47 const t = useTheme() 44 48 const {_} = useLingui() ··· 53 57 ref={containerRefs[index]} 54 58 collapsable={false}> 55 59 <Pressable 56 - onPress={onPress ? () => onPress(index, containerRefs) : undefined} 60 + onPress={ 61 + onPress 62 + ? () => onPress(index, containerRefs, thumbDimsRef.current.slice()) 63 + : undefined 64 + } 57 65 onPressIn={onPressIn ? () => onPressIn(index) : undefined} 58 66 onLongPress={onLongPress ? () => onLongPress(index) : undefined} 59 67 style={[ ··· 72 80 accessibilityLabel={image.alt} 73 81 accessibilityHint="" 74 82 accessibilityIgnoresInvertColors 83 + onLoad={e => { 84 + thumbDimsRef.current[index] = { 85 + width: e.source.width, 86 + height: e.source.height, 87 + } 88 + }} 75 89 /> 76 90 <MediaInsetBorder style={insetBorderStyle} /> 77 91 </Pressable>
+13
src/view/com/util/images/ImageLayoutGrid.tsx
··· 5 5 6 6 import {PostEmbedViewContext} from '#/view/com/util/post-embeds/types' 7 7 import {atoms as a, useBreakpoints} from '#/alf' 8 + import {Dimensions} from '../../lightbox/ImageViewing/@types' 8 9 import {GalleryItem} from './Gallery' 9 10 10 11 interface ImageLayoutGridProps { ··· 12 13 onPress?: ( 13 14 index: number, 14 15 containerRefs: AnimatedRef<React.Component<{}, {}, any>>[], 16 + fetchedDims: (Dimensions | null)[], 15 17 ) => void 16 18 onLongPress?: (index: number) => void 17 19 onPressIn?: (index: number) => void ··· 42 44 onPress?: ( 43 45 index: number, 44 46 containerRefs: AnimatedRef<React.Component<{}, {}, any>>[], 47 + fetchedDims: (Dimensions | null)[], 45 48 ) => void 46 49 onLongPress?: (index: number) => void 47 50 onPressIn?: (index: number) => void ··· 57 60 const containerRef2 = useAnimatedRef() 58 61 const containerRef3 = useAnimatedRef() 59 62 const containerRef4 = useAnimatedRef() 63 + const thumbDimsRef = React.useRef<(Dimensions | null)[]>([]) 60 64 61 65 switch (count) { 62 66 case 2: { ··· 69 73 index={0} 70 74 insetBorderStyle={noCorners(['topRight', 'bottomRight'])} 71 75 containerRefs={containerRefs} 76 + thumbDimsRef={thumbDimsRef} 72 77 /> 73 78 </View> 74 79 <View style={[a.flex_1, {aspectRatio: 1}]}> ··· 77 82 index={1} 78 83 insetBorderStyle={noCorners(['topLeft', 'bottomLeft'])} 79 84 containerRefs={containerRefs} 85 + thumbDimsRef={thumbDimsRef} 80 86 /> 81 87 </View> 82 88 </View> ··· 93 99 index={0} 94 100 insetBorderStyle={noCorners(['topRight', 'bottomRight'])} 95 101 containerRefs={containerRefs} 102 + thumbDimsRef={thumbDimsRef} 96 103 /> 97 104 </View> 98 105 <View style={[a.flex_1, {aspectRatio: 1}, gap]}> ··· 106 113 'bottomRight', 107 114 ])} 108 115 containerRefs={containerRefs} 116 + thumbDimsRef={thumbDimsRef} 109 117 /> 110 118 </View> 111 119 <View style={[a.flex_1]}> ··· 118 126 'topRight', 119 127 ])} 120 128 containerRefs={containerRefs} 129 + thumbDimsRef={thumbDimsRef} 121 130 /> 122 131 </View> 123 132 </View> ··· 145 154 'bottomRight', 146 155 ])} 147 156 containerRefs={containerRefs} 157 + thumbDimsRef={thumbDimsRef} 148 158 /> 149 159 </View> 150 160 <View style={[a.flex_1, {aspectRatio: 1.5}]}> ··· 157 167 'bottomRight', 158 168 ])} 159 169 containerRefs={containerRefs} 170 + thumbDimsRef={thumbDimsRef} 160 171 /> 161 172 </View> 162 173 </View> ··· 171 182 'bottomRight', 172 183 ])} 173 184 containerRefs={containerRefs} 185 + thumbDimsRef={thumbDimsRef} 174 186 /> 175 187 </View> 176 188 <View style={[a.flex_1, {aspectRatio: 1.5}]}> ··· 183 195 'topRight', 184 196 ])} 185 197 containerRefs={containerRefs} 198 + thumbDimsRef={thumbDimsRef} 186 199 /> 187 200 </View> 188 201 </View>
+8 -2
src/view/com/util/post-embeds/index.tsx
··· 35 35 import * as ListCard from '#/components/ListCard' 36 36 import {Embed as StarterPackCard} from '#/components/StarterPack/StarterPackCard' 37 37 import {ContentHider} from '../../../../components/moderation/ContentHider' 38 + import {Dimensions} from '../../lightbox/ImageViewing/@types' 38 39 import {AutoSizedImage} from '../images/AutoSizedImage' 39 40 import {ImageLayoutGrid} from '../images/ImageLayoutGrid' 40 41 import {ExternalLinkEmbed} from './ExternalLinkEmbed' ··· 148 149 const _openLightbox = ( 149 150 index: number, 150 151 thumbRects: (MeasuredDimensions | null)[], 152 + fetchedDims: (Dimensions | null)[], 151 153 ) => { 152 154 openLightbox({ 153 155 images: items.map((item, i) => ({ 154 156 ...item, 155 157 thumbRect: thumbRects[i] ?? null, 158 + thumbDimensions: fetchedDims[i] ?? null, 156 159 type: 'image', 157 160 })), 158 161 index, ··· 161 164 const onPress = ( 162 165 index: number, 163 166 refs: AnimatedRef<React.Component<{}, {}, any>>[], 167 + fetchedDims: (Dimensions | null)[], 164 168 ) => { 165 169 runOnUI(() => { 166 170 'worklet' 167 171 const rects = refs.map(ref => (ref ? measure(ref) : null)) 168 - runOnJS(_openLightbox)(index, rects) 172 + runOnJS(_openLightbox)(index, rects, fetchedDims) 169 173 })() 170 174 } 171 175 const onPressIn = (_: number) => { ··· 189 193 : 'constrained' 190 194 } 191 195 image={image} 192 - onPress={containerRef => onPress(0, [containerRef])} 196 + onPress={(containerRef, dims) => 197 + onPress(0, [containerRef], [dims]) 198 + } 193 199 onPressIn={() => onPressIn(0)} 194 200 hideBadge={ 195 201 viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia