about summary refs log tree commit diff
diff options
context:
space:
mode:
authordan <dan.abramov@gmail.com>2023-11-30 21:35:58 +0000
committerGitHub <noreply@github.com>2023-11-30 13:35:58 -0800
commit46b63accb8e73997f2a1bee24cfda220d29e048b (patch)
tree748de65b464e98d8241cc6fd8d11a9c17d9ec05c
parent143fc80951d3a0620c0a5e55f46229f2fc743758 (diff)
downloadvoidsky-46b63accb8e73997f2a1bee24cfda220d29e048b.tar.zst
Rewrite the shadow logic to look inside the cache (#2045)
* Reset

* Associate shadows with the cache

* Use colocated helpers

* Fix types

* Reorder for clarity

* More types

* Copy paste logic for profile

* Hook up profile query

* Hook up suggested follows

* Hook up other profile things

* Fix shape

* Pass setShadow into the effect deps

* Include reply posts in the shadow cache search

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>
-rw-r--r--src/state/cache/post-shadow.ts118
-rw-r--r--src/state/cache/profile-shadow.ts114
-rw-r--r--src/state/queries/list-members.ts40
-rw-r--r--src/state/queries/my-blocked-accounts.ts32
-rw-r--r--src/state/queries/my-muted-accounts.ts32
-rw-r--r--src/state/queries/notifications/feed.ts16
-rw-r--r--src/state/queries/post-feed.ts30
-rw-r--r--src/state/queries/post-liked-by.ts32
-rw-r--r--src/state/queries/post-reposted-by.ts32
-rw-r--r--src/state/queries/post-thread.ts42
-rw-r--r--src/state/queries/profile-followers.ts32
-rw-r--r--src/state/queries/profile-follows.ts32
-rw-r--r--src/state/queries/profile.ts25
-rw-r--r--src/state/queries/suggested-follows.ts55
14 files changed, 461 insertions, 171 deletions
diff --git a/src/state/cache/post-shadow.ts b/src/state/cache/post-shadow.ts
index b21bb7129..e02d4f1ea 100644
--- a/src/state/cache/post-shadow.ts
+++ b/src/state/cache/post-shadow.ts
@@ -1,12 +1,14 @@
-import {useEffect, useState, useMemo, useCallback} from 'react'
+import {useEffect, useState, useMemo} from 'react'
 import EventEmitter from 'eventemitter3'
 import {AppBskyFeedDefs} from '@atproto/api'
 import {batchedUpdates} from '#/lib/batchedUpdates'
 import {Shadow, castAsShadow} from './types'
+import {findAllPostsInQueryData as findAllPostsInNotifsQueryData} from '../queries/notifications/feed'
+import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from '../queries/post-feed'
+import {findAllPostsInQueryData as findAllPostsInThreadQueryData} from '../queries/post-thread'
+import {queryClient} from 'lib/react-query'
 export type {Shadow} from './types'
 
-const emitter = new EventEmitter()
-
 export interface PostShadow {
   likeUri: string | undefined
   likeCount: number | undefined
@@ -17,95 +19,83 @@ export interface PostShadow {
 
 export const POST_TOMBSTONE = Symbol('PostTombstone')
 
-interface CacheEntry {
-  ts: number
-  value: PostShadow
-}
-
-const firstSeenMap = new WeakMap<AppBskyFeedDefs.PostView, number>()
-function getFirstSeenTS(post: AppBskyFeedDefs.PostView): number {
-  let timeStamp = firstSeenMap.get(post)
-  if (timeStamp !== undefined) {
-    return timeStamp
-  }
-  timeStamp = Date.now()
-  firstSeenMap.set(post, timeStamp)
-  return timeStamp
-}
+const emitter = new EventEmitter()
+const shadows: WeakMap<
+  AppBskyFeedDefs.PostView,
+  Partial<PostShadow>
+> = new WeakMap()
 
 export function usePostShadow(
   post: AppBskyFeedDefs.PostView,
 ): Shadow<AppBskyFeedDefs.PostView> | typeof POST_TOMBSTONE {
-  const postSeenTS = getFirstSeenTS(post)
-  const [state, setState] = useState<CacheEntry>(() => ({
-    ts: postSeenTS,
-    value: fromPost(post),
-  }))
-
+  const [shadow, setShadow] = useState(() => shadows.get(post))
   const [prevPost, setPrevPost] = useState(post)
   if (post !== prevPost) {
-    // if we got a new prop, assume it's fresher
-    // than whatever shadow state we accumulated
     setPrevPost(post)
-    setState({
-      ts: postSeenTS,
-      value: fromPost(post),
-    })
+    setShadow(shadows.get(post))
   }
 
-  const onUpdate = useCallback(
-    (value: Partial<PostShadow>) => {
-      setState(s => ({ts: Date.now(), value: {...s.value, ...value}}))
-    },
-    [setState],
-  )
-
-  // react to shadow updates
   useEffect(() => {
+    function onUpdate() {
+      setShadow(shadows.get(post))
+    }
     emitter.addListener(post.uri, onUpdate)
     return () => {
       emitter.removeListener(post.uri, onUpdate)
     }
-  }, [post.uri, onUpdate])
+  }, [post, setShadow])
 
   return useMemo(() => {
-    return state.ts > postSeenTS
-      ? mergeShadow(post, state.value)
-      : castAsShadow(post)
-  }, [post, state, postSeenTS])
-}
-
-export function updatePostShadow(uri: string, value: Partial<PostShadow>) {
-  batchedUpdates(() => {
-    emitter.emit(uri, value)
-  })
-}
-
-function fromPost(post: AppBskyFeedDefs.PostView): PostShadow {
-  return {
-    likeUri: post.viewer?.like,
-    likeCount: post.likeCount,
-    repostUri: post.viewer?.repost,
-    repostCount: post.repostCount,
-    isDeleted: false,
-  }
+    if (shadow) {
+      return mergeShadow(post, shadow)
+    } else {
+      return castAsShadow(post)
+    }
+  }, [post, shadow])
 }
 
 function mergeShadow(
   post: AppBskyFeedDefs.PostView,
-  shadow: PostShadow,
+  shadow: Partial<PostShadow>,
 ): Shadow<AppBskyFeedDefs.PostView> | typeof POST_TOMBSTONE {
   if (shadow.isDeleted) {
     return POST_TOMBSTONE
   }
   return castAsShadow({
     ...post,
-    likeCount: shadow.likeCount,
-    repostCount: shadow.repostCount,
+    likeCount: 'likeCount' in shadow ? shadow.likeCount : post.likeCount,
+    repostCount:
+      'repostCount' in shadow ? shadow.repostCount : post.repostCount,
     viewer: {
       ...(post.viewer || {}),
-      like: shadow.likeUri,
-      repost: shadow.repostUri,
+      like: 'likeUri' in shadow ? shadow.likeUri : post.viewer?.like,
+      repost: 'repostUri' in shadow ? shadow.repostUri : post.viewer?.repost,
     },
   })
 }
