diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 458ef7ba..381d7843 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -29,10 +29,24 @@ export async function resolveName(store: RootStoreModel, didOrHandle: string) { if (didOrHandle.startsWith('did:')) { return didOrHandle } - const res = await store.agent.resolveHandle({ - handle: didOrHandle, - }) - return res.data.did + + // we run the resolution always to ensure freshness + const promise = store.agent + .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( diff --git a/src/state/models/cache/handle-resolutions.ts b/src/state/models/cache/handle-resolutions.ts new file mode 100644 index 00000000..2e2b6966 --- /dev/null +++ b/src/state/models/cache/handle-resolutions.ts @@ -0,0 +1,5 @@ +import {LRUMap} from 'lru_map' + +export class HandleResolutionsCache { + cache: LRUMap = new LRUMap(500) +} diff --git a/src/state/models/cache/posts.ts b/src/state/models/cache/posts.ts new file mode 100644 index 00000000..48621226 --- /dev/null +++ b/src/state/models/cache/posts.ts @@ -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 = 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) + } + } +} diff --git a/src/state/models/content/post-thread.ts b/src/state/models/content/post-thread.ts index 0a67c783..c500174a 100644 --- a/src/state/models/content/post-thread.ts +++ b/src/state/models/content/post-thread.ts @@ -12,6 +12,8 @@ import {PostThreadItemModel} from './post-thread-item' export class PostThreadModel { // state isLoading = false + isLoadingFromCache = false + isFromCache = false isRefreshing = false hasLoaded = false error = '' @@ -20,7 +22,7 @@ export class PostThreadModel { params: GetPostThread.QueryParams // data - thread?: PostThreadItemModel + thread?: PostThreadItemModel | null = null isBlocked = false constructor( @@ -52,7 +54,7 @@ export class PostThreadModel { } get hasContent() { - return typeof this.thread !== 'undefined' + return !!this.thread } get hasError() { @@ -82,10 +84,16 @@ export class PostThreadModel { if (!this.resolvedUri) { await this._resolveUri() } + if (this.hasContent) { await this.update() } 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) { if (this.hasLoaded && !isRefreshing) { return diff --git a/src/state/models/content/profile.ts b/src/state/models/content/profile.ts index 34b2ea28..c4cbe6d4 100644 --- a/src/state/models/content/profile.ts +++ b/src/state/models/content/profile.ts @@ -253,6 +253,12 @@ export class ProfileModel { try { const res = await this.rootStore.agent.getProfile(this.params) 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) await this._createRichText() this._xIdle() diff --git a/src/state/models/feeds/notifications.ts b/src/state/models/feeds/notifications.ts index 05e2ef0d..b7ac3a53 100644 --- a/src/state/models/feeds/notifications.ts +++ b/src/state/models/feeds/notifications.ts @@ -503,7 +503,9 @@ export class NotificationsFeedModel { const postsRes = await this.rootStore.agent.app.bsky.feed.getPosts({ 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]) return filtered[0] @@ -611,6 +613,7 @@ export class NotificationsFeedModel { ), ) for (const post of postsChunks.flat()) { + this.rootStore.posts.set(post.uri, post) const models = addedPostMap.get(post.uri) if (models?.length) { for (const model of models) { diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts index 4e6633d3..94d7228e 100644 --- a/src/state/models/feeds/posts.ts +++ b/src/state/models/feeds/posts.ts @@ -374,6 +374,9 @@ export class PostsFeedModel { this.rootStore.me.follows.hydrateProfiles( 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) @@ -405,6 +408,7 @@ export class PostsFeedModel { res: GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response, ) { for (const item of res.data.feed) { + this.rootStore.posts.fromFeedItem(item) const existingSlice = this.slices.find(slice => slice.containsUri(item.post.uri), ) diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts index d76ea07c..6ced8090 100644 --- a/src/state/models/root-store.ts +++ b/src/state/models/root-store.ts @@ -12,7 +12,9 @@ import {isObj, hasProp} from 'lib/type-guards' import {LogModel} from './log' import {SessionModel} from './session' import {ShellUiModel} from './ui/shell' +import {HandleResolutionsCache} from './cache/handle-resolutions' import {ProfilesCache} from './cache/profiles-view' +import {PostsCache} from './cache/posts' import {LinkMetasCache} from './cache/link-metas' import {NotificationsFeedItemModel} from './feeds/notifications' import {MeModel} from './me' @@ -45,7 +47,9 @@ export class RootStoreModel { preferences = new PreferencesModel(this) me = new MeModel(this) invitedUsers = new InvitedUsers(this) + handleResolutions = new HandleResolutionsCache() profiles = new ProfilesCache(this) + posts = new PostsCache(this) linkMetas = new LinkMetasCache(this) imageSizes = new ImageSizesCache() mutedThreads = new MutedThreads() diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx index 51f63dbb..e7282cf8 100644 --- a/src/view/com/post-thread/PostThread.tsx +++ b/src/view/com/post-thread/PostThread.tsx @@ -20,25 +20,37 @@ import {ComposePrompt} from '../composer/Prompt' import {ErrorMessage} from '../util/error/ErrorMessage' import {Text} from '../util/text/Text' 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 {useSetTitle} from 'lib/hooks/useSetTitle' import {useNavigation} from '@react-navigation/native' import {NavigationProp} from 'lib/routes/types' 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 DELETED = {_reactKey: '__deleted__', _isHighlightedPost: false} const BLOCKED = {_reactKey: '__blocked__', _isHighlightedPost: false} +const CHILD_SPINNER = { + _reactKey: '__child_spinner__', + _isHighlightedPost: false, +} const BOTTOM_COMPONENT = { _reactKey: '__bottom_component__', _isHighlightedPost: false, } type YieldedItem = | PostThreadItemModel + | typeof PARENT_SPINNER | typeof REPLY_PROMPT | typeof DELETED | typeof BLOCKED + | typeof PARENT_SPINNER export const PostThread = observer(function PostThread({ uri, @@ -55,10 +67,19 @@ export const PostThread = observer(function PostThread({ const navigation = useNavigation() const posts = React.useMemo(() => { 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 [] - }, [view.thread]) + }, [view.isLoadingFromCache, view.thread]) useSetTitle( view.thread?.postRecord && `${sanitizeDisplayName( @@ -80,17 +101,15 @@ export const PostThread = observer(function PostThread({ setIsRefreshing(false) }, [view, setIsRefreshing]) - const onLayout = React.useCallback(() => { + const onContentSizeChange = React.useCallback(() => { const index = posts.findIndex(post => post._isHighlightedPost) if (index !== -1) { ref.current?.scrollToIndex({ index, animated: false, - viewOffset: 40, }) } }, [posts, ref]) - const onScrollToIndexFailed = React.useCallback( (info: { index: number @@ -115,7 +134,13 @@ export const PostThread = observer(function PostThread({ const renderItem = React.useCallback( ({item}: {item: YieldedItem}) => { - if (item === REPLY_PROMPT) { + if (item === PARENT_SPINNER) { + return ( + + + + ) + } else if (item === REPLY_PROMPT) { return } else if (item === DELETED) { return ( @@ -150,6 +175,12 @@ export const PostThread = observer(function PostThread({ ]} /> ) + } else if (item === CHILD_SPINNER) { + return ( + + + + ) } else if (item instanceof PostThreadItemModel) { return } @@ -247,6 +278,9 @@ export const PostThread = observer(function PostThread({ ref={ref} data={posts} initialNumToRender={posts.length} + maintainVisibleContentPosition={ + view.isFromCache ? MAINTAIN_VISIBLE_CONTENT_POSITION : undefined + } keyExtractor={item => item._reactKey} renderItem={renderItem} refreshControl={ @@ -257,10 +291,12 @@ export const PostThread = observer(function PostThread({ titleColor={pal.colors.text} /> } - onLayout={onLayout} + onContentSizeChange={ + !isIOS || !view.isFromCache ? onContentSizeChange : undefined + } onScrollToIndexFailed={onScrollToIndexFailed} style={s.hContentRegion} - contentContainerStyle={s.contentContainerExtra} + contentContainerStyle={styles.contentContainerExtra} /> ) }) @@ -307,10 +343,17 @@ const styles = StyleSheet.create({ paddingHorizontal: 18, paddingVertical: 18, }, + parentSpinner: { + paddingVertical: 10, + }, + childSpinner: {}, bottomBorder: { borderBottomWidth: 1, }, bottomSpacer: { - height: 200, + height: 400, + }, + contentContainerExtra: { + paddingBottom: 500, }, })