about summary refs log tree commit diff
path: root/src/state/queries
diff options
context:
space:
mode:
Diffstat (limited to 'src/state/queries')
-rw-r--r--src/state/queries/feed.ts23
-rw-r--r--src/state/queries/list.ts40
-rw-r--r--src/state/queries/my-blocked-accounts.ts2
-rw-r--r--src/state/queries/my-muted-accounts.ts2
-rw-r--r--src/state/queries/notifications/feed.ts217
-rw-r--r--src/state/queries/notifications/types.ts34
-rw-r--r--src/state/queries/notifications/unread.tsx131
-rw-r--r--src/state/queries/notifications/util.ts183
-rw-r--r--src/state/queries/post-feed.ts261
-rw-r--r--src/state/queries/post-liked-by.ts2
-rw-r--r--src/state/queries/post-reposted-by.ts2
-rw-r--r--src/state/queries/post-thread.ts19
-rw-r--r--src/state/queries/post.ts3
-rw-r--r--src/state/queries/profile-feedgens.ts2
-rw-r--r--src/state/queries/profile-followers.ts2
-rw-r--r--src/state/queries/profile-lists.ts3
-rw-r--r--src/state/queries/profile.ts10
-rw-r--r--src/state/queries/resolve-uri.ts2
-rw-r--r--src/state/queries/service.ts3
-rw-r--r--src/state/queries/suggested-follows.ts2
20 files changed, 547 insertions, 396 deletions
diff --git a/src/state/queries/feed.ts b/src/state/queries/feed.ts
index 58c1261db..a73e64e8a 100644
--- a/src/state/queries/feed.ts
+++ b/src/state/queries/feed.ts
@@ -181,6 +181,9 @@ export function useIsFeedPublicQuery({uri}: {uri: string}) {
 
         if (msg.includes('missing jwt')) {
           return false
+        } else if (msg.includes('This feed requires being logged-in')) {
+          // e.g. https://github.com/bluesky-social/atproto/blob/99ab1ae55c463e8d5321a1eaad07a175bdd56fea/packages/bsky/src/feed-gen/best-of-follows.ts#L13
+          return false
         }
 
         return true
@@ -243,13 +246,19 @@ const FOLLOWING_FEED_STUB: FeedSourceInfo = {
   likeUri: '',
 }
 
-export function usePinnedFeedsInfos(): FeedSourceInfo[] {
+export function usePinnedFeedsInfos(): {
+  feeds: FeedSourceInfo[]
+  hasPinnedCustom: boolean
+} {
   const queryClient = useQueryClient()
   const [tabs, setTabs] = React.useState<FeedSourceInfo[]>([
     FOLLOWING_FEED_STUB,
   ])
   const {data: preferences} = usePreferencesQuery()
-  const pinnedFeedsKey = JSON.stringify(preferences?.feeds?.pinned)
+
+  const hasPinnedCustom = React.useMemo<boolean>(() => {
+    return tabs.some(tab => tab !== FOLLOWING_FEED_STUB)
+  }, [tabs])
 
   React.useEffect(() => {
     if (!preferences?.feeds?.pinned) return
@@ -296,13 +305,7 @@ export function usePinnedFeedsInfos(): FeedSourceInfo[] {
     }
 
     fetchFeedInfo()
-  }, [
-    queryClient,
-    setTabs,
-    preferences?.feeds?.pinned,
-    // ensure we react to re-ordering
-    pinnedFeedsKey,
-  ])
+  }, [queryClient, setTabs, preferences?.feeds?.pinned])
 
-  return tabs
+  return {feeds: tabs, hasPinnedCustom}
 }
diff --git a/src/state/queries/list.ts b/src/state/queries/list.ts
index ef05009d1..550baecb3 100644
--- a/src/state/queries/list.ts
+++ b/src/state/queries/list.ts
@@ -3,7 +3,6 @@ import {
   AppBskyGraphGetList,
   AppBskyGraphList,
   AppBskyGraphDefs,
-  BskyAgent,
 } from '@atproto/api'
 import {Image as RNImage} from 'react-native-image-crop-picker'
 import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
@@ -75,13 +74,9 @@ export function useListCreateMutation() {
         )
 
         // wait for the appview to update