+
+export function updatePostShadow(uri: string, value: Partial<PostShadow>) {
+  const cachedPosts = findPostsInCache(uri)
+  for (let post of cachedPosts) {
+    shadows.set(post, {...shadows.get(post), ...value})
+  }
+  batchedUpdates(() => {
+    emitter.emit(uri)
+  })
+}
+
+function* findPostsInCache(
+  uri: string,
+): Generator<AppBskyFeedDefs.PostView, void> {
+  for (let post of findAllPostsInFeedQueryData(queryClient, uri)) {
+    yield post
+  }
+  for (let post of findAllPostsInNotifsQueryData(queryClient, uri)) {
+    yield post
+  }
+  for (let node of findAllPostsInThreadQueryData(queryClient, uri)) {
+    if (node.type === 'post') {
+      yield node.post
+    }
+  }
+}
diff --git a/src/state/cache/profile-shadow.ts b/src/state/cache/profile-shadow.ts
index 6ebd39132..f85e1ad8d 100644
--- a/src/state/cache/profile-shadow.ts
+++ b/src/state/cache/profile-shadow.ts
@@ -1,107 +1,101 @@
-import {useEffect, useState, useMemo, useCallback} from 'react'
+import {useEffect, useState, useMemo} from 'react'
 import EventEmitter from 'eventemitter3'
 import {AppBskyActorDefs} from '@atproto/api'
 import {batchedUpdates} from '#/lib/batchedUpdates'
+import {findAllProfilesInQueryData as findAllProfilesInListMembersQueryData} from '../queries/list-members'
+import {findAllProfilesInQueryData as findAllProfilesInMyBlockedAccountsQueryData} from '../queries/my-blocked-accounts'
+import {findAllProfilesInQueryData as findAllProfilesInMyMutedAccountsQueryData} from '../queries/my-muted-accounts'
+import {findAllProfilesInQueryData as findAllProfilesInPostLikedByQueryData} from '../queries/post-liked-by'
+import {findAllProfilesInQueryData as findAllProfilesInPostRepostedByQueryData} from '../queries/post-reposted-by'
+import {findAllProfilesInQueryData as findAllProfilesInProfileQueryData} from '../queries/profile'
+import {findAllProfilesInQueryData as findAllProfilesInProfileFollowersQueryData} from '../queries/profile-followers'
+import {findAllProfilesInQueryData as findAllProfilesInProfileFollowsQueryData} from '../queries/profile-follows'
+import {findAllProfilesInQueryData as findAllProfilesInSuggestedFollowsQueryData} from '../queries/suggested-follows'
 import {Shadow, castAsShadow} from './types'
+import {queryClient} from 'lib/react-query'
 export type {Shadow} from './types'
 
-const emitter = new EventEmitter()
-
 export interface ProfileShadow {
   followingUri: string | undefined
   muted: boolean | undefined
   blockingUri: string | undefined
 }
 
-interface CacheEntry {
-  ts: number
-  value: ProfileShadow
-}
-
 type ProfileView =
   | AppBskyActorDefs.ProfileView
   | AppBskyActorDefs.ProfileViewBasic
   | AppBskyActorDefs.ProfileViewDetailed
 
-const firstSeenMap = new WeakMap<ProfileView, number>()
-function getFirstSeenTS(profile: ProfileView): number {
-  let timeStamp = firstSeenMap.get(profile)
-  if (timeStamp !== undefined) {
-    return timeStamp
-  }
-  timeStamp = Date.now()
-  firstSeenMap.set(profile, timeStamp)
-  return timeStamp
-}
+const shadows: WeakMap<ProfileView, Partial<ProfileShadow>> = new WeakMap()
+const emitter = new EventEmitter()
 
 export function useProfileShadow(profile: ProfileView): Shadow<ProfileView> {
-  const profileSeenTS = getFirstSeenTS(profile)
-  const [state, setState] = useState<CacheEntry>(() => ({
-    ts: profileSeenTS,
-    value: fromProfile(profile),
-  }))
-
-  const [prevProfile, setPrevProfile] = useState(profile)
-  if (profile !== prevProfile) {
-    // if we got a new prop, assume it's fresher
-    // than whatever shadow state we accumulated
-    setPrevProfile(profile)
-    setState({
-      ts: profileSeenTS,
-      value: fromProfile(profile),
-    })
+  const [shadow, setShadow] = useState(() => shadows.get(profile))
+  const [prevPost, setPrevPost] = useState(profile)
+  if (profile !== prevPost) {
+    setPrevPost(profile)
+    setShadow(shadows.get(profile))
   }
 
-  const onUpdate = useCallback(
-    (value: Partial<ProfileShadow>) => {
-      setState(s => ({ts: Date.now(), value: {...s.value, ...value}}))
-    },
-    [setState],
-  )
-
-  // react to shadow updates
   useEffect(() => {
+    function onUpdate() {
+      setShadow(shadows.get(profile))
+    }
     emitter.addListener(profile.did, onUpdate)
     return () => {
       emitter.removeListener(profile.did, onUpdate)
     }
-  }, [profile.did, onUpdate])
+  }, [profile])
 
   return useMemo(() => {
-    return state.ts > profileSeenTS
-      ? mergeShadow(profile, state.value)
-      : castAsShadow(profile)
-  }, [profile, state, profileSeenTS])
+    if (shadow) {
+      return mergeShadow(profile, shadow)
+    } else {
+      return castAsShadow(profile)
+    }
+  }, [profile, shadow])
 }
 
 export function updateProfileShadow(
-  uri: string,
+  did: string,
   value: Partial<ProfileShadow>,
 ) {
+  const cachedProfiles = findProfilesInCache(did)
+  for (let post of cachedProfiles) {
+    shadows.set(post, {...shadows.get(post), ...value})
+  }
   batchedUpdates(() => {
-    emitter.emit(uri, value)
+    emitter.emit(did, value)
   })
 }
 
