Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

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

Unify dimensions cache between lightbox and feed (#6047)

* Remove useless memo

* Use explicit values when useImageAspectRatio doesn't know

It's not very good that you can't distingiush when we haven't loaded vs when we're certain. This shifts the burden of dealing with missing values to the caller.

* Check cache early

* Handle src change

* Rewrite image-sizes.fetch to avoid mixing async styles

* Make image-sizes LRU

Code is copy paste from useImageDimensions.ts

* Rm unused fields

* Derive aspect on the fly

* Factor useImageDimensions out of useImageAspectRatio

* Move useImageDimensions into image-sizes

* Make lightbox use the same cache

* Wire up known dimensions to the lightbox

* Handle division by zero in the hook

* Use safe aspect for lightbox calculations

authored by

dan and committed by
GitHub
174988bc ac9d910e

+144 -176
+79 -21
src/lib/media/image-sizes.ts
··· 1 + import {useEffect, useState} from 'react' 1 2 import {Image} from 'react-native' 2 3 3 4 import type {Dimensions} from '#/lib/media/types' 4 5 5 - const sizes: Map<string, Dimensions> = new Map() 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) 6 23 const activeRequests: Map<string, Promise<Dimensions>> = new Map() 7 24 8 25 export function get(uri: string): Dimensions | undefined { 9 26 return sizes.get(uri) 10 27 } 11 28 12 - export async function fetch(uri: string): Promise<Dimensions> { 13 - const Dimensions = sizes.get(uri) 14 - if (Dimensions) { 15 - return Dimensions 29 + export function fetch(uri: string): Promise<Dimensions> { 30 + const dims = sizes.get(uri) 31 + if (dims) { 32 + return Promise.resolve(dims) 16 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 + } 17 57 18 - const prom = 19 - activeRequests.get(uri) || 20 - new Promise<Dimensions>(resolve => { 21 - Image.getSize( 22 - uri, 23 - (width: number, height: number) => resolve({width, height}), 24 - (err: any) => { 25 - console.error('Failed to fetch image dimensions for', uri, err) 26 - resolve({width: 0, height: 0}) 27 - }, 28 - ) 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) 29 78 }) 30 - activeRequests.set(uri, prom) 31 - const res = await prom 32 - activeRequests.delete(uri) 33 - sizes.set(uri, res) 34 - return res 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] 35 93 }
+2
src/state/lightbox.tsx
··· 3 3 import {AppBskyActorDefs} from '@atproto/api' 4 4 5 5 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 6 + import {Dimensions} from '#/lib/media/types' 6 7 7 8 type ProfileImageLightbox = { 8 9 type: 'profile-image' ··· 14 15 uri: string 15 16 thumbUri: string 16 17 alt?: string 18 + dimensions: Dimensions | null 17 19 } 18 20 19 21 type ImagesLightbox = {
+6 -1
src/view/com/lightbox/ImageViewing/@types/index.ts
··· 16 16 y: number 17 17 } 18 18 19 - export type ImageSource = {uri: string; thumbUri: string; alt?: string} 19 + export type ImageSource = { 20 + uri: string 21 + thumbUri: string 22 + alt?: string 23 + dimensions: Dimensions | null 24 + }
+9 -8
src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx
··· 12 12 } from 'react-native-reanimated' 13 13 import {Image} from 'expo-image' 14 14 15 + import {useImageDimensions} from '#/lib/media/image-sizes' 15 16 import type {Dimensions as ImageDimensions, ImageSource} from '../../@types' 16 - import useImageDimensions from '../../hooks/useImageDimensions' 17 17 import { 18 18 applyRounding, 19 19 createTransform, ··· 52 52 isScrollViewBeingDragged, 53 53 }: Props) => { 54 54 const [isScaled, setIsScaled] = useState(false) 55 - const imageDimensions = useImageDimensions(imageSrc) 55 + const [imageAspect, imageDimensions] = useImageDimensions({ 56 + src: imageSrc.uri, 57 + knownDimensions: imageSrc.dimensions, 58 + }) 56 59 const committedTransform = useSharedValue(initialTransform) 57 60 const panTranslation = useSharedValue({x: 0, y: 0}) 58 61 const pinchOrigin = useSharedValue({x: 0, y: 0}) ··· 119 122 candidateTransform: TransformMatrix, 120 123 ) { 121 124 'worklet' 122 - if (!imageDimensions) { 125 + if (!imageAspect) { 123 126 return [0, 0] 124 127 } 125 128 const [nextTranslateX, nextTranslateY, nextScale] = 126 129 readTransform(candidateTransform) 127 - const scaledDimensions = getScaledDimensions(imageDimensions, nextScale) 130 + const scaledDimensions = getScaledDimensions(imageAspect, nextScale) 128 131 const clampedTranslateX = clampTranslation( 129 132 nextTranslateX, 130 133 scaledDimensions.width, ··· 248 251 .numberOfTaps(2) 249 252 .onEnd(e => { 250 253 'worklet' 251 - if (!imageDimensions) { 254 + if (!imageDimensions || !imageAspect) { 252 255 return 253 256 } 254 257 const [, , committedScale] = readTransform(committedTransform.value) ··· 260 263 } 261 264 262 265 // Try to zoom in so that we get rid of the black bars (whatever the orientation was). 263 - const imageAspect = imageDimensions.width / imageDimensions.height 264 266 const screenAspect = SCREEN.width / SCREEN.height 265 267 const candidateScale = Math.max( 266 268 imageAspect / screenAspect, ··· 363 365 }) 364 366 365 367 function getScaledDimensions( 366 - imageDimensions: ImageDimensions, 368 + imageAspect: number, 367 369 scale: number, 368 370 ): ImageDimensions { 369 371 'worklet' 370 - const imageAspect = imageDimensions.width / imageDimensions.height 371 372 const screenAspect = SCREEN.width / SCREEN.height 372 373 const isLandscape = imageAspect > screenAspect 373 374 if (isLandscape) {
+9 -7
src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx
··· 19 19 import {Image} from 'expo-image' 20 20 21 21 import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' 22 - import {Dimensions as ImageDimensions, ImageSource} from '../../@types' 23 - import useImageDimensions from '../../hooks/useImageDimensions' 22 + import {useImageDimensions} from '#/lib/media/image-sizes' 23 + import {ImageSource} from '../../@types' 24 24 25 25 const SWIPE_CLOSE_OFFSET = 75 26 26 const SWIPE_CLOSE_VELOCITY = 1 ··· 47 47 const scrollViewRef = useAnimatedRef<Animated.ScrollView>() 48 48 const translationY = useSharedValue(0) 49 49 const [scaled, setScaled] = useState(false) 50 - const imageDimensions = useImageDimensions(imageSrc) 50 + const [imageAspect, imageDimensions] = useImageDimensions({ 51 + src: imageSrc.uri, 52 + knownDimensions: imageSrc.dimensions, 53 + }) 51 54 const maxZoomScale = imageDimensions 52 55 ? (imageDimensions.width / SCREEN.width) * MAX_ORIGINAL_IMAGE_ZOOM 53 56 : 1 ··· 99 102 const willZoom = !scaled 100 103 if (willZoom) { 101 104 nextZoomRect = getZoomRectAfterDoubleTap( 102 - imageDimensions, 105 + imageAspect, 103 106 absoluteX, 104 107 absoluteY, 105 108 ) ··· 179 182 }) 180 183 181 184 const getZoomRectAfterDoubleTap = ( 182 - imageDimensions: ImageDimensions | null, 185 + imageAspect: number | undefined, 183 186 touchX: number, 184 187 touchY: number, 185 188 ): { ··· 188 191 width: number 189 192 height: number 190 193 } => { 191 - if (!imageDimensions) { 194 + if (!imageAspect) { 192 195 return { 193 196 x: 0, 194 197 y: 0, ··· 199 202 200 203 // First, let's figure out how much we want to zoom in. 201 204 // We want to try to zoom in at least close enough to get rid of black bars. 202 - const imageAspect = imageDimensions.width / imageDimensions.height 203 205 const screenAspect = SCREEN.width / SCREEN.height 204 206 const zoom = Math.max( 205 207 imageAspect / screenAspect,
-93
src/view/com/lightbox/ImageViewing/hooks/useImageDimensions.ts
··· 1 - /** 2 - * Copyright (c) JOB TODAY S.A. and its affiliates. 3 - * 4 - * This source code is licensed under the MIT license found in the 5 - * LICENSE file in the root directory of this source tree. 6 - * 7 - */ 8 - 9 - import {useEffect, useState} from 'react' 10 - import {Image, ImageURISource} from 'react-native' 11 - 12 - import {Dimensions, ImageSource} from '../@types' 13 - 14 - const CACHE_SIZE = 50 15 - 16 - type CacheStorageItem = {key: string; value: any} 17 - 18 - const createCache = (cacheSize: number) => ({ 19 - _storage: [] as CacheStorageItem[], 20 - get(key: string): any { 21 - const {value} = 22 - this._storage.find(({key: storageKey}) => storageKey === key) || {} 23 - 24 - return value 25 - }, 26 - set(key: string, value: any) { 27 - if (this._storage.length >= cacheSize) { 28 - this._storage.shift() 29 - } 30 - 31 - this._storage.push({key, value}) 32 - }, 33 - }) 34 - 35 - const imageDimensionsCache = createCache(CACHE_SIZE) 36 - 37 - const useImageDimensions = (image: ImageSource): Dimensions | null => { 38 - const [dimensions, setDimensions] = useState<Dimensions | null>(null) 39 - 40 - const getImageDimensions = ( 41 - image: ImageSource, 42 - ): Promise<Dimensions | null> => { 43 - return new Promise(resolve => { 44 - if (image.uri) { 45 - const source = image as ImageURISource 46 - const cacheKey = source.uri as string 47 - const imageDimensions = imageDimensionsCache.get(cacheKey) 48 - if (imageDimensions) { 49 - resolve(imageDimensions) 50 - } else { 51 - Image.getSizeWithHeaders( 52 - // @ts-ignore 53 - source.uri, 54 - source.headers, 55 - (width: number, height: number) => { 56 - if (width > 0 && height > 0) { 57 - imageDimensionsCache.set(cacheKey, {width, height}) 58 - resolve({width, height}) 59 - } else { 60 - resolve(null) 61 - } 62 - }, 63 - () => { 64 - resolve(null) 65 - }, 66 - ) 67 - } 68 - } else { 69 - resolve(null) 70 - } 71 - }) 72 - } 73 - 74 - let isImageUnmounted = false 75 - 76 - useEffect(() => { 77 - // eslint-disable-next-line @typescript-eslint/no-shadow 78 - getImageDimensions(image).then(dimensions => { 79 - if (!isImageUnmounted) { 80 - setDimensions(dimensions) 81 - } 82 - }) 83 - 84 - return () => { 85 - // eslint-disable-next-line react-hooks/exhaustive-deps 86 - isImageUnmounted = true 87 - } 88 - }, [image]) 89 - 90 - return dimensions 91 - } 92 - 93 - export default useImageDimensions
+9 -1
src/view/com/lightbox/Lightbox.tsx
··· 32 32 return ( 33 33 <ImageView 34 34 images={[ 35 - {uri: opts.profile.avatar || '', thumbUri: opts.profile.avatar || ''}, 35 + { 36 + uri: opts.profile.avatar || '', 37 + thumbUri: opts.profile.avatar || '', 38 + dimensions: { 39 + // It's fine if it's actually smaller but we know it's 1:1. 40 + height: 1000, 41 + width: 1000, 42 + }, 43 + }, 36 44 ]} 37 45 initialImageIndex={0} 38 46 thumbDims={opts.thumbDims}
+11 -1
src/view/com/profile/ProfileSubpageHeader.tsx
··· 72 72 ) { 73 73 openLightbox({ 74 74 type: 'images', 75 - images: [{uri: avatar, thumbUri: avatar}], 75 + images: [ 76 + { 77 + uri: avatar, 78 + thumbUri: avatar, 79 + dimensions: { 80 + // It's fine if it's actually smaller but we know it's 1:1. 81 + height: 1000, 82 + width: 1000, 83 + }, 84 + }, 85 + ], 76 86 index: 0, 77 87 thumbDims: null, 78 88 })
+18 -43
src/view/com/util/images/AutoSizedImage.tsx
··· 5 5 import {msg} from '@lingui/macro' 6 6 import {useLingui} from '@lingui/react' 7 7 8 - import * as imageSizes from '#/lib/media/image-sizes' 8 + import {useImageDimensions} from '#/lib/media/image-sizes' 9 9 import {Dimensions} from '#/lib/media/types' 10 10 import {isNative} from '#/platform/detection' 11 11 import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' ··· 14 14 import {MediaInsetBorder} from '#/components/MediaInsetBorder' 15 15 import {Text} from '#/components/Typography' 16 16 17 - export function useImageAspectRatio({ 17 + function useImageAspectRatio({ 18 18 src, 19 - dimensions, 19 + knownDimensions, 20 20 }: { 21 21 src: string 22 - dimensions: Dimensions | undefined 22 + knownDimensions: Dimensions | null 23 23 }) { 24 - const [raw, setAspectRatio] = React.useState<number>( 25 - dimensions ? calc(dimensions) : 1, 26 - ) 27 - // this basically controls the width of the image 28 - const {isCropped, constrained, max} = React.useMemo(() => { 24 + const [raw] = useImageDimensions({src, knownDimensions}) 25 + let constrained: number | undefined 26 + let max: number | undefined 27 + let isCropped: boolean | undefined 28 + if (raw !== undefined) { 29 29 const ratio = 1 / 2 // max of 1:2 ratio in feeds 30 - const constrained = Math.max(raw, ratio) 31 - const max = Math.max(raw, 0.25) // max of 1:4 in thread 32 - const isCropped = raw < constrained 33 - return { 34 - isCropped, 35 - constrained, 36 - max, 37 - } 38 - }, [raw]) 39 - 40 - React.useEffect(() => { 41 - let aborted = false 42 - if (dimensions) return 43 - imageSizes.fetch(src).then(newDim => { 44 - if (aborted) return 45 - setAspectRatio(calc(newDim)) 46 - }) 47 - return () => { 48 - aborted = true 49 - } 50 - }, [dimensions, setAspectRatio, src]) 51 - 30 + constrained = Math.max(raw, ratio) 31 + max = Math.max(raw, 0.25) // max of 1:4 in thread 32 + isCropped = raw < constrained 33 + } 52 34 return { 53 - dimensions, 54 - raw, 55 35 constrained, 56 36 max, 57 37 isCropped, ··· 125 105 isCropped: rawIsCropped, 126 106 } = useImageAspectRatio({ 127 107 src: image.thumb, 128 - dimensions: image.aspectRatio, 108 + knownDimensions: image.aspectRatio ?? null, 129 109 }) 130 110 const cropDisabled = crop === 'none' 131 111 const isCropped = rawIsCropped && !cropDisabled ··· 222 202 a.rounded_md, 223 203 a.overflow_hidden, 224 204 t.atoms.bg_contrast_25, 225 - {aspectRatio: max}, 205 + {aspectRatio: max ?? 1}, 226 206 ]}> 227 207 {contents} 228 208 </Pressable> 229 209 ) 230 210 } else { 231 211 return ( 232 - <ConstrainedImage fullBleed={crop === 'square'} aspectRatio={constrained}> 212 + <ConstrainedImage 213 + fullBleed={crop === 'square'} 214 + aspectRatio={constrained ?? 1}> 233 215 <Pressable 234 216 onPress={onPress} 235 217 onLongPress={onLongPress} ··· 244 226 ) 245 227 } 246 228 } 247 - 248 - function calc(dim: Dimensions) { 249 - if (dim.width === 0 || dim.height === 0) { 250 - return 1 251 - } 252 - return dim.width / dim.height 253 - }
+1 -1
src/view/com/util/post-embeds/index.tsx
··· 145 145 uri: img.fullsize, 146 146 thumbUri: img.thumb, 147 147 alt: img.alt, 148 - aspectRatio: img.aspectRatio, 148 + dimensions: img.aspectRatio ?? null, 149 149 })) 150 150 const _openLightbox = ( 151 151 index: number,