-        await whenAppViewReady(
-          getAgent(),
-          res.uri,
-          (v: AppBskyGraphGetList.Response) => {
-            return typeof v?.data?.list.uri === 'string'
-          },
-        )
+        await whenAppViewReady(res.uri, (v: AppBskyGraphGetList.Response) => {
+          return typeof v?.data?.list.uri === 'string'
+        })
         return res
       },
       onSuccess() {
@@ -142,16 +137,12 @@ export function useListMetadataMutation() {
       ).data
 
       // wait for the appview to update
-      await whenAppViewReady(
-        getAgent(),
-        res.uri,
-        (v: AppBskyGraphGetList.Response) => {
-          const list = v.data.list
-          return (
-            list.name === record.name && list.description === record.description
-          )
-        },
-      )
+      await whenAppViewReady(res.uri, (v: AppBskyGraphGetList.Response) => {
+        const list = v.data.list
+        return (
+          list.name === record.name && list.description === record.description
+        )
+      })
       return res
     },
     onSuccess(data, variables) {
@@ -216,13 +207,9 @@ export function useListDeleteMutation() {
       }
 
       // wait for the appview to update
-      await whenAppViewReady(
-        getAgent(),
-        uri,
-        (v: AppBskyGraphGetList.Response) => {
-          return !v?.success
-        },
-      )
+      await whenAppViewReady(uri, (v: AppBskyGraphGetList.Response) => {
+        return !v?.success
+      })
     },
     onSuccess() {
       invalidateMyLists(queryClient)
@@ -271,7 +258,6 @@ export function useListBlockMutation() {
 }
 
 async function whenAppViewReady(
-  agent: BskyAgent,
   uri: string,
   fn: (res: AppBskyGraphGetList.Response) => boolean,
 ) {
@@ -280,7 +266,7 @@ async function whenAppViewReady(
     1e3, // 1s delay between tries
     fn,
     () =>
-      agent.app.bsky.graph.getList({
+      getAgent().app.bsky.graph.getList({
         list: uri,
         limit: 1,
       }),
diff --git a/src/state/queries/my-blocked-accounts.ts b/src/state/queries/my-blocked-accounts.ts
index 4d5bd7a0e..2c099c63d 100644
--- a/src/state/queries/my-blocked-accounts.ts
+++ b/src/state/queries/my-blocked-accounts.ts
@@ -2,7 +2,6 @@ import {AppBskyGraphGetBlocks} from '@atproto/api'
 import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
 
 import {getAgent} from '#/state/session'
-import {STALE} from '#/state/queries'
 
 export const RQKEY = () => ['my-blocked-accounts']
 type RQPageParam = string | undefined
@@ -15,7 +14,6 @@ export function useMyBlockedAccountsQuery() {
     QueryKey,
     RQPageParam
   >({
-    staleTime: STALE.MINUTES.ONE,
     queryKey: RQKEY(),
     async queryFn({pageParam}: {pageParam: RQPageParam}) {
       const res = await getAgent().app.bsky.graph.getBlocks({
diff --git a/src/state/queries/my-muted-accounts.ts b/src/state/queries/my-muted-accounts.ts
index 1d686637a..a175931b5 100644
--- a/src/state/queries/my-muted-accounts.ts
+++ b/src/state/queries/my-muted-accounts.ts
@@ -2,7 +2,6 @@ import {AppBskyGraphGetMutes} from '@atproto/api'
 import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
 
 import {getAgent} from '#/state/session'
-import {STALE} from '#/state/queries'
 
 export const RQKEY = () => ['my-muted-accounts']
 type RQPageParam = string | undefined
@@ -15,7 +14,6 @@ export function useMyMutedAccountsQuery() {
     QueryKey,
     RQPageParam
   >({
-    staleTime: STALE.MINUTES.ONE,
     queryKey: RQKEY(),
     async queryFn({pageParam}: {pageParam: RQPageParam}) {
       const res = await getAgent().app.bsky.graph.getMutes({
diff --git a/src/state/queries/notifications/feed.ts b/src/state/queries/notifications/feed.ts
index 54bd87540..5c519d045 100644
--- a/src/state/queries/notifications/feed.ts
+++ b/src/state/queries/notifications/feed.ts
@@ -1,12 +1,22 @@
-import {
-  AppBskyFeedDefs,
-  AppBskyFeedPost,
-  AppBskyFeedRepost,
-  AppBskyFeedLike,
-  AppBskyNotificationListNotifications,
-  BskyAgent,
-} from '@atproto/api'
-import chunk from 'lodash.chunk'
+/**
+ * NOTE
+ * The ./unread.ts API:
+ *
+ * - Provides a `checkUnread()` function to sync with the server,
+ * - Periodically calls `checkUnread()`, and
+ * - Caches the first page of notifications.
+ *
+ * IMPORTANT: This query uses ./unread.ts's cache as its first page,
+ * IMPORTANT: which means the cache-freshness of this query is driven by the unread API.
+ *
+ * Follow these rules:
+ *
+ * 1. Call `checkUnread()` if you want to fetch latest in the background.
+ * 2. Call `checkUnread({invalidate: true})` if you want latest to sync into this query's results immediately.
+ * 3. Don't call this query's `refetch()` if you're trying to sync latest; call `checkUnread()` instead.
+ */
+
+import {AppBskyFeedDefs} from '@atproto/api'
 import {
   useInfiniteQuery,
   InfiniteData,
@@ -14,50 +24,27 @@ import {
   useQueryClient,
   QueryClient,
 } from '@tanstack/react-query'
-import {getAgent} from '../../session'
 import {useModerationOpts} from '../preferences'
-import {shouldFilterNotif} from './util'
+import {useUnreadNotificationsApi} from './unread'
+import {fetchPage} from './util'
+import {FeedPage} from './types'
 import {useMutedThreads} from '#/state/muted-threads'
-import {precacheProfile as precacheResolvedUri} from '../resolve-uri'
 
-const GROUPABLE_REASONS = ['like', 'repost', 'follow']
+export type {NotificationType, FeedNotification, FeedPage} from './types'
+
 const PAGE_SIZE = 30
-const MS_1HR = 1e3 * 60 * 60
-const MS_2DAY = MS_1HR * 48
 
 type RQPageParam = string | undefined
-type NotificationType =
-  | 'post-like'
-  | 'feedgen-like'
-  | 'repost'
-  | 'mention'
-  | 'reply'
-  | 'quote'
-  | 'follow'
-  | 'unknown'
 
 export function RQKEY() {
   return ['notification-feed']
 }
 
-export interface FeedNotification {
-  _reactKey: string
-  type: NotificationType
-  notification: AppBskyNotificationListNotifications.Notification
-  additional?: AppBskyNotificationListNotifications.Notification[]
-  subjectUri?: string
-  subject?: AppBskyFeedDefs.PostView
-}
-
-export interface FeedPage {
-  cursor: string | undefined
-  items: FeedNotification[]
-}
-
 export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
   const queryClient = useQueryClient()
   const moderationOpts = useModerationOpts()
   const threadMutes = useMutedThreads()
+  const unreads = useUnreadNotificationsApi()
   const enabled = opts?.enabled !== false
 
   return useInfiniteQuery<
@@ -69,40 +56,21 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
   >({
     queryKey: RQKEY(),
     async queryFn({pageParam}: {pageParam: RQPageParam}) {
-      const res = await getAgent().listNotifications({
+      // for the first page, we check the cached page held by the unread-checker first
+      if (!pageParam) {
+        const cachedPage = unreads.getCachedUnreadPage()
+        if (cachedPage) {
+          return cachedPage
+        }
+      }
+      // do a normal fetch
+      return fetchPage({
         limit: PAGE_SIZE,
         cursor: pageParam,
+        queryClient,
+        moderationOpts,
+        threadMutes,
       })
-
-      // filter out notifs by mod rules
-      const notifs = res.data.notifications.filter(
-        notif => !shouldFilterNotif(notif, moderationOpts),
-      )
-
-      // group notifications which are essentially similar (follows, likes on a post)
-      let notifsGrouped = groupNotifications(notifs)
-
-      // we fetch subjects of notifications (usually posts) now instead of lazily
-      // in the UI to avoid relayouts
-      const subjects = await fetchSubjects(getAgent(), notifsGrouped)
-      for (const notif of notifsGrouped) {
-        if (notif.subjectUri) {
-          notif.subject = subjects.get(notif.subjectUri)
-          if (notif.subject) {
-            precacheResolvedUri(queryClient, notif.subject.author) // precache the handle->did resolution
-          }
-        }
-      }
-
-      // apply thread muting
-      notifsGrouped = notifsGrouped.filter(
-        notif => !isThreadMuted(notif, threadMutes),
-      )
-
-      return {
-        cursor: res.data.cursor,
-        items: notifsGrouped,
-      }
     },
     initialPageParam: undefined,
     getNextPageParam: lastPage => lastPage.cursor,
@@ -135,114 +103,3 @@ export function findPostInQueryData(
   }
   return undefined
 }
-
-function groupNotifications(
-  notifs: AppBskyNotificationListNotifications.Notification[],
-): FeedNotification[] {
-  const groupedNotifs: FeedNotification[] = []
-  for (const notif of notifs) {
-    const ts = +new Date(notif.indexedAt)
-    let grouped = false
-    if (GROUPABLE_REASONS.includes(notif.reason)) {
-      for (const groupedNotif of groupedNotifs) {
-        const ts2 = +new Date(groupedNotif.notification.indexedAt)
-        if (
-          Math.abs(ts2 - ts) < MS_2DAY &&
-          notif.reason === groupedNotif.notification.reason &&
-          notif.reasonSubject === groupedNotif.notification.reasonSubject &&
-          notif.author.did !== groupedNotif.notification.author.did
-        ) {
-          groupedNotif.additional = groupedNotif.additional || []
-          groupedNotif.additional.push(notif)
-          grouped = true
-          break
-        }
-      }
-    }
-    if (!grouped) {
-      const type = toKnownType(notif)
-      groupedNotifs.push({
-        _reactKey: `notif-${notif.uri}`,
-        type,
-        notification: notif,
-        subjectUri: getSubjectUri(type, notif),
-      })
-    }
-  }
-  return groupedNotifs
-}
-
-async function fetchSubjects(
-  agent: BskyAgent,
-  groupedNotifs: FeedNotification[],
-): Promise<Map<string, AppBskyFeedDefs.PostView>> {
-  const uris = new Set<string>()
-  for (const notif of groupedNotifs) {
-    if (notif.subjectUri) {
-      uris.add(notif.subjectUri)
-    }
-  }
-  const uriChunks = chunk(Array.from(uris), 25)
-  const postsChunks = await Promise.all(
-    uriChunks.map(uris =>
-      agent.app.bsky.feed.getPosts({uris}).then(res => res.data.posts),
-    ),
-  )
-  const map = new Map<string, AppBskyFeedDefs.PostView>()
-  for (const post of postsChunks.flat()) {
-    if (
-      AppBskyFeedPost.isRecord(post.record) &&
-      AppBskyFeedPost.validateRecord(post.record).success
-    ) {
-      map.set(post.uri, post)
-    }
-  }
-  return map
-}
-
-function toKnownType(
-  notif: AppBskyNotificationListNotifications.Notification,
-): NotificationType {
-  if (notif.reason === 'like') {
-    if (notif.reasonSubject?.includes('feed.generator')) {
-      return 'feedgen-like'
-    }
-    return 'post-like'
-  }
-  if (
-    notif.reason === 'repost' ||
-    notif.reason === 'mention' ||
-    notif.reason === 'reply' ||
-    notif.reason === 'quote' ||
-    notif.reason === 'follow'
-  ) {
-    return notif.reason as NotificationType
-  }
-  return 'unknown'
-}
-
-function getSubjectUri(
-  type: NotificationType,
-  notif: AppBskyNotificationListNotifications.Notification,
-): string | undefined {
-  if (type === 'reply' || type === 'quote' || type === 'mention') {
-    return notif.uri
-  } else if (type === 'post-like' || type === 'repost') {
-    if (
-      AppBskyFeedRepost.isRecord(notif.record) ||
-      AppBskyFeedLike.isRecord(notif.record)
-    ) {
-      return typeof notif.record.subject?.uri === 'string'
-        ? notif.record.subject?.uri
-        : undefined
-    }
-  }
-}
-
-function isThreadMuted(notif: FeedNotification, mutes: string[]): boolean {
-  if (!notif.subject) {
-    return false
-  }
-  const record = notif.subject.record as AppBskyFeedPost.Record // assured in fetchSubjects()
-  return mutes.includes(record.reply?.root.uri || notif.subject.uri)
-}
diff --git a/src/state/queries/notifications/types.ts b/src/state/queries/notifications/types.ts
new file mode 100644
index 000000000..0e88f1071
--- /dev/null
+++ b/src/state/queries/notifications/types.ts
@@ -0,0 +1,34 @@
+import {
+  AppBskyNotificationListNotifications,
+  AppBskyFeedDefs,
+} from '@atproto/api'
+
+export type NotificationType =
+  | 'post-like'
+  | 'feedgen-like'
+  | 'repost'
+  | 'mention'
+  | 'reply'
+  | 'quote'
+  | 'follow'
+  | 'unknown'
+
+export interface FeedNotification {
+  _reactKey: string
+  type: NotificationType
+  notification: AppBskyNotificationListNotifications.Notification
+  additional?: AppBskyNotificationListNotifications.Notification[]
+  subjectUri?: string
+  subject?: AppBskyFeedDefs.PostView
+}
+
+export interface FeedPage {
+  cursor: string | undefined
+  items: FeedNotification[]
+}
+
+export interface CachedFeedPage {
+  sessDid: string // used to invalidate on session changes
+  syncedAt: Date
+  data: FeedPage | undefined
+}
diff --git a/src/state/queries/notifications/unread.tsx b/src/state/queries/notifications/unread.tsx
index 36bc6528f..e0510e79e 100644
--- a/src/state/queries/notifications/unread.tsx
+++ b/src/state/queries/notifications/unread.tsx
@@ -1,10 +1,19 @@
+/**
+ * A kind of companion API to ./feed.ts. See that file for more info.
+ */
+
 import React from 'react'
 import * as Notifications from 'expo-notifications'
+import {useQueryClient} from '@tanstack/react-query'
 import BroadcastChannel from '#/lib/broadcast'
 import {useSession, getAgent} from '#/state/session'
 import {useModerationOpts} from '../preferences'
-import {shouldFilterNotif} from './util'
+import {fetchPage} from './util'
+import {CachedFeedPage, FeedPage} from './types'
 import {isNative} from '#/platform/detection'
+import {useMutedThreads} from '#/state/muted-threads'
+import {RQKEY as RQKEY_NOTIFS} from './feed'
+import {logger} from '#/logger'
 
 const UPDATE_INTERVAL = 30 * 1e3 // 30sec
 
@@ -14,7 +23,8 @@ type StateContext = string
 
 interface ApiContext {
   markAllRead: () => Promise<void>
-  checkUnread: () => Promise<void>
+  checkUnread: (opts?: {invalidate?: boolean}) => Promise<void>
+  getCachedUnreadPage: () => FeedPage | undefined
 }
 
 const stateContext = React.createContext<StateContext>('')
@@ -22,16 +32,23 @@ const stateContext = React.createContext<StateContext>('')
 const apiContext = React.createContext<ApiContext>({
   async markAllRead() {},
   async checkUnread() {},
+  getCachedUnreadPage: () => undefined,
 })
 
 export function Provider({children}: React.PropsWithChildren<{}>) {
-  const {hasSession} = useSession()
+  const {hasSession, currentAccount} = useSession()
+  const queryClient = useQueryClient()
   const moderationOpts = useModerationOpts()
+  const threadMutes = useMutedThreads()
 
   const [numUnread, setNumUnread] = React.useState('')
 
-  const checkUnreadRef = React.useRef<(() => Promise<void>) | null>(null)
-  const lastSyncRef = React.useRef<Date>(new Date())
+  const checkUnreadRef = React.useRef<ApiContext['checkUnread'] | null>(null)
+  const cacheRef = React.useRef<CachedFeedPage>({
+    sessDid: currentAccount?.did || '',
+    syncedAt: new Date(),
+    data: undefined,
+  })
 
   // periodic sync
   React.useEffect(() => {
@@ -46,14 +63,18 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
   // listen for broadcasts
   React.useEffect(() => {
     const listener = ({data}: MessageEvent) => {
-      lastSyncRef.current = new Date()
+      cacheRef.current = {
+        sessDid: currentAccount?.did || '',
+        syncedAt: new Date(),
+        data: undefined,
+      }
       setNumUnread(data.event)
     }
     broadcast.addEventListener('message', listener)
     return () => {
       broadcast.removeEventListener('message', listener)
     }
-  }, [setNumUnread])
+  }, [setNumUnread, currentAccount])
 
   // create API
   const api = React.useMemo<ApiContext>(() => {
@@ -61,7 +82,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
       async markAllRead() {
         // update server
         await getAgent().updateSeenNotifications(
-          lastSyncRef.current.toISOString(),
+          cacheRef.current.syncedAt.toISOString(),
         )
 
         // update & broadcast
@@ -69,38 +90,59 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
         broadcast.postMessage({event: ''})
       },
 
-      async checkUnread() {
-        const agent = getAgent()
-
-        if (!agent.session) return
-
-        // count
-        const res = await agent.listNotifications({limit: 40})
-        const filtered = res.data.notifications.filter(
-          notif => !notif.isRead && !shouldFilterNotif(notif, moderationOpts),
-        )
-        const num =
-          filtered.length >= 30
-            ? '30+'
-            : filtered.length === 0
-            ? ''
-            : String(filtered.length)
-        if (isNative) {
-          Notifications.setBadgeCountAsync(Math.min(filtered.length, 30))
+      async checkUnread({invalidate}: {invalidate?: boolean} = {}) {
+        try {
+          if (!getAgent().session) return
+
+          // count
+          const page = await fetchPage({
+            cursor: undefined,
+            limit: 40,
+            queryClient,
+            moderationOpts,
+            threadMutes,
+          })
+          const unreadCount = countUnread(page)
+          const unreadCountStr =
+            unreadCount >= 30
+              ? '30+'
+              : unreadCount === 0
+              ? ''
+              : String(unreadCount)
+          if (isNative) {
+            Notifications.setBadgeCountAsync(Math.min(unreadCount, 30))
+          }
+
+          // track last sync
+          const now = new Date()
+          const lastIndexed =
+            page.items[0] && new Date(page.items[0].notification.indexedAt)
+          cacheRef.current = {
+            sessDid: currentAccount?.did || '',
+            data: page,
+            syncedAt: !lastIndexed || now > lastIndexed ? now : lastIndexed,
+          }
+
+          // update & broadcast
+          setNumUnread(unreadCountStr)
+          if (invalidate) {
+            queryClient.resetQueries({queryKey: RQKEY_NOTIFS()})
+          }
+          broadcast.postMessage({event: unreadCountStr})
+        } catch (e) {
+          logger.error('Failed to check unread notifications', {error: e})
         }
+      },
 
-        // track last sync
-        const now = new Date()
-        const lastIndexed = filtered[0] && new Date(filtered[0].indexedAt)
-        lastSyncRef.current =
-          !lastIndexed || now > lastIndexed ? now : lastIndexed
-
-        // update & broadcast
-        setNumUnread(num)
-        broadcast.postMessage({event: num})
+      getCachedUnreadPage() {
+        // return cached page if was for the current user
+        // (protects against session changes serving data from the past session)
+        if (cacheRef.current.sessDid === currentAccount?.did) {
+          return cacheRef.current.data
+        }
       },
     }
-  }, [setNumUnread, moderationOpts])
+  }, [setNumUnread, queryClient, moderationOpts, threadMutes, currentAccount])
   checkUnreadRef.current = api.checkUnread
 
   return (
@@ -117,3 +159,20 @@ export function useUnreadNotifications() {
 export function useUnreadNotificationsApi() {
   return React.useContext(apiContext)
 }
+
+function countUnread(page: FeedPage) {
+  let num = 0
+  for (const item of page.items) {
+    if (!item.notification.isRead) {
+      num++
+    }
+    if (item.additional) {
+      for (const item2 of item.additional) {
+        if (!item2.isRead) {
+          num++
+        }
+      }
+    }
+  }
+  return num
+}
diff --git a/src/state/queries/notifications/util.ts b/src/state/queries/notifications/util.ts
index c49d1851a..b8f320473 100644
--- a/src/state/queries/notifications/util.ts
+++ b/src/state/queries/notifications/util.ts
@@ -3,10 +3,78 @@ import {
   ModerationOpts,
   moderateProfile,
   moderatePost,
+  AppBskyFeedDefs,
+  AppBskyFeedPost,
+  AppBskyFeedRepost,
+  AppBskyFeedLike,
 } from '@atproto/api'
+import chunk from 'lodash.chunk'
+import {QueryClient} from '@tanstack/react-query'
+import {getAgent} from '../../session'
+import {precacheProfile as precacheResolvedUri} from '../resolve-uri'
+import {NotificationType, FeedNotification, FeedPage} from './types'
+
+const GROUPABLE_REASONS = ['like', 'repost', 'follow']
+const MS_1HR = 1e3 * 60 * 60
+const MS_2DAY = MS_1HR * 48
+
+// exported api
+// =
+
+export async function fetchPage({
+  cursor,
+  limit,
+  queryClient,
+  moderationOpts,
+  threadMutes,
+}: {
+  cursor: string | undefined
+  limit: number
+  queryClient: QueryClient
+  moderationOpts: ModerationOpts | undefined
+  threadMutes: string[]
+}): Promise<FeedPage> {
+  const res = await getAgent().listNotifications({
+    limit,
+    cursor,
+  })
+
+  // filter out notifs by mod rules
+  const notifs = res.data.notifications.filter(
+    notif => !shouldFilterNotif(notif, moderationOpts),
+  )
+
+  // group notifications which are essentially similar (follows, likes on a post)
+  let notifsGrouped = groupNotifications(notifs)
+
+  // we fetch subjects of notifications (usually posts) now instead of lazily
+  // in the UI to avoid relayouts
+  const subjects = await fetchSubjects(notifsGrouped)
+  for (const notif of notifsGrouped) {
+    if (notif.subjectUri) {
+      notif.subject = subjects.get(notif.subjectUri)
+      if (notif.subject) {
+        precacheResolvedUri(queryClient, notif.subject.author) // precache the handle->did resolution
+      }
+    }
+  }
+
+  // apply thread muting
+  notifsGrouped = notifsGrouped.filter(
+    notif => !isThreadMuted(notif, threadMutes),
+  )
+
+  return {
+    cursor: res.data.cursor,
+    items: notifsGrouped,
+  }
+}
+
+// internal methods
+// =
 
 // TODO this should be in the sdk as moderateNotification -prf
-export function shouldFilterNotif(
+function shouldFilterNotif(
   notif: AppBskyNotificationListNotifications.Notification,
   moderationOpts: ModerationOpts | undefined,
 ): boolean {
@@ -36,3 +104,116 @@ export function shouldFilterNotif(
   // (this requires fetching the post)
   return false
 }
+
+function groupNotifications(
+  notifs: AppBskyNotificationListNotifications.Notification[],
+): FeedNotification[] {
+  const groupedNotifs: FeedNotification[] = []
+  for (const notif of notifs) {
+    const ts = +new Date(notif.indexedAt)
+    let grouped = false
+    if (GROUPABLE_REASONS.includes(notif.reason)) {
+      for (const groupedNotif of groupedNotifs) {
+        const ts2 = +new Date(groupedNotif.notification.indexedAt)
+        if (
+          Math.abs(ts2 - ts) < MS_2DAY &&
+          notif.reason === groupedNotif.notification.reason &&
+          notif.reasonSubject === groupedNotif.notification.reasonSubject &&
+          notif.author.did !== groupedNotif.notification.author.did &&
+          notif.isRead === groupedNotif.notification.isRead
+        ) {
+          groupedNotif.additional = groupedNotif.additional || []
+          groupedNotif.additional.push(notif)
+          grouped = true
+          break
+        }
+      }
+    }
+    if (!grouped) {
+      const type = toKnownType(notif)
+      groupedNotifs.push({
+        _reactKey: `notif-${notif.uri}`,
+        type,
+        notification: notif,
+        subjectUri: getSubjectUri(type, notif),
+      })
+    }
+  }
+  return groupedNotifs
+}
+
+async function fetchSubjects(
+  groupedNotifs: FeedNotification[],
+): Promise<Map<string, AppBskyFeedDefs.PostView>> {
+  const uris = new Set<string>()
+  for (const notif of groupedNotifs) {
+    if (notif.subjectUri) {
+      uris.add(notif.subjectUri)
+    }
+  }
+  const uriChunks = chunk(Array.from(uris), 25)
+  const postsChunks = await Promise.all(
+    uriChunks.map(uris =>
+      getAgent()
+        .app.bsky.feed.getPosts({uris})
+        .then(res => res.data.posts),
+    ),
+  )
+  const map = new Map<string, AppBskyFeedDefs.PostView>()
+  for (const post of postsChunks.flat()) {
+    if (
+      AppBskyFeedPost.isRecord(post.record) &&
+      AppBskyFeedPost.validateRecord(post.record).success
+    ) {
+      map.set(post.uri, post)
+    }
+  }
+  return map
+}
+
+function toKnownType(
+  notif: AppBskyNotificationListNotifications.Notification,
+): NotificationType {
+  if (notif.reason === 'like') {
+    if (notif.reasonSubject?.includes('feed.generator')) {
+      return 'feedgen-like'
+    }
+    return 'post-like'
+  }
+  if (
+    notif.reason === 'repost' ||
+    notif.reason === 'mention' ||
+    notif.reason === 'reply' ||
+    notif.reason === 'quote' ||
+    notif.reason === 'follow'
+  ) {
+    return notif.reason as NotificationType
+  }
+  return 'unknown'
+}
+
+function getSubjectUri(
+  type: NotificationType,
+  notif: AppBskyNotificationListNotifications.Notification,
+): string | undefined {
+  if (type === 'reply' || type === 'quote' || type === 'mention') {
+    return notif.uri
+  } else if (type === 'post-like' || type === 'repost') {
+    if (
+      AppBskyFeedRepost.isRecord(notif.record) ||
+      AppBskyFeedLike.isRecord(notif.record)
+    ) {
+      return typeof notif.record.subject?.uri === 'string'
+        ? notif.record.subject?.uri
+        : undefined
+    }
+  }
+}
+
+function isThreadMuted(notif: FeedNotification, mutes: string[]): boolean {
+  if (!notif.subject) {
+    return false
+  }
+  const record = notif.subject.record as AppBskyFeedPost.Record // assured in fetchSubjects()
+  return mutes.includes(record.reply?.root.uri || notif.subject.uri)
+}
diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts
index 113e6f2fb..7cf315ef6 100644
--- a/src/state/queries/post-feed.ts
+++ b/src/state/queries/post-feed.ts
@@ -1,4 +1,3 @@
-import {useCallback, useMemo} from 'react'
 import {AppBskyFeedDefs, AppBskyFeedPost, moderatePost} from '@atproto/api'
 import {
   useInfiniteQuery,
@@ -7,9 +6,8 @@ import {
   QueryClient,
   useQueryClient,
 } from '@tanstack/react-query'
-import {getAgent} from '../session'
 import {useFeedTuners} from '../preferences/feed-tuners'
-import {FeedTuner, NoopFeedTuner} from 'lib/api/feed-manip'
+import {FeedTuner, FeedTunerFn, NoopFeedTuner} from 'lib/api/feed-manip'
 import {FeedAPI, ReasonFeedSource} from 'lib/api/feed/types'
 import {FollowingFeedAPI} from 'lib/api/feed/following'
 import {AuthorFeedAPI} from 'lib/api/feed/author'
@@ -17,10 +15,13 @@ import {LikesFeedAPI} from 'lib/api/feed/likes'
 import {CustomFeedAPI} from 'lib/api/feed/custom'
 import {ListFeedAPI} from 'lib/api/feed/list'
 import {MergeFeedAPI} from 'lib/api/feed/merge'
-import {useModerationOpts} from '#/state/queries/preferences'
 import {logger} from '#/logger'
 import {STALE} from '#/state/queries'
 import {precacheFeedPosts as precacheResolvedUris} from './resolve-uri'
+import {getAgent} from '#/state/session'
+import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences/const'
+import {getModerationOpts} from '#/state/queries/preferences/moderation'
+import {KnownError} from '#/view/com/posts/FeedErrorMessage'
 
 type ActorDid = string
 type AuthorFilter =
@@ -42,7 +43,7 @@ export interface FeedParams {
   mergeFeedSources?: string[]
 }
 
-type RQPageParam = string | undefined
+type RQPageParam = {cursor: string | undefined; api: FeedAPI} | undefined
 
 export function RQKEY(feedDesc: FeedDescriptor, params?: FeedParams) {
   return ['post-feed', feedDesc, params || {}]
@@ -63,7 +64,15 @@ export interface FeedPostSlice {
   items: FeedPostSliceItem[]
 }
 
+export interface FeedPageUnselected {
+  api: FeedAPI
+  cursor: string | undefined
+  feed: AppBskyFeedDefs.FeedViewPost[]
+}
+
 export interface FeedPage {
+  api: FeedAPI
+  tuner: FeedTuner | NoopFeedTuner
   cursor: string | undefined
   slices: FeedPostSlice[]
 }
@@ -76,117 +85,139 @@ export function usePostFeedQuery(
   const queryClient = useQueryClient()
   const feedTuners = useFeedTuners(feedDesc)
   const enabled = opts?.enabled !== false
-  const moderationOpts = useModerationOpts()
-  const agent = getAgent()
-
-  const api: FeedAPI = useMemo(() => {
-    if (feedDesc === 'home') {
-      return new MergeFeedAPI(agent, params || {}, feedTuners)
-    } else if (feedDesc === 'following') {
-      return new FollowingFeedAPI(agent)
-    } else if (feedDesc.startsWith('author')) {
-      const [_, actor, filter] = feedDesc.split('|')
-      return new AuthorFeedAPI(agent, {actor, filter})
-    } else if (feedDesc.startsWith('likes')) {
-      const [_, actor] = feedDesc.split('|')
-      return new LikesFeedAPI(agent, {actor})
-    } else if (feedDesc.startsWith('feedgen')) {
-      const [_, feed] = feedDesc.split('|')
-      return new CustomFeedAPI(agent, {feed})
-    } else if (feedDesc.startsWith('list')) {
-      const [_, list] = feedDesc.split('|')
-      return new ListFeedAPI(agent, {list})
-    } else {
-      // shouldnt happen
-      return new FollowingFeedAPI(agent)
-    }
-  }, [feedDesc, params, feedTuners, agent])
-
-  const disableTuner = !!params?.disableTuner
-  const tuner = useMemo(
-    () => (disableTuner ? new NoopFeedTuner() : new FeedTuner()),
-    [disableTuner],
-  )
-
-  const pollLatest = useCallback(async () => {
-    if (!enabled) {
-      return false
-    }
-
-    logger.debug('usePostFeedQuery: pollLatest')
-
-    const post = await api.peekLatest()
-
-    if (post && moderationOpts) {
-      const slices = tuner.tune([post], feedTuners, {
-        dryRun: true,
-        maintainOrder: true,
-      })
-      if (slices[0]) {
-        if (
-          !moderatePost(slices[0].items[0].post, moderationOpts).content.filter
-        ) {
-          return true
-        }
-      }
-    }
 
-    return false
-  }, [api, tuner, feedTuners, moderationOpts, enabled])
-
-  const out = useInfiniteQuery<
-    FeedPage,
+  return useInfiniteQuery<
+    FeedPageUnselected,
     Error,
     InfiniteData<FeedPage>,
     QueryKey,
     RQPageParam
   >({
+    enabled,
     staleTime: STALE.INFINITY,
     queryKey: RQKEY(feedDesc, params),
     async queryFn({pageParam}: {pageParam: RQPageParam}) {
       logger.debug('usePostFeedQuery', {feedDesc, pageParam})
-      if (!pageParam) {
-        tuner.reset()
-      }
-      const res = await api.fetch({cursor: pageParam, limit: 30})
+
+      const {api, cursor} = pageParam
+        ? pageParam
+        : {
+            api: createApi(feedDesc, params || {}, feedTuners),
+            cursor: undefined,
+          }
+
+      const res = await api.fetch({cursor, limit: 30})
       precacheResolvedUris(queryClient, res.feed) // precache the handle->did resolution
-      const slices = tuner.tune(res.feed, feedTuners)
+
+      /*
+       * If this is a public view, we need to check if posts fail moderation.
+       * If all fail, we throw an error. If only some fail, we continue and let
+       * moderations happen later, which results in some posts being shown and
+       * some not.
+       */
+      if (!getAgent().session) {
+        assertSomePostsPassModeration(res.feed)
+      }
+
       return {
+        api,
         cursor: res.cursor,
-        slices: slices.map(slice => ({
-          _reactKey: slice._reactKey,
-          rootUri: slice.rootItem.post.uri,
-          isThread:
-            slice.items.length > 1 &&
-            slice.items.every(
-              item => item.post.author.did === slice.items[0].post.author.did,
-            ),
-          items: slice.items
-            .map((item, i) => {
-              if (
-                AppBskyFeedPost.isRecord(item.post.record) &&
-                AppBskyFeedPost.validateRecord(item.post.record).success
-              ) {
-                return {
-                  _reactKey: `${slice._reactKey}-${i}`,
-                  uri: item.post.uri,
-                  post: item.post,
-                  record: item.post.record,
-                  reason: i === 0 && slice.source ? slice.source : item.reason,
+        feed: res.feed,
+      }
+    },
+    initialPageParam: undefined,
+    getNextPageParam: lastPage => ({
+      api: lastPage.api,
+      cursor: lastPage.cursor,
+    }),
+    select(data) {
+      const tuner = params?.disableTuner
+        ? new NoopFeedTuner()
+        : new FeedTuner(feedTuners)
+      return {
+        pageParams: data.pageParams,
+        pages: data.pages.map(page => ({
+          api: page.api,
+          tuner,
+          cursor: page.cursor,
+          slices: tuner.tune(page.feed).map(slice => ({
+            _reactKey: slice._reactKey,
+            rootUri: slice.rootItem.post.uri,
+            isThread:
+              slice.items.length > 1 &&
+              slice.items.every(
+                item => item.post.author.did === slice.items[0].post.author.did,
+              ),
+            items: slice.items
+              .map((item, i) => {
+                if (
+                  AppBskyFeedPost.isRecord(item.post.record) &&
+                  AppBskyFeedPost.validateRecord(item.post.record).success
+                ) {
+                  return {
+                    _reactKey: `${slice._reactKey}-${i}`,
+                    uri: item.post.uri,
+                    post: item.post,
+                    record: item.post.record,
+                    reason:
+                      i === 0 && slice.source ? slice.source : item.reason,
+                  }
                 }
-              }
-              return undefined
-            })
-            .filter(Boolean) as FeedPostSliceItem[],
+                return undefined
+              })
+              .filter(Boolean) as FeedPostSliceItem[],
+          })),
         })),
       }
     },
-    initialPageParam: undefined,
-    getNextPageParam: lastPage => lastPage.cursor,
-    enabled,
   })
+}
+
+export async function pollLatest(page: FeedPage | undefined) {
+  if (!page) {
+    return false
+  }
 
-  return {...out, pollLatest}
+  logger.debug('usePostFeedQuery: pollLatest')
+  const post = await page.api.peekLatest()
+  if (post) {
+    const slices = page.tuner.tune([post], {
+      dryRun: true,
+      maintainOrder: true,
+    })
+    if (slices[0]) {
+      return true
+    }
+  }
+
+  return false
+}
+
+function createApi(
+  feedDesc: FeedDescriptor,
+  params: FeedParams,
+  feedTuners: FeedTunerFn[],
+) {
+  if (feedDesc === 'home') {
+    return new MergeFeedAPI(params, feedTuners)
+  } else if (feedDesc === 'following') {
+    return new FollowingFeedAPI()
+  } else if (feedDesc.startsWith('author')) {
+    const [_, actor, filter] = feedDesc.split('|')
+    return new AuthorFeedAPI({actor, filter})
+  } else if (feedDesc.startsWith('likes')) {
+    const [_, actor] = feedDesc.split('|')
+    return new LikesFeedAPI({actor})
+  } else if (feedDesc.startsWith('feedgen')) {
+    const [_, feed] = feedDesc.split('|')
+    return new CustomFeedAPI({feed})
+  } else if (feedDesc.startsWith('list')) {
+    const [_, list] = feedDesc.split('|')
+    return new ListFeedAPI({list})
+  } else {
+    // shouldnt happen
+    return new FollowingFeedAPI()
+  }
 }
 
 /**
@@ -196,8 +227,10 @@ export function usePostFeedQuery(
 export function findPostInQueryData(
   queryClient: QueryClient,
   uri: string,
-): FeedPostSliceItem | undefined {
-  const queryDatas = queryClient.getQueriesData<InfiniteData<FeedPage>>({
+): AppBskyFeedDefs.FeedViewPost | undefined {
+  const queryDatas = queryClient.getQueriesData<
+    InfiniteData<FeedPageUnselected>
+  >({
     queryKey: ['post-feed'],
   })
   for (const [_queryKey, queryData] of queryDatas) {
@@ -205,14 +238,34 @@ export function findPostInQueryData(
       continue
     }
     for (const page of queryData?.pages) {
-      for (const slice of page.slices) {
-        for (const item of slice.items) {
-          if (item.uri === uri) {
-            return item
-          }
+      for (const item of page.feed) {
+        if (item.post.uri === uri) {
+          return item
         }
       }
     }
   }
   return undefined
 }
+
+function assertSomePostsPassModeration(feed: AppBskyFeedDefs.FeedViewPost[]) {
+  // assume false
+  let somePostsPassModeration = false
+
+  for (const item of feed) {
+    const moderationOpts = getModerationOpts({
+      userDid: '',
+      preferences: DEFAULT_LOGGED_OUT_PREFERENCES,
+    })
+    const moderation = moderatePost(item.post, moderationOpts)
+
+    if (!moderation.content.filter) {
+      // we have a sfw post
+      somePostsPassModeration = true
+    }
+  }
+
+  if (!somePostsPassModeration) {
+    throw new Error(KnownError.FeedNSFPublic)
+  }
+}
diff --git a/src/state/queries/post-liked-by.ts b/src/state/queries/post-liked-by.ts
index 33b379a0c..528b3be70 100644
--- a/src/state/queries/post-liked-by.ts
+++ b/src/state/queries/post-liked-by.ts
@@ -2,7 +2,6 @@ import {AppBskyFeedGetLikes} from '@atproto/api'
 import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
 
 import {getAgent} from '#/state/session'
-import {STALE} from '#/state/queries'
 
 const PAGE_SIZE = 30
 type RQPageParam = string | undefined
@@ -18,7 +17,6 @@ export function usePostLikedByQuery(resolvedUri: string | undefined) {
     QueryKey,
     RQPageParam
   >({
-    staleTime: STALE.MINUTES.ONE,
     queryKey: RQKEY(resolvedUri || ''),
     async queryFn({pageParam}: {pageParam: RQPageParam}) {
       const res = await getAgent().getLikes({
diff --git a/src/state/queries/post-reposted-by.ts b/src/state/queries/post-reposted-by.ts
index 3a6fe1633..f9a80056f 100644
--- a/src/state/queries/post-reposted-by.ts
+++ b/src/state/queries/post-reposted-by.ts
@@ -2,7 +2,6 @@ import {AppBskyFeedGetRepostedBy} from '@atproto/api'
 import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
 
 import {getAgent} from '#/state/session'
-import {STALE} from '#/state/queries'
 
 const PAGE_SIZE = 30
 type RQPageParam = string | undefined
@@ -18,7 +17,6 @@ export function usePostRepostedByQuery(resolvedUri: string | undefined) {
     QueryKey,
     RQPageParam
   >({
-    staleTime: STALE.MINUTES.ONE,
     queryKey: RQKEY(resolvedUri || ''),
     async queryFn({pageParam}: {pageParam: RQPageParam}) {
       const res = await getAgent().getRepostedBy({
diff --git a/src/state/queries/post-thread.ts b/src/state/queries/post-thread.ts
index c616b05cc..d40af1fe2 100644
--- a/src/state/queries/post-thread.ts
+++ b/src/state/queries/post-thread.ts
@@ -7,11 +7,7 @@ import {useQuery, useQueryClient, QueryClient} from '@tanstack/react-query'
 
 import {getAgent} from '#/state/session'
 import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
-import {STALE} from '#/state/queries'
-import {
-  findPostInQueryData as findPostInFeedQueryData,
-  FeedPostSliceItem,
-} from './post-feed'
+import {findPostInQueryData as findPostInFeedQueryData} from './post-feed'
 import {findPostInQueryData as findPostInNotifsQueryData} from './notifications/feed'
 import {precacheThreadPosts as precacheResolvedUris} from './resolve-uri'
 
@@ -68,7 +64,6 @@ export type ThreadNode =
 export function usePostThreadQuery(uri: string | undefined) {
   const queryClient = useQueryClient()
   return useQuery<ThreadNode, Error>({
-    staleTime: STALE.MINUTES.ONE,
     queryKey: RQKEY(uri || ''),
     async queryFn() {
       const res = await getAgent().getPostThread({uri: uri!})
@@ -93,7 +88,7 @@ export function usePostThreadQuery(uri: string | undefined) {
       {
         const item = findPostInFeedQueryData(queryClient, uri)
         if (item) {
-          return feedItemToPlaceholderThread(item)
+          return feedViewPostToPlaceholderThread(item)
         }
       }
       {
@@ -275,13 +270,15 @@ function threadNodeToPlaceholderThread(
   }
 }
 
-function feedItemToPlaceholderThread(item: FeedPostSliceItem): ThreadNode {
+function feedViewPostToPlaceholderThread(
+  item: AppBskyFeedDefs.FeedViewPost,
+): ThreadNode {
   return {
     type: 'post',
     _reactKey: item.post.uri,
     uri: item.post.uri,
     post: item.post,
-    record: item.record,
+    record: item.post.record as AppBskyFeedPost.Record, // validated in post-feed
     parent: undefined,
     replies: undefined,
     viewer: item.post.viewer,
@@ -291,7 +288,7 @@ function feedItemToPlaceholderThread(item: FeedPostSliceItem): ThreadNode {
       hasMore: false,
       showChildReplyLine: false,
       showParentReplyLine: false,
-      isParentLoading: !!item.record.reply,
+      isParentLoading: !!(item.post.record as AppBskyFeedPost.Record).reply,
       isChildLoading: !!item.post.replyCount,
     },
   }
@@ -305,7 +302,7 @@ function postViewToPlaceholderThread(
     _reactKey: post.uri,
     uri: post.uri,
     post: post,
-    record: post.record as AppBskyFeedPost.Record, // validate in notifs
+    record: post.record as AppBskyFeedPost.Record, // validated in notifs
     parent: undefined,
     replies: undefined,
     viewer: post.viewer,
diff --git a/src/state/queries/post.ts b/src/state/queries/post.ts
index d4193b8ce..b31696446 100644
--- a/src/state/queries/post.ts
+++ b/src/state/queries/post.ts
@@ -4,13 +4,11 @@ import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
 
 import {getAgent} from '#/state/session'
 import {updatePostShadow} from '#/state/cache/post-shadow'
-import {STALE} from '#/state/queries'
 
 export const RQKEY = (postUri: string) => ['post', postUri]
 
 export function usePostQuery(uri: string | undefined) {
   return useQuery<AppBskyFeedDefs.PostView>({
-    staleTime: STALE.MINUTES.ONE,
     queryKey: RQKEY(uri || ''),
     async queryFn() {
       const res = await getAgent().getPosts({uris: [uri!]})
@@ -29,7 +27,6 @@ export function useGetPost() {
   return React.useCallback(
     async ({uri}: {uri: string}) => {
       return queryClient.fetchQuery({
-        staleTime: STALE.MINUTES.ONE,
         queryKey: RQKEY(uri || ''),
         async queryFn() {
           const urip = new AtUri(uri)
diff --git a/src/state/queries/profile-feedgens.ts b/src/state/queries/profile-feedgens.ts
index 04860430e..7d33eb9c8 100644
--- a/src/state/queries/profile-feedgens.ts
+++ b/src/state/queries/profile-feedgens.ts
@@ -2,7 +2,6 @@ import {AppBskyFeedGetActorFeeds} from '@atproto/api'
 import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
 
 import {getAgent} from '#/state/session'
-import {STALE} from '#/state/queries'
 
 const PAGE_SIZE = 30
 type RQPageParam = string | undefined
@@ -22,7 +21,6 @@ export function useProfileFeedgensQuery(
     QueryKey,
     RQPageParam
   >({
-    staleTime: STALE.MINUTES.ONE,
     queryKey: RQKEY(did),
     async queryFn({pageParam}: {pageParam: RQPageParam}) {
       const res = await getAgent().app.bsky.feed.getActorFeeds({
diff --git a/src/state/queries/profile-followers.ts b/src/state/queries/profile-followers.ts
index 774bd23f1..b2008851d 100644
--- a/src/state/queries/profile-followers.ts
+++ b/src/state/queries/profile-followers.ts
@@ -2,7 +2,6 @@ import {AppBskyGraphGetFollowers} from '@atproto/api'
 import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
 
 import {getAgent} from '#/state/session'
-import {STALE} from '#/state/queries'
 
 const PAGE_SIZE = 30
 type RQPageParam = string | undefined
@@ -17,7 +16,6 @@ export function useProfileFollowersQuery(did: string | undefined) {
     QueryKey,
     RQPageParam
   >({
-    staleTime: STALE.MINUTES.FIVE,
     queryKey: RQKEY(did || ''),
     async queryFn({pageParam}: {pageParam: RQPageParam}) {
       const res = await getAgent().app.bsky.graph.getFollowers({
diff --git a/src/state/queries/profile-lists.ts b/src/state/queries/profile-lists.ts
index 997c85910..505d33b9f 100644
--- a/src/state/queries/profile-lists.ts
+++ b/src/state/queries/profile-lists.ts
@@ -1,8 +1,6 @@
 import {AppBskyGraphGetLists} from '@atproto/api'
 import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
-
 import {getAgent} from '#/state/session'
-import {STALE} from '#/state/queries'
 
 const PAGE_SIZE = 30
 type RQPageParam = string | undefined
@@ -18,7 +16,6 @@ export function useProfileListsQuery(did: string, opts?: {enabled?: boolean}) {
     QueryKey,
     RQPageParam
   >({
-    staleTime: STALE.MINUTES.ONE,
     queryKey: RQKEY(did),
     async queryFn({pageParam}: {pageParam: RQPageParam}) {
       const res = await getAgent().app.bsky.graph.getLists({
diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts
index e27bac9a6..62e8f39c0 100644
--- a/src/state/queries/profile.ts
+++ b/src/state/queries/profile.ts
@@ -4,7 +4,6 @@ import {
   AppBskyActorDefs,
   AppBskyActorProfile,
   AppBskyActorGetProfile,
-  BskyAgent,
 } from '@atproto/api'
 import {useQuery, useQueryClient, useMutation} from '@tanstack/react-query'
 import {Image as RNImage} from 'react-native-image-crop-picker'
@@ -22,6 +21,10 @@ export const RQKEY = (did: string) => ['profile', did]
 
 export function useProfileQuery({did}: {did: string | undefined}) {
   return useQuery({
+    // WARNING
+    // this staleTime is load-bearing
+    // if you remove it, the UI infinite-loops
+    // -prf
     staleTime: STALE.MINUTES.FIVE,
     queryKey: RQKEY(did || ''),
     queryFn: async () => {
@@ -68,7 +71,7 @@ export function useProfileUpdateMutation() {
         }
         return existing
       })
-      await whenAppViewReady(getAgent(), profile.did, res => {
+      await whenAppViewReady(profile.did, res => {
         if (typeof newUserAvatar !== 'undefined') {
           if (newUserAvatar === null && res.data.avatar) {
             // url hasnt cleared yet
@@ -464,7 +467,6 @@ function useProfileUnblockMutation() {
 }
 
 async function whenAppViewReady(
-  agent: BskyAgent,
   actor: string,
   fn: (res: AppBskyActorGetProfile.Response) => boolean,
 ) {
@@ -472,6 +474,6 @@ async function whenAppViewReady(
     5, // 5 tries
     1e3, // 1s delay between tries
     fn,
-    () => agent.app.bsky.actor.getProfile({actor}),
+    () => getAgent().app.bsky.actor.getProfile({actor}),
   )
 }
diff --git a/src/state/queries/resolve-uri.ts b/src/state/queries/resolve-uri.ts
index 05a9f4b1c..a75998466 100644
--- a/src/state/queries/resolve-uri.ts
+++ b/src/state/queries/resolve-uri.ts
@@ -23,7 +23,7 @@ export function useResolveUriQuery(uri: string | undefined): UriUseQueryResult {
 
 export function useResolveDidQuery(didOrHandle: string | undefined) {
   return useQuery<string, Error>({
-    staleTime: STALE.INFINITY,
+    staleTime: STALE.HOURS.ONE,
     queryKey: RQKEY(didOrHandle || ''),
     async queryFn() {
       if (!didOrHandle) {
diff --git a/src/state/queries/service.ts b/src/state/queries/service.ts
index c7df89960..5f7e10778 100644
--- a/src/state/queries/service.ts
+++ b/src/state/queries/service.ts
@@ -1,13 +1,10 @@
 import {BskyAgent} from '@atproto/api'
 import {useQuery} from '@tanstack/react-query'
 
-import {STALE} from '#/state/queries'
-
 export const RQKEY = (serviceUrl: string) => ['service', serviceUrl]
 
 export function useServiceQuery(serviceUrl: string) {
   return useQuery({
-    staleTime: STALE.HOURS.ONE,
     queryKey: RQKEY(serviceUrl),
     queryFn: async () => {
       const agent = new BskyAgent({service: serviceUrl})
diff --git a/src/state/queries/suggested-follows.ts b/src/state/queries/suggested-follows.ts
index 176bbe15b..eadcb590a 100644
--- a/src/state/queries/suggested-follows.ts
+++ b/src/state/queries/suggested-follows.ts
@@ -90,7 +90,7 @@ export function useGetSuggestedFollowersByActor() {
   return React.useCallback(
     async (actor: string) => {
       const res = await queryClient.fetchQuery({
-        staleTime: 60 * 1000,
+        staleTime: STALE.MINUTES.ONE,
         queryKey: suggestedFollowsByActorQueryKey(actor),
         queryFn: async () => {
           const res =