-function fromProfile(profile: ProfileView): ProfileShadow {
-  return {
-    followingUri: profile.viewer?.following,
-    muted: profile.viewer?.muted,
-    blockingUri: profile.viewer?.blocking,
-  }
-}
-
 function mergeShadow(
   profile: ProfileView,
-  shadow: ProfileShadow,
+  shadow: Partial<ProfileShadow>,
 ): Shadow<ProfileView> {
   return castAsShadow({
     ...profile,
     viewer: {
       ...(profile.viewer || {}),
-      following: shadow.followingUri,
-      muted: shadow.muted,
-      blocking: shadow.blockingUri,
+      following:
+        'followingUri' in shadow
+          ? shadow.followingUri
+          : profile.viewer?.following,
+      muted: 'muted' in shadow ? shadow.muted : profile.viewer?.muted,
+      blocking:
+        'blockingUri' in shadow ? shadow.blockingUri : profile.viewer?.blocking,
     },
   })
 }
+
+function* findProfilesInCache(did: string): Generator<ProfileView, void> {
+  yield* findAllProfilesInListMembersQueryData(queryClient, did)
+  yield* findAllProfilesInMyBlockedAccountsQueryData(queryClient, did)
+  yield* findAllProfilesInMyMutedAccountsQueryData(queryClient, did)
+  yield* findAllProfilesInPostLikedByQueryData(queryClient, did)
+  yield* findAllProfilesInPostRepostedByQueryData(queryClient, did)
+  yield* findAllProfilesInProfileQueryData(queryClient, did)
+  yield* findAllProfilesInProfileFollowersQueryData(queryClient, did)
+  yield* findAllProfilesInProfileFollowsQueryData(queryClient, did)
+  yield* findAllProfilesInSuggestedFollowsQueryData(queryClient, did)
+}
diff --git a/src/state/queries/list-members.ts b/src/state/queries/list-members.ts
index 7aa91b1dc..d84089c90 100644
--- a/src/state/queries/list-members.ts
+++ b/src/state/queries/list-members.ts
@@ -1,5 +1,10 @@
-import {AppBskyGraphGetList} from '@atproto/api'
-import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
+import {AppBskyActorDefs, AppBskyGraphGetList} from '@atproto/api'
+import {
+  useInfiniteQuery,
+  InfiniteData,
+  QueryClient,
+  QueryKey,
+} from '@tanstack/react-query'
 
 import {getAgent} from '#/state/session'
 import {STALE} from '#/state/queries'
@@ -31,3 +36,34 @@ export function useListMembersQuery(uri: string) {
     getNextPageParam: lastPage => lastPage.cursor,
   })
 }
+
+export function* findAllProfilesInQueryData(
+  queryClient: QueryClient,
+  did: string,
+): Generator<AppBskyActorDefs.ProfileView, void> {
+  const queryDatas = queryClient.getQueriesData<
+    InfiniteData<AppBskyGraphGetList.OutputSchema>
+  >({
+    queryKey: ['list-members'],
+  })
+  for (const [_queryKey, queryData] of queryDatas) {
+    if (!queryData) {
+      continue
+    }
+    for (const [_queryKey, queryData] of queryDatas) {
+      if (!queryData?.pages) {
+        continue
+      }
+      for (const page of queryData?.pages) {
+        if (page.list.creator.did === did) {
+          yield page.list.creator
+        }
+        for (const item of page.items) {
+          if (item.subject.did === did) {
+            yield item.subject
+          }
+        }
+      }
+    }
+  }
+}
diff --git a/src/state/queries/my-blocked-accounts.ts b/src/state/queries/my-blocked-accounts.ts
index 2c099c63d..badaaec34 100644
--- a/src/state/queries/my-blocked-accounts.ts
+++ b/src/state/queries/my-blocked-accounts.ts
@@ -1,5 +1,10 @@
-import {AppBskyGraphGetBlocks} from '@atproto/api'
-import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
+import {AppBskyActorDefs, AppBskyGraphGetBlocks} from '@atproto/api'
+import {
+  useInfiniteQuery,
+  InfiniteData,
+  QueryClient,
+  QueryKey,
+} from '@tanstack/react-query'
 
 import {getAgent} from '#/state/session'
 
@@ -26,3 +31,26 @@ export function useMyBlockedAccountsQuery() {
     getNextPageParam: lastPage => lastPage.cursor,
   })
 }
