From 858d4c8c8811ca8e16bffe3bfe0d541e576177ec Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Tue, 21 Mar 2023 12:59:10 -0500 Subject: [PATCH] Introduce an image sizes cache to improve feed layouts (close #213) (#335) * Introduce an image sizes cache to improve feed layouts (close #213) * Clear out resolved promises from the image cache --- src/lib/media/manip.ts | 9 +-- src/state/models/cache/image-sizes.ts | 37 ++++++++++++ .../link-metas.ts} | 4 +- src/state/models/{ => cache}/my-follows.ts | 4 +- src/state/models/me.ts | 6 +- src/state/models/root-store.ts | 6 +- src/view/com/util/images/AutoSizedImage.tsx | 56 +++++++++++++------ 7 files changed, 92 insertions(+), 30 deletions(-) create mode 100644 src/state/models/cache/image-sizes.ts rename src/state/models/{link-metas-view.ts => cache/link-metas.ts} (92%) rename src/state/models/{ => cache}/my-follows.ts (97%) diff --git a/src/lib/media/manip.ts b/src/lib/media/manip.ts index e44ee390..6ff8b691 100644 --- a/src/lib/media/manip.ts +++ b/src/lib/media/manip.ts @@ -5,6 +5,11 @@ import RNFS from 'react-native-fs' import uuid from 'react-native-uuid' import * as Toast from 'view/com/util/Toast' +export interface Dim { + width: number + height: number +} + export interface DownloadAndResizeOpts { uri: string width: number @@ -119,10 +124,6 @@ export async function compressIfNeeded( return finalImg } -export interface Dim { - width: number - height: number -} export function scaleDownDimensions(dim: Dim, max: Dim): Dim { if (dim.width < max.width && dim.height < max.height) { return dim diff --git a/src/state/models/cache/image-sizes.ts b/src/state/models/cache/image-sizes.ts new file mode 100644 index 00000000..ff048627 --- /dev/null +++ b/src/state/models/cache/image-sizes.ts @@ -0,0 +1,37 @@ +import {Image} from 'react-native' +import {Dim} from 'lib/media/manip' + +export class ImageSizesCache { + sizes: Map = new Map() + private activeRequests: Map> = new Map() + + constructor() {} + + get(uri: string): Dim | undefined { + return this.sizes.get(uri) + } + + async fetch(uri: string): Promise { + const dim = this.sizes.get(uri) + if (dim) { + return dim + } + const prom = + this.activeRequests.get(uri) || + new Promise(resolve => { + Image.getSize( + uri, + (width: number, height: number) => resolve({width, height}), + (err: any) => { + console.error('Failed to fetch image dimensions for', uri, err) + resolve({width: 0, height: 0}) + }, + ) + }) + this.activeRequests.set(uri, prom) + const res = await prom + this.activeRequests.delete(uri) + this.sizes.set(uri, res) + return res + } +} diff --git a/src/state/models/link-metas-view.ts b/src/state/models/cache/link-metas.ts similarity index 92% rename from src/state/models/link-metas-view.ts rename to src/state/models/cache/link-metas.ts index 59447008..607968c8 100644 --- a/src/state/models/link-metas-view.ts +++ b/src/state/models/cache/link-metas.ts @@ -1,10 +1,10 @@ import {makeAutoObservable} from 'mobx' import {LRUMap} from 'lru_map' -import {RootStoreModel} from './root-store' +import {RootStoreModel} from '../root-store' import {LinkMeta, getLinkMeta} from 'lib/link-meta/link-meta' type CacheValue = Promise | LinkMeta -export class LinkMetasViewModel { +export class LinkMetasCache { cache: LRUMap = new LRUMap(100) constructor(public rootStore: RootStoreModel) { diff --git a/src/state/models/my-follows.ts b/src/state/models/cache/my-follows.ts similarity index 97% rename from src/state/models/my-follows.ts rename to src/state/models/cache/my-follows.ts index bf1bf960..725b7841 100644 --- a/src/state/models/my-follows.ts +++ b/src/state/models/cache/my-follows.ts @@ -1,6 +1,6 @@ import {makeAutoObservable, runInAction} from 'mobx' import {FollowRecord, AppBskyActorProfile, AppBskyActorRef} from '@atproto/api' -import {RootStoreModel} from './root-store' +import {RootStoreModel} from '../root-store' import {bundleAsync} from 'lib/async/bundle' const CACHE_TTL = 1000 * 60 * 60 // hourly @@ -16,7 +16,7 @@ type Profile = * follows. It should be periodically refreshed and updated any time * the user makes a change to their follows. */ -export class MyFollowsModel { +export class MyFollowsCache { // data followDidToRecordMap: Record = {} lastSync = 0 diff --git a/src/state/models/me.ts b/src/state/models/me.ts index 192e8f19..12074915 100644 --- a/src/state/models/me.ts +++ b/src/state/models/me.ts @@ -2,7 +2,7 @@ import {makeAutoObservable, runInAction} from 'mobx' import {RootStoreModel} from './root-store' import {FeedModel} from './feed-view' import {NotificationsViewModel} from './notifications-view' -import {MyFollowsModel} from './my-follows' +import {MyFollowsCache} from './cache/my-follows' import {isObj, hasProp} from 'lib/type-guards' export class MeModel { @@ -15,7 +15,7 @@ export class MeModel { followersCount: number | undefined mainFeed: FeedModel notifications: NotificationsViewModel - follows: MyFollowsModel + follows: MyFollowsCache constructor(public rootStore: RootStoreModel) { makeAutoObservable( @@ -27,7 +27,7 @@ export class MeModel { algorithm: 'reverse-chronological', }) this.notifications = new NotificationsViewModel(this.rootStore, {}) - this.follows = new MyFollowsModel(this.rootStore) + this.follows = new MyFollowsCache(this.rootStore) } clear() { diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts index 03550f1b..4a8d09b4 100644 --- a/src/state/models/root-store.ts +++ b/src/state/models/root-store.ts @@ -13,10 +13,11 @@ import {LogModel} from './log' import {SessionModel} from './session' import {ShellUiModel} from './ui/shell' import {ProfilesViewModel} from './profiles-view' -import {LinkMetasViewModel} from './link-metas-view' +import {LinkMetasCache} from './cache/link-metas' import {NotificationsViewItemModel} from './notifications-view' import {MeModel} from './me' import {resetToTab} from '../../Navigation' +import {ImageSizesCache} from './cache/image-sizes' export const appInfo = z.object({ build: z.string(), @@ -34,7 +35,8 @@ export class RootStoreModel { shell = new ShellUiModel(this) me = new MeModel(this) profiles = new ProfilesViewModel(this) - linkMetas = new LinkMetasViewModel(this) + linkMetas = new LinkMetasCache(this) + imageSizes = new ImageSizesCache() // HACK // this flag is to track the lexicon breaking refactor diff --git a/src/view/com/util/images/AutoSizedImage.tsx b/src/view/com/util/images/AutoSizedImage.tsx index 0443c7be..24dbe6a5 100644 --- a/src/view/com/util/images/AutoSizedImage.tsx +++ b/src/view/com/util/images/AutoSizedImage.tsx @@ -1,7 +1,15 @@ import React from 'react' -import {StyleProp, StyleSheet, TouchableOpacity, ViewStyle} from 'react-native' -import Image, {OnLoadEvent} from 'view/com/util/images/Image' +import { + Image, + StyleProp, + StyleSheet, + TouchableOpacity, + ViewStyle, +} from 'react-native' +// import Image from 'view/com/util/images/Image' import {clamp} from 'lib/numbers' +import {useStores} from 'state/index' +import {Dim} from 'lib/media/manip' export const DELAY_PRESS_IN = 500 const MIN_ASPECT_RATIO = 0.33 // 1/3 @@ -22,16 +30,27 @@ export function AutoSizedImage({ style?: StyleProp children?: React.ReactNode }) { - const [aspectRatio, setAspectRatio] = React.useState(1) - const onLoad = (e: OnLoadEvent) => { - setAspectRatio( - clamp( - e.nativeEvent.width / e.nativeEvent.height, - MIN_ASPECT_RATIO, - MAX_ASPECT_RATIO, - ), - ) - } + const store = useStores() + const [dim, setDim] = React.useState( + store.imageSizes.get(uri), + ) + const [aspectRatio, setAspectRatio] = React.useState( + dim ? calc(dim) : 1, + ) + React.useEffect(() => { + let aborted = false + if (dim) { + return + } + store.imageSizes.fetch(uri).then(newDim => { + if (aborted) { + return + } + setDim(newDim) + setAspectRatio(calc(newDim)) + }) + }, [dim, setDim, setAspectRatio, store, uri]) + return ( - + {children} ) } +function calc(dim: Dim) { + if (dim.width === 0 || dim.height === 0) { + return 1 + } + return clamp(dim.width / dim.height, MIN_ASPECT_RATIO, MAX_ASPECT_RATIO) +} + const styles = StyleSheet.create({ container: { overflow: 'hidden',