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/feed-manip.ts35
-rw-r--r--src/lib/api/feed/author.ts11
-rw-r--r--src/lib/api/feed/custom.ts11
-rw-r--r--src/lib/api/feed/following.ts9
-rw-r--r--src/lib/api/feed/likes.ts11
-rw-r--r--src/lib/api/feed/list.ts11
-rw-r--r--src/lib/api/feed/merge.ts40
-rw-r--r--src/lib/hooks/useAccountSwitcher.ts23
-rw-r--r--src/lib/notifications/notifications.ts4
-rw-r--r--src/state/queries/list.ts40
-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.ts182
-rw-r--r--src/state/queries/post-feed.ts145
-rw-r--r--src/state/queries/profile.ts6
-rw-r--r--src/state/session/index.tsx6
-rw-r--r--src/view/com/auth/login/ChooseAccountForm.tsx53
-rw-r--r--src/view/com/feeds/FeedPage.tsx4
-rw-r--r--src/view/com/feeds/FeedSourceCard.tsx28
-rw-r--r--src/view/com/notifications/Feed.tsx31
-rw-r--r--src/view/com/posts/Feed.tsx8
-rw-r--r--src/view/com/util/LoadingPlaceholder.tsx28
-rw-r--r--src/view/screens/Feeds.tsx1
-rw-r--r--src/view/screens/Notifications.tsx22
-rw-r--r--src/view/screens/Profile.tsx2
-rw-r--r--src/view/screens/ProfileFeed.tsx2
-rw-r--r--src/view/screens/ProfileList.tsx4
-rw-r--r--src/view/screens/SavedFeeds.tsx133
-rw-r--r--src/view/screens/Settings.tsx18
-rw-r--r--src/view/shell/Drawer.tsx2
-rw-r--r--src/view/shell/bottom-bar/BottomBar.tsx2
-rw-r--r--src/view/shell/desktop/LeftNav.tsx2
33 files changed, 732 insertions, 524 deletions
diff --git a/src/lib/api/feed-manip.ts b/src/lib/api/feed-manip.ts
index 912302d0a..1123c4e23 100644
--- a/src/lib/api/feed-manip.ts
+++ b/src/lib/api/feed-manip.ts
@@ -16,14 +16,7 @@ export type FeedTunerFn = (
 export class FeedViewPostsSlice {
   isFlattenedReply = false
 
-  constructor(public items: FeedViewPost[] = []) {}
-
-  get _reactKey() {
-    const rootItem = this.isFlattenedReply ? this.items[1] : this.items[0]
-    return `slice-${rootItem.post.uri}-${
-      rootItem.reason?.indexedAt || rootItem.post.indexedAt
-    }`
-  }
+  constructor(public items: FeedViewPost[], public _reactKey: string) {}
 
   get uri() {
     if (this.isFlattenedReply) {
@@ -118,28 +111,34 @@ export class FeedViewPostsSlice {
 }
 
 export class NoopFeedTuner {
-  reset() {}
+  private keyCounter = 0
+
+  reset() {
+    this.keyCounter = 0
+  }
   tune(
     feed: FeedViewPost[],
-    _tunerFns: FeedTunerFn[] = [],
     _opts?: {dryRun: boolean; maintainOrder: boolean},
   ): FeedViewPostsSlice[] {
-    return feed.map(item => new FeedViewPostsSlice([item]))
+    return feed.map(
+      item => new FeedViewPostsSlice([item], `slice-${this.keyCounter++}`),
+    )
   }
 }
 
 export class FeedTuner {
+  private keyCounter = 0
   seenUris: Set<string> = new Set()
 
-  constructor() {}
+  constructor(public tunerFns: FeedTunerFn[]) {}
 
   reset() {
+    this.keyCounter = 0
     this.seenUris.clear()
   }
 
   tune(
     feed: FeedViewPost[],
-    tunerFns: FeedTunerFn[] = [],
     {dryRun, maintainOrder}: {dryRun: boolean; maintainOrder: boolean} = {
       dryRun: false,
       maintainOrder: false,
@@ -148,7 +147,9 @@ export class FeedTuner {
     let slices: FeedViewPostsSlice[] = []
 
     if (maintainOrder) {
-      slices = feed.map(item => new FeedViewPostsSlice([item]))
+      slices = feed.map(
+        item => new FeedViewPostsSlice([item], `slice-${this.keyCounter++}`),
+      )
     } else {
       // arrange the posts into thread slices
       for (let i = feed.length - 1; i >= 0; i--) {
@@ -164,12 +165,14 @@ export class FeedTuner {
             continue
           }
         }
-        slices.unshift(new FeedViewPostsSlice([item]))
+        slices.unshift(
+          new FeedViewPostsSlice([item], `slice-${this.keyCounter++}`),
+        )
       }
     }
 
     // run the custom tuners
-    for (const tunerFn of tunerFns) {
+    for (const tunerFn of this.tunerFns) {
       slices = tunerFn(this, slices.slice())
     }
 
diff --git a/src/lib/api/feed/author.ts b/src/lib/api/feed/author.ts
index 77c167869..92df84f8b 100644
--- a/src/lib/api/feed/author.ts
+++ b/src/lib/api/feed/author.ts
@@ -1,18 +1,15 @@
 import {
   AppBskyFeedDefs,
   AppBskyFeedGetAuthorFeed as GetAuthorFeed,
-  BskyAgent,
 } from '@atproto/api'
 import {FeedAPI, FeedAPIResponse} from './types'
+import {getAgent} from '#/state/session'
 
 export class AuthorFeedAPI implements FeedAPI {
-  constructor(
-    public agent: BskyAgent,
-    public params: GetAuthorFeed.QueryParams,
-  ) {}
+  constructor(public params: GetAuthorFeed.QueryParams) {}
 
   async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
-    const res = await this.agent.getAuthorFeed({
+    const res = await getAgent().getAuthorFeed({
       ...this.params,
       limit: 1,
     })
@@ -26,7 +23,7 @@ export class AuthorFeedAPI implements FeedAPI {
     cursor: string | undefined
     limit: number
   }): Promise<FeedAPIResponse> {
-    const res = await this.agent.getAuthorFeed({
+    const res = await getAgent().getAuthorFeed({
       ...this.params,
       cursor,
       limit,
diff --git a/src/lib/api/feed/custom.ts b/src/lib/api/feed/custom.ts
index 0be98fb4a..47ffc65ed 100644
--- a/src/lib/api/feed/custom.ts
+++ b/src/lib/api/feed/custom.ts
@@ -1,18 +1,15 @@
 import {
   AppBskyFeedDefs,
   AppBskyFeedGetFeed as GetCustomFeed,
-  BskyAgent,
 } from '@atproto/api'
 import {FeedAPI, FeedAPIResponse} from './types'
+import {getAgent} from '#/state/session'
 
 export class CustomFeedAPI implements FeedAPI {
-  constructor(
-    public agent: BskyAgent,
-    public params: GetCustomFeed.QueryParams,
-  ) {}
+  constructor(public params: GetCustomFeed.QueryParams) {}
 
   async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
-    const res = await this.agent.app.bsky.feed.getFeed({
+    const res = await getAgent().app.bsky.feed.getFeed({
       ...this.params,
       limit: 1,
     })
@@ -26,7 +23,7 @@ export class CustomFeedAPI implements FeedAPI {
     cursor: string | undefined
     limit: number
   }): Promise<FeedAPIResponse> {
-    const res = await this.agent.app.bsky.feed.getFeed({
+    const res = await getAgent().app.bsky.feed.getFeed({
       ...this.params,
       cursor,
       limit,
diff --git a/src/lib/api/feed/following.ts b/src/lib/api/feed/following.ts
index 13f06c7ab..24389b5ed 100644
--- a/src/lib/api/feed/following.ts
+++ b/src/lib/api/feed/following.ts
@@ -1,11 +1,12 @@
-import {AppBskyFeedDefs, BskyAgent} from '@atproto/api'
+import {AppBskyFeedDefs} from '@atproto/api'
 import {FeedAPI, FeedAPIResponse} from './types'
+import {getAgent} from '#/state/session'
 
 export class FollowingFeedAPI implements FeedAPI {
-  constructor(public agent: BskyAgent) {}
+  constructor() {}
 
   async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
-    const res = await this.agent.getTimeline({
+    const res = await getAgent().getTimeline({
       limit: 1,
     })
     return res.data.feed[0]
@@ -18,7 +19,7 @@ export class FollowingFeedAPI implements FeedAPI {
     cursor: string | undefined
     limit: number
   }): Promise<FeedAPIResponse> {
-    const res = await this.agent.getTimeline({
+    const res = await getAgent().getTimeline({
       cursor,
       limit,
     })
diff --git a/src/lib/api/feed/likes.ts b/src/lib/api/feed/likes.ts
index 434ed7719..2b0afdf11 100644
--- a/src/lib/api/feed/likes.ts
+++ b/src/lib/api/feed/likes.ts
@@ -1,18 +1,15 @@
 import {
   AppBskyFeedDefs,
   AppBskyFeedGetActorLikes as GetActorLikes,
-  BskyAgent,
 } from '@atproto/api'
 import {FeedAPI, FeedAPIResponse} from './types'
+import {getAgent} from '#/state/session'
 
 export class LikesFeedAPI implements FeedAPI {
-  constructor(
-    public agent: BskyAgent,
-    public params: GetActorLikes.QueryParams,
-  ) {}
+  constructor(public params: GetActorLikes.QueryParams) {}
 
   async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
-    const res = await this.agent.getActorLikes({
+    const res = await getAgent().getActorLikes({
       ...this.params,
       limit: 1,
     })
@@ -26,7 +23,7 @@ export class LikesFeedAPI implements FeedAPI {
     cursor: string | undefined
     limit: number
   }): Promise<FeedAPIResponse> {
-    const res = await this.agent.getActorLikes({
+    const res = await getAgent().getActorLikes({
       ...this.params,
       cursor,
       limit,
diff --git a/src/lib/api/feed/list.ts b/src/lib/api/feed/list.ts
index 6cb0730e7..19f2ff177 100644
--- a/src/lib/api/feed/list.ts
+++ b/src/lib/api/feed/list.ts
@@ -1,18 +1,15 @@
 import {
   AppBskyFeedDefs,
   AppBskyFeedGetListFeed as GetListFeed,
-  BskyAgent,
 } from '@atproto/api'
 import {FeedAPI, FeedAPIResponse} from './types'
+import {getAgent} from '#/state/session'
 
 export class ListFeedAPI implements FeedAPI {
-  constructor(
-    public agent: BskyAgent,
-    public params: GetListFeed.QueryParams,
-  ) {}
+  constructor(public params: GetListFeed.QueryParams) {}
 
   async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
-    const res = await this.agent.app.bsky.feed.getListFeed({
+    const res = await getAgent().app.bsky.feed.getListFeed({
       ...this.params,
       limit: 1,
     })
@@ -26,7 +23,7 @@ export class ListFeedAPI implements FeedAPI {
     cursor: string | undefined
     limit: number
   }): Promise<FeedAPIResponse> {
-    const res = await this.agent.app.bsky.feed.getListFeed({
+    const res = await getAgent().app.bsky.feed.getListFeed({
       ...this.params,
       cursor,
       limit,
diff --git a/src/lib/api/feed/merge.ts b/src/lib/api/feed/merge.ts
index 7a0f02887..11e963f0a 100644
--- a/src/lib/api/feed/merge.ts
+++ b/src/lib/api/feed/merge.ts
@@ -1,4 +1,4 @@
-import {AppBskyFeedDefs, AppBskyFeedGetTimeline, BskyAgent} from '@atproto/api'
+import {AppBskyFeedDefs, AppBskyFeedGetTimeline} from '@atproto/api'
 import shuffle from 'lodash.shuffle'
 import {timeout} from 'lib/async/timeout'
 import {bundleAsync} from 'lib/async/bundle'
@@ -7,6 +7,7 @@ import {FeedTuner} from '../feed-manip'
 import {FeedAPI, FeedAPIResponse, ReasonFeedSource} from './types'
 import {FeedParams} from '#/state/queries/post-feed'
 import {FeedTunerFn} from '../feed-manip'
+import {getAgent} from '#/state/session'
 
 const REQUEST_WAIT_MS = 500 // 500ms
 const POST_AGE_CUTOFF = 60e3 * 60 * 24 // 24hours
@@ -18,16 +19,12 @@ export class MergeFeedAPI implements FeedAPI {
   itemCursor = 0
   sampleCursor = 0
 
-  constructor(
-    public agent: BskyAgent,
-    public params: FeedParams,
-    public feedTuners: FeedTunerFn[],
-  ) {
-    this.following = new MergeFeedSource_Following(this.agent, this.feedTuners)
+  constructor(public params: FeedParams, public feedTuners: FeedTunerFn[]) {
+    this.following = new MergeFeedSource_Following(this.feedTuners)
   }
 
   reset() {
-    this.following = new MergeFeedSource_Following(this.agent, this.feedTuners)
+    this.following = new MergeFeedSource_Following(this.feedTuners)
     this.customFeeds = [] // just empty the array, they will be captured in _fetchNext()
     this.feedCursor = 0
     this.itemCursor = 0
@@ -35,8 +32,7 @@ export class MergeFeedAPI implements FeedAPI {
     if (this.params.mergeFeedEnabled && this.params.mergeFeedSources) {
       this.customFeeds = shuffle(
         this.params.mergeFeedSources.map(
-          feedUri =>
-            new MergeFeedSource_Custom(this.agent, feedUri, this.feedTuners),
+          feedUri => new MergeFeedSource_Custom(feedUri, this.feedTuners),
         ),
       )
     } else {
@@ -45,7 +41,7 @@ export class MergeFeedAPI implements FeedAPI {
   }
 
   async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
-    const res = await this.agent.getTimeline({
+    const res = await getAgent().getTimeline({
       limit: 1,
     })
     return res.data.feed[0]
@@ -137,7 +133,7 @@ class MergeFeedSource {
   queue: AppBskyFeedDefs.FeedViewPost[] = []
   hasMore = true
 
-  constructor(public agent: BskyAgent, public feedTuners: FeedTunerFn[]) {}
+  constructor(public feedTuners: FeedTunerFn[]) {}
 
   get numReady() {
     return this.queue.length
@@ -184,7 +180,7 @@ class MergeFeedSource {
 }
 
 class MergeFeedSource_Following extends MergeFeedSource {
-  tuner = new FeedTuner()
+  tuner = new FeedTuner(this.feedTuners)
 
   reset() {
     super.reset()
@@ -199,9 +195,9 @@ class MergeFeedSource_Following extends MergeFeedSource {
     cursor: string | undefined,
     limit: number,
   ): Promise<AppBskyFeedGetTimeline.Response> {
-    const res = await this.agent.getTimeline({cursor, limit})
+    const res = await getAgent().getTimeline({cursor, limit})
     // run the tuner pre-emptively to ensure better mixing
-    const slices = this.tuner.tune(res.data.feed, this.feedTuners, {
+    const slices = this.tuner.tune(res.data.feed, {
       dryRun: false,
       maintainOrder: true,
     })
@@ -213,20 +209,16 @@ class MergeFeedSource_Following extends MergeFeedSource {
 class MergeFeedSource_Custom extends MergeFeedSource {
   minDate: Date
 
-  constructor(
-    public agent: BskyAgent,
-    public feedUri: string,
-    public feedTuners: FeedTunerFn[],
-  ) {
-    super(agent, feedTuners)
+  constructor(public feedUri: string, public feedTuners: FeedTunerFn[]) {
+    super(feedTuners)
     this.sourceInfo = {
       $type: 'reasonFeedSource',
       displayName: feedUri.split('/').pop() || '',
       uri: feedUriToHref(feedUri),
     }
     this.minDate = new Date(Date.now() - POST_AGE_CUTOFF)
-    this.agent.app.bsky.feed
-      .getFeedGenerator({
+    getAgent()
+      .app.bsky.feed.getFeedGenerator({
         feed: feedUri,
       })
       .then(
@@ -244,7 +236,7 @@ class MergeFeedSource_Custom extends MergeFeedSource {
     limit: number,
   ): Promise<AppBskyFeedGetTimeline.Response> {
     try {
-      const res = await this.agent.app.bsky.feed.getFeed({
+      const res = await getAgent().app.bsky.feed.getFeed({
         cursor,
         limit,
         feed: this.feedUri,
diff --git a/src/lib/hooks/useAccountSwitcher.ts b/src/lib/hooks/useAccountSwitcher.ts
index 82f4565e9..3851fe601 100644
--- a/src/lib/hooks/useAccountSwitcher.ts
+++ b/src/lib/hooks/useAccountSwitcher.ts
@@ -7,22 +7,34 @@ import {useAnalytics} from '#/lib/analytics/analytics'
 import {useSessionApi, SessionAccount} from '#/state/session'
 import * as Toast from '#/view/com/util/Toast'
 import {useCloseAllActiveElements} from '#/state/util'
+import {useLoggedOutViewControls} from '#/state/shell/logged-out'
 
 export function useAccountSwitcher() {
   const {track} = useAnalytics()
   const {selectAccount, clearCurrentAccount} = useSessionApi()
   const closeAllActiveElements = useCloseAllActiveElements()
   const navigation = useNavigation<NavigationProp>()
+  const {setShowLoggedOut} = useLoggedOutViewControls()
 
   const onPressSwitchAccount = useCallback(
-    async (acct: SessionAccount) => {
+    async (account: SessionAccount) => {
       track('Settings:SwitchAccountButtonClicked')
 
       try {
-        closeAllActiveElements()
-        navigation.navigate(isWeb ? 'Home' : 'HomeTab')
-        await selectAccount(acct)
-        Toast.show(`Signed in as ${acct.handle}`)
+        if (account.accessJwt) {
+          closeAllActiveElements()
+          navigation.navigate(isWeb ? 'Home' : 'HomeTab')
+          await selectAccount(account)
+          setTimeout(() => {
+            Toast.show(`Signed in as @${account.handle}`)
+          }, 100)
+        } else {
+          setShowLoggedOut(true)
+          Toast.show(
+            `Please sign in as @${account.handle}`,
+            'circle-exclamation',
+          )
+        }
       } catch (e) {
         Toast.show('Sorry! We need you to enter your password.')
         clearCurrentAccount() // back user out to login
@@ -34,6 +46,7 @@ export function useAccountSwitcher() {
       selectAccount,
       closeAllActiveElements,
       navigation,
+      setShowLoggedOut,
     ],
   )
 
diff --git a/src/lib/notifications/notifications.ts b/src/lib/notifications/notifications.ts
index 2320e1c7b..9c499be08 100644
--- a/src/lib/notifications/notifications.ts
+++ b/src/lib/notifications/notifications.ts
@@ -83,7 +83,7 @@ export function init(queryClient: QueryClient) {
     )
     if (event.request.trigger.type === 'push') {
       // refresh notifications in the background
-      queryClient.invalidateQueries({queryKey: RQKEY_NOTIFS()})
+      queryClient.resetQueries({queryKey: RQKEY_NOTIFS()})
       // handle payload-based deeplinks
       let payload
       if (isIOS) {
@@ -121,7 +121,7 @@ export function init(queryClient: QueryClient) {
           logger.DebugContext.notifications,
         )
         track('Notificatons:OpenApp')
-        queryClient.invalidateQueries({queryKey: RQKEY_NOTIFS()})
+        queryClient.resetQueries({queryKey: RQKEY_NOTIFS()})
         resetToTab('NotificationsTab') // open notifications tab
       }
     },
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/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..48e1b8dd8 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,115 @@ 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
+        ) {
+          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..74c0c064e 100644
--- a/src/state/queries/post-feed.ts
+++ b/src/state/queries/post-feed.ts
@@ -1,5 +1,4 @@
-import {useCallback, useMemo} from 'react'
-import {AppBskyFeedDefs, AppBskyFeedPost, moderatePost} from '@atproto/api'
+import {AppBskyFeedDefs, AppBskyFeedPost} from '@atproto/api'
 import {
   useInfiniteQuery,
   InfiniteData,
@@ -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,7 +15,6 @@ 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'
@@ -42,7 +39,9 @@ export interface FeedParams {
   mergeFeedSources?: string[]
 }
 
-type RQPageParam = string | undefined
+type RQPageParam =
+  | {cursor: string | undefined; api: FeedAPI; tuner: FeedTuner | NoopFeedTuner}
+  | undefined
 
 export function RQKEY(feedDesc: FeedDescriptor, params?: FeedParams) {
   return ['post-feed', feedDesc, params || {}]
@@ -64,6 +63,8 @@ export interface FeedPostSlice {
 }
 
 export interface FeedPage {
+  api: FeedAPI
+  tuner: FeedTuner | NoopFeedTuner
   cursor: string | undefined
   slices: FeedPostSlice[]
 }
@@ -76,65 +77,8 @@ 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<
+  return useInfiniteQuery<
     FeedPage,
     Error,
     InfiniteData<FeedPage>,
@@ -145,13 +89,23 @@ export function usePostFeedQuery(
     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, tuner, cursor} = pageParam
+        ? pageParam
+        : {
+            api: createApi(feedDesc, params || {}, feedTuners),
+            tuner: params?.disableTuner
+              ? new NoopFeedTuner()
+              : new FeedTuner(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)
+      const slices = tuner.tune(res.feed)
       return {
+        api,
+        tuner,
         cursor: res.cursor,
         slices: slices.map(slice => ({
           _reactKey: slice._reactKey,
@@ -182,11 +136,60 @@ export function usePostFeedQuery(
       }
     },
     initialPageParam: undefined,
-    getNextPageParam: lastPage => lastPage.cursor,
+    getNextPageParam: lastPage => ({
+      api: lastPage.api,
+      tuner: lastPage.tuner,
+      cursor: lastPage.cursor,
+    }),
     enabled,
   })
+}
+
+export async function pollLatest(page: FeedPage | undefined) {
+  if (!page) {
+    return false
+  }
+
+  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
+}
 
-  return {...out, pollLatest}
+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()
+  }
 }
 
 /**
diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts
index e27bac9a6..9467a2553 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'
@@ -68,7 +67,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 +463,6 @@ function useProfileUnblockMutation() {
 }
 
 async function whenAppViewReady(
-  agent: BskyAgent,
   actor: string,
   fn: (res: AppBskyActorGetProfile.Response) => boolean,
 ) {
@@ -472,6 +470,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/session/index.tsx b/src/state/session/index.tsx
index 946c742ad..e6def1fab 100644
--- a/src/state/session/index.tsx
+++ b/src/state/session/index.tsx
@@ -13,6 +13,12 @@ import {useCloseAllActiveElements} from '#/state/util'
 
 let __globalAgent: BskyAgent = PUBLIC_BSKY_AGENT
 
+/**
+ * NOTE
+ * Never hold on to the object returned by this function.
+ * Call `getAgent()` at the time of invocation to ensure
+ * that you never have a stale agent.
+ */
 export function getAgent() {
   return __globalAgent
 }
diff --git a/src/view/com/auth/login/ChooseAccountForm.tsx b/src/view/com/auth/login/ChooseAccountForm.tsx
index 8c94ef2da..73ddfc9d6 100644
--- a/src/view/com/auth/login/ChooseAccountForm.tsx
+++ b/src/view/com/auth/login/ChooseAccountForm.tsx
@@ -1,23 +1,30 @@
 import React from 'react'
 import {ScrollView, TouchableOpacity, View} from 'react-native'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {
+  FontAwesomeIcon,
+  FontAwesomeIconStyle,
+} from '@fortawesome/react-native-fontawesome'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {Text} from '../../util/text/Text'
 import {UserAvatar} from '../../util/UserAvatar'
-import {s} from 'lib/styles'
+import {s, colors} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
 import {Trans, msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {styles} from './styles'
 import {useSession, useSessionApi, SessionAccount} from '#/state/session'
 import {useProfileQuery} from '#/state/queries/profile'
+import {useLoggedOutViewControls} from '#/state/shell/logged-out'
+import * as Toast from '#/view/com/util/Toast'
 
 function AccountItem({
   account,
   onSelect,
+  isCurrentAccount,
 }: {
   account: SessionAccount
   onSelect: (account: SessionAccount) => void
+  isCurrentAccount: boolean
 }) {
   const pal = usePalette('default')
   const {_} = useLingui()
@@ -48,11 +55,19 @@ function AccountItem({
             {account.handle}
           </Text>
         </Text>
-        <FontAwesomeIcon
-          icon="angle-right"
-          size={16}
-          style={[pal.text, s.mr10]}
-        />
+        {isCurrentAccount ? (
+          <FontAwesomeIcon
+            icon="check"
+            size={16}
+            style={[{color: colors.green3} as FontAwesomeIconStyle, s.mr10]}
+          />
+        ) : (
+          <FontAwesomeIcon
+            icon="angle-right"
+            size={16}
+            style={[pal.text, s.mr10]}
+          />
+        )}
       </View>
     </TouchableOpacity>
   )
@@ -67,8 +82,9 @@ export const ChooseAccountForm = ({
   const {track, screen} = useAnalytics()
   const pal = usePalette('default')
   const {_} = useLingui()
-  const {accounts} = useSession()
+  const {accounts, currentAccount} = useSession()
   const {initSession} = useSessionApi()
+  const {setShowLoggedOut} = useLoggedOutViewControls()
 
   React.useEffect(() => {
     screen('Choose Account')
@@ -77,13 +93,21 @@ export const ChooseAccountForm = ({
   const onSelect = React.useCallback(
     async (account: SessionAccount) => {
       if (account.accessJwt) {
-        await initSession(account)
-        track('Sign In', {resumedSession: true})
+        if (account.did === currentAccount?.did) {
+          setShowLoggedOut(false)
+          Toast.show(`Already signed in as @${account.handle}`)
+        } else {
+          await initSession(account)
+          track('Sign In', {resumedSession: true})
+          setTimeout(() => {
+            Toast.show(`Signed in as @${account.handle}`)
+          }, 100)
+        }
       } else {
         onSelectAccount(account)
       }
     },
-    [track, initSession, onSelectAccount],
+    [currentAccount, track, initSession, onSelectAccount, setShowLoggedOut],
   )
 
   return (
@@ -94,7 +118,12 @@ export const ChooseAccountForm = ({
         <Trans>Sign in as...</Trans>
       </Text>
       {accounts.map(account => (
-        <AccountItem key={account.did} account={account} onSelect={onSelect} />
+        <AccountItem
+          key={account.did}
+          account={account}
+          onSelect={onSelect}
+          isCurrentAccount={account.did === currentAccount?.did}
+        />
       ))}
       <TouchableOpacity
         testID="chooseNewAccountBtn"
diff --git a/src/view/com/feeds/FeedPage.tsx b/src/view/com/feeds/FeedPage.tsx
index 885cd2a15..1a32d29c8 100644
--- a/src/view/com/feeds/FeedPage.tsx
+++ b/src/view/com/feeds/FeedPage.tsx
@@ -62,7 +62,7 @@ export function FeedPage({
   const onSoftReset = React.useCallback(() => {
     if (isPageFocused) {
       scrollToTop()
-      queryClient.invalidateQueries({queryKey: FEED_RQKEY(feed)})
+      queryClient.resetQueries({queryKey: FEED_RQKEY(feed)})
       setHasNew(false)
     }
   }, [isPageFocused, scrollToTop, queryClient, feed, setHasNew])
@@ -83,7 +83,7 @@ export function FeedPage({
 
   const onPressLoadLatest = React.useCallback(() => {
     scrollToTop()
-    queryClient.invalidateQueries({queryKey: FEED_RQKEY(feed)})
+    queryClient.resetQueries({queryKey: FEED_RQKEY(feed)})
     setHasNew(false)
   }, [scrollToTop, feed, queryClient, setHasNew])
 
diff --git a/src/view/com/feeds/FeedSourceCard.tsx b/src/view/com/feeds/FeedSourceCard.tsx
index d8b67767b..1f2af069b 100644
--- a/src/view/com/feeds/FeedSourceCard.tsx
+++ b/src/view/com/feeds/FeedSourceCard.tsx
@@ -17,12 +17,14 @@ import {useModalControls} from '#/state/modals'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {
+  usePinFeedMutation,
   UsePreferencesQueryResponse,
   usePreferencesQuery,
   useSaveFeedMutation,
   useRemoveFeedMutation,
 } from '#/state/queries/preferences'
 import {useFeedSourceInfoQuery, FeedSourceInfo} from '#/state/queries/feed'
+import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
 
 export function FeedSourceCard({
   feedUri,
@@ -30,17 +32,27 @@ export function FeedSourceCard({
   showSaveBtn = false,
   showDescription = false,
   showLikes = false,
+  LoadingComponent,
+  pinOnSave = false,
 }: {
   feedUri: string
   style?: StyleProp<ViewStyle>
   showSaveBtn?: boolean
   showDescription?: boolean
   showLikes?: boolean
+  LoadingComponent?: JSX.Element
+  pinOnSave?: boolean
 }) {
   const {data: preferences} = usePreferencesQuery()
   const {data: feed} = useFeedSourceInfoQuery({uri: feedUri})
 
-  if (!feed || !preferences) return null
+  if (!feed || !preferences) {
+    return LoadingComponent ? (
+      LoadingComponent
+    ) : (
+      <FeedLoadingPlaceholder style={{flex: 1}} />
+    )
+  }
 
   return (
     <FeedSourceCardLoaded
@@ -50,6 +62,7 @@ export function FeedSourceCard({
       showSaveBtn={showSaveBtn}
       showDescription={showDescription}
       showLikes={showLikes}
+      pinOnSave={pinOnSave}
     />
   )
 }
@@ -61,6 +74,7 @@ export function FeedSourceCardLoaded({
   showSaveBtn = false,
   showDescription = false,
   showLikes = false,
+  pinOnSave = false,
 }: {
   feed: FeedSourceInfo
   preferences: UsePreferencesQueryResponse
@@ -68,6 +82,7 @@ export function FeedSourceCardLoaded({
   showSaveBtn?: boolean
   showDescription?: boolean
   showLikes?: boolean
+  pinOnSave?: boolean
 }) {
   const pal = usePalette('default')
   const {_} = useLingui()
@@ -78,6 +93,7 @@ export function FeedSourceCardLoaded({
     useSaveFeedMutation()
   const {isPending: isRemovePending, mutateAsync: removeFeed} =
     useRemoveFeedMutation()
+  const {isPending: isPinPending, mutateAsync: pinFeed} = usePinFeedMutation()
 
   const isSaved = Boolean(preferences?.feeds?.saved?.includes(feed.uri))
 
@@ -103,14 +119,18 @@ export function FeedSourceCardLoaded({
       })
     } else {
       try {
-        await saveFeed({uri: feed.uri})
+        if (pinOnSave) {
+          await pinFeed({uri: feed.uri})
+        } else {
+          await saveFeed({uri: feed.uri})
+        }
         Toast.show('Added to my feeds')
       } catch (e) {
         Toast.show('There was an issue contacting your server')
         logger.error('Failed to save feed', {error: e})
       }
     }
-  }, [isSaved, openModal, feed, removeFeed, saveFeed, _])
+  }, [isSaved, openModal, feed, removeFeed, saveFeed, _, pinOnSave, pinFeed])
 
   if (!feed || !preferences) return null
 
@@ -150,7 +170,7 @@ export function FeedSourceCardLoaded({
         {showSaveBtn && feed.type === 'feed' && (
           <View>
             <Pressable
-              disabled={isSavePending || isRemovePending}
+              disabled={isSavePending || isPinPending || isRemovePending}
               accessibilityRole="button"
               accessibilityLabel={
                 isSaved ? 'Remove from my feeds' : 'Add to my feeds'
diff --git a/src/view/com/notifications/Feed.tsx b/src/view/com/notifications/Feed.tsx
index ba88f78c0..c496d5f7c 100644
--- a/src/view/com/notifications/Feed.tsx
+++ b/src/view/com/notifications/Feed.tsx
@@ -35,15 +35,13 @@ export function Feed({
   const [isPTRing, setIsPTRing] = React.useState(false)
 
   const moderationOpts = useModerationOpts()
-  const {markAllRead} = useUnreadNotificationsApi()
+  const {markAllRead, checkUnread} = useUnreadNotificationsApi()
   const {
     data,
-    isLoading,
     isFetching,
     isFetched,
     isError,
     error,
-    refetch,
     hasNextPage,
     isFetchingNextPage,
     fetchNextPage,
@@ -52,13 +50,11 @@ export function Feed({
   const firstItem = data?.pages[0]?.items[0]
 
   // mark all read on fresh data
+  // (this will fire each time firstItem changes)
   React.useEffect(() => {
-    let cleanup
     if (firstItem) {
-      const to = setTimeout(() => markAllRead(), 250)
-      cleanup = () => clearTimeout(to)
+      markAllRead()
     }
-    return cleanup
   }, [firstItem, markAllRead])
 
   const items = React.useMemo(() => {
@@ -83,7 +79,7 @@ export function Feed({
   const onRefresh = React.useCallback(async () => {
     try {
       setIsPTRing(true)
-      await refetch()
+      await checkUnread({invalidate: true})
     } catch (err) {
       logger.error('Failed to refresh notifications feed', {
         error: err,
@@ -91,7 +87,7 @@ export function Feed({
     } finally {
       setIsPTRing(false)
     }
-  }, [refetch, setIsPTRing])
+  }, [checkUnread, setIsPTRing])
 
   const onEndReached = React.useCallback(async () => {
     if (isFetching || !hasNextPage || isError) return
@@ -136,21 +132,6 @@ export function Feed({
     [onPressRetryLoadMore, moderationOpts],
   )
 
-  const showHeaderSpinner = !isPTRing && isFetching && !isLoading
-  const FeedHeader = React.useCallback(
-    () => (
-      <View>
-        {ListHeaderComponent ? <ListHeaderComponent /> : null}
-        {showHeaderSpinner ? (
-          <View style={{padding: 10}}>
-            <ActivityIndicator />
-          </View>
-        ) : null}
-      </View>
-    ),
-    [ListHeaderComponent, showHeaderSpinner],
-  )
-
   const FeedFooter = React.useCallback(
     () =>
       isFetchingNextPage ? (
@@ -180,7 +161,7 @@ export function Feed({
         data={items}
         keyExtractor={item => item._reactKey}
         renderItem={renderItem}
-        ListHeaderComponent={FeedHeader}
+        ListHeaderComponent={ListHeaderComponent}
         ListFooterComponent={FeedFooter}
         refreshControl={
           <RefreshControl
diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx
index fc6d77696..393c1bc91 100644
--- a/src/view/com/posts/Feed.tsx
+++ b/src/view/com/posts/Feed.tsx
@@ -23,6 +23,7 @@ import {
   FeedDescriptor,
   FeedParams,
   usePostFeedQuery,
+  pollLatest,
 } from '#/state/queries/post-feed'
 import {useModerationOpts} from '#/state/queries/preferences'
 
@@ -84,22 +85,21 @@ let Feed = ({
     hasNextPage,
     isFetchingNextPage,
     fetchNextPage,
-    pollLatest,
   } = usePostFeedQuery(feed, feedParams, opts)
   const isEmpty = !isFetching && !data?.pages[0]?.slices.length
 
   const checkForNew = React.useCallback(async () => {
-    if (!isFetched || isFetching || !onHasNew) {
+    if (!data?.pages[0] || isFetching || !onHasNew) {
       return
     }
     try {
-      if (await pollLatest()) {
+      if (await pollLatest(data.pages[0])) {
         onHasNew(true)
       }
     } catch (e) {
       logger.error('Poll latest failed', {feed, error: String(e)})
     }
-  }, [feed, isFetched, isFetching, pollLatest, onHasNew])
+  }, [feed, data, isFetching, onHasNew])
 
   React.useEffect(() => {
     // we store the interval handler in a ref to avoid needless
diff --git a/src/view/com/util/LoadingPlaceholder.tsx b/src/view/com/util/LoadingPlaceholder.tsx
index 461cbcbe5..74e36ff7b 100644
--- a/src/view/com/util/LoadingPlaceholder.tsx
+++ b/src/view/com/util/LoadingPlaceholder.tsx
@@ -171,14 +171,22 @@ export function ProfileCardFeedLoadingPlaceholder() {
 
 export function FeedLoadingPlaceholder({
   style,
+  showLowerPlaceholder = true,
+  showTopBorder = true,
 }: {
   style?: StyleProp<ViewStyle>
+  showTopBorder?: boolean
+  showLowerPlaceholder?: boolean
 }) {
   const pal = usePalette('default')
   return (
     <View
       style={[
-        {paddingHorizontal: 12, paddingVertical: 18, borderTopWidth: 1},
+        {
+          paddingHorizontal: 12,
+          paddingVertical: 18,
+          borderTopWidth: showTopBorder ? 1 : 0,
+        },
         pal.border,
         style,
       ]}>
@@ -193,14 +201,16 @@ export function FeedLoadingPlaceholder({
           <LoadingPlaceholder width={120} height={8} />
         </View>
       </View>
-      <View style={{paddingHorizontal: 5}}>
-        <LoadingPlaceholder
-          width={260}
-          height={8}
-          style={{marginVertical: 12}}
-        />
-        <LoadingPlaceholder width={120} height={8} />
-      </View>
+      {showLowerPlaceholder && (
+        <View style={{paddingHorizontal: 5}}>
+          <LoadingPlaceholder
+            width={260}
+            height={8}
+            style={{marginVertical: 12}}
+          />
+          <LoadingPlaceholder width={120} height={8} />
+        </View>
+      )}
     </View>
   )
 }
diff --git a/src/view/screens/Feeds.tsx b/src/view/screens/Feeds.tsx
index ced8592c5..f319fbc39 100644
--- a/src/view/screens/Feeds.tsx
+++ b/src/view/screens/Feeds.tsx
@@ -437,6 +437,7 @@ export function FeedsScreen(_props: Props) {
             showSaveBtn={hasSession}
             showDescription
             showLikes
+            pinOnSave
           />
         )
       } else if (item.type === 'popularFeedsNoResults') {
diff --git a/src/view/screens/Notifications.tsx b/src/view/screens/Notifications.tsx
index 8516d1667..0f442038b 100644
--- a/src/view/screens/Notifications.tsx
+++ b/src/view/screens/Notifications.tsx
@@ -19,7 +19,10 @@ import {logger} from '#/logger'
 import {useSetMinimalShellMode} from '#/state/shell'
 import {Trans, msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
-import {useUnreadNotifications} from '#/state/queries/notifications/unread'
+import {
+  useUnreadNotifications,
+  useUnreadNotificationsApi,
+} from '#/state/queries/notifications/unread'
 import {RQKEY as NOTIFS_RQKEY} from '#/state/queries/notifications/feed'
 import {listenSoftReset, emitSoftReset} from '#/state/events'
 
@@ -35,8 +38,9 @@ export function NotificationsScreen({}: Props) {
   const {screen} = useAnalytics()
   const pal = usePalette('default')
   const {isDesktop} = useWebMediaQueries()
-  const unreadNotifs = useUnreadNotifications()
   const queryClient = useQueryClient()
+  const unreadNotifs = useUnreadNotifications()
+  const unreadApi = useUnreadNotificationsApi()
   const hasNew = !!unreadNotifs
 
   // event handlers
@@ -48,10 +52,16 @@ export function NotificationsScreen({}: Props) {
 
   const onPressLoadLatest = React.useCallback(() => {
     scrollToTop()
-    queryClient.invalidateQueries({
-      queryKey: NOTIFS_RQKEY(),
-    })
-  }, [scrollToTop, queryClient])
+    if (hasNew) {
+      // render what we have now
+      queryClient.resetQueries({
+        queryKey: NOTIFS_RQKEY(),
+      })
+    } else {
+      // check with the server
+      unreadApi.checkUnread({invalidate: true})
+    }
+  }, [scrollToTop, queryClient, unreadApi, hasNew])
 
   // on-visible setup
   // =
diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx
index 35efe3a0c..3e9a59929 100644
--- a/src/view/screens/Profile.tsx
+++ b/src/view/screens/Profile.tsx
@@ -404,7 +404,7 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
 
     const onScrollToTop = React.useCallback(() => {
       scrollElRef.current?.scrollToOffset({offset: -headerHeight})
-      queryClient.invalidateQueries({queryKey: FEED_RQKEY(feed)})
+      queryClient.resetQueries({queryKey: FEED_RQKEY(feed)})
       setHasNew(false)
     }, [scrollElRef, headerHeight, queryClient, feed, setHasNew])
     React.useImperativeHandle(ref, () => ({
diff --git a/src/view/screens/ProfileFeed.tsx b/src/view/screens/ProfileFeed.tsx
index 1471db9c6..e38543e6b 100644
--- a/src/view/screens/ProfileFeed.tsx
+++ b/src/view/screens/ProfileFeed.tsx
@@ -502,7 +502,7 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
 
     const onScrollToTop = useCallback(() => {
       scrollElRef.current?.scrollToOffset({offset: -headerHeight})
-      queryClient.invalidateQueries({queryKey: FEED_RQKEY(feed)})
+      queryClient.resetQueries({queryKey: FEED_RQKEY(feed)})
       setHasNew(false)
     }, [scrollElRef, headerHeight, queryClient, feed, setHasNew])
 
diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx
index cc6d85e6f..9be499561 100644
--- a/src/view/screens/ProfileList.tsx
+++ b/src/view/screens/ProfileList.tsx
@@ -127,7 +127,7 @@ function ProfileListScreenLoaded({
       list,
       onChange() {
         if (isCurateList) {
-          queryClient.invalidateQueries({
+          queryClient.resetQueries({
             // TODO(eric) should construct these strings with a fn too
             queryKey: FEED_RQKEY(`list|${list.uri}`),
           })
@@ -530,7 +530,7 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
 
     const onScrollToTop = useCallback(() => {
       scrollElRef.current?.scrollToOffset({offset: -headerHeight})
-      queryClient.invalidateQueries({queryKey: FEED_RQKEY(feed)})
+      queryClient.resetQueries({queryKey: FEED_RQKEY(feed)})
       setHasNew(false)
     }, [scrollElRef, headerHeight, queryClient, feed, setHasNew])
     React.useImperativeHandle(ref, () => ({
diff --git a/src/view/screens/SavedFeeds.tsx b/src/view/screens/SavedFeeds.tsx
index ce668877b..858a58a3c 100644
--- a/src/view/screens/SavedFeeds.tsx
+++ b/src/view/screens/SavedFeeds.tsx
@@ -1,14 +1,7 @@
 import React from 'react'
-import {
-  StyleSheet,
-  View,
-  ActivityIndicator,
-  Pressable,
-  TouchableOpacity,
-} from 'react-native'
+import {StyleSheet, View, ActivityIndicator, Pressable} from 'react-native'
 import {useFocusEffect} from '@react-navigation/native'
 import {NativeStackScreenProps} from '@react-navigation/native-stack'
-import {useQueryClient} from '@tanstack/react-query'
 import {track} from '#/lib/analytics/analytics'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {usePalette} from 'lib/hooks/usePalette'
@@ -32,9 +25,8 @@ import {
   usePinFeedMutation,
   useUnpinFeedMutation,
   useSetSaveFeedsMutation,
-  preferencesQueryKey,
-  UsePreferencesQueryResponse,
 } from '#/state/queries/preferences'
+import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
 
 const HITSLOP_TOP = {
   top: 20,
@@ -57,6 +49,24 @@ export function SavedFeeds({}: Props) {
   const {screen} = useAnalytics()
   const setMinimalShellMode = useSetMinimalShellMode()
   const {data: preferences} = usePreferencesQuery()
+  const {
+    mutateAsync: setSavedFeeds,
+    variables: optimisticSavedFeedsResponse,
+    reset: resetSaveFeedsMutationState,
+    error: setSavedFeedsError,
+  } = useSetSaveFeedsMutation()
+
+  /*
+   * Use optimistic data if exists and no error, otherwise fallback to remote
+   * data
+   */
+  const currentFeeds =
+    optimisticSavedFeedsResponse && !setSavedFeedsError
+      ? optimisticSavedFeedsResponse
+      : preferences?.feeds || {saved: [], pinned: []}
+  const unpinned = currentFeeds.saved.filter(f => {
+    return !currentFeeds.pinned?.includes(f)
+  })
 
   useFocusEffect(
     React.useCallback(() => {
@@ -80,7 +90,7 @@ export function SavedFeeds({}: Props) {
           </Text>
         </View>
         {preferences?.feeds ? (
-          !preferences.feeds.pinned.length ? (
+          !currentFeeds.pinned.length ? (
             <View
               style={[
                 pal.border,
@@ -93,8 +103,15 @@ export function SavedFeeds({}: Props) {
               </Text>
             </View>
           ) : (
-            preferences?.feeds?.pinned?.map(uri => (
-              <ListItem key={uri} feedUri={uri} isPinned />
+            currentFeeds.pinned.map(uri => (
+              <ListItem
+                key={uri}
+                feedUri={uri}
+                isPinned
+                setSavedFeeds={setSavedFeeds}
+                resetSaveFeedsMutationState={resetSaveFeedsMutationState}
+                currentFeeds={currentFeeds}
+              />
             ))
           )
         ) : (
@@ -106,7 +123,7 @@ export function SavedFeeds({}: Props) {
           </Text>
         </View>
         {preferences?.feeds ? (
-          !preferences.feeds.unpinned.length ? (
+          !unpinned.length ? (
             <View
               style={[
                 pal.border,
@@ -119,8 +136,15 @@ export function SavedFeeds({}: Props) {
               </Text>
             </View>
           ) : (
-            preferences.feeds.unpinned.map(uri => (
-              <ListItem key={uri} feedUri={uri} isPinned={false} />
+            unpinned.map(uri => (
+              <ListItem
+                key={uri}
+                feedUri={uri}
+                isPinned={false}
+                setSavedFeeds={setSavedFeeds}
+                resetSaveFeedsMutationState={resetSaveFeedsMutationState}
+                currentFeeds={currentFeeds}
+              />
             ))
           )
         ) : (
@@ -151,22 +175,30 @@ export function SavedFeeds({}: Props) {
 function ListItem({
   feedUri,
   isPinned,
+  currentFeeds,
+  setSavedFeeds,
+  resetSaveFeedsMutationState,
 }: {
   feedUri: string // uri
   isPinned: boolean
+  currentFeeds: {saved: string[]; pinned: string[]}
+  setSavedFeeds: ReturnType<typeof useSetSaveFeedsMutation>['mutateAsync']
+  resetSaveFeedsMutationState: ReturnType<
+    typeof useSetSaveFeedsMutation
+  >['reset']
 }) {
   const pal = usePalette('default')
-  const queryClient = useQueryClient()
   const {isPending: isPinPending, mutateAsync: pinFeed} = usePinFeedMutation()
   const {isPending: isUnpinPending, mutateAsync: unpinFeed} =
     useUnpinFeedMutation()
-  const {isPending: isMovePending, mutateAsync: setSavedFeeds} =
-    useSetSaveFeedsMutation()
+  const isPending = isPinPending || isUnpinPending
 
   const onTogglePinned = React.useCallback(async () => {
     Haptics.default()
 
     try {
+      resetSaveFeedsMutationState()
+
       if (isPinned) {
         await unpinFeed({uri: feedUri})
       } else {
@@ -176,24 +208,20 @@ function ListItem({
       Toast.show('There was an issue contacting the server')
       logger.error('Failed to toggle pinned feed', {error: e})
     }
-  }, [feedUri, isPinned, pinFeed, unpinFeed])
+  }, [feedUri, isPinned, pinFeed, unpinFeed, resetSaveFeedsMutationState])
 
   const onPressUp = React.useCallback(async () => {
     if (!isPinned) return
 
-    const feeds =
-      queryClient.getQueryData<UsePreferencesQueryResponse>(
-        preferencesQueryKey,
-      )?.feeds
     // create new array, do not mutate
-    const pinned = feeds?.pinned ? [...feeds.pinned] : []
+    const pinned = [...currentFeeds.pinned]
     const index = pinned.indexOf(feedUri)
 
     if (index === -1 || index === 0) return
     ;[pinned[index], pinned[index - 1]] = [pinned[index - 1], pinned[index]]
 
     try {
-      await setSavedFeeds({saved: feeds?.saved ?? [], pinned})
+      await setSavedFeeds({saved: currentFeeds.saved, pinned})
       track('CustomFeed:Reorder', {
         uri: feedUri,
         index: pinned.indexOf(feedUri),
@@ -202,24 +230,19 @@ function ListItem({
       Toast.show('There was an issue contacting the server')
       logger.error('Failed to set pinned feed order', {error: e})
     }
-  }, [feedUri, isPinned, queryClient, setSavedFeeds])
+  }, [feedUri, isPinned, setSavedFeeds, currentFeeds])
 
   const onPressDown = React.useCallback(async () => {
     if (!isPinned) return
 
-    const feeds =
-      queryClient.getQueryData<UsePreferencesQueryResponse>(
-        preferencesQueryKey,
-      )?.feeds
-    // create new array, do not mutate
-    const pinned = feeds?.pinned ? [...feeds.pinned] : []
+    const pinned = [...currentFeeds.pinned]
     const index = pinned.indexOf(feedUri)
 
     if (index === -1 || index >= pinned.length - 1) return
     ;[pinned[index], pinned[index + 1]] = [pinned[index + 1], pinned[index]]
 
     try {
-      await setSavedFeeds({saved: feeds?.saved ?? [], pinned})
+      await setSavedFeeds({saved: currentFeeds.saved, pinned})
       track('CustomFeed:Reorder', {
         uri: feedUri,
         index: pinned.indexOf(feedUri),
@@ -228,7 +251,7 @@ function ListItem({
       Toast.show('There was an issue contacting the server')
       logger.error('Failed to set pinned feed order', {error: e})
     }
-  }, [feedUri, isPinned, queryClient, setSavedFeeds])
+  }, [feedUri, isPinned, setSavedFeeds, currentFeeds])
 
   return (
     <Pressable
@@ -236,24 +259,30 @@ function ListItem({
       style={[styles.itemContainer, pal.border]}>
       {isPinned ? (
         <View style={styles.webArrowButtonsContainer}>
-          <TouchableOpacity
-            disabled={isMovePending}
+          <Pressable
+            disabled={isPending}
             accessibilityRole="button"
             onPress={onPressUp}
-            hitSlop={HITSLOP_TOP}>
+            hitSlop={HITSLOP_TOP}
+            style={state => ({
+              opacity: state.hovered || state.focused || isPending ? 0.5 : 1,
+            })}>
             <FontAwesomeIcon
               icon="arrow-up"
               size={12}
               style={[pal.text, styles.webArrowUpButton]}
             />
-          </TouchableOpacity>
-          <TouchableOpacity
-            disabled={isMovePending}
+          </Pressable>
+          <Pressable
+            disabled={isPending}
             accessibilityRole="button"
             onPress={onPressDown}
-            hitSlop={HITSLOP_BOTTOM}>
+            hitSlop={HITSLOP_BOTTOM}
+            style={state => ({
+              opacity: state.hovered || state.focused || isPending ? 0.5 : 1,
+            })}>
             <FontAwesomeIcon icon="arrow-down" size={12} style={[pal.text]} />
-          </TouchableOpacity>
+          </Pressable>
         </View>
       ) : null}
       <FeedSourceCard
@@ -261,18 +290,28 @@ function ListItem({
         feedUri={feedUri}
         style={styles.noBorder}
         showSaveBtn
+        LoadingComponent={
+          <FeedLoadingPlaceholder
+            style={{flex: 1}}
+            showLowerPlaceholder={false}
+            showTopBorder={false}
+          />
+        }
       />
-      <TouchableOpacity
-        disabled={isPinPending || isUnpinPending}
+      <Pressable
+        disabled={isPending}
         accessibilityRole="button"
         hitSlop={10}
-        onPress={onTogglePinned}>
+        onPress={onTogglePinned}
+        style={state => ({
+          opacity: state.hovered || state.focused || isPending ? 0.5 : 1,
+        })}>
         <FontAwesomeIcon
           icon="thumb-tack"
           size={20}
           color={isPinned ? colors.blue3 : pal.colors.icon}
         />
-      </TouchableOpacity>
+      </Pressable>
     </Pressable>
   )
 }
diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx
index 88cc2d532..579a04b01 100644
--- a/src/view/screens/Settings.tsx
+++ b/src/view/screens/Settings.tsx
@@ -10,11 +10,7 @@ import {
   View,
   ViewStyle,
 } from 'react-native'
-import {
-  useFocusEffect,
-  useNavigation,
-  StackActions,
-} from '@react-navigation/native'
+import {useFocusEffect, useNavigation} from '@react-navigation/native'
 import {
   FontAwesomeIcon,
   FontAwesomeIconStyle,
@@ -74,6 +70,8 @@ import {STATUS_PAGE_URL} from 'lib/constants'
 import {Trans, msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useQueryClient} from '@tanstack/react-query'
+import {useLoggedOutViewControls} from '#/state/shell/logged-out'
+import {useCloseAllActiveElements} from '#/state/util'
 
 function SettingsAccountCard({account}: {account: SessionAccount}) {
   const pal = usePalette('default')
@@ -155,13 +153,14 @@ export function SettingsScreen({}: Props) {
   const {screen, track} = useAnalytics()
   const {openModal} = useModalControls()
   const {isSwitchingAccounts, accounts, currentAccount} = useSession()
-  const {clearCurrentAccount} = useSessionApi()
   const [debugHeaderEnabled, toggleDebugHeader] = useDebugHeaderSetting(
     getAgent(),
   )
   const {mutate: clearPreferences} = useClearPreferencesMutation()
   const {data: invites} = useInviteCodesQuery()
   const invitesAvailable = invites?.available?.length ?? 0
+  const {setShowLoggedOut} = useLoggedOutViewControls()
+  const closeAllActiveElements = useCloseAllActiveElements()
 
   const primaryBg = useCustomPalette<ViewStyle>({
     light: {backgroundColor: colors.blue0},
@@ -190,10 +189,9 @@ export function SettingsScreen({}: Props) {
 
   const onPressAddAccount = React.useCallback(() => {
     track('Settings:AddAccountButtonClicked')
-    navigation.navigate('HomeTab')
-    navigation.dispatch(StackActions.popToTop())
-    clearCurrentAccount()
-  }, [track, navigation, clearCurrentAccount])
+    setShowLoggedOut(true)
+    closeAllActiveElements()
+  }, [track, setShowLoggedOut, closeAllActiveElements])
 
   const onPressChangeHandle = React.useCallback(() => {
     track('Settings:ChangeHandleButtonClicked')
diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx
index 9df9b70b3..b2bb6ea1e 100644
--- a/src/view/shell/Drawer.tsx
+++ b/src/view/shell/Drawer.tsx
@@ -141,7 +141,7 @@ export function DrawerContent() {
         } else {
           if (tab === 'Notifications') {
             // fetch new notifs on view
-            queryClient.invalidateQueries({
+            queryClient.resetQueries({
               queryKey: NOTIFS_RQKEY(),
             })
           }
diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx
index dfb18cc4a..a97ff8afc 100644
--- a/src/view/shell/bottom-bar/BottomBar.tsx
+++ b/src/view/shell/bottom-bar/BottomBar.tsx
@@ -62,7 +62,7 @@ export function BottomBar({navigation}: BottomTabBarProps) {
       } else {
         if (tab === 'Notifications') {
           // fetch new notifs on view
-          queryClient.invalidateQueries({
+          queryClient.resetQueries({
             queryKey: NOTIFS_RQKEY(),
           })
         }
diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx
index a0052e0ca..8daa381d5 100644
--- a/src/view/shell/desktop/LeftNav.tsx
+++ b/src/view/shell/desktop/LeftNav.tsx
@@ -150,7 +150,7 @@ function NavItem({count, href, icon, iconFilled, label}: NavItemProps) {
       } else {
         if (href === '/notifications') {
           // fetch new notifs on view
-          queryClient.invalidateQueries({
+          queryClient.resetQueries({
             queryKey: NOTIFS_RQKEY(),
           })
         }