+
+export function* findAllProfilesInQueryData(
+  queryClient: QueryClient,
+  did: string,
+): Generator<AppBskyActorDefs.ProfileView, void> {
+  const queryDatas = queryClient.getQueriesData<
+    InfiniteData<AppBskyGraphGetBlocks.OutputSchema>
+  >({
+    queryKey: ['my-blocked-accounts'],
+  })
+  for (const [_queryKey, queryData] of queryDatas) {
+    if (!queryData?.pages) {
+      continue
+    }
+    for (const page of queryData?.pages) {
+      for (const block of page.blocks) {
+        if (block.did === did) {
+          yield block
+        }
+      }
+    }
+  }
+}
diff --git a/src/state/queries/my-muted-accounts.ts b/src/state/queries/my-muted-accounts.ts
index a175931b5..8929e04d3 100644
--- a/src/state/queries/my-muted-accounts.ts
+++ b/src/state/queries/my-muted-accounts.ts
@@ -1,5 +1,10 @@
-import {AppBskyGraphGetMutes} from '@atproto/api'
-import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
+import {AppBskyActorDefs, AppBskyGraphGetMutes} from '@atproto/api'
+import {
+  useInfiniteQuery,
+  InfiniteData,
+  QueryClient,
+  QueryKey,
+} from '@tanstack/react-query'
 
 import {getAgent} from '#/state/session'
 
@@ -26,3 +31,26 @@ export function useMyMutedAccountsQuery() {
     getNextPageParam: lastPage => lastPage.cursor,
   })
 }
+
+export function* findAllProfilesInQueryData(
+  queryClient: QueryClient,
+  did: string,
+): Generator<AppBskyActorDefs.ProfileView, void> {
+  const queryDatas = queryClient.getQueriesData<
+    InfiniteData<AppBskyGraphGetMutes.OutputSchema>
+  >({
+    queryKey: ['my-muted-accounts'],
+  })
+  for (const [_queryKey, queryData] of queryDatas) {
+    if (!queryData?.pages) {
+      continue
+    }
+    for (const page of queryData?.pages) {
+      for (const mute of page.mutes) {
+        if (mute.did === did) {
+          yield mute
+        }
+      }
+    }
+  }
+}
diff --git a/src/state/queries/notifications/feed.ts b/src/state/queries/notifications/feed.ts
index 5c519d045..0fd9a2fef 100644
--- a/src/state/queries/notifications/feed.ts
+++ b/src/state/queries/notifications/feed.ts
@@ -86,6 +86,19 @@ export function findPostInQueryData(
   queryClient: QueryClient,
   uri: string,
 ): AppBskyFeedDefs.PostView | undefined {
+  const generator = findAllPostsInQueryData(queryClient, uri)
+  const result = generator.next()
+  if (result.done) {
+    return undefined
+  } else {
+    return result.value
+  }
+}
+
+export function* findAllPostsInQueryData(
+  queryClient: QueryClient,
+  uri: string,
+): Generator<AppBskyFeedDefs.PostView, void> {
   const queryDatas = queryClient.getQueriesData<InfiniteData<FeedPage>>({
     queryKey: ['notification-feed'],
   })
@@ -96,10 +109,9 @@ export function findPostInQueryData(
     for (const page of queryData?.pages) {
       for (const item of page.items) {
         if (item.subject?.uri === uri) {
-          return item.subject
+          yield item.subject
         }
       }
     }
   }
-  return undefined
 }
diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts
index d87beb779..209f1f544 100644
--- a/src/state/queries/post-feed.ts
+++ b/src/state/queries/post-feed.ts
@@ -232,7 +232,20 @@ function createApi(
 export function findPostInQueryData(
   queryClient: QueryClient,
   uri: string,
-): AppBskyFeedDefs.FeedViewPost | undefined {
+): AppBskyFeedDefs.PostView | undefined {
+  const generator = findAllPostsInQueryData(queryClient, uri)
+  const result = generator.next()
+  if (result.done) {
+    return undefined
+  } else {
+    return result.value
+  }
+}
+
+export function* findAllPostsInQueryData(
+  queryClient: QueryClient,
+  uri: string,
+): Generator<AppBskyFeedDefs.PostView, void> {
   const queryDatas = queryClient.getQueriesData<
     InfiniteData<FeedPageUnselected>
   >({
@@ -245,12 +258,23 @@ export function findPostInQueryData(
     for (const page of queryData?.pages) {
       for (const item of page.feed) {
         if (item.post.uri === uri) {
-          return item
+          yield item.post
+        }
+        if (
+          AppBskyFeedDefs.isPostView(item.reply?.parent) &&
+          item.reply?.parent?.uri === uri
+        ) {
+          yield item.reply.parent
+        }
+        if (
+          AppBskyFeedDefs.isPostView(item.reply?.root) &&
+          item.reply?.root?.uri === uri
+        ) {
+          yield item.reply.root
         }
       }
     }
   }
-  return undefined
 }
 
 function assertSomePostsPassModeration(feed: AppBskyFeedDefs.FeedViewPost[]) {
diff --git a/src/state/queries/post-liked-by.ts b/src/state/queries/post-liked-by.ts
index 528b3be70..2cde07f28 100644
--- a/src/state/queries/post-liked-by.ts
+++ b/src/state/queries/post-liked-by.ts
@@ -1,5 +1,10 @@
-import {AppBskyFeedGetLikes} from '@atproto/api'
-import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
+import {AppBskyActorDefs, AppBskyFeedGetLikes} from '@atproto/api'
+import {
+  useInfiniteQuery,
+  InfiniteData,
+  QueryClient,
+  QueryKey,
+} from '@tanstack/react-query'
 
 import {getAgent} from '#/state/session'
 
@@ -31,3 +36,26 @@ export function usePostLikedByQuery(resolvedUri: string | undefined) {
     enabled: !!resolvedUri,
   })
 }
+
+export function* findAllProfilesInQueryData(
+  queryClient: QueryClient,
+  did: string,
+): Generator<AppBskyActorDefs.ProfileView, void> {
+  const queryDatas = queryClient.getQueriesData<
+    InfiniteData<AppBskyFeedGetLikes.OutputSchema>
+  >({
+    queryKey: ['post-liked-by'],
+  })
+  for (const [_queryKey, queryData] of queryDatas) {
+    if (!queryData?.pages) {
+      continue
+    }
+    for (const page of queryData?.pages) {
+      for (const like of page.likes) {
+        if (like.actor.did === did) {
+          yield like.actor
+        }
+      }
+    }
+  }
+}
diff --git a/src/state/queries/post-reposted-by.ts b/src/state/queries/post-reposted-by.ts
index f9a80056f..db5fa6514 100644
--- a/src/state/queries/post-reposted-by.ts
+++ b/src/state/queries/post-reposted-by.ts
@@ -1,5 +1,10 @@
-import {AppBskyFeedGetRepostedBy} from '@atproto/api'
-import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
+import {AppBskyActorDefs, AppBskyFeedGetRepostedBy} from '@atproto/api'
+import {
+  useInfiniteQuery,
+  InfiniteData,
+  QueryClient,
+  QueryKey,
+} from '@tanstack/react-query'
 
 import {getAgent} from '#/state/session'
 
@@ -31,3 +36,26 @@ export function usePostRepostedByQuery(resolvedUri: string | undefined) {
     enabled: !!resolvedUri,
   })
 }
