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:
parent
7256169506
commit
a63f97aef2
9 changed files with 167 additions and 18 deletions
|
@ -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(
|
||||||
|
|
5
src/state/models/cache/handle-resolutions.ts
vendored
Normal file
5
src/state/models/cache/handle-resolutions.ts
vendored
Normal 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
31
src/state/models/cache/posts.ts
vendored
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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),
|
||||||
)
|
)
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue