Bluesky app fork with some witchin' additions 💫

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

authored by

Paul Frazee and committed by
GitHub
a63f97ae 72561695

+167 -18
+18 -4
src/lib/api/index.ts
··· 29 29 if (didOrHandle.startsWith('did:')) { 30 30 return didOrHandle 31 31 } 32 - const res = await store.agent.resolveHandle({ 33 - handle: didOrHandle, 34 - }) 35 - return res.data.did 32 + 33 + // we run the resolution always to ensure freshness 34 + const promise = store.agent 35 + .resolveHandle({ 36 + handle: didOrHandle, 37 + }) 38 + .then(res => { 39 + store.handleResolutions.cache.set(didOrHandle, res.data.did) 40 + return res.data.did 41 + }) 42 + 43 + // but we can return immediately if it's cached 44 + const cached = store.handleResolutions.cache.get(didOrHandle) 45 + if (cached) { 46 + return cached 47 + } 48 + 49 + return promise 36 50 } 37 51 38 52 export async function uploadBlob(
+5
src/state/models/cache/handle-resolutions.ts
··· 1 + import {LRUMap} from 'lru_map' 2 + 3 + export class HandleResolutionsCache { 4 + cache: LRUMap<string, string> = new LRUMap(500) 5 + }
+31
src/state/models/cache/posts.ts
··· 1 + import {LRUMap} from 'lru_map' 2 + import {RootStoreModel} from '../root-store' 3 + import {AppBskyFeedDefs} from '@atproto/api' 4 + 5 + type PostView = AppBskyFeedDefs.PostView 6 + 7 + export class PostsCache { 8 + cache: LRUMap<string, PostView> = new LRUMap(500) 9 + 10 + constructor(public rootStore: RootStoreModel) {} 11 + 12 + set(uri: string, postView: PostView) { 13 + this.cache.set(uri, postView) 14 + if (postView.author.handle) { 15 + this.rootStore.handleResolutions.cache.set( 16 + postView.author.handle, 17 + postView.author.did, 18 + ) 19 + } 20 + } 21 + 22 + fromFeedItem(feedItem: AppBskyFeedDefs.FeedViewPost) { 23 + this.set(feedItem.post.uri, feedItem.post) 24 + if ( 25 + feedItem.reply?.parent && 26 + AppBskyFeedDefs.isPostView(feedItem.reply?.parent) 27 + ) { 28 + this.set(feedItem.reply.parent.uri, feedItem.reply.parent) 29 + } 30 + } 31 + }
+42 -3
src/state/models/content/post-thread.ts
··· 12 12 export class PostThreadModel { 13 13 // state 14 14 isLoading = false 15 + isLoadingFromCache = false 16 + isFromCache = false 15 17 isRefreshing = false 16 18 hasLoaded = false 17 19 error = '' ··· 20 22 params: GetPostThread.QueryParams 21 23 22 24 // data 23 - thread?: PostThreadItemModel 25 + thread?: PostThreadItemModel | null = null 24 26 isBlocked = false 25 27 26 28 constructor( ··· 52 54 } 53 55 54 56 get hasContent() { 55 - return typeof this.thread !== 'undefined' 57 + return !!this.thread 56 58 } 57 59 58 60 get hasError() { ··· 82 84 if (!this.resolvedUri) { 83 85 await this._resolveUri() 84 86 } 87 + 85 88 if (this.hasContent) { 86 89 await this.update() 87 90 } else { 88 - await this._load() 91 + const precache = this.rootStore.posts.cache.get(this.resolvedUri) 92 + if (precache) { 93 + await this._loadPrecached(precache) 94 + } else { 95 + await this._load() 96 + } 89 97 } 90 98 } 91 99 ··· 167 175 runInAction(() => { 168 176 this.resolvedUri = urip.toString() 169 177 }) 178 + } 179 + 180 + async _loadPrecached(precache: AppBskyFeedDefs.PostView) { 181 + // start with the cached version 182 + this.isLoadingFromCache = true 183 + this.isFromCache = true 184 + this._replaceAll({ 185 + success: true, 186 + headers: {}, 187 + data: { 188 + thread: { 189 + post: precache, 190 + }, 191 + }, 192 + }) 193 + this._xIdle() 194 + 195 + // then update in the background 196 + try { 197 + const res = await this.rootStore.agent.getPostThread( 198 + Object.assign({}, this.params, {uri: this.resolvedUri}), 199 + ) 200 + this._replaceAll(res) 201 + } catch (e: any) { 202 + console.log(e) 203 + this._xIdle(e) 204 + } finally { 205 + runInAction(() => { 206 + this.isLoadingFromCache = false 207 + }) 208 + } 170 209 } 171 210 172 211 async _load(isRefreshing = false) {
+6
src/state/models/content/profile.ts
··· 253 253 try { 254 254 const res = await this.rootStore.agent.getProfile(this.params) 255 255 this.rootStore.profiles.overwrite(this.params.actor, res) // cache invalidation 256 + if (res.data.handle) { 257 + this.rootStore.handleResolutions.cache.set( 258 + res.data.handle, 259 + res.data.did, 260 + ) 261 + } 256 262 this._replaceAll(res) 257 263 await this._createRichText() 258 264 this._xIdle()
+4 -1
src/state/models/feeds/notifications.ts
··· 503 503 const postsRes = await this.rootStore.agent.app.bsky.feed.getPosts({ 504 504 uris: [addedUri], 505 505 }) 506 - notif.setAdditionalData(postsRes.data.posts[0]) 506 + const post = postsRes.data.posts[0] 507 + notif.setAdditionalData(post) 508 + this.rootStore.posts.set(post.uri, post) 507 509 } 508 510 const filtered = this._filterNotifications([notif]) 509 511 return filtered[0] ··· 611 613 ), 612 614 ) 613 615 for (const post of postsChunks.flat()) { 616 + this.rootStore.posts.set(post.uri, post) 614 617 const models = addedPostMap.get(post.uri) 615 618 if (models?.length) { 616 619 for (const model of models) {
+4
src/state/models/feeds/posts.ts
··· 374 374 this.rootStore.me.follows.hydrateProfiles( 375 375 res.data.feed.map(item => item.post.author), 376 376 ) 377 + for (const item of res.data.feed) { 378 + this.rootStore.posts.fromFeedItem(item) 379 + } 377 380 378 381 const slices = this.tuner.tune(res.data.feed, this.feedTuners) 379 382 ··· 405 408 res: GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response, 406 409 ) { 407 410 for (const item of res.data.feed) { 411 + this.rootStore.posts.fromFeedItem(item) 408 412 const existingSlice = this.slices.find(slice => 409 413 slice.containsUri(item.post.uri), 410 414 )
+4
src/state/models/root-store.ts
··· 12 12 import {LogModel} from './log' 13 13 import {SessionModel} from './session' 14 14 import {ShellUiModel} from './ui/shell' 15 + import {HandleResolutionsCache} from './cache/handle-resolutions' 15 16 import {ProfilesCache} from './cache/profiles-view' 17 + import {PostsCache} from './cache/posts' 16 18 import {LinkMetasCache} from './cache/link-metas' 17 19 import {NotificationsFeedItemModel} from './feeds/notifications' 18 20 import {MeModel} from './me' ··· 45 47 preferences = new PreferencesModel(this) 46 48 me = new MeModel(this) 47 49 invitedUsers = new InvitedUsers(this) 50 + handleResolutions = new HandleResolutionsCache() 48 51 profiles = new ProfilesCache(this) 52 + posts = new PostsCache(this) 49 53 linkMetas = new LinkMetasCache(this) 50 54 imageSizes = new ImageSizesCache() 51 55 mutedThreads = new MutedThreads()
+53 -10
src/view/com/post-thread/PostThread.tsx
··· 20 20 import {ErrorMessage} from '../util/error/ErrorMessage' 21 21 import {Text} from '../util/text/Text' 22 22 import {s} from 'lib/styles' 23 - import {isDesktopWeb, isMobileWeb} from 'platform/detection' 23 + import {isIOS, isDesktopWeb, isMobileWeb} from 'platform/detection' 24 24 import {usePalette} from 'lib/hooks/usePalette' 25 25 import {useSetTitle} from 'lib/hooks/useSetTitle' 26 26 import {useNavigation} from '@react-navigation/native' 27 27 import {NavigationProp} from 'lib/routes/types' 28 28 import {sanitizeDisplayName} from 'lib/strings/display-names' 29 29 30 + const MAINTAIN_VISIBLE_CONTENT_POSITION = {minIndexForVisible: 0} 31 + 32 + const PARENT_SPINNER = { 33 + _reactKey: '__parent_spinner__', 34 + _isHighlightedPost: false, 35 + } 30 36 const REPLY_PROMPT = {_reactKey: '__reply__', _isHighlightedPost: false} 31 37 const DELETED = {_reactKey: '__deleted__', _isHighlightedPost: false} 32 38 const BLOCKED = {_reactKey: '__blocked__', _isHighlightedPost: false} 39 + const CHILD_SPINNER = { 40 + _reactKey: '__child_spinner__', 41 + _isHighlightedPost: false, 42 + } 33 43 const BOTTOM_COMPONENT = { 34 44 _reactKey: '__bottom_component__', 35 45 _isHighlightedPost: false, 36 46 } 37 47 type YieldedItem = 38 48 | PostThreadItemModel 49 + | typeof PARENT_SPINNER 39 50 | typeof REPLY_PROMPT 40 51 | typeof DELETED 41 52 | typeof BLOCKED 53 + | typeof PARENT_SPINNER 42 54 43 55 export const PostThread = observer(function PostThread({ 44 56 uri, ··· 55 67 const navigation = useNavigation<NavigationProp>() 56 68 const posts = React.useMemo(() => { 57 69 if (view.thread) { 58 - return Array.from(flattenThread(view.thread)).concat([BOTTOM_COMPONENT]) 70 + const arr = Array.from(flattenThread(view.thread)) 71 + if (view.isLoadingFromCache) { 72 + if (view.thread?.postRecord?.reply) { 73 + arr.unshift(PARENT_SPINNER) 74 + } 75 + arr.push(CHILD_SPINNER) 76 + } else { 77 + arr.push(BOTTOM_COMPONENT) 78 + } 79 + return arr 59 80 } 60 81 return [] 61 - }, [view.thread]) 82 + }, [view.isLoadingFromCache, view.thread]) 62 83 useSetTitle( 63 84 view.thread?.postRecord && 64 85 `${sanitizeDisplayName( ··· 80 101 setIsRefreshing(false) 81 102 }, [view, setIsRefreshing]) 82 103 83 - const onLayout = React.useCallback(() => { 104 + const onContentSizeChange = React.useCallback(() => { 84 105 const index = posts.findIndex(post => post._isHighlightedPost) 85 106 if (index !== -1) { 86 107 ref.current?.scrollToIndex({ 87 108 index, 88 109 animated: false, 89 - viewOffset: 40, 90 110 }) 91 111 } 92 112 }, [posts, ref]) 93 - 94 113 const onScrollToIndexFailed = React.useCallback( 95 114 (info: { 96 115 index: number ··· 115 134 116 135 const renderItem = React.useCallback( 117 136 ({item}: {item: YieldedItem}) => { 118 - if (item === REPLY_PROMPT) { 137 + if (item === PARENT_SPINNER) { 138 + return ( 139 + <View style={styles.parentSpinner}> 140 + <ActivityIndicator /> 141 + </View> 142 + ) 143 + } else if (item === REPLY_PROMPT) { 119 144 return <ComposePrompt onPressCompose={onPressReply} /> 120 145 } else if (item === DELETED) { 121 146 return ( ··· 149 174 isMobileWeb && styles.bottomSpacer, 150 175 ]} 151 176 /> 177 + ) 178 + } else if (item === CHILD_SPINNER) { 179 + return ( 180 + <View style={styles.childSpinner}> 181 + <ActivityIndicator /> 182 + </View> 152 183 ) 153 184 } else if (item instanceof PostThreadItemModel) { 154 185 return <PostThreadItem item={item} onPostReply={onRefresh} /> ··· 247 278 ref={ref} 248 279 data={posts} 249 280 initialNumToRender={posts.length} 281 + maintainVisibleContentPosition={ 282 + view.isFromCache ? MAINTAIN_VISIBLE_CONTENT_POSITION : undefined 283 + } 250 284 keyExtractor={item => item._reactKey} 251 285 renderItem={renderItem} 252 286 refreshControl={ ··· 257 291 titleColor={pal.colors.text} 258 292 /> 259 293 } 260 - onLayout={onLayout} 294 + onContentSizeChange={ 295 + !isIOS || !view.isFromCache ? onContentSizeChange : undefined 296 + } 261 297 onScrollToIndexFailed={onScrollToIndexFailed} 262 298 style={s.hContentRegion} 263 - contentContainerStyle={s.contentContainerExtra} 299 + contentContainerStyle={styles.contentContainerExtra} 264 300 /> 265 301 ) 266 302 }) ··· 307 343 paddingHorizontal: 18, 308 344 paddingVertical: 18, 309 345 }, 346 + parentSpinner: { 347 + paddingVertical: 10, 348 + }, 349 + childSpinner: {}, 310 350 bottomBorder: { 311 351 borderBottomWidth: 1, 312 352 }, 313 353 bottomSpacer: { 314 - height: 200, 354 + height: 400, 355 + }, 356 + contentContainerExtra: { 357 + paddingBottom: 500, 315 358 }, 316 359 })