+
+export function* findAllProfilesInQueryData(
+  queryClient: QueryClient,
+  did: string,
+): Generator<AppBskyActorDefs.ProfileView, void> {
+  const queryDatas = queryClient.getQueriesData<
+    InfiniteData<AppBskyFeedGetRepostedBy.OutputSchema>
+  >({
+    queryKey: ['post-reposted-by'],
+  })
+  for (const [_queryKey, queryData] of queryDatas) {
+    if (!queryData?.pages) {
+      continue
+    }
+    for (const page of queryData?.pages) {
+      for (const repostedBy of page.repostedBy) {
+        if (repostedBy.did === did) {
+          yield repostedBy
+        }
+      }
+    }
+  }
+}
diff --git a/src/state/queries/post-thread.ts b/src/state/queries/post-thread.ts
index d40af1fe2..cde45723a 100644
--- a/src/state/queries/post-thread.ts
+++ b/src/state/queries/post-thread.ts
@@ -88,7 +88,7 @@ export function usePostThreadQuery(uri: string | undefined) {
       {
         const item = findPostInFeedQueryData(queryClient, uri)
         if (item) {
-          return feedViewPostToPlaceholderThread(item)
+          return postViewToPlaceholderThread(item)
         }
       }
       {
@@ -213,6 +213,19 @@ function findPostInQueryData(
   queryClient: QueryClient,
   uri: string,
 ): ThreadNode | undefined {
+  const generator = findAllPostsInQueryData(queryClient, uri)
+  const result = generator.next()
+  if (result.done) {
+    return undefined
+  } else {
+    return result.value
+  }
+}
+
+export function* findAllPostsInQueryData(
+  queryClient: QueryClient,
+  uri: string,
+): Generator<ThreadNode, void> {
   const queryDatas = queryClient.getQueriesData<ThreadNode>({
     queryKey: ['post-thread'],
   })
@@ -222,11 +235,10 @@ function findPostInQueryData(
     }
     for (const item of traverseThread(queryData)) {
       if (item.uri === uri) {
-        return item
+        yield item
       }
     }
   }
-  return undefined
 }
 
 function* traverseThread(node: ThreadNode): Generator<ThreadNode, void> {
@@ -270,30 +282,6 @@ function threadNodeToPlaceholderThread(
   }
 }
 
-function feedViewPostToPlaceholderThread(
-  item: AppBskyFeedDefs.FeedViewPost,
-): ThreadNode {
-  return {
-    type: 'post',
-    _reactKey: item.post.uri,
-    uri: item.post.uri,
-    post: item.post,
-    record: item.post.record as AppBskyFeedPost.Record, // validated in post-feed
-    parent: undefined,
-    replies: undefined,
-    viewer: item.post.viewer,
-    ctx: {
-      depth: 0,
-      isHighlightedPost: true,
-      hasMore: false,
-      showChildReplyLine: false,
-      showParentReplyLine: false,
-      isParentLoading: !!(item.post.record as AppBskyFeedPost.Record).reply,
-      isChildLoading: !!item.post.replyCount,
-    },
-  }
-}
-
 function postViewToPlaceholderThread(
   post: AppBskyFeedDefs.PostView,
 ): ThreadNode {
diff --git a/src/state/queries/profile-followers.ts b/src/state/queries/profile-followers.ts
index b2008851d..fdefc8253 100644
--- a/src/state/queries/profile-followers.ts
+++ b/src/state/queries/profile-followers.ts
@@ -1,5 +1,10 @@
-import {AppBskyGraphGetFollowers} from '@atproto/api'
-import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
+import {AppBskyActorDefs, AppBskyGraphGetFollowers} from '@atproto/api'
+import {
+  useInfiniteQuery,
+  InfiniteData,
+  QueryClient,
+  QueryKey,
+} from '@tanstack/react-query'
 
 import {getAgent} from '#/state/session'
 
@@ -30,3 +35,26 @@ export function useProfileFollowersQuery(did: string | undefined) {
     enabled: !!did,
   })
 }
+
+export function* findAllProfilesInQueryData(
+  queryClient: QueryClient,
+  did: string,
+): Generator<AppBskyActorDefs.ProfileView, void> {
+  const queryDatas = queryClient.getQueriesData<
+    InfiniteData<AppBskyGraphGetFollowers.OutputSchema>
+  >({
+    queryKey: ['profile-followers'],
+  })
+  for (const [_queryKey, queryData] of queryDatas) {
+    if (!queryData?.pages) {
+      continue
+    }
+    for (const page of queryData?.pages) {
+      for (const follower of page.followers) {
+        if (follower.did === did) {
+          yield follower
+        }
+      }
+    }
+  }
+}
diff --git a/src/state/queries/profile-follows.ts b/src/state/queries/profile-follows.ts
index 8af1fba07..428c8aebd 100644
--- a/src/state/queries/profile-follows.ts
+++ b/src/state/queries/profile-follows.ts
@@ -1,5 +1,10 @@
-import {AppBskyGraphGetFollows} from '@atproto/api'
-import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
+import {AppBskyActorDefs, AppBskyGraphGetFollows} from '@atproto/api'
+import {
+  useInfiniteQuery,
+  InfiniteData,
+  QueryClient,
+  QueryKey,
+} from '@tanstack/react-query'
 
 import {getAgent} from '#/state/session'
 import {STALE} from '#/state/queries'
@@ -33,3 +38,26 @@ export function useProfileFollowsQuery(did: string | undefined) {
     enabled: !!did,
   })
 }
