* Introduce an image sizes cache to improve feed layouts (close #213) * Clear out resolved promises from the image cachezio/stable
parent
c1d454b7cf
commit
858d4c8c88
|
@ -5,6 +5,11 @@ import RNFS from 'react-native-fs'
|
||||||
import uuid from 'react-native-uuid'
|
import uuid from 'react-native-uuid'
|
||||||
import * as Toast from 'view/com/util/Toast'
|
import * as Toast from 'view/com/util/Toast'
|
||||||
|
|
||||||
|
export interface Dim {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface DownloadAndResizeOpts {
|
export interface DownloadAndResizeOpts {
|
||||||
uri: string
|
uri: string
|
||||||
width: number
|
width: number
|
||||||
|
@ -119,10 +124,6 @@ export async function compressIfNeeded(
|
||||||
return finalImg
|
return finalImg
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Dim {
|
|
||||||
width: number
|
|
||||||
height: number
|
|
||||||
}
|
|
||||||
export function scaleDownDimensions(dim: Dim, max: Dim): Dim {
|
export function scaleDownDimensions(dim: Dim, max: Dim): Dim {
|
||||||
if (dim.width < max.width && dim.height < max.height) {
|
if (dim.width < max.width && dim.height < max.height) {
|
||||||
return dim
|
return dim
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,10 +1,10 @@
|
||||||
import {makeAutoObservable} from 'mobx'
|
import {makeAutoObservable} from 'mobx'
|
||||||
import {LRUMap} from 'lru_map'
|
import {LRUMap} from 'lru_map'
|
||||||
import {RootStoreModel} from './root-store'
|
import {RootStoreModel} from '../root-store'
|
||||||
import {LinkMeta, getLinkMeta} from 'lib/link-meta/link-meta'
|
import {LinkMeta, getLinkMeta} from 'lib/link-meta/link-meta'
|
||||||
|
|
||||||
type CacheValue = Promise<LinkMeta> | LinkMeta
|
type CacheValue = Promise<LinkMeta> | LinkMeta
|
||||||
export class LinkMetasViewModel {
|
export class LinkMetasCache {
|
||||||
cache: LRUMap<string, CacheValue> = new LRUMap(100)
|
cache: LRUMap<string, CacheValue> = new LRUMap(100)
|
||||||
|
|
||||||
constructor(public rootStore: RootStoreModel) {
|
constructor(public rootStore: RootStoreModel) {
|
|
@ -1,6 +1,6 @@
|
||||||
import {makeAutoObservable, runInAction} from 'mobx'
|
import {makeAutoObservable, runInAction} from 'mobx'
|
||||||
import {FollowRecord, AppBskyActorProfile, AppBskyActorRef} from '@atproto/api'
|
import {FollowRecord, AppBskyActorProfile, AppBskyActorRef} from '@atproto/api'
|
||||||
import {RootStoreModel} from './root-store'
|
import {RootStoreModel} from '../root-store'
|
||||||
import {bundleAsync} from 'lib/async/bundle'
|
import {bundleAsync} from 'lib/async/bundle'
|
||||||
|
|
||||||
const CACHE_TTL = 1000 * 60 * 60 // hourly
|
const CACHE_TTL = 1000 * 60 * 60 // hourly
|
||||||
|
@ -16,7 +16,7 @@ type Profile =
|
||||||
* follows. It should be periodically refreshed and updated any time
|
* follows. It should be periodically refreshed and updated any time
|
||||||
* the user makes a change to their follows.
|
* the user makes a change to their follows.
|
||||||
*/
|
*/
|
||||||
export class MyFollowsModel {
|
export class MyFollowsCache {
|
||||||
// data
|
// data
|
||||||
followDidToRecordMap: Record<string, string> = {}
|
followDidToRecordMap: Record<string, string> = {}
|
||||||
lastSync = 0
|
lastSync = 0
|
|
@ -2,7 +2,7 @@ import {makeAutoObservable, runInAction} from 'mobx'
|
||||||
import {RootStoreModel} from './root-store'
|
import {RootStoreModel} from './root-store'
|
||||||
import {FeedModel} from './feed-view'
|
import {FeedModel} from './feed-view'
|
||||||
import {NotificationsViewModel} from './notifications-view'
|
import {NotificationsViewModel} from './notifications-view'
|
||||||
import {MyFollowsModel} from './my-follows'
|
import {MyFollowsCache} from './cache/my-follows'
|
||||||
import {isObj, hasProp} from 'lib/type-guards'
|
import {isObj, hasProp} from 'lib/type-guards'
|
||||||
|
|
||||||
export class MeModel {
|
export class MeModel {
|
||||||
|
@ -15,7 +15,7 @@ export class MeModel {
|
||||||
followersCount: number | undefined
|
followersCount: number | undefined
|
||||||
mainFeed: FeedModel
|
mainFeed: FeedModel
|
||||||
notifications: NotificationsViewModel
|
notifications: NotificationsViewModel
|
||||||
follows: MyFollowsModel
|
follows: MyFollowsCache
|
||||||
|
|
||||||
constructor(public rootStore: RootStoreModel) {
|
constructor(public rootStore: RootStoreModel) {
|
||||||
makeAutoObservable(
|
makeAutoObservable(
|
||||||
|
@ -27,7 +27,7 @@ export class MeModel {
|
||||||
algorithm: 'reverse-chronological',
|
algorithm: 'reverse-chronological',
|
||||||
})
|
})
|
||||||
this.notifications = new NotificationsViewModel(this.rootStore, {})
|
this.notifications = new NotificationsViewModel(this.rootStore, {})
|
||||||
this.follows = new MyFollowsModel(this.rootStore)
|
this.follows = new MyFollowsCache(this.rootStore)
|
||||||
}
|
}
|
||||||
|
|
||||||
clear() {
|
clear() {
|
||||||
|
|
|
@ -13,10 +13,11 @@ import {LogModel} from './log'
|
||||||
import {SessionModel} from './session'
|
import {SessionModel} from './session'
|
||||||
import {ShellUiModel} from './ui/shell'
|
import {ShellUiModel} from './ui/shell'
|
||||||
import {ProfilesViewModel} from './profiles-view'
|
import {ProfilesViewModel} from './profiles-view'
|
||||||
import {LinkMetasViewModel} from './link-metas-view'
|
import {LinkMetasCache} from './cache/link-metas'
|
||||||
import {NotificationsViewItemModel} from './notifications-view'
|
import {NotificationsViewItemModel} from './notifications-view'
|
||||||
import {MeModel} from './me'
|
import {MeModel} from './me'
|
||||||
import {resetToTab} from '../../Navigation'
|
import {resetToTab} from '../../Navigation'
|
||||||
|
import {ImageSizesCache} from './cache/image-sizes'
|
||||||
|
|
||||||
export const appInfo = z.object({
|
export const appInfo = z.object({
|
||||||
build: z.string(),
|
build: z.string(),
|
||||||
|
@ -34,7 +35,8 @@ export class RootStoreModel {
|
||||||
shell = new ShellUiModel(this)
|
shell = new ShellUiModel(this)
|
||||||
me = new MeModel(this)
|
me = new MeModel(this)
|
||||||
profiles = new ProfilesViewModel(this)
|
profiles = new ProfilesViewModel(this)
|
||||||
linkMetas = new LinkMetasViewModel(this)
|
linkMetas = new LinkMetasCache(this)
|
||||||
|
imageSizes = new ImageSizesCache()
|
||||||
|
|
||||||
// HACK
|
// HACK
|
||||||
// this flag is to track the lexicon breaking refactor
|
// this flag is to track the lexicon breaking refactor
|
||||||
|
|
|
@ -1,7 +1,15 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {StyleProp, StyleSheet, TouchableOpacity, ViewStyle} from 'react-native'
|
import {
|
||||||
import Image, {OnLoadEvent} from 'view/com/util/images/Image'
|
Image,
|
||||||
|
StyleProp,
|
||||||
|
StyleSheet,
|
||||||
|
TouchableOpacity,
|
||||||
|
ViewStyle,
|
||||||
|
} from 'react-native'
|
||||||
|
// import Image from 'view/com/util/images/Image'
|
||||||
import {clamp} from 'lib/numbers'
|
import {clamp} from 'lib/numbers'
|
||||||
|
import {useStores} from 'state/index'
|
||||||
|
import {Dim} from 'lib/media/manip'
|
||||||
|
|
||||||
export const DELAY_PRESS_IN = 500
|
export const DELAY_PRESS_IN = 500
|
||||||
const MIN_ASPECT_RATIO = 0.33 // 1/3
|
const MIN_ASPECT_RATIO = 0.33 // 1/3
|
||||||
|
@ -22,16 +30,27 @@ export function AutoSizedImage({
|
||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
const [aspectRatio, setAspectRatio] = React.useState<number>(1)
|
const store = useStores()
|
||||||
const onLoad = (e: OnLoadEvent) => {
|
const [dim, setDim] = React.useState<Dim | undefined>(
|
||||||
setAspectRatio(
|
store.imageSizes.get(uri),
|
||||||
clamp(
|
)
|
||||||
e.nativeEvent.width / e.nativeEvent.height,
|
const [aspectRatio, setAspectRatio] = React.useState<number>(
|
||||||
MIN_ASPECT_RATIO,
|
dim ? calc(dim) : 1,
|
||||||
MAX_ASPECT_RATIO,
|
)
|
||||||
),
|
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 (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
|
@ -39,16 +58,19 @@ export function AutoSizedImage({
|
||||||
onPressIn={onPressIn}
|
onPressIn={onPressIn}
|
||||||
delayPressIn={DELAY_PRESS_IN}
|
delayPressIn={DELAY_PRESS_IN}
|
||||||
style={[styles.container, style]}>
|
style={[styles.container, style]}>
|
||||||
<Image
|
<Image style={[styles.image, {aspectRatio}]} source={{uri}} />
|
||||||
style={[styles.image, {aspectRatio}]}
|
|
||||||
source={{uri}}
|
|
||||||
onLoad={onLoad}
|
|
||||||
/>
|
|
||||||
{children}
|
{children}
|
||||||
</TouchableOpacity>
|
</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({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
|
|
Loading…
Reference in New Issue