Use a post and handle-resolution cache to enable quick postthread loading (#1097)

* Use a post and handle-resolution cache to enable quick postthread loading

* Fix positioning of thread when loaded from cache and give more visual cues

* Include parent posts in cache

* Include notifications in cache
This commit is contained in:
Paul Frazee 2023-08-03 09:44:43 -07:00 committed by GitHub
parent 7256169506
commit a63f97aef2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 167 additions and 18 deletions

View file

@ -29,10 +29,24 @@ export async function resolveName(store: RootStoreModel, didOrHandle: string) {
if (didOrHandle.startsWith('did:')) { if (didOrHandle.startsWith('did:')) {
return didOrHandle return didOrHandle
} }
const res = await store.agent.resolveHandle({
handle: didOrHandle, // we run the resolution always to ensure freshness
}) const promise = store.agent
return res.data.did .resolveHandle({
handle: didOrHandle,
})
.then(res => {
store.handleResolutions.cache.set(didOrHandle, res.data.did)
return res.data.did
})
// but we can return immediately if it's cached
const cached = store.handleResolutions.cache.get(didOrHandle)
if (cached) {
return cached
}
return promise
} }
export async function uploadBlob( export async function uploadBlob(

View file

@ -0,0 +1,5 @@
import {LRUMap} from 'lru_map'
export class HandleResolutionsCache {
cache: LRUMap<string, string> = new LRUMap(500)
}

31
src/state/models/cache/posts.ts vendored Normal file
View file

@ -0,0 +1,31 @@
import {LRUMap} from 'lru_map'
import {RootStoreModel} from '../root-store'
import {AppBskyFeedDefs} from '@atproto/api'
type PostView = AppBskyFeedDefs.PostView
export class PostsCache {
cache: LRUMap<string, PostView> = new LRUMap(500)
constructor(public rootStore: RootStoreModel) {}
set(uri: string, postView: PostView) {
this.cache.set(uri, postView)
if (postView.author.handle) {
this.rootStore.handleResolutions.cache.set(
postView.author.handle,
postView.author.did,
)
}
}
fromFeedItem(feedItem: AppBskyFeedDefs.FeedViewPost) {
this.set(feedItem.post.uri, feedItem.post)
if (
feedItem.reply?.parent &&
AppBskyFeedDefs.isPostView(feedItem.reply?.parent)
) {
this.set(feedItem.reply.parent.uri, feedItem.reply.parent)
}
}
}

View file

@ -12,6 +12,8 @@ import {PostThreadItemModel} from './post-thread-item'
export class PostThreadModel { export class PostThreadModel {
// state // state
isLoading = false isLoading = false
isLoadingFromCache = false
isFromCache = false
isRefreshing = false isRefreshing = false
hasLoaded = false hasLoaded = false
error = '' error = ''
@ -20,7 +22,7 @@ export class PostThreadModel {
params: GetPostThread.QueryParams params: GetPostThread.QueryParams
// data // data
thread?: PostThreadItemModel thread?: PostThreadItemModel | null = null
isBlocked = false isBlocked = false
constructor( constructor(
@ -52,7 +54,7 @@ export class PostThreadModel {
} }
get hasContent() { get hasContent() {
return typeof this.thread !== 'undefined' return !!this.thread
} }
get hasError() { get hasError() {
@ -82,10 +84,16 @@ export class PostThreadModel {
if (!this.resolvedUri) { if (!this.resolvedUri) {
await this._resolveUri() await this._resolveUri()
} }
if (this.hasContent) { if (this.hasContent) {
await this.update() await this.update()
} else { } else {
await this._load() const precache = this.rootStore.posts.cache.get(this.resolvedUri)
if (precache) {
await this._loadPrecached(precache)
} else {
await this._load()
}
} }
} }
@ -169,6 +177,37 @@ export class PostThreadModel {
}) })
} }
async _loadPrecached(precache: AppBskyFeedDefs.PostView) {
// start with the cached version
this.isLoadingFromCache = true
this.isFromCache = true
this._replaceAll({
success: true,
headers: {},
data: {
thread: {
post: precache,
},
},
})
this._xIdle()
// then update in the background
try {
const res = await this.rootStore.agent.getPostThread(
Object.assign({}, this.params, {uri: this.resolvedUri}),
)
this._replaceAll(res)
} catch (e: any) {
console.log(e)
this._xIdle(e)
} finally {
runInAction(() => {
this.isLoadingFromCache = false
})
}
}
async _load(isRefreshing = false) { async _load(isRefreshing = false) {
if (this.hasLoaded && !isRefreshing) { if (this.hasLoaded && !isRefreshing) {
return return

View file

@ -253,6 +253,12 @@ export class ProfileModel {
try { try {
const res = await this.rootStore.agent.getProfile(this.params) const res = await this.rootStore.agent.getProfile(this.params)
this.rootStore.profiles.overwrite(this.params.actor, res) // cache invalidation this.rootStore.profiles.overwrite(this.params.actor, res) // cache invalidation
if (res.data.handle) {
this.rootStore.handleResolutions.cache.set(
res.data.handle,
res.data.did,
)
}
this._replaceAll(res) this._replaceAll(res)
await this._createRichText() await this._createRichText()
this._xIdle() this._xIdle()

View file

@ -503,7 +503,9 @@ export class NotificationsFeedModel {
const postsRes = await this.rootStore.agent.app.bsky.feed.getPosts({ const postsRes = await this.rootStore.agent.app.bsky.feed.getPosts({
uris: [addedUri], uris: [addedUri],
}) })
notif.setAdditionalData(postsRes.data.posts[0]) const post = postsRes.data.posts[0]
notif.setAdditionalData(post)
this.rootStore.posts.set(post.uri, post)
} }
const filtered = this._filterNotifications([notif]) const filtered = this._filterNotifications([notif])
return filtered[0] return filtered[0]
@ -611,6 +613,7 @@ export class NotificationsFeedModel {
), ),
) )
for (const post of postsChunks.flat()) { for (const post of postsChunks.flat()) {
this.rootStore.posts.set(post.uri, post)
const models = addedPostMap.get(post.uri) const models = addedPostMap.get(post.uri)
if (models?.length) { if (models?.length) {
for (const model of models) { for (const model of models) {

View file

@ -374,6 +374,9 @@ export class PostsFeedModel {
this.rootStore.me.follows.hydrateProfiles( this.rootStore.me.follows.hydrateProfiles(
res.data.feed.map(item => item.post.author), res.data.feed.map(item => item.post.author),
) )
for (const item of res.data.feed) {
this.rootStore.posts.fromFeedItem(item)
}
const slices = this.tuner.tune(res.data.feed, this.feedTuners) const slices = this.tuner.tune(res.data.feed, this.feedTuners)
@ -405,6 +408,7 @@ export class PostsFeedModel {
res: GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response, res: GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response,
) { ) {
for (const item of res.data.feed) { for (const item of res.data.feed) {
this.rootStore.posts.fromFeedItem(item)
const existingSlice = this.slices.find(slice => const existingSlice = this.slices.find(slice =>
slice.containsUri(item.post.uri), slice.containsUri(item.post.uri),
) )

View file

@ -12,7 +12,9 @@ import {isObj, hasProp} from 'lib/type-guards'
import {LogModel} from './log' import {LogModel} from './log'
import {SessionModel} from './session' import {SessionModel} from './session'
import {ShellUiModel} from './ui/shell' import {ShellUiModel} from './ui/shell'
import {HandleResolutionsCache} from './cache/handle-resolutions'
import {ProfilesCache} from './cache/profiles-view' import {ProfilesCache} from './cache/profiles-view'
import {PostsCache} from './cache/posts'
import {LinkMetasCache} from './cache/link-metas' import {LinkMetasCache} from './cache/link-metas'
import {NotificationsFeedItemModel} from './feeds/notifications' import {NotificationsFeedItemModel} from './feeds/notifications'
import {MeModel} from './me' import {MeModel} from './me'
@ -45,7 +47,9 @@ export class RootStoreModel {
preferences = new PreferencesModel(this) preferences = new PreferencesModel(this)
me = new MeModel(this) me = new MeModel(this)
invitedUsers = new InvitedUsers(this) invitedUsers = new InvitedUsers(this)
handleResolutions = new HandleResolutionsCache()
profiles = new ProfilesCache(this) profiles = new ProfilesCache(this)
posts = new PostsCache(this)
linkMetas = new LinkMetasCache(this) linkMetas = new LinkMetasCache(this)
imageSizes = new ImageSizesCache() imageSizes = new ImageSizesCache()
mutedThreads = new MutedThreads() mutedThreads = new MutedThreads()

View file

@ -20,25 +20,37 @@ import {ComposePrompt} from '../composer/Prompt'
import {ErrorMessage} from '../util/error/ErrorMessage' import {ErrorMessage} from '../util/error/ErrorMessage'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
import {s} from 'lib/styles' import {s} from 'lib/styles'
import {isDesktopWeb, isMobileWeb} from 'platform/detection' import {isIOS, isDesktopWeb, isMobileWeb} from 'platform/detection'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useSetTitle} from 'lib/hooks/useSetTitle' import {useSetTitle} from 'lib/hooks/useSetTitle'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from 'lib/routes/types' import {NavigationProp} from 'lib/routes/types'
import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeDisplayName} from 'lib/strings/display-names'
const MAINTAIN_VISIBLE_CONTENT_POSITION = {minIndexForVisible: 0}
const PARENT_SPINNER = {
_reactKey: '__parent_spinner__',
_isHighlightedPost: false,
}
const REPLY_PROMPT = {_reactKey: '__reply__', _isHighlightedPost: false} const REPLY_PROMPT = {_reactKey: '__reply__', _isHighlightedPost: false}
const DELETED = {_reactKey: '__deleted__', _isHighlightedPost: false} const DELETED = {_reactKey: '__deleted__', _isHighlightedPost: false}
const BLOCKED = {_reactKey: '__blocked__', _isHighlightedPost: false} const BLOCKED = {_reactKey: '__blocked__', _isHighlightedPost: false}
const CHILD_SPINNER = {
_reactKey: '__child_spinner__',
_isHighlightedPost: false,
}
const BOTTOM_COMPONENT = { const BOTTOM_COMPONENT = {
_reactKey: '__bottom_component__', _reactKey: '__bottom_component__',
_isHighlightedPost: false, _isHighlightedPost: false,
} }
type YieldedItem = type YieldedItem =
| PostThreadItemModel | PostThreadItemModel
| typeof PARENT_SPINNER
| typeof REPLY_PROMPT | typeof REPLY_PROMPT
| typeof DELETED | typeof DELETED
| typeof BLOCKED | typeof BLOCKED
| typeof PARENT_SPINNER
export const PostThread = observer(function PostThread({ export const PostThread = observer(function PostThread({
uri, uri,
@ -55,10 +67,19 @@ export const PostThread = observer(function PostThread({
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const posts = React.useMemo(() => { const posts = React.useMemo(() => {
if (view.thread) { if (view.thread) {
return Array.from(flattenThread(view.thread)).concat([BOTTOM_COMPONENT]) const arr = Array.from(flattenThread(view.thread))
if (view.isLoadingFromCache) {
if (view.thread?.postRecord?.reply) {
arr.unshift(PARENT_SPINNER)
}
arr.push(CHILD_SPINNER)
} else {
arr.push(BOTTOM_COMPONENT)
}
return arr
} }
return [] return []
}, [view.thread]) }, [view.isLoadingFromCache, view.thread])
useSetTitle( useSetTitle(
view.thread?.postRecord && view.thread?.postRecord &&
`${sanitizeDisplayName( `${sanitizeDisplayName(
@ -80,17 +101,15 @@ export const PostThread = observer(function PostThread({
setIsRefreshing(false) setIsRefreshing(false)
}, [view, setIsRefreshing]) }, [view, setIsRefreshing])
const onLayout = React.useCallback(() => { const onContentSizeChange = React.useCallback(() => {
const index = posts.findIndex(post => post._isHighlightedPost) const index = posts.findIndex(post => post._isHighlightedPost)
if (index !== -1) { if (index !== -1) {
ref.current?.scrollToIndex({ ref.current?.scrollToIndex({
index, index,
animated: false, animated: false,
viewOffset: 40,
}) })
} }
}, [posts, ref]) }, [posts, ref])
const onScrollToIndexFailed = React.useCallback( const onScrollToIndexFailed = React.useCallback(
(info: { (info: {
index: number index: number
@ -115,7 +134,13 @@ export const PostThread = observer(function PostThread({
const renderItem = React.useCallback( const renderItem = React.useCallback(
({item}: {item: YieldedItem}) => { ({item}: {item: YieldedItem}) => {
if (item === REPLY_PROMPT) { if (item === PARENT_SPINNER) {
return (
<View style={styles.parentSpinner}>
<ActivityIndicator />
</View>
)
} else if (item === REPLY_PROMPT) {
return <ComposePrompt onPressCompose={onPressReply} /> return <ComposePrompt onPressCompose={onPressReply} />
} else if (item === DELETED) { } else if (item === DELETED) {
return ( return (
@ -150,6 +175,12 @@ export const PostThread = observer(function PostThread({
]} ]}
/> />
) )
} else if (item === CHILD_SPINNER) {
return (
<View style={styles.childSpinner}>
<ActivityIndicator />
</View>
)
} else if (item instanceof PostThreadItemModel) { } else if (item instanceof PostThreadItemModel) {
return <PostThreadItem item={item} onPostReply={onRefresh} /> return <PostThreadItem item={item} onPostReply={onRefresh} />
} }
@ -247,6 +278,9 @@ export const PostThread = observer(function PostThread({
ref={ref} ref={ref}
data={posts} data={posts}
initialNumToRender={posts.length} initialNumToRender={posts.length}
maintainVisibleContentPosition={
view.isFromCache ? MAINTAIN_VISIBLE_CONTENT_POSITION : undefined
}
keyExtractor={item => item._reactKey} keyExtractor={item => item._reactKey}
renderItem={renderItem} renderItem={renderItem}
refreshControl={ refreshControl={
@ -257,10 +291,12 @@ export const PostThread = observer(function PostThread({
titleColor={pal.colors.text} titleColor={pal.colors.text}
/> />
} }
onLayout={onLayout} onContentSizeChange={
!isIOS || !view.isFromCache ? onContentSizeChange : undefined
}
onScrollToIndexFailed={onScrollToIndexFailed} onScrollToIndexFailed={onScrollToIndexFailed}
style={s.hContentRegion} style={s.hContentRegion}
contentContainerStyle={s.contentContainerExtra} contentContainerStyle={styles.contentContainerExtra}
/> />
) )
}) })
@ -307,10 +343,17 @@ const styles = StyleSheet.create({
paddingHorizontal: 18, paddingHorizontal: 18,
paddingVertical: 18, paddingVertical: 18,
}, },
parentSpinner: {
paddingVertical: 10,
},
childSpinner: {},
bottomBorder: { bottomBorder: {
borderBottomWidth: 1, borderBottomWidth: 1,
}, },
bottomSpacer: { bottomSpacer: {
height: 200, height: 400,
},
contentContainerExtra: {
paddingBottom: 500,
}, },
}) })