+
+export function* findAllProfilesInQueryData(
+  queryClient: QueryClient,
+  did: string,
+): Generator<AppBskyActorDefs.ProfileView, void> {
+  const queryDatas = queryClient.getQueriesData<
+    InfiniteData<AppBskyGraphGetFollows.OutputSchema>
+  >({
+    queryKey: ['profile-follows'],
+  })
+  for (const [_queryKey, queryData] of queryDatas) {
+    if (!queryData?.pages) {
+      continue
+    }
+    for (const page of queryData?.pages) {
+      for (const follow of page.follows) {
+        if (follow.did === did) {
+          yield follow
+        }
+      }
+    }
+  }
+}
diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts
index 62e8f39c0..9435d7ad5 100644
--- a/src/state/queries/profile.ts
+++ b/src/state/queries/profile.ts
@@ -5,7 +5,12 @@ import {
   AppBskyActorProfile,
   AppBskyActorGetProfile,
 } from '@atproto/api'
-import {useQuery, useQueryClient, useMutation} from '@tanstack/react-query'
+import {
+  useQuery,
+  useQueryClient,
+  useMutation,
+  QueryClient,
+} from '@tanstack/react-query'
 import {Image as RNImage} from 'react-native-image-crop-picker'
 import {useSession, getAgent} from '../session'
 import {updateProfileShadow} from '../cache/profile-shadow'
@@ -477,3 +482,21 @@ async function whenAppViewReady(
     () => getAgent().app.bsky.actor.getProfile({actor}),
   )
 }
+
+export function* findAllProfilesInQueryData(
+  queryClient: QueryClient,
+  did: string,
+): Generator<AppBskyActorDefs.ProfileViewDetailed, void> {
+  const queryDatas =
+    queryClient.getQueriesData<AppBskyActorDefs.ProfileViewDetailed>({
+      queryKey: ['profile'],
+    })
+  for (const [_queryKey, queryData] of queryDatas) {
+    if (!queryData) {
+      continue
+    }
+    if (queryData.did === did) {
+      yield queryData
+    }
+  }
+}
diff --git a/src/state/queries/suggested-follows.ts b/src/state/queries/suggested-follows.ts
index eadcb590a..932226b75 100644
--- a/src/state/queries/suggested-follows.ts
+++ b/src/state/queries/suggested-follows.ts
@@ -1,5 +1,6 @@
 import React from 'react'
 import {
+  AppBskyActorDefs,
   AppBskyActorGetSuggestions,
   AppBskyGraphGetSuggestedFollowsByActor,
   moderateProfile,
@@ -9,6 +10,7 @@ import {
   useQueryClient,
   useQuery,
   InfiniteData,
+  QueryClient,
   QueryKey,
 } from '@tanstack/react-query'
 
@@ -106,3 +108,56 @@ export function useGetSuggestedFollowersByActor() {
     [queryClient],
   )
 }
+
+export function* findAllProfilesInQueryData(
+  queryClient: QueryClient,
+  did: string,
+): Generator<AppBskyActorDefs.ProfileView, void> {
+  yield* findAllProfilesInSuggestedFollowsQueryData(queryClient, did)
+  yield* findAllProfilesInSuggestedFollowsByActorQueryData(queryClient, did)
+}
+
+function* findAllProfilesInSuggestedFollowsQueryData(
+  queryClient: QueryClient,
+  did: string,
+) {
+  const queryDatas = queryClient.getQueriesData<
+    InfiniteData<AppBskyActorGetSuggestions.OutputSchema>
+  >({
+    queryKey: ['suggested-follows'],
+  })
+  for (const [_queryKey, queryData] of queryDatas) {
+    if (!queryData?.pages) {
+      continue
+    }
+    for (const page of queryData?.pages) {
+      for (const actor of page.actors) {
+        if (actor.did === did) {
+          yield actor
+        }
+      }
+    }
+  }
+}
+
+function* findAllProfilesInSuggestedFollowsByActorQueryData(
+  queryClient: QueryClient,
+  did: string,
+) {
+  const queryDatas =
+    queryClient.getQueriesData<AppBskyGraphGetSuggestedFollowsByActor.OutputSchema>(
+      {
+        queryKey: ['suggested-follows-by-actor'],
+      },
+    )
+  for (const [_queryKey, queryData] of queryDatas) {
+    if (!queryData) {
+      continue
+    }
+    for (const suggestion of queryData.suggestions) {
+      if (suggestion.did === did) {
+        yield suggestion
+      }
+    }
+  }
+}