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
This commit is contained in:
dan
2024-11-04 21:28:27 +00:00
committed by GitHub
parent ac9d910e1e
commit 174988bc5a
10 changed files with 144 additions and 176 deletions
+79 -21
View File
@@ -1,35 +1,93 @@
import {useEffect, useState} from 'react'
import {Image} from 'react-native' import {Image} from 'react-native'
import type {Dimensions} from '#/lib/media/types' import type {Dimensions} from '#/lib/media/types'
const sizes: Map<string, Dimensions> = new Map() type CacheStorageItem<T> = {key: string; value: T}
const createCache = <T>(cacheSize: number) => ({
_storage: [] as CacheStorageItem<T>[],
get(key: string) {
const {value} =
this._storage.find(({key: storageKey}) => storageKey === key) || {}
return value
},
set(key: string, value: T) {
if (this._storage.length >= cacheSize) {
this._storage.shift()
}
this._storage.push({key, value})
},
})
const sizes = createCache<Dimensions>(50)
const activeRequests: Map<string, Promise<Dimensions>> = new Map() const activeRequests: Map<string, Promise<Dimensions>> = new Map()
export function get(uri: string): Dimensions | undefined { export function get(uri: string): Dimensions | undefined {
return sizes.get(uri) return sizes.get(uri)
} }
export async function fetch(uri: string): Promise<Dimensions> { export function fetch(uri: string): Promise<Dimensions> {
const Dimensions = sizes.get(uri) const dims = sizes.get(uri)
if (Dimensions) { if (dims) {
return Dimensions return Promise.resolve(dims)
}
const activeRequest = activeRequests.get(uri)
if (activeRequest) {
return activeRequest
}
const prom = new Promise<Dimensions>((resolve, reject) => {
Image.getSize(
uri,
(width: number, height: number) => {
const size = {width, height}
sizes.set(uri, size)
resolve(size)
},
(err: any) => {
console.error('Failed to fetch image dimensions for', uri, err)
reject(new Error('Could not fetch dimensions'))
},
)
}).finally(() => {
activeRequests.delete(uri)
})
activeRequests.set(uri, prom)
return prom
}
export function useImageDimensions({
src,
knownDimensions,
}: {
src: string
knownDimensions: Dimensions | null
}): [number | undefined, Dimensions | undefined] {
const [dims, setDims] = useState(() => knownDimensions ?? get(src))
const [prevSrc, setPrevSrc] = useState(src)
if (src !== prevSrc) {
setDims(knownDimensions ?? get(src))
setPrevSrc(src)
} }
const prom = useEffect(() => {
activeRequests.get(uri) || let aborted = false
new Promise<Dimensions>(resolve => { if (dims !== undefined) return
Image.getSize( fetch(src).then(newDims => {
uri, if (aborted) return
(width: number, height: number) => resolve({width, height}), setDims(newDims)
(err: any) => {
console.error('Failed to fetch image dimensions for', uri, err)
resolve({width: 0, height: 0})
},
)
}) })
activeRequests.set(uri, prom) return () => {
const res = await prom aborted = true
activeRequests.delete(uri) }
sizes.set(uri, res) }, [dims, setDims, src])
return res
let aspectRatio: number | undefined
if (dims) {
aspectRatio = dims.width / dims.height
if (Number.isNaN(aspectRatio)) {
aspectRatio = undefined
}
}
return [aspectRatio, dims]
} }
+2
View File
@@ -3,6 +3,7 @@ import type {MeasuredDimensions} from 'react-native-reanimated'
import {AppBskyActorDefs} from '@atproto/api' import {AppBskyActorDefs} from '@atproto/api'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {Dimensions} from '#/lib/media/types'
type ProfileImageLightbox = { type ProfileImageLightbox = {
type: 'profile-image' type: 'profile-image'
@@ -14,6 +15,7 @@ type ImagesLightboxItem = {
uri: string uri: string
thumbUri: string thumbUri: string
alt?: string alt?: string
dimensions: Dimensions | null
} }
type ImagesLightbox = { type ImagesLightbox = {
@@ -16,4 +16,9 @@ export type Position = {
y: number y: number
} }
export type ImageSource = {uri: string; thumbUri: string; alt?: string} export type ImageSource = {
uri: string
thumbUri: string
alt?: string
dimensions: Dimensions | null
}
@@ -12,8 +12,8 @@ import Animated, {
} from 'react-native-reanimated' } from 'react-native-reanimated'
import {Image} from 'expo-image' import {Image} from 'expo-image'
import {useImageDimensions} from '#/lib/media/image-sizes'
import type {Dimensions as ImageDimensions, ImageSource} from '../../@types' import type {Dimensions as ImageDimensions, ImageSource} from '../../@types'
import useImageDimensions from '../../hooks/useImageDimensions'
import { import {
applyRounding, applyRounding,
createTransform, createTransform,
@@ -52,7 +52,10 @@ const ImageItem = ({
isScrollViewBeingDragged, isScrollViewBeingDragged,
}: Props) => { }: Props) => {
const [isScaled, setIsScaled] = useState(false) const [isScaled, setIsScaled] = useState(false)
const imageDimensions = useImageDimensions(imageSrc) const [imageAspect, imageDimensions] = useImageDimensions({
src: imageSrc.uri,
knownDimensions: imageSrc.dimensions,
})
const committedTransform = useSharedValue(initialTransform) const committedTransform = useSharedValue(initialTransform)
const panTranslation = useSharedValue({x: 0, y: 0}) const panTranslation = useSharedValue({x: 0, y: 0})
const pinchOrigin = useSharedValue({x: 0, y: 0}) const pinchOrigin = useSharedValue({x: 0, y: 0})
@@ -119,12 +122,12 @@ const ImageItem = ({
candidateTransform: TransformMatrix, candidateTransform: TransformMatrix,
) { ) {
'worklet' 'worklet'
if (!imageDimensions) { if (!imageAspect) {
return [0, 0] return [0, 0]
} }
const [nextTranslateX, nextTranslateY, nextScale] = const [nextTranslateX, nextTranslateY, nextScale] =
readTransform(candidateTransform) readTransform(candidateTransform)
const scaledDimensions = getScaledDimensions(imageDimensions, nextScale) const scaledDimensions = getScaledDimensions(imageAspect, nextScale)
const clampedTranslateX = clampTranslation( const clampedTranslateX = clampTranslation(
nextTranslateX, nextTranslateX,
scaledDimensions.width, scaledDimensions.width,
@@ -248,7 +251,7 @@ const ImageItem = ({
.numberOfTaps(2) .numberOfTaps(2)
.onEnd(e => { .onEnd(e => {
'worklet' 'worklet'
if (!imageDimensions) { if (!imageDimensions || !imageAspect) {
return return
} }
const [, , committedScale] = readTransform(committedTransform.value) const [, , committedScale] = readTransform(committedTransform.value)
@@ -260,7 +263,6 @@ const ImageItem = ({
} }
// Try to zoom in so that we get rid of the black bars (whatever the orientation was). // Try to zoom in so that we get rid of the black bars (whatever the orientation was).
const imageAspect = imageDimensions.width / imageDimensions.height
const screenAspect = SCREEN.width / SCREEN.height const screenAspect = SCREEN.width / SCREEN.height
const candidateScale = Math.max( const candidateScale = Math.max(
imageAspect / screenAspect, imageAspect / screenAspect,
@@ -363,11 +365,10 @@ const styles = StyleSheet.create({
}) })
function getScaledDimensions( function getScaledDimensions(
imageDimensions: ImageDimensions, imageAspect: number,
scale: number, scale: number,
): ImageDimensions { ): ImageDimensions {
'worklet' 'worklet'
const imageAspect = imageDimensions.width / imageDimensions.height
const screenAspect = SCREEN.width / SCREEN.height const screenAspect = SCREEN.width / SCREEN.height
const isLandscape = imageAspect > screenAspect const isLandscape = imageAspect > screenAspect
if (isLandscape) { if (isLandscape) {
@@ -19,8 +19,8 @@ import Animated, {
import {Image} from 'expo-image' import {Image} from 'expo-image'
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
import {Dimensions as ImageDimensions, ImageSource} from '../../@types' import {useImageDimensions} from '#/lib/media/image-sizes'
import useImageDimensions from '../../hooks/useImageDimensions' import {ImageSource} from '../../@types'
const SWIPE_CLOSE_OFFSET = 75 const SWIPE_CLOSE_OFFSET = 75
const SWIPE_CLOSE_VELOCITY = 1 const SWIPE_CLOSE_VELOCITY = 1
@@ -47,7 +47,10 @@ const ImageItem = ({
const scrollViewRef = useAnimatedRef<Animated.ScrollView>() const scrollViewRef = useAnimatedRef<Animated.ScrollView>()
const translationY = useSharedValue(0) const translationY = useSharedValue(0)
const [scaled, setScaled] = useState(false) const [scaled, setScaled] = useState(false)
const imageDimensions = useImageDimensions(imageSrc) const [imageAspect, imageDimensions] = useImageDimensions({
src: imageSrc.uri,
knownDimensions: imageSrc.dimensions,
})
const maxZoomScale = imageDimensions const maxZoomScale = imageDimensions
? (imageDimensions.width / SCREEN.width) * MAX_ORIGINAL_IMAGE_ZOOM ? (imageDimensions.width / SCREEN.width) * MAX_ORIGINAL_IMAGE_ZOOM
: 1 : 1
@@ -99,7 +102,7 @@ const ImageItem = ({
const willZoom = !scaled const willZoom = !scaled
if (willZoom) { if (willZoom) {
nextZoomRect = getZoomRectAfterDoubleTap( nextZoomRect = getZoomRectAfterDoubleTap(
imageDimensions, imageAspect,
absoluteX, absoluteX,
absoluteY, absoluteY,
) )
@@ -179,7 +182,7 @@ const styles = StyleSheet.create({
}) })
const getZoomRectAfterDoubleTap = ( const getZoomRectAfterDoubleTap = (
imageDimensions: ImageDimensions | null, imageAspect: number | undefined,
touchX: number, touchX: number,
touchY: number, touchY: number,
): { ): {
@@ -188,7 +191,7 @@ const getZoomRectAfterDoubleTap = (
width: number width: number
height: number height: number
} => { } => {
if (!imageDimensions) { if (!imageAspect) {
return { return {
x: 0, x: 0,
y: 0, y: 0,
@@ -199,7 +202,6 @@ const getZoomRectAfterDoubleTap = (
// First, let's figure out how much we want to zoom in. // First, let's figure out how much we want to zoom in.
// We want to try to zoom in at least close enough to get rid of black bars. // We want to try to zoom in at least close enough to get rid of black bars.
const imageAspect = imageDimensions.width / imageDimensions.height
const screenAspect = SCREEN.width / SCREEN.height const screenAspect = SCREEN.width / SCREEN.height
const zoom = Math.max( const zoom = Math.max(
imageAspect / screenAspect, imageAspect / screenAspect,
@@ -1,93 +0,0 @@
/**
* Copyright (c) JOB TODAY S.A. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import {useEffect, useState} from 'react'
import {Image, ImageURISource} from 'react-native'
import {Dimensions, ImageSource} from '../@types'
const CACHE_SIZE = 50
type CacheStorageItem = {key: string; value: any}
const createCache = (cacheSize: number) => ({
_storage: [] as CacheStorageItem[],
get(key: string): any {
const {value} =
this._storage.find(({key: storageKey}) => storageKey === key) || {}
return value
},
set(key: string, value: any) {
if (this._storage.length >= cacheSize) {
this._storage.shift()
}
this._storage.push({key, value})
},
})
const imageDimensionsCache = createCache(CACHE_SIZE)
const useImageDimensions = (image: ImageSource): Dimensions | null => {
const [dimensions, setDimensions] = useState<Dimensions | null>(null)
const getImageDimensions = (
image: ImageSource,
): Promise<Dimensions | null> => {
return new Promise(resolve => {
if (image.uri) {
const source = image as ImageURISource
const cacheKey = source.uri as string
const imageDimensions = imageDimensionsCache.get(cacheKey)
if (imageDimensions) {
resolve(imageDimensions)
} else {
Image.getSizeWithHeaders(
// @ts-ignore
source.uri,
source.headers,
(width: number, height: number) => {
if (width > 0 && height > 0) {
imageDimensionsCache.set(cacheKey, {width, height})
resolve({width, height})
} else {
resolve(null)
}
},
() => {
resolve(null)
},
)
}
} else {
resolve(null)
}
})
}
let isImageUnmounted = false
useEffect(() => {
// eslint-disable-next-line @typescript-eslint/no-shadow
getImageDimensions(image).then(dimensions => {
if (!isImageUnmounted) {
setDimensions(dimensions)
}
})
return () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
isImageUnmounted = true
}
}, [image])
return dimensions
}
export default useImageDimensions
+9 -1
View File
@@ -32,7 +32,15 @@ export function Lightbox() {
return ( return (
<ImageView <ImageView
images={[ images={[
{uri: opts.profile.avatar || '', thumbUri: opts.profile.avatar || ''}, {
uri: opts.profile.avatar || '',
thumbUri: opts.profile.avatar || '',
dimensions: {
// It's fine if it's actually smaller but we know it's 1:1.
height: 1000,
width: 1000,
},
},
]} ]}
initialImageIndex={0} initialImageIndex={0}
thumbDims={opts.thumbDims} thumbDims={opts.thumbDims}
+11 -1
View File
@@ -72,7 +72,17 @@ export function ProfileSubpageHeader({
) { ) {
openLightbox({ openLightbox({
type: 'images', type: 'images',
images: [{uri: avatar, thumbUri: avatar}], images: [
{
uri: avatar,
thumbUri: avatar,
dimensions: {
// It's fine if it's actually smaller but we know it's 1:1.
height: 1000,
width: 1000,
},
},
],
index: 0, index: 0,
thumbDims: null, thumbDims: null,
}) })
+18 -43
View File
@@ -5,7 +5,7 @@ import {AppBskyEmbedImages} from '@atproto/api'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import * as imageSizes from '#/lib/media/image-sizes' import {useImageDimensions} from '#/lib/media/image-sizes'
import {Dimensions} from '#/lib/media/types' import {Dimensions} from '#/lib/media/types'
import {isNative} from '#/platform/detection' import {isNative} from '#/platform/detection'
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
@@ -14,44 +14,24 @@ import {ArrowsDiagonalOut_Stroke2_Corner0_Rounded as Fullscreen} from '#/compone
import {MediaInsetBorder} from '#/components/MediaInsetBorder' import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
export function useImageAspectRatio({ function useImageAspectRatio({
src, src,
dimensions, knownDimensions,
}: { }: {
src: string src: string
dimensions: Dimensions | undefined knownDimensions: Dimensions | null
}) { }) {
const [raw, setAspectRatio] = React.useState<number>( const [raw] = useImageDimensions({src, knownDimensions})
dimensions ? calc(dimensions) : 1, let constrained: number | undefined
) let max: number | undefined
// this basically controls the width of the image let isCropped: boolean | undefined
const {isCropped, constrained, max} = React.useMemo(() => { if (raw !== undefined) {
const ratio = 1 / 2 // max of 1:2 ratio in feeds const ratio = 1 / 2 // max of 1:2 ratio in feeds
const constrained = Math.max(raw, ratio) constrained = Math.max(raw, ratio)
const max = Math.max(raw, 0.25) // max of 1:4 in thread max = Math.max(raw, 0.25) // max of 1:4 in thread
const isCropped = raw < constrained isCropped = raw < constrained
return { }
isCropped,
constrained,
max,
}
}, [raw])
React.useEffect(() => {
let aborted = false
if (dimensions) return
imageSizes.fetch(src).then(newDim => {
if (aborted) return
setAspectRatio(calc(newDim))
})
return () => {
aborted = true
}
}, [dimensions, setAspectRatio, src])
return { return {
dimensions,
raw,
constrained, constrained,
max, max,
isCropped, isCropped,
@@ -125,7 +105,7 @@ export function AutoSizedImage({
isCropped: rawIsCropped, isCropped: rawIsCropped,
} = useImageAspectRatio({ } = useImageAspectRatio({
src: image.thumb, src: image.thumb,
dimensions: image.aspectRatio, knownDimensions: image.aspectRatio ?? null,
}) })
const cropDisabled = crop === 'none' const cropDisabled = crop === 'none'
const isCropped = rawIsCropped && !cropDisabled const isCropped = rawIsCropped && !cropDisabled
@@ -222,14 +202,16 @@ export function AutoSizedImage({
a.rounded_md, a.rounded_md,
a.overflow_hidden, a.overflow_hidden,
t.atoms.bg_contrast_25, t.atoms.bg_contrast_25,
{aspectRatio: max}, {aspectRatio: max ?? 1},
]}> ]}>
{contents} {contents}
</Pressable> </Pressable>
) )
} else { } else {
return ( return (
<ConstrainedImage fullBleed={crop === 'square'} aspectRatio={constrained}> <ConstrainedImage
fullBleed={crop === 'square'}
aspectRatio={constrained ?? 1}>
<Pressable <Pressable
onPress={onPress} onPress={onPress}
onLongPress={onLongPress} onLongPress={onLongPress}
@@ -244,10 +226,3 @@ export function AutoSizedImage({
) )
} }
} }
function calc(dim: Dimensions) {
if (dim.width === 0 || dim.height === 0) {
return 1
}
return dim.width / dim.height
}
+1 -1
View File
@@ -145,7 +145,7 @@ export function PostEmbeds({
uri: img.fullsize, uri: img.fullsize,
thumbUri: img.thumb, thumbUri: img.thumb,
alt: img.alt, alt: img.alt,
aspectRatio: img.aspectRatio, dimensions: img.aspectRatio ?? null,
})) }))
const _openLightbox = ( const _openLightbox = (
index: number, index: number,