From 858d4c8c8811ca8e16bffe3bfe0d541e576177ec Mon Sep 17 00:00:00 2001
From: Paul Frazee <pfrazee@gmail.com>
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<string, Dim> = new Map()
+  private activeRequests: Map<string, Promise<Dim>> = new Map()
+
+  constructor() {}
+
+  get(uri: string): Dim | undefined {
+    return this.sizes.get(uri)
+  }
+
+  async fetch(uri: string): Promise<Dim> {
+    const dim = this.sizes.get(uri)
+    if (dim) {
+      return dim
+    }
+    const prom =
+      this.activeRequests.get(uri) ||
+      new Promise<Dim>(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> | LinkMeta
-export class LinkMetasViewModel {
+export class LinkMetasCache {
   cache: LRUMap<string, CacheValue> = 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<string, string> = {}
   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<ViewStyle>
   children?: React.ReactNode
 }) {
-  const [aspectRatio, setAspectRatio] = React.useState<number>(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<Dim | undefined>(
+    store.imageSizes.get(uri),
+  )
+  const [aspectRatio, setAspectRatio] = React.useState<number>(
+    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 (
     <TouchableOpacity
       onPress={onPress}
@@ -39,16 +58,19 @@ export function AutoSizedImage({
       onPressIn={onPressIn}
       delayPressIn={DELAY_PRESS_IN}
       style={[styles.container, style]}>
-      <Image
-        style={[styles.image, {aspectRatio}]}
-        source={{uri}}
-        onLoad={onLoad}
-      />
+      <Image style={[styles.image, {aspectRatio}]} source={{uri}} />
       {children}
     </TouchableOpacity>
   )
 }
 
+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',