about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/lib/api/index.ts22
-rw-r--r--src/state/models/cache/handle-resolutions.ts5
-rw-r--r--src/state/models/cache/posts.ts31
-rw-r--r--src/state/models/content/post-thread.ts45
-rw-r--r--src/state/models/content/profile.ts6
-rw-r--r--src/state/models/feeds/notifications.ts5
-rw-r--r--src/state/models/feeds/posts.ts4
-rw-r--r--src/state/models/root-store.ts4
-rw-r--r--src/view/com/post-thread/PostThread.tsx63
9 files changed, 167 insertions, 18 deletions
diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts
index 458ef7baa..381d78435 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 000000000..2e2b69661
--- /dev/null
+++ b/src/state/models/cache/handle-resolutions.ts
@@ -0,0 +1,5 @@
+import {LRUMap} from 'lru_map'
+
+export class HandleResolutionsCache {
+  cache: LRUMap<string, string> = new LRUMap(500)
+}
diff --git a/src/state/models/cache/posts.ts b/src/state/models/cache/posts.ts
new file mode 100644
index 000000000..48621226a
--- /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<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)
+    }
+  }
+}
diff --git a/src/state/models/content/post-thread.ts b/src/state/models/content/post-thread.ts
index 0a67c783e..c500174a5 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 34b2ea28e..c4cbe6d44 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 05e2ef0db..b7ac3a53b 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 4e6633d38..94d7228ed 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 d76ea07c9..6ced8090a 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 51f63dbb3..e7282cf83 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<NavigationProp>()
   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 (
+          <View style={styles.parentSpinner}>
+            <ActivityIndicator />
+          </View>
+        )
+      } else if (item === REPLY_PROMPT) {
         return <ComposePrompt onPressCompose={onPressReply} />
       } else if (item === DELETED) {
         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) {
         return <PostThreadItem item={item} onPostReply={onRefresh} />
       }
@@ -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,
   },
 })