From 858d4c8c8811ca8e16bffe3bfe0d541e576177ec Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Tue, 21 Mar 2023 12:59:10 -0500 Subject: 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 ++++++++ src/state/models/cache/link-metas.ts | 44 ++++++++++ src/state/models/cache/my-follows.ts | 126 ++++++++++++++++++++++++++++ src/state/models/link-metas-view.ts | 44 ---------- src/state/models/me.ts | 6 +- src/state/models/my-follows.ts | 126 ---------------------------- src/state/models/root-store.ts | 6 +- src/view/com/util/images/AutoSizedImage.tsx | 56 +++++++++---- 9 files changed, 258 insertions(+), 196 deletions(-) create mode 100644 src/state/models/cache/image-sizes.ts create mode 100644 src/state/models/cache/link-metas.ts create mode 100644 src/state/models/cache/my-follows.ts delete mode 100644 src/state/models/link-metas-view.ts delete mode 100644 src/state/models/my-follows.ts (limited to 'src') diff --git a/src/lib/media/manip.ts b/src/lib/media/manip.ts index e44ee3907..6ff8b691c 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 000000000..ff0486278 --- /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/cache/link-metas.ts b/src/state/models/cache/link-metas.ts new file mode 100644 index 000000000..607968c80 --- /dev/null +++ b/src/state/models/cache/link-metas.ts @@ -0,0 +1,44 @@ +import {makeAutoObservable} from 'mobx' +import {LRUMap} from 'lru_map' +import {RootStoreModel} from '../root-store' +import {LinkMeta, getLinkMeta} from 'lib/link-meta/link-meta' + +type CacheValue = Promise | LinkMeta +export class LinkMetasCache { + cache: LRUMap = new LRUMap(100) + + constructor(public rootStore: RootStoreModel) { + makeAutoObservable( + this, + { + rootStore: false, + cache: false, + }, + {autoBind: true}, + ) + } + + // public api + // = + + async getLinkMeta(url: string) { + const cached = this.cache.get(url) + if (cached) { + try { + return await cached + } catch (e) { + // ignore, we'll try again + } + } + try { + const promise = getLinkMeta(this.rootStore, url) + this.cache.set(url, promise) + const res = await promise + this.cache.set(url, res) + return res + } catch (e) { + this.cache.delete(url) + throw e + } + } +} diff --git a/src/state/models/cache/my-follows.ts b/src/state/models/cache/my-follows.ts new file mode 100644 index 000000000..725b7841e --- /dev/null +++ b/src/state/models/cache/my-follows.ts @@ -0,0 +1,126 @@ +import {makeAutoObservable, runInAction} from 'mobx' +import {FollowRecord, AppBskyActorProfile, AppBskyActorRef} from '@atproto/api' +import {RootStoreModel} from '../root-store' +import {bundleAsync} from 'lib/async/bundle' + +const CACHE_TTL = 1000 * 60 * 60 // hourly +type FollowsListResponse = Awaited> +type FollowsListResponseRecord = FollowsListResponse['records'][0] +type Profile = + | AppBskyActorProfile.ViewBasic + | AppBskyActorProfile.View + | AppBskyActorRef.WithInfo + +/** + * This model is used to maintain a synced local cache of the user's + * follows. It should be periodically refreshed and updated any time + * the user makes a change to their follows. + */ +export class MyFollowsCache { + // data + followDidToRecordMap: Record = {} + lastSync = 0 + myDid?: string + + constructor(public rootStore: RootStoreModel) { + makeAutoObservable( + this, + { + rootStore: false, + }, + {autoBind: true}, + ) + } + + // public api + // = + + clear() { + this.followDidToRecordMap = {} + this.lastSync = 0 + this.myDid = undefined + } + + fetchIfNeeded = bundleAsync(async () => { + if ( + this.myDid !== this.rootStore.me.did || + Object.keys(this.followDidToRecordMap).length === 0 || + Date.now() - this.lastSync > CACHE_TTL + ) { + return await this.fetch() + } + }) + + fetch = bundleAsync(async () => { + this.rootStore.log.debug('MyFollowsModel:fetch running full fetch') + let before + let records: FollowsListResponseRecord[] = [] + do { + const res: FollowsListResponse = + await this.rootStore.api.app.bsky.graph.follow.list({ + user: this.rootStore.me.did, + before, + }) + records = records.concat(res.records) + before = res.cursor + } while (typeof before !== 'undefined') + runInAction(() => { + this.followDidToRecordMap = {} + for (const record of records) { + this.followDidToRecordMap[record.value.subject.did] = record.uri + } + this.lastSync = Date.now() + this.myDid = this.rootStore.me.did + }) + }) + + isFollowing(did: string) { + return !!this.followDidToRecordMap[did] + } + + get numFollows() { + return Object.keys(this.followDidToRecordMap).length + } + + get isEmpty() { + return Object.keys(this.followDidToRecordMap).length === 0 + } + + getFollowUri(did: string): string { + const v = this.followDidToRecordMap[did] + if (!v) { + throw new Error('Not a followed user') + } + return v + } + + addFollow(did: string, recordUri: string) { + this.followDidToRecordMap[did] = recordUri + } + + removeFollow(did: string) { + delete this.followDidToRecordMap[did] + } + + /** + * Use this to incrementally update the cache as views provide information + */ + hydrate(did: string, recordUri: string | undefined) { + if (recordUri) { + this.followDidToRecordMap[did] = recordUri + } else { + delete this.followDidToRecordMap[did] + } + } + + /** + * Use this to incrementally update the cache as views provide information + */ + hydrateProfiles(profiles: Profile[]) { + for (const profile of profiles) { + if (profile.viewer) { + this.hydrate(profile.did, profile.viewer.following) + } + } + } +} diff --git a/src/state/models/link-metas-view.ts b/src/state/models/link-metas-view.ts deleted file mode 100644 index 59447008a..000000000 --- a/src/state/models/link-metas-view.ts +++ /dev/null @@ -1,44 +0,0 @@ -import {makeAutoObservable} from 'mobx' -import {LRUMap} from 'lru_map' -import {RootStoreModel} from './root-store' -import {LinkMeta, getLinkMeta} from 'lib/link-meta/link-meta' - -type CacheValue = Promise | LinkMeta -export class LinkMetasViewModel { - cache: LRUMap = new LRUMap(100) - - constructor(public rootStore: RootStoreModel) { - makeAutoObservable( - this, - { - rootStore: false, - cache: false, - }, - {autoBind: true}, - ) - } - - // public api - // = - - async getLinkMeta(url: string) { - const cached = this.cache.get(url) - if (cached) { - try { - return await cached - } catch (e) { - // ignore, we'll try again - } - } - try { - const promise = getLinkMeta(this.rootStore, url) - this.cache.set(url, promise) - const res = await promise - this.cache.set(url, res) - return res - } catch (e) { - this.cache.delete(url) - throw e - } - } -} diff --git a/src/state/models/me.ts b/src/state/models/me.ts index 192e8f19f..120749155 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/my-follows.ts b/src/state/models/my-follows.ts deleted file mode 100644 index bf1bf9600..000000000 --- a/src/state/models/my-follows.ts +++ /dev/null @@ -1,126 +0,0 @@ -import {makeAutoObservable, runInAction} from 'mobx' -import {FollowRecord, AppBskyActorProfile, AppBskyActorRef} from '@atproto/api' -import {RootStoreModel} from './root-store' -import {bundleAsync} from 'lib/async/bundle' - -const CACHE_TTL = 1000 * 60 * 60 // hourly -type FollowsListResponse = Awaited> -type FollowsListResponseRecord = FollowsListResponse['records'][0] -type Profile = - | AppBskyActorProfile.ViewBasic - | AppBskyActorProfile.View - | AppBskyActorRef.WithInfo - -/** - * This model is used to maintain a synced local cache of the user's - * follows. It should be periodically refreshed and updated any time - * the user makes a change to their follows. - */ -export class MyFollowsModel { - // data - followDidToRecordMap: Record = {} - lastSync = 0 - myDid?: string - - constructor(public rootStore: RootStoreModel) { - makeAutoObservable( - this, - { - rootStore: false, - }, - {autoBind: true}, - ) - } - - // public api - // = - - clear() { - this.followDidToRecordMap = {} - this.lastSync = 0 - this.myDid = undefined - } - - fetchIfNeeded = bundleAsync(async () => { - if ( - this.myDid !== this.rootStore.me.did || - Object.keys(this.followDidToRecordMap).length === 0 || - Date.now() - this.lastSync > CACHE_TTL - ) { - return await this.fetch() - } - }) - - fetch = bundleAsync(async () => { - this.rootStore.log.debug('MyFollowsModel:fetch running full fetch') - let before - let records: FollowsListResponseRecord[] = [] - do { - const res: FollowsListResponse = - await this.rootStore.api.app.bsky.graph.follow.list({ - user: this.rootStore.me.did, - before, - }) - records = records.concat(res.records) - before = res.cursor - } while (typeof before !== 'undefined') - runInAction(() => { - this.followDidToRecordMap = {} - for (const record of records) { - this.followDidToRecordMap[record.value.subject.did] = record.uri - } - this.lastSync = Date.now() - this.myDid = this.rootStore.me.did - }) - }) - - isFollowing(did: string) { - return !!this.followDidToRecordMap[did] - } - - get numFollows() { - return Object.keys(this.followDidToRecordMap).length - } - - get isEmpty() { - return Object.keys(this.followDidToRecordMap).length === 0 - } - - getFollowUri(did: string): string { - const v = this.followDidToRecordMap[did] - if (!v) { - throw new Error('Not a followed user') - } - return v - } - - addFollow(did: string, recordUri: string) { - this.followDidToRecordMap[did] = recordUri - } - - removeFollow(did: string) { - delete this.followDidToRecordMap[did] - } - - /** - * Use this to incrementally update the cache as views provide information - */ - hydrate(did: string, recordUri: string | undefined) { - if (recordUri) { - this.followDidToRecordMap[did] = recordUri - } else { - delete this.followDidToRecordMap[did] - } - } - - /** - * Use this to incrementally update the cache as views provide information - */ - hydrateProfiles(profiles: Profile[]) { - for (const profile of profiles) { - if (profile.viewer) { - this.hydrate(profile.did, profile.viewer.following) - } - } - } -} diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts index 03550f1b0..4a8d09b41 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 0443c7be4..24dbe6a52 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', -- cgit 1.4.1