about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--src/App.native.tsx63
-rw-r--r--src/App.web.tsx61
-rw-r--r--src/Navigation.tsx18
-rw-r--r--src/lib/broadcast/index.ts (renamed from src/state/persisted/broadcast/index.ts)5
-rw-r--r--src/lib/broadcast/index.web.ts (renamed from src/state/persisted/broadcast/index.web.ts)0
-rw-r--r--src/lib/hooks/useSetTitle.ts12
-rw-r--r--src/lib/notifications/notifications.ts10
-rw-r--r--src/state/models/feeds/notifications.ts671
-rw-r--r--src/state/models/me.ts20
-rw-r--r--src/state/models/root-store.ts8
-rw-r--r--src/state/persisted/index.ts2
-rw-r--r--src/state/queries/notifications/feed.ts212
-rw-r--r--src/state/queries/notifications/unread.tsx113
-rw-r--r--src/state/queries/notifications/util.ts38
-rw-r--r--src/state/queries/preferences/index.ts32
-rw-r--r--src/view/com/notifications/Feed.tsx181
-rw-r--r--src/view/com/notifications/FeedItem.tsx167
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx4
-rw-r--r--src/view/com/post/Post.tsx272
-rw-r--r--src/view/com/posts/Feed.tsx10
-rw-r--r--src/view/com/posts/FeedItem.tsx2
-rw-r--r--src/view/com/util/forms/PostDropdownBtn.tsx98
-rw-r--r--src/view/com/util/forms/PostDropdownBtn2.tsx210
-rw-r--r--src/view/com/util/post-ctrls/PostCtrls.tsx186
-rw-r--r--src/view/com/util/post-ctrls/PostCtrls2.tsx200
-rw-r--r--src/view/screens/Notifications.tsx55
-rw-r--r--src/view/shell/Drawer.tsx10
-rw-r--r--src/view/shell/bottom-bar/BottomBar.tsx10
-rw-r--r--src/view/shell/desktop/LeftNav.tsx4
29 files changed, 938 insertions, 1736 deletions
diff --git a/src/App.native.tsx b/src/App.native.tsx
index 8479465fd..e976fce4f 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -31,6 +31,7 @@ import {
   useSession,
   useSessionApi,
 } from 'state/session'
+import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
 import * as persisted from '#/state/persisted'
 import {i18n} from '@lingui/core'
 import {I18nProvider} from '@lingui/react'
@@ -53,7 +54,7 @@ const InnerApp = observer(function AppImpl() {
     setupState().then(store => {
       setRootStore(store)
       analytics.init(store)
-      notifications.init(store)
+      notifications.init(store, queryClient)
       store.onSessionDropped(() => {
         Toast.show('Sorry! Your session expired. Please log in again.')
       })
@@ -72,22 +73,20 @@ const InnerApp = observer(function AppImpl() {
   }
 
   return (
-    <QueryClientProvider client={queryClient}>
-      <ThemeProvider theme={colorMode}>
-        <RootSiblingParent>
-          <analytics.Provider>
-            <RootStoreProvider value={rootStore}>
-              <I18nProvider i18n={i18n}>
-                <GestureHandlerRootView style={s.h100pct}>
-                  <TestCtrls />
-                  <Shell />
-                </GestureHandlerRootView>
-              </I18nProvider>
-            </RootStoreProvider>
-          </analytics.Provider>
-        </RootSiblingParent>
-      </ThemeProvider>
-    </QueryClientProvider>
+    <ThemeProvider theme={colorMode}>
+      <RootSiblingParent>
+        <analytics.Provider>
+          <RootStoreProvider value={rootStore}>
+            <I18nProvider i18n={i18n}>
+              <GestureHandlerRootView style={s.h100pct}>
+                <TestCtrls />
+                <Shell />
+              </GestureHandlerRootView>
+            </I18nProvider>
+          </RootStoreProvider>
+        </analytics.Provider>
+      </RootSiblingParent>
+    </ThemeProvider>
   )
 })
 
@@ -103,19 +102,23 @@ function App() {
   }
 
   return (
-    <SessionProvider>
-      <ShellStateProvider>
-        <PrefsStateProvider>
-          <MutedThreadsProvider>
-            <InvitesStateProvider>
-              <ModalStateProvider>
-                <InnerApp />
-              </ModalStateProvider>
-            </InvitesStateProvider>
-          </MutedThreadsProvider>
-        </PrefsStateProvider>
-      </ShellStateProvider>
-    </SessionProvider>
+    <QueryClientProvider client={queryClient}>
+      <SessionProvider>
+        <ShellStateProvider>
+          <PrefsStateProvider>
+            <MutedThreadsProvider>
+              <UnreadNotifsProvider>
+                <InvitesStateProvider>
+                  <ModalStateProvider>
+                    <InnerApp />
+                  </ModalStateProvider>
+                </InvitesStateProvider>
+              </UnreadNotifsProvider>
+            </MutedThreadsProvider>
+          </PrefsStateProvider>
+        </ShellStateProvider>
+      </SessionProvider>
+    </QueryClientProvider>
   )
 }
 
diff --git a/src/App.web.tsx b/src/App.web.tsx
index 81e03d079..5967fb751 100644
--- a/src/App.web.tsx
+++ b/src/App.web.tsx
@@ -29,6 +29,7 @@ import {
   useSession,
   useSessionApi,
 } from 'state/session'
+import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
 import * as persisted from '#/state/persisted'
 
 const InnerApp = observer(function AppImpl() {
@@ -60,22 +61,20 @@ const InnerApp = observer(function AppImpl() {
   }
 
   return (
-    <QueryClientProvider client={queryClient}>
-      <ThemeProvider theme={colorMode}>
-        <RootSiblingParent>
-          <analytics.Provider>
-            <RootStoreProvider value={rootStore}>
-              <I18nProvider i18n={i18n}>
-                <SafeAreaProvider>
-                  <Shell />
-                </SafeAreaProvider>
-              </I18nProvider>
-              <ToastContainer />
-            </RootStoreProvider>
-          </analytics.Provider>
-        </RootSiblingParent>
-      </ThemeProvider>
-    </QueryClientProvider>
+    <ThemeProvider theme={colorMode}>
+      <RootSiblingParent>
+        <analytics.Provider>
+          <RootStoreProvider value={rootStore}>
+            <I18nProvider i18n={i18n}>
+              <SafeAreaProvider>
+                <Shell />
+              </SafeAreaProvider>
+            </I18nProvider>
+            <ToastContainer />
+          </RootStoreProvider>
+        </analytics.Provider>
+      </RootSiblingParent>
+    </ThemeProvider>
   )
 })
 
@@ -91,19 +90,23 @@ function App() {
   }
 
   return (
-    <SessionProvider>
-      <ShellStateProvider>
-        <PrefsStateProvider>
-          <MutedThreadsProvider>
-            <InvitesStateProvider>
-              <ModalStateProvider>
-                <InnerApp />
-              </ModalStateProvider>
-            </InvitesStateProvider>
-          </MutedThreadsProvider>
-        </PrefsStateProvider>
-      </ShellStateProvider>
-    </SessionProvider>
+    <QueryClientProvider client={queryClient}>
+      <SessionProvider>
+        <ShellStateProvider>
+          <PrefsStateProvider>
+            <MutedThreadsProvider>
+              <UnreadNotifsProvider>
+                <InvitesStateProvider>
+                  <ModalStateProvider>
+                    <InnerApp />
+                  </ModalStateProvider>
+                </InvitesStateProvider>
+              </UnreadNotifsProvider>
+            </MutedThreadsProvider>
+          </PrefsStateProvider>
+        </ShellStateProvider>
+      </SessionProvider>
+    </QueryClientProvider>
   )
 }
 
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index 381f33cf9..fb88dc84f 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -1,7 +1,6 @@
 import * as React from 'react'
 import {StyleSheet} from 'react-native'
 import * as SplashScreen from 'expo-splash-screen'
-import {observer} from 'mobx-react-lite'
 import {
   NavigationContainer,
   createNavigationContainerRef,
@@ -33,10 +32,10 @@ import {isNative} from 'platform/detection'
 import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
 import {router} from './routes'
 import {usePalette} from 'lib/hooks/usePalette'
-import {useStores} from './state'
 import {bskyTitle} from 'lib/strings/headings'
 import {JSX} from 'react/jsx-runtime'
 import {timeout} from 'lib/async/timeout'
+import {useUnreadNotifications} from './state/queries/notifications/unread'
 
 import {HomeScreen} from './view/screens/Home'
 import {SearchScreen} from './view/screens/Search'
@@ -346,7 +345,7 @@ function NotificationsTabNavigator() {
   )
 }
 
-const MyProfileTabNavigator = observer(function MyProfileTabNavigatorImpl() {
+function MyProfileTabNavigator() {
   const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark)
   return (
     <MyProfileTab.Navigator
@@ -368,18 +367,17 @@ const MyProfileTabNavigator = observer(function MyProfileTabNavigatorImpl() {
       {commonScreens(MyProfileTab as typeof HomeTab)}
     </MyProfileTab.Navigator>
   )
-})
+}
 
 /**
  * The FlatNavigator is used by Web to represent the routes
  * in a single ("flat") stack.
  */
-const FlatNavigator = observer(function FlatNavigatorImpl() {
+const FlatNavigator = () => {
   const pal = usePalette('default')
-  const store = useStores()
-  const unreadCountLabel = store.me.notifications.unreadCountLabel
+  const numUnread = useUnreadNotifications()
 
-  const title = (page: string) => bskyTitle(page, unreadCountLabel)
+  const title = (page: string) => bskyTitle(page, numUnread)
   return (
     <Flat.Navigator
       screenOptions={{
@@ -409,10 +407,10 @@ const FlatNavigator = observer(function FlatNavigatorImpl() {
         getComponent={() => NotificationsScreen}
         options={{title: title('Notifications')}}
       />
-      {commonScreens(Flat as typeof HomeTab, unreadCountLabel)}
+      {commonScreens(Flat as typeof HomeTab, numUnread)}
     </Flat.Navigator>
   )
-})
+}
 
 /**
  * The RoutesContainer should wrap all components which need access
diff --git a/src/state/persisted/broadcast/index.ts b/src/lib/broadcast/index.ts
index e0e7f724b..aa3aef580 100644
--- a/src/state/persisted/broadcast/index.ts
+++ b/src/lib/broadcast/index.ts
@@ -3,4 +3,9 @@ export default class BroadcastChannel {
   postMessage(_data: any) {}
   close() {}
   onmessage: (event: MessageEvent) => void = () => {}
+  addEventListener(_type: string, _listener: (event: MessageEvent) => void) {}
+  removeEventListener(
+    _type: string,
+    _listener: (event: MessageEvent) => void,
+  ) {}
 }
diff --git a/src/state/persisted/broadcast/index.web.ts b/src/lib/broadcast/index.web.ts
index 33b3548ad..33b3548ad 100644
--- a/src/state/persisted/broadcast/index.web.ts
+++ b/src/lib/broadcast/index.web.ts
diff --git a/src/lib/hooks/useSetTitle.ts b/src/lib/hooks/useSetTitle.ts
index c5c7a5ca1..129023f71 100644
--- a/src/lib/hooks/useSetTitle.ts
+++ b/src/lib/hooks/useSetTitle.ts
@@ -3,18 +3,14 @@ import {useNavigation} from '@react-navigation/native'
 
 import {NavigationProp} from 'lib/routes/types'
 import {bskyTitle} from 'lib/strings/headings'
-import {useStores} from 'state/index'
+import {useUnreadNotifications} from '#/state/queries/notifications/unread'
 
-/**
- * Requires consuming component to be wrapped in `observer`:
- * https://stackoverflow.com/a/71488009
- */
 export function useSetTitle(title?: string) {
   const navigation = useNavigation<NavigationProp>()
-  const {unreadCountLabel} = useStores().me.notifications
+  const numUnread = useUnreadNotifications()
   useEffect(() => {
     if (title) {
-      navigation.setOptions({title: bskyTitle(title, unreadCountLabel)})
+      navigation.setOptions({title: bskyTitle(title, numUnread)})
     }
-  }, [title, navigation, unreadCountLabel])
+  }, [title, navigation, numUnread])
 }
diff --git a/src/lib/notifications/notifications.ts b/src/lib/notifications/notifications.ts
index 73f9c56f6..d46479a05 100644
--- a/src/lib/notifications/notifications.ts
+++ b/src/lib/notifications/notifications.ts
@@ -1,18 +1,18 @@
 import * as Notifications from 'expo-notifications'
+import {QueryClient} from '@tanstack/react-query'
 import {RootStoreModel} from '../../state'
 import {resetToTab} from '../../Navigation'
 import {devicePlatform, isIOS} from 'platform/detection'
 import {track} from 'lib/analytics/analytics'
 import {logger} from '#/logger'
+import {RQKEY as RQKEY_NOTIFS} from '#/state/queries/notifications/feed'
 
 const SERVICE_DID = (serviceUrl?: string) =>
   serviceUrl?.includes('staging')
     ? 'did:web:api.staging.bsky.dev'
     : 'did:web:api.bsky.app'
 
-export function init(store: RootStoreModel) {
-  store.onUnreadNotifications(count => Notifications.setBadgeCountAsync(count))
-
+export function init(store: RootStoreModel, queryClient: QueryClient) {
   store.onSessionLoaded(async () => {
     // request notifications permission once the user has logged in
     const perms = await Notifications.getPermissionsAsync()
@@ -83,7 +83,7 @@ export function init(store: RootStoreModel) {
     )
     if (event.request.trigger.type === 'push') {
       // refresh notifications in the background
-      store.me.notifications.syncQueue()
+      queryClient.invalidateQueries({queryKey: RQKEY_NOTIFS()})
       // handle payload-based deeplinks
       let payload
       if (isIOS) {
@@ -121,7 +121,7 @@ export function init(store: RootStoreModel) {
           logger.DebugContext.notifications,
         )
         track('Notificatons:OpenApp')
-        store.me.notifications.refresh() // refresh notifications
+        queryClient.invalidateQueries({queryKey: RQKEY_NOTIFS()})
         resetToTab('NotificationsTab') // open notifications tab
       }
     },
diff --git a/src/state/models/feeds/notifications.ts b/src/state/models/feeds/notifications.ts
deleted file mode 100644
index 5f34feb66..000000000
--- a/src/state/models/feeds/notifications.ts
+++ /dev/null
@@ -1,671 +0,0 @@
-import {makeAutoObservable, runInAction} from 'mobx'
-import {
-  AppBskyNotificationListNotifications as ListNotifications,
-  AppBskyActorDefs,
-  AppBskyFeedDefs,
-  AppBskyFeedPost,
-  AppBskyFeedRepost,
-  AppBskyFeedLike,
-  AppBskyGraphFollow,
-  ComAtprotoLabelDefs,
-  moderatePost,
-  moderateProfile,
-} from '@atproto/api'
-import AwaitLock from 'await-lock'
-import chunk from 'lodash.chunk'
-import {bundleAsync} from 'lib/async/bundle'
-import {RootStoreModel} from '../root-store'
-import {PostThreadModel} from '../content/post-thread'
-import {cleanError} from 'lib/strings/errors'
-import {logger} from '#/logger'
-import {isThreadMuted} from '#/state/muted-threads'
-
-const GROUPABLE_REASONS = ['like', 'repost', 'follow']
-const PAGE_SIZE = 30
-const MS_1HR = 1e3 * 60 * 60
-const MS_2DAY = MS_1HR * 48
-
-export const MAX_VISIBLE_NOTIFS = 30
-
-export interface GroupedNotification extends ListNotifications.Notification {
-  additional?: ListNotifications.Notification[]
-}
-
-type SupportedRecord =
-  | AppBskyFeedPost.Record
-  | AppBskyFeedRepost.Record
-  | AppBskyFeedLike.Record
-  | AppBskyGraphFollow.Record
-
-export class NotificationsFeedItemModel {
-  // ui state
-  _reactKey: string = ''
-
-  // data
-  uri: string = ''
-  cid: string = ''
-  author: AppBskyActorDefs.ProfileViewBasic = {
-    did: '',
-    handle: '',
-    avatar: '',
-  }
-  reason: string = ''
-  reasonSubject?: string
-  record?: SupportedRecord
-  isRead: boolean = false
-  indexedAt: string = ''
-  labels?: ComAtprotoLabelDefs.Label[]
-  additional?: NotificationsFeedItemModel[]
-
-  // additional data
-  additionalPost?: PostThreadModel
-
-  constructor(
-    public rootStore: RootStoreModel,
-    reactKey: string,
-    v: GroupedNotification,
-  ) {
-    makeAutoObservable(this, {rootStore: false})
-    this._reactKey = reactKey
-    this.copy(v)
-  }
-
-  copy(v: GroupedNotification, preserve = false) {
-    this.uri = v.uri
-    this.cid = v.cid
-    this.author = v.author
-    this.reason = v.reason
-    this.reasonSubject = v.reasonSubject
-    this.record = this.toSupportedRecord(v.record)
-    this.isRead = v.isRead
-    this.indexedAt = v.indexedAt
-    this.labels = v.labels
-    if (v.additional?.length) {
-      this.additional = []
-      for (const add of v.additional) {
-        this.additional.push(
-          new NotificationsFeedItemModel(this.rootStore, '', add),
-        )
-      }
-    } else if (!preserve) {
-      this.additional = undefined
-    }
-  }
-
-  get shouldFilter(): boolean {
-    if (this.additionalPost?.thread) {
-      const postMod = moderatePost(
-        this.additionalPost.thread.data.post,
-        this.rootStore.preferences.moderationOpts,
-      )
-      return postMod.content.filter || false
-    }
-    const profileMod = moderateProfile(
-      this.author,
-      this.rootStore.preferences.moderationOpts,
-    )
-    return profileMod.account.filter || false
-  }
-
-  get numUnreadInGroup(): number {
-    if (this.additional?.length) {
-      return (
-        this.additional.reduce(
-          (acc, notif) => acc + notif.numUnreadInGroup,
-          0,
-        ) + (this.isRead ? 0 : 1)
-      )
-    }
-    return this.isRead ? 0 : 1
-  }
-
-  markGroupRead() {
-    if (this.additional?.length) {
-      for (const notif of this.additional) {
-        notif.markGroupRead()
-      }
-    }
-    this.isRead = true
-  }
-
-  get isLike() {
-    return this.reason === 'like' && !this.isCustomFeedLike // the reason property for custom feed likes is also 'like'
-  }
-
-  get isRepost() {
-    return this.reason === 'repost'
-  }
-
-  get isMention() {
-    return this.reason === 'mention'
-  }
-
-  get isReply() {
-    return this.reason === 'reply'
-  }
-
-  get isQuote() {
-    return this.reason === 'quote'
-  }
-
-  get isFollow() {
-    return this.reason === 'follow'
-  }
-
-  get isCustomFeedLike() {
-    return (
-      this.reason === 'like' && this.reasonSubject?.includes('feed.generator')
-    )
-  }
-
-  get needsAdditionalData() {
-    if (
-      this.isLike ||
-      this.isRepost ||
-      this.isReply ||
-      this.isQuote ||
-      this.isMention
-    ) {
-      return !this.additionalPost
-    }
-    return false
-  }
-
-  get additionalDataUri(): string | undefined {
-    if (this.isReply || this.isQuote || this.isMention) {
-      return this.uri
-    } else if (this.isLike || this.isRepost) {
-      return this.subjectUri
-    }
-  }
-
-  get subjectUri(): string {
-    if (this.reasonSubject) {
-      return this.reasonSubject
-    }
-    const record = this.record
-    if (
-      AppBskyFeedRepost.isRecord(record) ||
-      AppBskyFeedLike.isRecord(record)
-    ) {
-      return record.subject.uri
-    }
-    return ''
-  }
-
-  get reasonSubjectRootUri(): string | undefined {
-    if (this.additionalPost) {
-      return this.additionalPost.rootUri
-    }
-    return undefined
-  }
-
-  toSupportedRecord(v: unknown): SupportedRecord | undefined {
-    for (const ns of [
-      AppBskyFeedPost,
-      AppBskyFeedRepost,
-      AppBskyFeedLike,
-      AppBskyGraphFollow,
-    ]) {
-      if (ns.isRecord(v)) {
-        const valid = ns.validateRecord(v)
-        if (valid.success) {
-          return v
-        } else {
-          logger.warn('Received an invalid record', {
-            record: v,
-            error: valid.error,
-          })
-          return
-        }
-      }
-    }
-    logger.warn(
-      'app.bsky.notifications.list served an unsupported record type',
-      {record: v},
-    )
-  }
-
-  setAdditionalData(additionalPost: AppBskyFeedDefs.PostView) {
-    if (this.additionalPost) {
-      this.additionalPost._replaceAll({
-        success: true,
-        headers: {},
-        data: {
-          thread: {
-            post: additionalPost,
-          },
-        },
-      })
-    } else {
-      this.additionalPost = PostThreadModel.fromPostView(
-        this.rootStore,
-        additionalPost,
-      )
-    }
-  }
-}
-
-export class NotificationsFeedModel {
-  // state
-  isLoading = false
-  isRefreshing = false
-  hasLoaded = false
-  error = ''
-  loadMoreError = ''
-  hasMore = true
-  loadMoreCursor?: string
-
-  /**
-   * The last time notifications were seen. Refers to either the
-   * user's machine clock or the value of the `indexedAt` property on their
-   * latest notification, whichever was greater at the time of viewing.
-   */
-  lastSync?: Date
-
-  // used to linearize async modifications to state
-  lock = new AwaitLock()
-
-  // data
-  notifications: NotificationsFeedItemModel[] = []
-  queuedNotifications: undefined | NotificationsFeedItemModel[] = undefined
-  unreadCount = 0
-
-  // this is used to help trigger push notifications
-  mostRecentNotificationUri: string | undefined
-
-  constructor(public rootStore: RootStoreModel) {
-    makeAutoObservable(
-      this,
-      {
-        rootStore: false,
-        mostRecentNotificationUri: false,
-      },
-      {autoBind: true},
-    )
-  }
-
-  get hasContent() {
-    return this.notifications.length !== 0
-  }
-
-  get hasError() {
-    return this.error !== ''
-  }
-
-  get isEmpty() {
-    return this.hasLoaded && !this.hasContent
-  }
-
-  get hasNewLatest() {
-    return Boolean(
-      this.queuedNotifications && this.queuedNotifications?.length > 0,
-    )
-  }
-
-  get unreadCountLabel(): string {
-    const count = this.unreadCount
-    if (count >= MAX_VISIBLE_NOTIFS) {
-      return `${MAX_VISIBLE_NOTIFS}+`
-    }
-    if (count === 0) {
-      return ''
-    }
-    return String(count)
-  }
-
-  // public api
-  // =
-
-  /**
-   * Nuke all data
-   */
-  clear() {
-    logger.debug('NotificationsModel:clear')
-    this.isLoading = false
-    this.isRefreshing = false
-    this.hasLoaded = false
-    this.error = ''
-    this.hasMore = true
-    this.loadMoreCursor = undefined
-    this.notifications = []
-    this.unreadCount = 0
-    this.rootStore.emitUnreadNotifications(0)
-    this.mostRecentNotificationUri = undefined
-  }
-
-  /**
-   * Load for first render
-   */
-  setup = bundleAsync(async (isRefreshing: boolean = false) => {
-    logger.debug('NotificationsModel:refresh', {isRefreshing})
-    await this.lock.acquireAsync()
-    try {
-      this._xLoading(isRefreshing)
-      try {
-        const res = await this.rootStore.agent.listNotifications({
-          limit: PAGE_SIZE,
-        })
-        await this._replaceAll(res)
-        this._setQueued(undefined)
-        this._countUnread()
-        this._xIdle()
-      } catch (e: any) {
-        this._xIdle(e)
-      }
-    } finally {
-      this.lock.release()
-    }
-  })
-
-  /**
-   * Reset and load
-   */
-  async refresh() {
-    this.isRefreshing = true // set optimistically for UI
-    return this.setup(true)
-  }
-
-  /**
-   * Sync the next set of notifications to show
-   */
-  syncQueue = bundleAsync(async () => {
-    logger.debug('NotificationsModel:syncQueue')
-    if (this.unreadCount >= MAX_VISIBLE_NOTIFS) {
-      return // no need to check
-    }
-    await this.lock.acquireAsync()
-    try {
-      const res = await this.rootStore.agent.listNotifications({
-        limit: PAGE_SIZE,
-      })
-
-      const queue = []
-      for (const notif of res.data.notifications) {
-        if (this.notifications.length) {
-          if (isEq(notif, this.notifications[0])) {
-            break
-          }
-        } else {
-          if (!notif.isRead) {
-            break
-          }
-        }
-        queue.push(notif)
-      }
-
-      // NOTE
-      // because filtering depends on the added information we have to fetch
-      // the full models here. this is *not* ideal performance and we need
-      // to update the notifications route to give all the info we need
-      // -prf
-      const queueModels = await this._fetchItemModels(queue)
-      this._setQueued(this._filterNotifications(queueModels))
-      this._countUnread()
-    } catch (e) {
-      logger.error('NotificationsModel:syncQueue failed', {
-        error: e,
-      })
-    } finally {
-      this.lock.release()
-    }
-
-    // if there are no notifications, we should refresh the list
-    // this will only run for new users who have no notifications
-    // NOTE: needs to be after the lock is released
-    if (this.isEmpty) {
-      this.refresh()
-    }
-  })
-
-  /**
-   * Load more posts to the end of the notifications
-   */
-  loadMore = bundleAsync(async () => {
-    if (!this.hasMore) {
-      return
-    }
-    await this.lock.acquireAsync()
-    try {
-      this._xLoading()
-      try {
-        const res = await this.rootStore.agent.listNotifications({
-          limit: PAGE_SIZE,
-          cursor: this.loadMoreCursor,
-        })
-        await this._appendAll(res)
-        this._xIdle()
-      } catch (e: any) {
-        this._xIdle(undefined, e)
-        runInAction(() => {
-          this.hasMore = false
-        })
-      }
-    } finally {
-      this.lock.release()
-    }
-  })
-
-  /**
-   * Attempt to load more again after a failure
-   */
-  async retryLoadMore() {
-    this.loadMoreError = ''
-    this.hasMore = true
-    return this.loadMore()
-  }
-
-  // unread notification in-place
-  // =
-  async update() {
-    const promises = []
-    for (const item of this.notifications) {
-      if (item.additionalPost) {
-        promises.push(item.additionalPost.update())
-      }
-    }
-    await Promise.all(promises).catch(e => {
-      logger.error('Uncaught failure during notifications update()', e)
-    })
-  }
-
-  /**
-   * Update read/unread state
-   */
-  async markAllRead() {
-    try {
-      for (const notif of this.notifications) {
-        notif.markGroupRead()
-      }
-      this._countUnread()
-      await this.rootStore.agent.updateSeenNotifications(
-        this.lastSync ? this.lastSync.toISOString() : undefined,
-      )
-    } catch (e: any) {
-      logger.warn('Failed to update notifications read state', {
-        error: e,
-      })
-    }
-  }
-
-  // state transitions
-  // =
-
-  _xLoading(isRefreshing = false) {
-    this.isLoading = true
-    this.isRefreshing = isRefreshing
-    this.error = ''
-  }
-
-  _xIdle(error?: any, loadMoreError?: any) {
-    this.isLoading = false
-    this.isRefreshing = false
-    this.hasLoaded = true
-    this.error = cleanError(error)
-    this.loadMoreError = cleanError(loadMoreError)
-    if (error) {
-      logger.error('Failed to fetch notifications', {error})
-    }
-    if (loadMoreError) {
-      logger.error('Failed to load more notifications', {
-        error: loadMoreError,
-      })
-    }
-  }
-
-  // helper functions
-  // =
-
-  async _replaceAll(res: ListNotifications.Response) {
-    const latest = res.data.notifications[0]
-
-    if (latest) {
-      const now = new Date()
-      const lastIndexed = new Date(latest.indexedAt)
-      const nowOrLastIndexed = now > lastIndexed ? now : lastIndexed
-
-      this.mostRecentNotificationUri = latest.uri
-      this.lastSync = nowOrLastIndexed
-    }
-
-    return this._appendAll(res, true)
-  }
-
-  async _appendAll(res: ListNotifications.Response, replace = false) {
-    this.loadMoreCursor = res.data.cursor
-    this.hasMore = !!this.loadMoreCursor
-    const itemModels = await this._processNotifications(res.data.notifications)
-    runInAction(() => {
-      if (replace) {
-        this.notifications = itemModels
-      } else {
-        this.notifications = this.notifications.concat(itemModels)
-      }
-    })
-  }
-
-  _filterNotifications(
-    items: NotificationsFeedItemModel[],
-  ): NotificationsFeedItemModel[] {
-    return items
-      .filter(item => {
-        const hideByLabel = item.shouldFilter
-        let mutedThread = !!(
-          item.reasonSubjectRootUri && isThreadMuted(item.reasonSubjectRootUri)
-        )
-        return !hideByLabel && !mutedThread
-      })
-      .map(item => {
-        if (item.additional?.length) {
-          item.additional = this._filterNotifications(item.additional)
-        }
-        return item
-      })
-  }
-
-  async _fetchItemModels(
-    items: ListNotifications.Notification[],
-  ): Promise<NotificationsFeedItemModel[]> {
-    // construct item models and track who needs more data
-    const itemModels: NotificationsFeedItemModel[] = []
-    const addedPostMap = new Map<string, NotificationsFeedItemModel[]>()
-    for (const item of items) {
-      const itemModel = new NotificationsFeedItemModel(
-        this.rootStore,
-        `notification-${item.uri}`,
-        item,
-      )
-      const uri = itemModel.additionalDataUri
-      if (uri) {
-        const models = addedPostMap.get(uri) || []
-        models.push(itemModel)
-        addedPostMap.set(uri, models)
-      }
-      itemModels.push(itemModel)
-    }
-
-    // fetch additional data
-    if (addedPostMap.size > 0) {
-      const uriChunks = chunk(Array.from(addedPostMap.keys()), 25)
-      const postsChunks = await Promise.all(
-        uriChunks.map(uris =>
-          this.rootStore.agent.app.bsky.feed
-            .getPosts({uris})
-            .then(res => res.data.posts),
-        ),
-      )
-      for (const post of postsChunks.flat()) {
-        this.rootStore.posts.set(post.uri, post)
-        const models = addedPostMap.get(post.uri)
-        if (models?.length) {
-          for (const model of models) {
-            model.setAdditionalData(post)
-          }
-        }
-      }
-    }
-
-    return itemModels
-  }
-
-  async _processNotifications(
-    items: ListNotifications.Notification[],
-  ): Promise<NotificationsFeedItemModel[]> {
-    const itemModels = await this._fetchItemModels(groupNotifications(items))
-    return this._filterNotifications(itemModels)
-  }
-
-  _setQueued(queued: undefined | NotificationsFeedItemModel[]) {
-    this.queuedNotifications = queued
-  }
-
-  _countUnread() {
-    let unread = 0
-    for (const notif of this.notifications) {
-      unread += notif.numUnreadInGroup
-    }
-    if (this.queuedNotifications) {
-      unread += this.queuedNotifications.filter(notif => !notif.isRead).length
-    }
-    this.unreadCount = unread
-    this.rootStore.emitUnreadNotifications(unread)
-  }
-}
-
-function groupNotifications(
-  items: ListNotifications.Notification[],
-): GroupedNotification[] {
-  const items2: GroupedNotification[] = []
-  for (const item of items) {
-    const ts = +new Date(item.indexedAt)
-    let grouped = false
-    if (GROUPABLE_REASONS.includes(item.reason)) {
-      for (const item2 of items2) {
-        const ts2 = +new Date(item2.indexedAt)
-        if (
-          Math.abs(ts2 - ts) < MS_2DAY &&
-          item.reason === item2.reason &&
-          item.reasonSubject === item2.reasonSubject &&
-          item.author.did !== item2.author.did
-        ) {
-          item2.additional = item2.additional || []
-          item2.additional.push(item)
-          grouped = true
-          break
-        }
-      }
-    }
-    if (!grouped) {
-      items2.push(item)
-    }
-  }
-  return items2
-}
-
-type N = ListNotifications.Notification | NotificationsFeedItemModel
-function isEq(a: N, b: N) {
-  // this function has a key subtlety- the indexedAt comparison
-  // the reason for this is reposts: they set the URI of the original post, not of the repost record
-  // the indexedAt time will be for the repost however, so we use that to help us
-  return a.uri === b.uri && a.indexedAt === b.indexedAt
-}
diff --git a/src/state/models/me.ts b/src/state/models/me.ts
index c17fcf183..427b0e35e 100644
--- a/src/state/models/me.ts
+++ b/src/state/models/me.ts
@@ -4,13 +4,11 @@ import {
   ComAtprotoServerListAppPasswords,
 } from '@atproto/api'
 import {RootStoreModel} from './root-store'
-import {NotificationsFeedModel} from './feeds/notifications'
 import {MyFollowsCache} from './cache/my-follows'
 import {isObj, hasProp} from 'lib/type-guards'
 import {logger} from '#/logger'
 
 const PROFILE_UPDATE_INTERVAL = 10 * 60 * 1e3 // 10min
-const NOTIFS_UPDATE_INTERVAL = 30 * 1e3 // 30sec
 
 export class MeModel {
   did: string = ''
@@ -20,12 +18,10 @@ export class MeModel {
   avatar: string = ''
   followsCount: number | undefined
   followersCount: number | undefined
-  notifications: NotificationsFeedModel
   follows: MyFollowsCache
   invites: ComAtprotoServerDefs.InviteCode[] = []
   appPasswords: ComAtprotoServerListAppPasswords.AppPassword[] = []
   lastProfileStateUpdate = Date.now()
-  lastNotifsUpdate = Date.now()
 
   get invitesAvailable() {
     return this.invites.filter(isInviteAvailable).length
@@ -37,12 +33,10 @@ export class MeModel {
       {rootStore: false, serialize: false, hydrate: false},
       {autoBind: true},
     )
-    this.notifications = new NotificationsFeedModel(this.rootStore)
     this.follows = new MyFollowsCache(this.rootStore)
   }
 
   clear() {
-    this.notifications.clear()
     this.follows.clear()
     this.rootStore.profiles.cache.clear()
     this.rootStore.posts.cache.clear()
@@ -99,16 +93,6 @@ export class MeModel {
     if (sess.hasSession) {
       this.did = sess.currentSession?.did || ''
       await this.fetchProfile()
-      /* dont await */ this.notifications.setup().catch(e => {
-        logger.error('Failed to setup notifications model', {
-          error: e,
-        })
-      })
-      /* dont await */ this.notifications.setup().catch(e => {
-        logger.error('Failed to setup notifications model', {
-          error: e,
-        })
-      })
       this.rootStore.emitSessionLoaded()
       await this.fetchInviteCodes()
       await this.fetchAppPasswords()
@@ -125,10 +109,6 @@ export class MeModel {
       await this.fetchInviteCodes()
       await this.fetchAppPasswords()
     }
-    if (Date.now() - this.lastNotifsUpdate > NOTIFS_UPDATE_INTERVAL) {
-      this.lastNotifsUpdate = Date.now()
-      await this.notifications.syncQueue()
-    }
   }
 
   async fetchProfile() {
diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts
index c07cf3078..288e8b8e1 100644
--- a/src/state/models/root-store.ts
+++ b/src/state/models/root-store.ts
@@ -203,14 +203,6 @@ export class RootStoreModel {
   emitScreenSoftReset() {
     DeviceEventEmitter.emit('screen-soft-reset')
   }
-
-  // the unread notifications count has changed
-  onUnreadNotifications(handler: (count: number) => void): EmitterSubscription {
-    return DeviceEventEmitter.addListener('unread-notifications', handler)
-  }
-  emitUnreadNotifications(count: number) {
-    DeviceEventEmitter.emit('unread-notifications', count)
-  }
 }
 
 const throwawayInst = new RootStoreModel(
diff --git a/src/state/persisted/index.ts b/src/state/persisted/index.ts
index a5c38513f..f6eff4257 100644
--- a/src/state/persisted/index.ts
+++ b/src/state/persisted/index.ts
@@ -3,7 +3,7 @@ import {logger} from '#/logger'
 import {defaults, Schema} from '#/state/persisted/schema'
 import {migrate} from '#/state/persisted/legacy'
 import * as store from '#/state/persisted/store'
-import BroadcastChannel from '#/state/persisted/broadcast'
+import BroadcastChannel from '#/lib/broadcast'
 
 export type {Schema, PersistedAccount} from '#/state/persisted/schema'
 export {defaults} from '#/state/persisted/schema'
diff --git a/src/state/queries/notifications/feed.ts b/src/state/queries/notifications/feed.ts
new file mode 100644
index 000000000..9d491c3a4
--- /dev/null
+++ b/src/state/queries/notifications/feed.ts
@@ -0,0 +1,212 @@
+import {
+  AppBskyFeedDefs,
+  AppBskyFeedPost,
+  AppBskyFeedRepost,
+  AppBskyFeedLike,
+  AppBskyNotificationListNotifications,
+  BskyAgent,
+} from '@atproto/api'
+import chunk from 'lodash.chunk'
+import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
+import {useSession} from '../../session'
+import {useModerationOpts} from '../preferences'
+import {shouldFilterNotif} from './util'
+import {useMutedThreads} from '#/state/muted-threads'
+
+const GROUPABLE_REASONS = ['like', 'repost', 'follow']
+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 {agent} = useSession()
+  const moderationOpts = useModerationOpts()
+  const threadMutes = useMutedThreads()
+  const enabled = opts?.enabled !== false
+
+  return useInfiniteQuery<
+    FeedPage,
+    Error,
+    InfiniteData<FeedPage>,
+    QueryKey,
+    RQPageParam
+  >({
+    queryKey: RQKEY(),
+    async queryFn({pageParam}: {pageParam: RQPageParam}) {
+      const res = await agent.listNotifications({
+        limit: PAGE_SIZE,
+        cursor: pageParam,
+      })
+
+      // 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(agent, notifsGrouped)
+      for (const notif of notifsGrouped) {
+        if (notif.subjectUri) {
+          notif.subject = subjects.get(notif.subjectUri)
+        }
+      }
+
+      // apply thread muting
+      notifsGrouped = notifsGrouped.filter(
+        notif => !isThreadMuted(notif, threadMutes),
+      )
+
+      return {
+        cursor: res.data.cursor,
+        items: notifsGrouped,
+      }
+    },
+    initialPageParam: undefined,
+    getNextPageParam: lastPage => lastPage.cursor,
+    enabled,
+  })
+}
+
+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/unread.tsx b/src/state/queries/notifications/unread.tsx
new file mode 100644
index 000000000..91aa6f3c2
--- /dev/null
+++ b/src/state/queries/notifications/unread.tsx
@@ -0,0 +1,113 @@
+import React from 'react'
+import * as Notifications from 'expo-notifications'
+import BroadcastChannel from '#/lib/broadcast'
+import {useSession} from '#/state/session'
+import {useModerationOpts} from '../preferences'
+import {shouldFilterNotif} from './util'
+import {isNative} from '#/platform/detection'
+
+const UPDATE_INTERVAL = 30 * 1e3 // 30sec
+
+const broadcast = new BroadcastChannel('NOTIFS_BROADCAST_CHANNEL')
+
+type StateContext = string
+
+interface ApiContext {
+  markAllRead: () => Promise<void>
+  checkUnread: () => Promise<void>
+}
+
+const stateContext = React.createContext<StateContext>('')
+
+const apiContext = React.createContext<ApiContext>({
+  async markAllRead() {},
+  async checkUnread() {},
+})
+
+export function Provider({children}: React.PropsWithChildren<{}>) {
+  const {hasSession, agent} = useSession()
+  const moderationOpts = useModerationOpts()
+
+  const [numUnread, setNumUnread] = React.useState('')
+
+  const checkUnreadRef = React.useRef<(() => Promise<void>) | null>(null)
+  const lastSyncRef = React.useRef<Date>(new Date())
+
+  // periodic sync
+  React.useEffect(() => {
+    if (!hasSession || !checkUnreadRef.current) {
+      return
+    }
+    checkUnreadRef.current() // fire on init
+    const interval = setInterval(checkUnreadRef.current, UPDATE_INTERVAL)
+    return () => clearInterval(interval)
+  }, [hasSession])
+
+  // listen for broadcasts
+  React.useEffect(() => {
+    const listener = ({data}: MessageEvent) => {
+      lastSyncRef.current = new Date()
+      setNumUnread(data.event)
+    }
+    broadcast.addEventListener('message', listener)
+    return () => {
+      broadcast.removeEventListener('message', listener)
+    }
+  }, [setNumUnread])
+
+  // create API
+  const api = React.useMemo<ApiContext>(() => {
+    return {
+      async markAllRead() {
+        // update server
+        await agent.updateSeenNotifications(lastSyncRef.current.toISOString())
+
+        // update & broadcast
+        setNumUnread('')
+        broadcast.postMessage({event: ''})
+      },
+
+      async checkUnread() {
+        // 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))
+        }
+
+        // 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})
+      },
+    }
+  }, [setNumUnread, agent, moderationOpts])
+  checkUnreadRef.current = api.checkUnread
+
+  return (
+    <stateContext.Provider value={numUnread}>
+      <apiContext.Provider value={api}>{children}</apiContext.Provider>
+    </stateContext.Provider>
+  )
+}
+
+export function useUnreadNotifications() {
+  return React.useContext(stateContext)
+}
+
+export function useUnreadNotificationsApi() {
+  return React.useContext(apiContext)
+}
diff --git a/src/state/queries/notifications/util.ts b/src/state/queries/notifications/util.ts
new file mode 100644
index 000000000..c49d1851a
--- /dev/null
+++ b/src/state/queries/notifications/util.ts
@@ -0,0 +1,38 @@
+import {
+  AppBskyNotificationListNotifications,
+  ModerationOpts,
+  moderateProfile,
+  moderatePost,
+} from '@atproto/api'
+
+// TODO this should be in the sdk as moderateNotification -prf
+export function shouldFilterNotif(
+  notif: AppBskyNotificationListNotifications.Notification,
+  moderationOpts: ModerationOpts | undefined,
+): boolean {
+  if (!moderationOpts) {
+    return false
+  }
+  const profile = moderateProfile(notif.author, moderationOpts)
+  if (
+    profile.account.filter ||
+    profile.profile.filter ||
+    notif.author.viewer?.muted
+  ) {
+    return true
+  }
+  if (
+    notif.type === 'reply' ||
+    notif.type === 'quote' ||
+    notif.type === 'mention'
+  ) {
+    // NOTE: the notification overlaps the post enough for this to work
+    const post = moderatePost(notif, moderationOpts)
+    if (post.content.filter) {
+      return true
+    }
+  }
+  // TODO: thread muting is not being applied
+  // (this requires fetching the post)
+  return false
+}
diff --git a/src/state/queries/preferences/index.ts b/src/state/queries/preferences/index.ts
index d64bbd954..4f10b01a6 100644
--- a/src/state/queries/preferences/index.ts
+++ b/src/state/queries/preferences/index.ts
@@ -1,5 +1,11 @@
+import {useEffect, useState} from 'react'
 import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
-import {LabelPreference, BskyFeedViewPreference} from '@atproto/api'
+import {
+  LabelPreference,
+  BskyFeedViewPreference,
+  ModerationOpts,
+} from '@atproto/api'
+import isEqual from 'lodash.isequal'
 
 import {track} from '#/lib/analytics/analytics'
 import {getAge} from '#/lib/strings/time'
@@ -15,6 +21,7 @@ import {
   DEFAULT_HOME_FEED_PREFS,
   DEFAULT_THREAD_VIEW_PREFS,
 } from '#/state/queries/preferences/const'
+import {getModerationOpts} from '#/state/queries/preferences/moderation'
 
 export * from '#/state/queries/preferences/types'
 export * from '#/state/queries/preferences/moderation'
@@ -23,7 +30,7 @@ export * from '#/state/queries/preferences/const'
 export const usePreferencesQueryKey = ['getPreferences']
 
 export function usePreferencesQuery() {
-  const {agent} = useSession()
+  const {agent, hasSession} = useSession()
   return useQuery({
     queryKey: usePreferencesQueryKey,
     queryFn: async () => {
@@ -76,9 +83,30 @@ export function usePreferencesQuery() {
       }
       return preferences
     },
+    enabled: hasSession,
   })
 }
 
+export function useModerationOpts() {
+  const {currentAccount} = useSession()
+  const [opts, setOpts] = useState<ModerationOpts | undefined>()
+  const prefs = usePreferencesQuery()
+  useEffect(() => {
+    if (!prefs.data) {
+      return
+    }
+    // only update this hook when the moderation options change
+    const newOpts = getModerationOpts({
+      userDid: currentAccount?.did || '',
+      preferences: prefs.data,
+    })
+    if (!isEqual(opts, newOpts)) {
+      setOpts(newOpts)
+    }
+  }, [prefs.data, currentAccount, opts, setOpts])
+  return opts
+}
+
 export function useClearPreferencesMutation() {
   const {agent} = useSession()
   const queryClient = useQueryClient()
diff --git a/src/view/com/notifications/Feed.tsx b/src/view/com/notifications/Feed.tsx
index 4794a9867..e82c654be 100644
--- a/src/view/com/notifications/Feed.tsx
+++ b/src/view/com/notifications/Feed.tsx
@@ -1,8 +1,6 @@
 import React, {MutableRefObject} from 'react'
-import {observer} from 'mobx-react-lite'
 import {CenteredView, FlatList} from '../util/Views'
 import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native'
-import {NotificationsFeedModel} from 'state/models/feeds/notifications'
 import {FeedItem} from './FeedItem'
 import {NotificationFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
 import {ErrorMessage} from '../util/error/ErrorMessage'
@@ -12,20 +10,22 @@ import {OnScrollHandler} from 'lib/hooks/useOnMainScroll'
 import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
 import {s} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
+import {useNotificationFeedQuery} from '#/state/queries/notifications/feed'
+import {useUnreadNotificationsApi} from '#/state/queries/notifications/unread'
 import {logger} from '#/logger'
+import {cleanError} from '#/lib/strings/errors'
+import {useModerationOpts} from '#/state/queries/preferences'
 
 const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
 const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
-const LOADING_SPINNER = {_reactKey: '__loading_spinner__'}
+const LOADING_ITEM = {_reactKey: '__loading__'}
 
-export const Feed = observer(function Feed({
-  view,
+export function Feed({
   scrollElRef,
   onPressTryAgain,
   onScroll,
   ListHeaderComponent,
 }: {
-  view: NotificationsFeedModel
   scrollElRef?: MutableRefObject<FlatList<any> | null>
   onPressTryAgain?: () => void
   onScroll?: OnScrollHandler
@@ -33,35 +33,54 @@ export const Feed = observer(function Feed({
 }) {
   const pal = usePalette('default')
   const [isPTRing, setIsPTRing] = React.useState(false)
-  const data = React.useMemo(() => {
-    let feedItems: any[] = []
-    if (view.isRefreshing && !isPTRing) {
-      feedItems = [LOADING_SPINNER]
+
+  const moderationOpts = useModerationOpts()
+  const {markAllRead} = useUnreadNotificationsApi()
+  const {
+    data,
+    dataUpdatedAt,
+    isFetching,
+    isFetched,
+    isError,
+    error,
+    refetch,
+    hasNextPage,
+    isFetchingNextPage,
+    fetchNextPage,
+  } = useNotificationFeedQuery({enabled: !!moderationOpts})
+  const isEmpty = !isFetching && !data?.pages[0]?.items.length
+  const firstItem = data?.pages[0]?.items[0]
+
+  // mark all read on fresh data
+  React.useEffect(() => {
+    if (firstItem) {
+      markAllRead()
     }
-    if (view.hasLoaded) {
-      if (view.isEmpty) {
-        feedItems = feedItems.concat([EMPTY_FEED_ITEM])
-      } else {
-        feedItems = feedItems.concat(view.notifications)
+  }, [firstItem, markAllRead])
+
+  const items = React.useMemo(() => {
+    let arr: any[] = []
+    if (isFetched) {
+      if (isEmpty) {
+        arr = arr.concat([EMPTY_FEED_ITEM])
+      } else if (data) {
+        for (const page of data?.pages) {
+          arr = arr.concat(page.items)
+        }
       }
+      if (isError && !isEmpty) {
+        arr = arr.concat([LOAD_MORE_ERROR_ITEM])
+      }
+    } else {
+      arr.push(LOADING_ITEM)
     }
-    if (view.loadMoreError) {
-      feedItems = (feedItems || []).concat([LOAD_MORE_ERROR_ITEM])
-    }
-    return feedItems
-  }, [
-    view.hasLoaded,
-    view.isEmpty,
-    view.notifications,
-    view.loadMoreError,
-    view.isRefreshing,
-    isPTRing,
-  ])
+    return arr
+  }, [isFetched, isError, isEmpty, data])
 
   const onRefresh = React.useCallback(async () => {
     try {
       setIsPTRing(true)
-      await view.refresh()
+      await refetch()
     } catch (err) {
       logger.error('Failed to refresh notifications feed', {
         error: err,
@@ -69,21 +88,21 @@ export const Feed = observer(function Feed({
     } finally {
       setIsPTRing(false)
     }
-  }, [view, setIsPTRing])
+  }, [refetch, setIsPTRing])
 
   const onEndReached = React.useCallback(async () => {
+    if (isFetching || !hasNextPage || isError) return
+
     try {
-      await view.loadMore()
+      await fetchNextPage()
     } catch (err) {
-      logger.error('Failed to load more notifications', {
-        error: err,
-      })
+      logger.error('Failed to load more notifications', {error: err})
     }
-  }, [view])
+  }, [isFetching, hasNextPage, isError, fetchNextPage])
 
   const onPressRetryLoadMore = React.useCallback(() => {
-    view.retryLoadMore()
-  }, [view])
+    fetchNextPage()
+  }, [fetchNextPage])
 
   // TODO optimize renderItem or FeedItem, we're getting this notice from RN: -prf
   //   VirtualizedList: You have a large list that is slow to update - make sure your
@@ -106,78 +125,72 @@ export const Feed = observer(function Feed({
             onPress={onPressRetryLoadMore}
           />
         )
-      } else if (item === LOADING_SPINNER) {
-        return (
-          <View style={styles.loading}>
-            <ActivityIndicator size="small" />
-          </View>
-        )
+      } else if (item === LOADING_ITEM) {
+        return <NotificationFeedLoadingPlaceholder />
       }
-      return <FeedItem item={item} />
+      return (
+        <FeedItem
+          item={item}
+          dataUpdatedAt={dataUpdatedAt}
+          moderationOpts={moderationOpts!}
+        />
+      )
     },
-    [onPressRetryLoadMore],
+    [onPressRetryLoadMore, dataUpdatedAt, moderationOpts],
   )
 
   const FeedFooter = React.useCallback(
     () =>
-      view.isLoading ? (
+      isFetchingNextPage ? (
         <View style={styles.feedFooter}>
           <ActivityIndicator />
         </View>
       ) : (
         <View />
       ),
-    [view],
+    [isFetchingNextPage],
   )
 
   const scrollHandler = useAnimatedScrollHandler(onScroll || {})
   return (
     <View style={s.hContentRegion}>
-      <CenteredView>
-        {view.isLoading && !data.length && (
-          <NotificationFeedLoadingPlaceholder />
-        )}
-        {view.hasError && (
+      {error && (
+        <CenteredView>
           <ErrorMessage
-            message={view.error}
+            message={cleanError(error)}
             onPressTryAgain={onPressTryAgain}
           />
-        )}
-      </CenteredView>
-      {data.length ? (
-        <FlatList
-          testID="notifsFeed"
-          ref={scrollElRef}
-          data={data}
-          keyExtractor={item => item._reactKey}
-          renderItem={renderItem}
-          ListHeaderComponent={ListHeaderComponent}
-          ListFooterComponent={FeedFooter}
-          refreshControl={
-            <RefreshControl
-              refreshing={isPTRing}
-              onRefresh={onRefresh}
-              tintColor={pal.colors.text}
-              titleColor={pal.colors.text}
-            />
-          }
-          onEndReached={onEndReached}
-          onEndReachedThreshold={0.6}
-          onScroll={scrollHandler}
-          scrollEventThrottle={1}
-          contentContainerStyle={s.contentContainer}
-          // @ts-ignore our .web version only -prf
-          desktopFixedHeight
-        />
-      ) : null}
+        </CenteredView>
+      )}
+      <FlatList
+        testID="notifsFeed"
+        ref={scrollElRef}
+        data={items}
+        keyExtractor={item => item._reactKey}
+        renderItem={renderItem}
+        ListHeaderComponent={ListHeaderComponent}
+        ListFooterComponent={FeedFooter}
+        refreshControl={
+          <RefreshControl
+            refreshing={isPTRing}
+            onRefresh={onRefresh}
+            tintColor={pal.colors.text}
+            titleColor={pal.colors.text}
+          />
+        }
+        onEndReached={onEndReached}
+        onEndReachedThreshold={0.6}
+        onScroll={scrollHandler}
+        scrollEventThrottle={1}
+        contentContainerStyle={s.contentContainer}
+        // @ts-ignore our .web version only -prf
+        desktopFixedHeight
+      />
     </View>
   )
-})
+}
 
 const styles = StyleSheet.create({
-  loading: {
-    paddingVertical: 20,
-  },
   feedFooter: {paddingTop: 20},
   emptyState: {paddingVertical: 40},
 })
diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx
index 0387ed38d..dd785a682 100644
--- a/src/view/com/notifications/FeedItem.tsx
+++ b/src/view/com/notifications/FeedItem.tsx
@@ -1,5 +1,4 @@
 import React, {useMemo, useState, useEffect} from 'react'
-import {observer} from 'mobx-react-lite'
 import {
   Animated,
   TouchableOpacity,
@@ -9,6 +8,9 @@ import {
 } from 'react-native'
 import {
   AppBskyEmbedImages,
+  AppBskyFeedDefs,
+  AppBskyFeedPost,
+  ModerationOpts,
   ProfileModeration,
   moderateProfile,
   AppBskyEmbedRecordWithMedia,
@@ -19,8 +21,7 @@ import {
   FontAwesomeIconStyle,
   Props,
 } from '@fortawesome/react-native-fontawesome'
-import {NotificationsFeedItemModel} from 'state/models/feeds/notifications'
-import {PostThreadModel} from 'state/models/content/post-thread'
+import {FeedNotification} from '#/state/queries/notifications/feed'
 import {s, colors} from 'lib/styles'
 import {niceDate} from 'lib/strings/time'
 import {sanitizeDisplayName} from 'lib/strings/display-names'
@@ -33,7 +34,6 @@ import {UserPreviewLink} from '../util/UserPreviewLink'
 import {ImageHorzList} from '../util/images/ImageHorzList'
 import {Post} from '../post/Post'
 import {Link, TextLink} from '../util/Link'
-import {useStores} from 'state/index'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
 import {formatCount} from '../util/numeric/format'
@@ -56,40 +56,36 @@ interface Author {
   moderation: ProfileModeration
 }
 
-export const FeedItem = observer(function FeedItemImpl({
+export function FeedItem({
   item,
+  dataUpdatedAt,
+  moderationOpts,
 }: {
-  item: NotificationsFeedItemModel
+  item: FeedNotification
+  dataUpdatedAt: number
+  moderationOpts: ModerationOpts
 }) {
-  const store = useStores()
   const pal = usePalette('default')
   const [isAuthorsExpanded, setAuthorsExpanded] = useState<boolean>(false)
   const itemHref = useMemo(() => {
-    if (item.isLike || item.isRepost) {
-      const urip = new AtUri(item.subjectUri)
-      return `/profile/${urip.host}/post/${urip.rkey}`
-    } else if (item.isFollow) {
-      return makeProfileLink(item.author)
-    } else if (item.isReply) {
-      const urip = new AtUri(item.uri)
+    if (item.type === 'post-like' || item.type === 'repost') {
+      if (item.subjectUri) {
+        const urip = new AtUri(item.subjectUri)
+        return `/profile/${urip.host}/post/${urip.rkey}`
+      }
+    } else if (item.type === 'follow') {
+      return makeProfileLink(item.notification.author)
+    } else if (item.type === 'reply') {
+      const urip = new AtUri(item.notification.uri)
       return `/profile/${urip.host}/post/${urip.rkey}`
-    } else if (item.isCustomFeedLike) {
-      const urip = new AtUri(item.subjectUri)
-      return `/profile/${urip.host}/feed/${urip.rkey}`
+    } else if (item.type === 'feedgen-like') {
+      if (item.subjectUri) {
+        const urip = new AtUri(item.subjectUri)
+        return `/profile/${urip.host}/feed/${urip.rkey}`
+      }
     }
     return ''
   }, [item])
-  const itemTitle = useMemo(() => {
-    if (item.isLike || item.isRepost) {
-      return 'Post'
-    } else if (item.isFollow) {
-      return item.author.handle
-    } else if (item.isReply) {
-      return 'Post'
-    } else if (item.isCustomFeedLike) {
-      return 'Custom Feed'
-    }
-  }, [item])
 
   const onToggleAuthorsExpanded = () => {
     setAuthorsExpanded(currentlyExpanded => !currentlyExpanded)
@@ -98,15 +94,12 @@ export const FeedItem = observer(function FeedItemImpl({
   const authors: Author[] = useMemo(() => {
     return [
       {
-        href: makeProfileLink(item.author),
-        did: item.author.did,
-        handle: item.author.handle,
-        displayName: item.author.displayName,
-        avatar: item.author.avatar,
-        moderation: moderateProfile(
-          item.author,
-          store.preferences.moderationOpts,
-        ),
+        href: makeProfileLink(item.notification.author),
+        did: item.notification.author.did,
+        handle: item.notification.author.handle,
+        displayName: item.notification.author.displayName,
+        avatar: item.notification.author.avatar,
+        moderation: moderateProfile(item.notification.author, moderationOpts),
       },
       ...(item.additional?.map(({author}) => {
         return {
@@ -115,33 +108,36 @@ export const FeedItem = observer(function FeedItemImpl({
           handle: author.handle,
           displayName: author.displayName,
           avatar: author.avatar,
-          moderation: moderateProfile(author, store.preferences.moderationOpts),
+          moderation: moderateProfile(author, moderationOpts),
         }
       }) || []),
     ]
-  }, [store, item.additional, item.author])
+  }, [item, moderationOpts])
 
-  if (item.additionalPost?.notFound) {
+  if (item.subjectUri && !item.subject) {
     // don't render anything if the target post was deleted or unfindable
     return <View />
   }
 
-  if (item.isReply || item.isMention || item.isQuote) {
-    if (!item.additionalPost || item.additionalPost?.error) {
-      // hide errors - it doesnt help the user to show them
-      return <View />
+  if (
+    item.type === 'reply' ||
+    item.type === 'mention' ||
+    item.type === 'quote'
+  ) {
+    if (!item.subject) {
+      return null
     }
     return (
       <Link
-        testID={`feedItem-by-${item.author.handle}`}
+        testID={`feedItem-by-${item.notification.author.handle}`}
         href={itemHref}
-        title={itemTitle}
         noFeedback
         accessible={false}>
         <Post
-          view={item.additionalPost}
+          post={item.subject}
+          dataUpdatedAt={dataUpdatedAt}
           style={
-            item.isRead
+            item.notification.isRead
               ? undefined
               : {
                   backgroundColor: pal.colors.unreadNotifBg,
@@ -156,23 +152,25 @@ export const FeedItem = observer(function FeedItemImpl({
   let action = ''
   let icon: Props['icon'] | 'HeartIconSolid'
   let iconStyle: Props['style'] = []
-  if (item.isLike) {
+  if (item.type === 'post-like') {
     action = 'liked your post'
     icon = 'HeartIconSolid'
     iconStyle = [
       s.likeColor as FontAwesomeIconStyle,
       {position: 'relative', top: -4},
     ]
-  } else if (item.isRepost) {
+  } else if (item.type === 'repost') {
     action = 'reposted your post'
     icon = 'retweet'
     iconStyle = [s.green3 as FontAwesomeIconStyle]
-  } else if (item.isFollow) {
+  } else if (item.type === 'follow') {
     action = 'followed you'
     icon = 'user-plus'
     iconStyle = [s.blue3 as FontAwesomeIconStyle]
-  } else if (item.isCustomFeedLike) {
-    action = `liked your custom feed '${new AtUri(item.subjectUri).rkey}'`
+  } else if (item.type === 'feedgen-like') {
+    action = `liked your custom feed${
+      item.subjectUri ? ` '${new AtUri(item.subjectUri).rkey}}'` : ''
+    }`
     icon = 'HeartIconSolid'
     iconStyle = [
       s.likeColor as FontAwesomeIconStyle,
@@ -184,12 +182,12 @@ export const FeedItem = observer(function FeedItemImpl({
 
   return (
     <Link
-      testID={`feedItem-by-${item.author.handle}`}
+      testID={`feedItem-by-${item.notification.author.handle}`}
       style={[
         styles.outer,
         pal.view,
         pal.border,
-        item.isRead
+        item.notification.isRead
           ? undefined
           : {
               backgroundColor: pal.colors.unreadNotifBg,
@@ -197,9 +195,11 @@ export const FeedItem = observer(function FeedItemImpl({
             },
       ]}
       href={itemHref}
-      title={itemTitle}
       noFeedback
-      accessible={(item.isLike && authors.length === 1) || item.isRepost}>
+      accessible={
+        (item.type === 'post-like' && authors.length === 1) ||
+        item.type === 'repost'
+      }>
       <View style={styles.layoutIcon}>
         {/* TODO: Prevent conditional rendering and move toward composable
         notifications for clearer accessibility labeling */}
@@ -244,24 +244,24 @@ export const FeedItem = observer(function FeedItemImpl({
               </>
             ) : undefined}
             <Text style={[pal.text]}> {action}</Text>
-            <TimeElapsed timestamp={item.indexedAt}>
+            <TimeElapsed timestamp={item.notification.indexedAt}>
               {({timeElapsed}) => (
                 <Text
                   style={[pal.textLight, styles.pointer]}
-                  title={niceDate(item.indexedAt)}>
+                  title={niceDate(item.notification.indexedAt)}>
                   {' ' + timeElapsed}
                 </Text>
               )}
             </TimeElapsed>
           </Text>
         </ExpandListPressable>
-        {item.isLike || item.isRepost || item.isQuote ? (
-          <AdditionalPostText additionalPost={item.additionalPost} />
+        {item.type === 'post-like' || item.type === 'repost' ? (
+          <AdditionalPostText post={item.subject} />
         ) : null}
       </View>
     </Link>
   )
-})
+}
 
 function ExpandListPressable({
   hasMultipleAuthors,
@@ -423,34 +423,25 @@ function ExpandedAuthorsList({
   )
 }
 
-function AdditionalPostText({
-  additionalPost,
-}: {
-  additionalPost?: PostThreadModel
-}) {
+function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) {
   const pal = usePalette('default')
-  if (
-    !additionalPost ||
-    !additionalPost.thread?.postRecord ||
-    additionalPost.error
-  ) {
-    return <View />
+  if (post && AppBskyFeedPost.isRecord(post?.record)) {
+    const text = post.record.text
+    const images = AppBskyEmbedImages.isView(post.embed)
+      ? post.embed.images
+      : AppBskyEmbedRecordWithMedia.isView(post.embed) &&
+        AppBskyEmbedImages.isView(post.embed.media)
+      ? post.embed.media.images
+      : undefined
+    return (
+      <>
+        {text?.length > 0 && <Text style={pal.textLight}>{text}</Text>}
+        {images && images?.length > 0 && (
+          <ImageHorzList images={images} style={styles.additionalPostImages} />
+        )}
+      </>
+    )
   }
-  const text = additionalPost.thread?.postRecord.text
-  const images = AppBskyEmbedImages.isView(additionalPost.thread.post.embed)
-    ? additionalPost.thread.post.embed.images
-    : AppBskyEmbedRecordWithMedia.isView(additionalPost.thread.post.embed) &&
-      AppBskyEmbedImages.isView(additionalPost.thread.post.embed.media)
-    ? additionalPost.thread.post.embed.media.images
-    : undefined
-  return (
-    <>
-      {text?.length > 0 && <Text style={pal.textLight}>{text}</Text>}
-      {images && images?.length > 0 && (
-        <ImageHorzList images={images} style={styles.additionalPostImages} />
-      )}
-    </>
-  )
 }
 
 const styles = StyleSheet.create({
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index 0535cab53..88889fd18 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -23,8 +23,8 @@ import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers'
 import {useStores} from 'state/index'
 import {PostMeta} from '../util/PostMeta'
 import {PostEmbeds} from '../util/post-embeds'
-import {PostCtrls} from '../util/post-ctrls/PostCtrls2'
-import {PostDropdownBtn} from '../util/forms/PostDropdownBtn2'
+import {PostCtrls} from '../util/post-ctrls/PostCtrls'
+import {PostDropdownBtn} from '../util/forms/PostDropdownBtn'
 import {PostHider} from '../util/moderation/PostHider'
 import {ContentHider} from '../util/moderation/ContentHider'
 import {PostAlerts} from '../util/moderation/PostAlerts'
diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx
index 667584f68..4a5b8041e 100644
--- a/src/view/com/post/Post.tsx
+++ b/src/view/com/post/Post.tsx
@@ -1,19 +1,14 @@
-import React, {useState} from 'react'
+import React, {useState, useMemo} from 'react'
+import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
 import {
-  ActivityIndicator,
-  Linking,
-  StyleProp,
-  StyleSheet,
-  View,
-  ViewStyle,
-} from 'react-native'
-import {AppBskyFeedPost as FeedPost} from '@atproto/api'
-import {observer} from 'mobx-react-lite'
-import Clipboard from '@react-native-clipboard/clipboard'
-import {AtUri} from '@atproto/api'
+  AppBskyFeedDefs,
+  AppBskyFeedPost,
+  AtUri,
+  moderatePost,
+  PostModeration,
+  RichText as RichTextAPI,
+} from '@atproto/api'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {PostThreadModel} from 'state/models/content/post-thread'
-import {PostThreadItemModel} from 'state/models/content/post-thread-item'
 import {Link, TextLink} from '../util/Link'
 import {UserInfoText} from '../util/UserInfoText'
 import {PostMeta} from '../util/PostMeta'
@@ -23,174 +18,111 @@ import {ContentHider} from '../util/moderation/ContentHider'
 import {PostAlerts} from '../util/moderation/PostAlerts'
 import {Text} from '../util/text/Text'
 import {RichText} from '../util/text/RichText'
-import * as Toast from '../util/Toast'
 import {PreviewableUserAvatar} from '../util/UserAvatar'
 import {useStores} from 'state/index'
 import {s, colors} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
-import {getTranslatorLink} from '../../../locale/helpers'
 import {makeProfileLink} from 'lib/routes/links'
 import {MAX_POST_LINES} from 'lib/constants'
 import {countLines} from 'lib/strings/helpers'
-import {logger} from '#/logger'
-import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads'
-import {useLanguagePrefs} from '#/state/preferences'
+import {useModerationOpts} from '#/state/queries/preferences'
+import {usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow'
 
-export const Post = observer(function PostImpl({
-  view,
+export function Post({
+  post,
+  dataUpdatedAt,
   showReplyLine,
-  hideError,
   style,
 }: {
-  view: PostThreadModel
+  post: AppBskyFeedDefs.PostView
+  dataUpdatedAt: number
   showReplyLine?: boolean
-  hideError?: boolean
   style?: StyleProp<ViewStyle>
 }) {
-  const pal = usePalette('default')
-  const [deleted, setDeleted] = useState(false)
-
-  // deleted
-  // =
-  if (deleted) {
-    return <View />
-  }
-
-  // loading
-  // =
-  if (!view.hasContent && view.isLoading) {
-    return (
-      <View style={pal.view}>
-        <ActivityIndicator />
-      </View>
-    )
+  const moderationOpts = useModerationOpts()
+  const record = useMemo<AppBskyFeedPost.Record | undefined>(
+    () =>
+      AppBskyFeedPost.isRecord(post.record) &&
+      AppBskyFeedPost.validateRecord(post.record).success
+        ? post.record
+        : undefined,
+    [post],
+  )
+  const postShadowed = usePostShadow(post, dataUpdatedAt)
+  const richText = useMemo(
+    () =>
+      record
+        ? new RichTextAPI({
+            text: record.text,
+            facets: record.facets,
+          })
+        : undefined,
+    [record],
+  )
+  const moderation = useMemo(
+    () => (moderationOpts ? moderatePost(post, moderationOpts) : undefined),
+    [moderationOpts, post],
+  )
+  if (postShadowed === POST_TOMBSTONE) {
+    return null
   }
-
-  // error
-  // =
-  if (view.hasError || !view.thread || !view.thread?.postRecord) {
-    if (hideError) {
-      return <View />
-    }
+  if (record && richText && moderation) {
     return (
-      <View style={pal.view}>
-        <Text>{view.error || 'Thread not found'}</Text>
-      </View>
+      <PostInner
+        post={postShadowed}
+        record={record}
+        richText={richText}
+        moderation={moderation}
+        showReplyLine={showReplyLine}
+        style={style}
+      />
     )
   }
+  return null
+}
 
-  // loaded
-  // =
-
-  return (
-    <PostLoaded
-      item={view.thread}
-      record={view.thread.postRecord}
-      setDeleted={setDeleted}
-      showReplyLine={showReplyLine}
-      style={style}
-    />
-  )
-})
-
-const PostLoaded = observer(function PostLoadedImpl({
-  item,
+function PostInner({
+  post,
   record,
-  setDeleted,
+  richText,
+  moderation,
   showReplyLine,
   style,
 }: {
-  item: PostThreadItemModel
-  record: FeedPost.Record
-  setDeleted: (v: boolean) => void
+  post: AppBskyFeedDefs.PostView
+  record: AppBskyFeedPost.Record
+  richText: RichTextAPI
+  moderation: PostModeration
   showReplyLine?: boolean
   style?: StyleProp<ViewStyle>
 }) {
   const pal = usePalette('default')
   const store = useStores()
-  const mutedThreads = useMutedThreads()
-  const toggleThreadMute = useToggleThreadMute()
-  const langPrefs = useLanguagePrefs()
-  const [limitLines, setLimitLines] = React.useState(
-    countLines(item.richText?.text) >= MAX_POST_LINES,
+  const [limitLines, setLimitLines] = useState(
+    countLines(richText?.text) >= MAX_POST_LINES,
   )
-  const itemUri = item.post.uri
-  const itemCid = item.post.cid
-  const itemUrip = new AtUri(item.post.uri)
-  const itemHref = makeProfileLink(item.post.author, 'post', itemUrip.rkey)
-  const itemTitle = `Post by ${item.post.author.handle}`
+  const itemUrip = new AtUri(post.uri)
+  const itemHref = makeProfileLink(post.author, 'post', itemUrip.rkey)
   let replyAuthorDid = ''
   if (record.reply) {
     const urip = new AtUri(record.reply.parent?.uri || record.reply.root.uri)
     replyAuthorDid = urip.hostname
   }
 
-  const translatorUrl = getTranslatorLink(
-    record?.text || '',
-    langPrefs.primaryLanguage,
-  )
-
   const onPressReply = React.useCallback(() => {
     store.shell.openComposer({
       replyTo: {
-        uri: item.post.uri,
-        cid: item.post.cid,
-        text: record.text as string,
+        uri: post.uri,
+        cid: post.cid,
+        text: record.text,
         author: {
-          handle: item.post.author.handle,
-          displayName: item.post.author.displayName,
-          avatar: item.post.author.avatar,
+          handle: post.author.handle,
+          displayName: post.author.displayName,
+          avatar: post.author.avatar,
         },
       },
     })
-  }, [store, item, record])
-
-  const onPressToggleRepost = React.useCallback(() => {
-    return item
-      .toggleRepost()
-      .catch(e => logger.error('Failed to toggle repost', {error: e}))
-  }, [item])
-
-  const onPressToggleLike = React.useCallback(() => {
-    return item
-      .toggleLike()
-      .catch(e => logger.error('Failed to toggle like', {error: e}))
-  }, [item])
-
-  const onCopyPostText = React.useCallback(() => {
-    Clipboard.setString(record.text)
-    Toast.show('Copied to clipboard')
-  }, [record])
-
-  const onOpenTranslate = React.useCallback(() => {
-    Linking.openURL(translatorUrl)
-  }, [translatorUrl])
-
-  const onToggleThreadMute = React.useCallback(() => {
-    try {
-      const muted = toggleThreadMute(item.data.rootUri)
-      if (muted) {
-        Toast.show('You will no longer receive notifications for this thread')
-      } else {
-        Toast.show('You will now receive notifications for this thread')
-      }
-    } catch (e) {
-      logger.error('Failed to toggle thread mute', {error: e})
-    }
-  }, [item, toggleThreadMute])
-
-  const onDeletePost = React.useCallback(() => {
-    item.delete().then(
-      () => {
-        setDeleted(true)
-        Toast.show('Post deleted')
-      },
-      e => {
-        logger.error('Failed to delete post', {error: e})
-        Toast.show('Failed to delete post, please try again')
-      },
-    )
-  }, [item, setDeleted])
+  }, [store, post, record])
 
   const onPressShowMore = React.useCallback(() => {
     setLimitLines(false)
@@ -203,17 +135,17 @@ const PostLoaded = observer(function PostLoadedImpl({
         <View style={styles.layoutAvi}>
           <PreviewableUserAvatar
             size={52}
-            did={item.post.author.did}
-            handle={item.post.author.handle}
-            avatar={item.post.author.avatar}
-            moderation={item.moderation.avatar}
+            did={post.author.did}
+            handle={post.author.handle}
+            avatar={post.author.avatar}
+            moderation={moderation.avatar}
           />
         </View>
         <View style={styles.layoutContent}>
           <PostMeta
-            author={item.post.author}
-            authorHasWarning={!!item.post.author.labels?.length}
-            timestamp={item.post.indexedAt}
+            author={post.author}
+            authorHasWarning={!!post.author.labels?.length}
+            timestamp={post.indexedAt}
             postHref={itemHref}
           />
           {replyAuthorDid !== '' && (
@@ -239,19 +171,16 @@ const PostLoaded = observer(function PostLoadedImpl({
             </View>
           )}
           <ContentHider
-            moderation={item.moderation.content}
+            moderation={moderation.content}
             style={styles.contentHider}
             childContainerStyle={styles.contentHiderChild}>
-            <PostAlerts
-              moderation={item.moderation.content}
-              style={styles.alert}
-            />
-            {item.richText?.text ? (
+            <PostAlerts moderation={moderation.content} style={styles.alert} />
+            {richText.text ? (
               <View style={styles.postTextContainer}>
                 <RichText
                   testID="postText"
                   type="post-text"
-                  richText={item.richText}
+                  richText={richText}
                   lineHeight={1.3}
                   numberOfLines={limitLines ? MAX_POST_LINES : undefined}
                   style={s.flex1}
@@ -266,45 +195,20 @@ const PostLoaded = observer(function PostLoadedImpl({
                 href="#"
               />
             ) : undefined}
-            {item.post.embed ? (
+            {post.embed ? (
               <ContentHider
-                moderation={item.moderation.embed}
+                moderation={moderation.embed}
                 style={styles.contentHider}>
-                <PostEmbeds
-                  embed={item.post.embed}
-                  moderation={item.moderation.embed}
-                />
+                <PostEmbeds embed={post.embed} moderation={moderation.embed} />
               </ContentHider>
             ) : null}
           </ContentHider>
-          <PostCtrls
-            itemUri={itemUri}
-            itemCid={itemCid}
-            itemHref={itemHref}
-            itemTitle={itemTitle}
-            author={item.post.author}
-            indexedAt={item.post.indexedAt}
-            text={item.richText?.text || record.text}
-            isAuthor={item.post.author.did === store.me.did}
-            replyCount={item.post.replyCount}
-            repostCount={item.post.repostCount}
-            likeCount={item.post.likeCount}
-            isReposted={!!item.post.viewer?.repost}
-            isLiked={!!item.post.viewer?.like}
-            isThreadMuted={mutedThreads.includes(item.data.rootUri)}
-            onPressReply={onPressReply}
-            onPressToggleRepost={onPressToggleRepost}
-            onPressToggleLike={onPressToggleLike}
-            onCopyPostText={onCopyPostText}
-            onOpenTranslate={onOpenTranslate}
-            onToggleThreadMute={onToggleThreadMute}
-            onDeletePost={onDeletePost}
-          />
+          <PostCtrls post={post} record={record} onPressReply={onPressReply} />
         </View>
       </View>
     </Link>
   )
-})
+}
 
 const styles = StyleSheet.create({
   outer: {
diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx
index 50afc1950..6cbad7f71 100644
--- a/src/view/com/posts/Feed.tsx
+++ b/src/view/com/posts/Feed.tsx
@@ -68,7 +68,7 @@ export function Feed({
   const pal = usePalette('default')
   const theme = useTheme()
   const {track} = useAnalytics()
-  const [isRefreshing, setIsRefreshing] = React.useState(false)
+  const [isPTRing, setIsPTRing] = React.useState(false)
   const checkForNewRef = React.useRef<(() => void) | null>(null)
 
   const opts = React.useMemo(() => ({enabled}), [enabled])
@@ -137,15 +137,15 @@ export function Feed({
 
   const onRefresh = React.useCallback(async () => {
     track('Feed:onRefresh')
-    setIsRefreshing(true)
+    setIsPTRing(true)
     try {
       await refetch()
       onHasNew?.(false)
     } catch (err) {
       logger.error('Failed to refresh posts feed', {error: err})
     }
-    setIsRefreshing(false)
-  }, [refetch, track, setIsRefreshing, onHasNew])
+    setIsPTRing(false)
+  }, [refetch, track, setIsPTRing, onHasNew])
 
   const onEndReached = React.useCallback(async () => {
     if (isFetching || !hasNextPage || isError) return
@@ -233,7 +233,7 @@ export function Feed({
         ListHeaderComponent={ListHeaderComponent}
         refreshControl={
           <RefreshControl
-            refreshing={isRefreshing}
+            refreshing={isPTRing}
             onRefresh={onRefresh}
             tintColor={pal.colors.text}
             titleColor={pal.colors.text}
diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx
index c5a841e31..d24a18f0e 100644
--- a/src/view/com/posts/FeedItem.tsx
+++ b/src/view/com/posts/FeedItem.tsx
@@ -16,7 +16,7 @@ import {Link, TextLinkOnWebOnly, TextLink} from '../util/Link'
 import {Text} from '../util/text/Text'
 import {UserInfoText} from '../util/UserInfoText'
 import {PostMeta} from '../util/PostMeta'
-import {PostCtrls} from '../util/post-ctrls/PostCtrls2'
+import {PostCtrls} from '../util/post-ctrls/PostCtrls'
 import {PostEmbeds} from '../util/post-embeds'
 import {ContentHider} from '../util/moderation/ContentHider'
 import {PostAlerts} from '../util/moderation/PostAlerts'
diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx
index a10841450..c457e0a46 100644
--- a/src/view/com/util/forms/PostDropdownBtn.tsx
+++ b/src/view/com/util/forms/PostDropdownBtn.tsx
@@ -1,6 +1,8 @@
 import React from 'react'
-import {StyleProp, View, ViewStyle} from 'react-native'
+import {Linking, StyleProp, View, ViewStyle} from 'react-native'
+import Clipboard from '@react-native-clipboard/clipboard'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {AppBskyFeedDefs, AppBskyFeedPost, AtUri} from '@atproto/api'
 import {toShareUrl} from 'lib/strings/url-helpers'
 import {useTheme} from 'lib/ThemeContext'
 import {shareUrl} from 'lib/sharing'
@@ -8,41 +10,83 @@ import {
   NativeDropdown,
   DropdownItem as NativeDropdownItem,
 } from './NativeDropdown'
+import * as Toast from '../Toast'
 import {EventStopper} from '../EventStopper'
-import {useLingui} from '@lingui/react'
-import {msg} from '@lingui/macro'
 import {useModalControls} from '#/state/modals'
+import {makeProfileLink} from '#/lib/routes/links'
+import {getTranslatorLink} from '#/locale/helpers'
+import {useStores} from '#/state'
+import {usePostDeleteMutation} from '#/state/queries/post'
+import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads'
+import {useLanguagePrefs} from '#/state/preferences'
+import {logger} from '#/logger'
 
 export function PostDropdownBtn({
   testID,
-  itemUri,
-  itemCid,
-  itemHref,
-  isAuthor,
-  isThreadMuted,
-  onCopyPostText,
-  onOpenTranslate,
-  onToggleThreadMute,
-  onDeletePost,
+  post,
+  record,
   style,
 }: {
   testID: string
-  itemUri: string
-  itemCid: string
-  itemHref: string
-  itemTitle: string
-  isAuthor: boolean
-  isThreadMuted: boolean
-  onCopyPostText: () => void
-  onOpenTranslate: () => void
-  onToggleThreadMute: () => void
-  onDeletePost: () => void
+  post: AppBskyFeedDefs.PostView
+  record: AppBskyFeedPost.Record
   style?: StyleProp<ViewStyle>
 }) {
+  const store = useStores()
   const theme = useTheme()
-  const {_} = useLingui()
   const defaultCtrlColor = theme.palette.default.postCtrl
   const {openModal} = useModalControls()
+  const langPrefs = useLanguagePrefs()
+  const mutedThreads = useMutedThreads()
+  const toggleThreadMute = useToggleThreadMute()
+  const postDeleteMutation = usePostDeleteMutation()
+
+  const rootUri = record.reply?.root?.uri || post.uri
+  const isThreadMuted = mutedThreads.includes(rootUri)
+  const isAuthor = post.author.did === store.me.did
+  const href = React.useMemo(() => {
+    const urip = new AtUri(post.uri)
+    return makeProfileLink(post.author, 'post', urip.rkey)
+  }, [post.uri, post.author])
+
+  const translatorUrl = getTranslatorLink(
+    record.text,
+    langPrefs.primaryLanguage,
+  )
+
+  const onDeletePost = React.useCallback(() => {
+    postDeleteMutation.mutateAsync({uri: post.uri}).then(
+      () => {
+        Toast.show('Post deleted')
+      },
+      e => {
+        logger.error('Failed to delete post', {error: e})
+        Toast.show('Failed to delete post, please try again')
+      },
+    )
+  }, [post, postDeleteMutation])
+
+  const onToggleThreadMute = React.useCallback(() => {
+    try {
+      const muted = toggleThreadMute(rootUri)
+      if (muted) {
+        Toast.show('You will no longer receive notifications for this thread')
+      } else {
+        Toast.show('You will now receive notifications for this thread')
+      }
+    } catch (e) {
+      logger.error('Failed to toggle thread mute', {error: e})
+    }
+  }, [rootUri, toggleThreadMute])
+
+  const onCopyPostText = React.useCallback(() => {
+    Clipboard.setString(record?.text || '')
+    Toast.show('Copied to clipboard')
+  }, [record])
+
+  const onOpenTranslate = React.useCallback(() => {
+    Linking.openURL(translatorUrl)
+  }, [translatorUrl])
 
   const dropdownItems: NativeDropdownItem[] = [
     {
@@ -76,7 +120,7 @@ export function PostDropdownBtn({
     {
       label: 'Share',
       onPress() {
-        const url = toShareUrl(itemHref)
+        const url = toShareUrl(href)
         shareUrl(url)
       },
       testID: 'postDropdownShareBtn',
@@ -113,8 +157,8 @@ export function PostDropdownBtn({
       onPress() {
         openModal({
           name: 'report',
-          uri: itemUri,
-          cid: itemCid,
+          uri: post.uri,
+          cid: post.cid,
         })
       },
       testID: 'postDropdownReportBtn',
@@ -155,7 +199,7 @@ export function PostDropdownBtn({
       <NativeDropdown
         testID={testID}
         items={dropdownItems}
-        accessibilityLabel={_(msg`More post options`)}
+        accessibilityLabel="More post options"
         accessibilityHint="">
         <View style={style}>
           <FontAwesomeIcon icon="ellipsis" size={20} color={defaultCtrlColor} />
diff --git a/src/view/com/util/forms/PostDropdownBtn2.tsx b/src/view/com/util/forms/PostDropdownBtn2.tsx
deleted file mode 100644
index c457e0a46..000000000
--- a/src/view/com/util/forms/PostDropdownBtn2.tsx
+++ /dev/null
@@ -1,210 +0,0 @@
-import React from 'react'
-import {Linking, StyleProp, View, ViewStyle} from 'react-native'
-import Clipboard from '@react-native-clipboard/clipboard'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {AppBskyFeedDefs, AppBskyFeedPost, AtUri} from '@atproto/api'
-import {toShareUrl} from 'lib/strings/url-helpers'
-import {useTheme} from 'lib/ThemeContext'
-import {shareUrl} from 'lib/sharing'
-import {
-  NativeDropdown,
-  DropdownItem as NativeDropdownItem,
-} from './NativeDropdown'
-import * as Toast from '../Toast'
-import {EventStopper} from '../EventStopper'
-import {useModalControls} from '#/state/modals'
-import {makeProfileLink} from '#/lib/routes/links'
-import {getTranslatorLink} from '#/locale/helpers'
-import {useStores} from '#/state'
-import {usePostDeleteMutation} from '#/state/queries/post'
-import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads'
-import {useLanguagePrefs} from '#/state/preferences'
-import {logger} from '#/logger'
-
-export function PostDropdownBtn({
-  testID,
-  post,
-  record,
-  style,
-}: {
-  testID: string
-  post: AppBskyFeedDefs.PostView
-  record: AppBskyFeedPost.Record
-  style?: StyleProp<ViewStyle>
-}) {
-  const store = useStores()
-  const theme = useTheme()
-  const defaultCtrlColor = theme.palette.default.postCtrl
-  const {openModal} = useModalControls()
-  const langPrefs = useLanguagePrefs()
-  const mutedThreads = useMutedThreads()
-  const toggleThreadMute = useToggleThreadMute()
-  const postDeleteMutation = usePostDeleteMutation()
-
-  const rootUri = record.reply?.root?.uri || post.uri
-  const isThreadMuted = mutedThreads.includes(rootUri)
-  const isAuthor = post.author.did === store.me.did
-  const href = React.useMemo(() => {
-    const urip = new AtUri(post.uri)
-    return makeProfileLink(post.author, 'post', urip.rkey)
-  }, [post.uri, post.author])
-
-  const translatorUrl = getTranslatorLink(
-    record.text,
-    langPrefs.primaryLanguage,
-  )
-
-  const onDeletePost = React.useCallback(() => {
-    postDeleteMutation.mutateAsync({uri: post.uri}).then(
-      () => {
-        Toast.show('Post deleted')
-      },
-      e => {
-        logger.error('Failed to delete post', {error: e})
-        Toast.show('Failed to delete post, please try again')
-      },
-    )
-  }, [post, postDeleteMutation])
-
-  const onToggleThreadMute = React.useCallback(() => {
-    try {
-      const muted = toggleThreadMute(rootUri)
-      if (muted) {
-        Toast.show('You will no longer receive notifications for this thread')
-      } else {
-        Toast.show('You will now receive notifications for this thread')
-      }
-    } catch (e) {
-      logger.error('Failed to toggle thread mute', {error: e})
-    }
-  }, [rootUri, toggleThreadMute])
-
-  const onCopyPostText = React.useCallback(() => {
-    Clipboard.setString(record?.text || '')
-    Toast.show('Copied to clipboard')
-  }, [record])
-
-  const onOpenTranslate = React.useCallback(() => {
-    Linking.openURL(translatorUrl)
-  }, [translatorUrl])
-
-  const dropdownItems: NativeDropdownItem[] = [
-    {
-      label: 'Translate',
-      onPress() {
-        onOpenTranslate()
-      },
-      testID: 'postDropdownTranslateBtn',
-      icon: {
-        ios: {
-          name: 'character.book.closed',
-        },
-        android: 'ic_menu_sort_alphabetically',
-        web: 'language',
-      },
-    },
-    {
-      label: 'Copy post text',
-      onPress() {
-        onCopyPostText()
-      },
-      testID: 'postDropdownCopyTextBtn',
-      icon: {
-        ios: {
-          name: 'doc.on.doc',
-        },
-        android: 'ic_menu_edit',
-        web: ['far', 'paste'],
-      },
-    },
-    {
-      label: 'Share',
-      onPress() {
-        const url = toShareUrl(href)
-        shareUrl(url)
-      },
-      testID: 'postDropdownShareBtn',
-      icon: {
-        ios: {
-          name: 'square.and.arrow.up',
-        },
-        android: 'ic_menu_share',
-        web: 'share',
-      },
-    },
-    {
-      label: 'separator',
-    },
-    {
-      label: isThreadMuted ? 'Unmute thread' : 'Mute thread',
-      onPress() {
-        onToggleThreadMute()
-      },
-      testID: 'postDropdownMuteThreadBtn',
-      icon: {
-        ios: {
-          name: 'speaker.slash',
-        },
-        android: 'ic_lock_silent_mode',
-        web: 'comment-slash',
-      },
-    },
-    {
-      label: 'separator',
-    },
-    !isAuthor && {
-      label: 'Report post',
-      onPress() {
-        openModal({
-          name: 'report',
-          uri: post.uri,
-          cid: post.cid,
-        })
-      },
-      testID: 'postDropdownReportBtn',
-      icon: {
-        ios: {
-          name: 'exclamationmark.triangle',
-        },
-        android: 'ic_menu_report_image',
-        web: 'circle-exclamation',
-      },
-    },
-    isAuthor && {
-      label: 'separator',
-    },
-    isAuthor && {
-      label: 'Delete post',
-      onPress() {
-        openModal({
-          name: 'confirm',
-          title: 'Delete this post?',
-          message: 'Are you sure? This can not be undone.',
-          onPressConfirm: onDeletePost,
-        })
-      },
-      testID: 'postDropdownDeleteBtn',
-      icon: {
-        ios: {
-          name: 'trash',
-        },
-        android: 'ic_menu_delete',
-        web: ['far', 'trash-can'],
-      },
-    },
-  ].filter(Boolean) as NativeDropdownItem[]
-
-  return (
-    <EventStopper>
-      <NativeDropdown
-        testID={testID}
-        items={dropdownItems}
-        accessibilityLabel="More post options"
-        accessibilityHint="">
-        <View style={style}>
-          <FontAwesomeIcon icon="ellipsis" size={20} color={defaultCtrlColor} />
-        </View>
-      </NativeDropdown>
-    </EventStopper>
-  )
-}
diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx
index 7bcea0e79..a764ed525 100644
--- a/src/view/com/util/post-ctrls/PostCtrls.tsx
+++ b/src/view/com/util/post-ctrls/PostCtrls.tsx
@@ -6,6 +6,7 @@ import {
   View,
   ViewStyle,
 } from 'react-native'
+import {AppBskyFeedDefs, AppBskyFeedPost} from '@atproto/api'
 import {Text} from '../text/Text'
 import {PostDropdownBtn} from '../forms/PostDropdownBtn'
 import {HeartIcon, HeartIconSolid, CommentBottomArrow} from 'lib/icons'
@@ -17,160 +18,155 @@ import {RepostButton} from './RepostButton'
 import {Haptics} from 'lib/haptics'
 import {HITSLOP_10, HITSLOP_20} from 'lib/constants'
 import {useModalControls} from '#/state/modals'
+import {
+  usePostLikeMutation,
+  usePostUnlikeMutation,
+  usePostRepostMutation,
+  usePostUnrepostMutation,
+} from '#/state/queries/post'
 
-interface PostCtrlsOpts {
-  itemUri: string
-  itemCid: string
-  itemHref: string
-  itemTitle: string
-  isAuthor: boolean
-  author: {
-    did: string
-    handle: string
-    displayName?: string | undefined
-    avatar?: string | undefined
-  }
-  text: string
-  indexedAt: string
+export function PostCtrls({
+  big,
+  post,
+  record,
+  style,
+  onPressReply,
+}: {
   big?: boolean
+  post: AppBskyFeedDefs.PostView
+  record: AppBskyFeedPost.Record
   style?: StyleProp<ViewStyle>
-  replyCount?: number
-  repostCount?: number
-  likeCount?: number
-  isReposted: boolean
-  isLiked: boolean
-  isThreadMuted: boolean
   onPressReply: () => void
-  onPressToggleRepost: () => Promise<void>
-  onPressToggleLike: () => Promise<void>
-  onCopyPostText: () => void
-  onOpenTranslate: () => void
-  onToggleThreadMute: () => void
-  onDeletePost: () => void
-}
-
-export function PostCtrls(opts: PostCtrlsOpts) {
+}) {
   const store = useStores()
   const theme = useTheme()
   const {closeModal} = useModalControls()
+  const postLikeMutation = usePostLikeMutation()
+  const postUnlikeMutation = usePostUnlikeMutation()
+  const postRepostMutation = usePostRepostMutation()
+  const postUnrepostMutation = usePostUnrepostMutation()
+
   const defaultCtrlColor = React.useMemo(
     () => ({
       color: theme.palette.default.postCtrl,
     }),
     [theme],
   ) as StyleProp<ViewStyle>
+
+  const onPressToggleLike = React.useCallback(async () => {
+    if (!post.viewer?.like) {
+      Haptics.default()
+      postLikeMutation.mutate({
+        uri: post.uri,
+        cid: post.cid,
+        likeCount: post.likeCount || 0,
+      })
+    } else {
+      postUnlikeMutation.mutate({
+        postUri: post.uri,
+        likeUri: post.viewer.like,
+        likeCount: post.likeCount || 0,
+      })
+    }
+  }, [post, postLikeMutation, postUnlikeMutation])
+
   const onRepost = useCallback(() => {
     closeModal()
-    if (!opts.isReposted) {
+    if (!post.viewer?.repost) {
       Haptics.default()
-      opts.onPressToggleRepost().catch(_e => undefined)
+      postRepostMutation.mutate({
+        uri: post.uri,
+        cid: post.cid,
+        repostCount: post.repostCount || 0,
+      })
     } else {
-      opts.onPressToggleRepost().catch(_e => undefined)
+      postUnrepostMutation.mutate({
+        postUri: post.uri,
+        repostUri: post.viewer.repost,
+        repostCount: post.repostCount || 0,
+      })
     }
-  }, [opts, closeModal])
+  }, [post, closeModal, postRepostMutation, postUnrepostMutation])
 
   const onQuote = useCallback(() => {
     closeModal()
     store.shell.openComposer({
       quote: {
-        uri: opts.itemUri,
-        cid: opts.itemCid,
-        text: opts.text,
-        author: opts.author,
-        indexedAt: opts.indexedAt,
+        uri: post.uri,
+        cid: post.cid,
+        text: record.text,
+        author: post.author,
+        indexedAt: post.indexedAt,
       },
     })
     Haptics.default()
-  }, [
-    opts.author,
-    opts.indexedAt,
-    opts.itemCid,
-    opts.itemUri,
-    opts.text,
-    store.shell,
-    closeModal,
-  ])
-
-  const onPressToggleLikeWrapper = async () => {
-    if (!opts.isLiked) {
-      Haptics.default()
-      await opts.onPressToggleLike().catch(_e => undefined)
-    } else {
-      await opts.onPressToggleLike().catch(_e => undefined)
-    }
-  }
-
+  }, [post, record, store.shell, closeModal])
   return (
-    <View style={[styles.ctrls, opts.style]}>
+    <View style={[styles.ctrls, style]}>
       <TouchableOpacity
         testID="replyBtn"
-        style={[styles.ctrl, !opts.big && styles.ctrlPad, {paddingLeft: 0}]}
-        onPress={opts.onPressReply}
+        style={[styles.ctrl, !big && styles.ctrlPad, {paddingLeft: 0}]}
+        onPress={onPressReply}
         accessibilityRole="button"
-        accessibilityLabel={`Reply (${opts.replyCount} ${
-          opts.replyCount === 1 ? 'reply' : 'replies'
+        accessibilityLabel={`Reply (${post.replyCount} ${
+          post.replyCount === 1 ? 'reply' : 'replies'
         })`}
         accessibilityHint=""
-        hitSlop={opts.big ? HITSLOP_20 : HITSLOP_10}>
+        hitSlop={big ? HITSLOP_20 : HITSLOP_10}>
         <CommentBottomArrow
-          style={[defaultCtrlColor, opts.big ? s.mt2 : styles.mt1]}
+          style={[defaultCtrlColor, big ? s.mt2 : styles.mt1]}
           strokeWidth={3}
-          size={opts.big ? 20 : 15}
+          size={big ? 20 : 15}
         />
-        {typeof opts.replyCount !== 'undefined' ? (
+        {typeof post.replyCount !== 'undefined' ? (
           <Text style={[defaultCtrlColor, s.ml5, s.f15]}>
-            {opts.replyCount}
+            {post.replyCount}
           </Text>
         ) : undefined}
       </TouchableOpacity>
-      <RepostButton {...opts} onRepost={onRepost} onQuote={onQuote} />
+      <RepostButton
+        big={big}
+        isReposted={!!post.viewer?.repost}
+        repostCount={post.repostCount}
+        onRepost={onRepost}
+        onQuote={onQuote}
+      />
       <TouchableOpacity
         testID="likeBtn"
-        style={[styles.ctrl, !opts.big && styles.ctrlPad]}
-        onPress={onPressToggleLikeWrapper}
+        style={[styles.ctrl, !big && styles.ctrlPad]}
+        onPress={onPressToggleLike}
         accessibilityRole="button"
-        accessibilityLabel={`${opts.isLiked ? 'Unlike' : 'Like'} (${
-          opts.likeCount
-        } ${pluralize(opts.likeCount || 0, 'like')})`}
+        accessibilityLabel={`${post.viewer?.like ? 'Unlike' : 'Like'} (${
+          post.likeCount
+        } ${pluralize(post.likeCount || 0, 'like')})`}
         accessibilityHint=""
-        hitSlop={opts.big ? HITSLOP_20 : HITSLOP_10}>
-        {opts.isLiked ? (
-          <HeartIconSolid
-            style={styles.ctrlIconLiked}
-            size={opts.big ? 22 : 16}
-          />
+        hitSlop={big ? HITSLOP_20 : HITSLOP_10}>
+        {post.viewer?.like ? (
+          <HeartIconSolid style={styles.ctrlIconLiked} size={big ? 22 : 16} />
         ) : (
           <HeartIcon
-            style={[defaultCtrlColor, opts.big ? styles.mt1 : undefined]}
+            style={[defaultCtrlColor, big ? styles.mt1 : undefined]}
             strokeWidth={3}
-            size={opts.big ? 20 : 16}
+            size={big ? 20 : 16}
           />
         )}
-        {typeof opts.likeCount !== 'undefined' ? (
+        {typeof post.likeCount !== 'undefined' ? (
           <Text
             testID="likeCount"
             style={
-              opts.isLiked
+              post.viewer?.like
                 ? [s.bold, s.red3, s.f15, s.ml5]
                 : [defaultCtrlColor, s.f15, s.ml5]
             }>
-            {opts.likeCount}
+            {post.likeCount}
           </Text>
         ) : undefined}
       </TouchableOpacity>
-      {opts.big ? undefined : (
+      {big ? undefined : (
         <PostDropdownBtn
           testID="postDropdownBtn"
-          itemUri={opts.itemUri}
-          itemCid={opts.itemCid}
-          itemHref={opts.itemHref}
-          itemTitle={opts.itemTitle}
-          isAuthor={opts.isAuthor}
-          isThreadMuted={opts.isThreadMuted}
-          onCopyPostText={opts.onCopyPostText}
-          onOpenTranslate={opts.onOpenTranslate}
-          onToggleThreadMute={opts.onToggleThreadMute}
-          onDeletePost={opts.onDeletePost}
+          post={post}
+          record={record}
           style={styles.ctrlPad}
         />
       )}
diff --git a/src/view/com/util/post-ctrls/PostCtrls2.tsx b/src/view/com/util/post-ctrls/PostCtrls2.tsx
deleted file mode 100644
index 7c8ebaee7..000000000
--- a/src/view/com/util/post-ctrls/PostCtrls2.tsx
+++ /dev/null
@@ -1,200 +0,0 @@
-import React, {useCallback} from 'react'
-import {
-  StyleProp,
-  StyleSheet,
-  TouchableOpacity,
-  View,
-  ViewStyle,
-} from 'react-native'
-import {AppBskyFeedDefs, AppBskyFeedPost} from '@atproto/api'
-import {Text} from '../text/Text'
-import {PostDropdownBtn} from '../forms/PostDropdownBtn2'
-import {HeartIcon, HeartIconSolid, CommentBottomArrow} from 'lib/icons'
-import {s, colors} from 'lib/styles'
-import {pluralize} from 'lib/strings/helpers'
-import {useTheme} from 'lib/ThemeContext'
-import {useStores} from 'state/index'
-import {RepostButton} from './RepostButton'
-import {Haptics} from 'lib/haptics'
-import {HITSLOP_10, HITSLOP_20} from 'lib/constants'
-import {useModalControls} from '#/state/modals'
-import {
-  usePostLikeMutation,
-  usePostUnlikeMutation,
-  usePostRepostMutation,
-  usePostUnrepostMutation,
-} from '#/state/queries/post'
-
-export function PostCtrls({
-  big,
-  post,
-  record,
-  style,
-  onPressReply,
-}: {
-  big?: boolean
-  post: AppBskyFeedDefs.PostView
-  record: AppBskyFeedPost.Record
-  style?: StyleProp<ViewStyle>
-  onPressReply: () => void
-}) {
-  const store = useStores()
-  const theme = useTheme()
-  const {closeModal} = useModalControls()
-  const postLikeMutation = usePostLikeMutation()
-  const postUnlikeMutation = usePostUnlikeMutation()
-  const postRepostMutation = usePostRepostMutation()
-  const postUnrepostMutation = usePostUnrepostMutation()
-
-  const defaultCtrlColor = React.useMemo(
-    () => ({
-      color: theme.palette.default.postCtrl,
-    }),
-    [theme],
-  ) as StyleProp<ViewStyle>
-
-  const onPressToggleLike = React.useCallback(async () => {
-    if (!post.viewer?.like) {
-      Haptics.default()
-      postLikeMutation.mutate({
-        uri: post.uri,
-        cid: post.cid,
-        likeCount: post.likeCount || 0,
-      })
-    } else {
-      postUnlikeMutation.mutate({
-        postUri: post.uri,
-        likeUri: post.viewer.like,
-        likeCount: post.likeCount || 0,
-      })
-    }
-  }, [post, postLikeMutation, postUnlikeMutation])
-
-  const onRepost = useCallback(() => {
-    closeModal()
-    if (!post.viewer?.repost) {
-      Haptics.default()
-      postRepostMutation.mutate({
-        uri: post.uri,
-        cid: post.cid,
-        repostCount: post.repostCount || 0,
-      })
-    } else {
-      postUnrepostMutation.mutate({
-        postUri: post.uri,
-        repostUri: post.viewer.repost,
-        repostCount: post.repostCount || 0,
-      })
-    }
-  }, [post, closeModal, postRepostMutation, postUnrepostMutation])
-
-  const onQuote = useCallback(() => {
-    closeModal()
-    store.shell.openComposer({
-      quote: {
-        uri: post.uri,
-        cid: post.cid,
-        text: record.text,
-        author: post.author,
-        indexedAt: post.indexedAt,
-      },
-    })
-    Haptics.default()
-  }, [post, record, store.shell, closeModal])
-  return (
-    <View style={[styles.ctrls, style]}>
-      <TouchableOpacity
-        testID="replyBtn"
-        style={[styles.ctrl, !big && styles.ctrlPad, {paddingLeft: 0}]}
-        onPress={onPressReply}
-        accessibilityRole="button"
-        accessibilityLabel={`Reply (${post.replyCount} ${
-          post.replyCount === 1 ? 'reply' : 'replies'
-        })`}
-        accessibilityHint=""
-        hitSlop={big ? HITSLOP_20 : HITSLOP_10}>
-        <CommentBottomArrow
-          style={[defaultCtrlColor, big ? s.mt2 : styles.mt1]}
-          strokeWidth={3}
-          size={big ? 20 : 15}
-        />
-        {typeof post.replyCount !== 'undefined' ? (
-          <Text style={[defaultCtrlColor, s.ml5, s.f15]}>
-            {post.replyCount}
-          </Text>
-        ) : undefined}
-      </TouchableOpacity>
-      <RepostButton
-        big={big}
-        isReposted={!!post.viewer?.repost}
-        repostCount={post.repostCount}
-        onRepost={onRepost}
-        onQuote={onQuote}
-      />
-      <TouchableOpacity
-        testID="likeBtn"
-        style={[styles.ctrl, !big && styles.ctrlPad]}
-        onPress={onPressToggleLike}
-        accessibilityRole="button"
-        accessibilityLabel={`${post.viewer?.like ? 'Unlike' : 'Like'} (${
-          post.likeCount
-        } ${pluralize(post.likeCount || 0, 'like')})`}
-        accessibilityHint=""
-        hitSlop={big ? HITSLOP_20 : HITSLOP_10}>
-        {post.viewer?.like ? (
-          <HeartIconSolid style={styles.ctrlIconLiked} size={big ? 22 : 16} />
-        ) : (
-          <HeartIcon
-            style={[defaultCtrlColor, big ? styles.mt1 : undefined]}
-            strokeWidth={3}
-            size={big ? 20 : 16}
-          />
-        )}
-        {typeof post.likeCount !== 'undefined' ? (
-          <Text
-            testID="likeCount"
-            style={
-              post.viewer?.like
-                ? [s.bold, s.red3, s.f15, s.ml5]
-                : [defaultCtrlColor, s.f15, s.ml5]
-            }>
-            {post.likeCount}
-          </Text>
-        ) : undefined}
-      </TouchableOpacity>
-      {big ? undefined : (
-        <PostDropdownBtn
-          testID="postDropdownBtn"
-          post={post}
-          record={record}
-          style={styles.ctrlPad}
-        />
-      )}
-      {/* used for adding pad to the right side */}
-      <View />
-    </View>
-  )
-}
-
-const styles = StyleSheet.create({
-  ctrls: {
-    flexDirection: 'row',
-    justifyContent: 'space-between',
-  },
-  ctrl: {
-    flexDirection: 'row',
-    alignItems: 'center',
-  },
-  ctrlPad: {
-    paddingTop: 5,
-    paddingBottom: 5,
-    paddingLeft: 5,
-    paddingRight: 5,
-  },
-  ctrlIconLiked: {
-    color: colors.like,
-  },
-  mt1: {
-    marginTop: 1,
-  },
-})
diff --git a/src/view/screens/Notifications.tsx b/src/view/screens/Notifications.tsx
index b03e73376..e0f234073 100644
--- a/src/view/screens/Notifications.tsx
+++ b/src/view/screens/Notifications.tsx
@@ -1,7 +1,7 @@
 import React from 'react'
 import {FlatList, View} from 'react-native'
 import {useFocusEffect} from '@react-navigation/native'
-import {observer} from 'mobx-react-lite'
+import {useQueryClient} from '@tanstack/react-query'
 import {
   NativeStackScreenProps,
   NotificationsTabNavigatorParams,
@@ -13,21 +13,21 @@ import {TextLink} from 'view/com/util/Link'
 import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
 import {useStores} from 'state/index'
 import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
-import {useTabFocusEffect} from 'lib/hooks/useTabFocusEffect'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {s, colors} from 'lib/styles'
 import {useAnalytics} from 'lib/analytics/analytics'
-import {isWeb} from 'platform/detection'
 import {logger} from '#/logger'
 import {useSetMinimalShellMode} from '#/state/shell'
+import {useUnreadNotifications} from '#/state/queries/notifications/unread'
+import {RQKEY as NOTIFS_RQKEY} from '#/state/queries/notifications/feed'
 
 type Props = NativeStackScreenProps<
   NotificationsTabNavigatorParams,
   'Notifications'
 >
 export const NotificationsScreen = withAuthRequired(
-  observer(function NotificationsScreenImpl({}: Props) {
+  function NotificationsScreenImpl({}: Props) {
     const store = useStores()
     const setMinimalShellMode = useSetMinimalShellMode()
     const [onMainScroll, isScrolledDown, resetMainScroll] = useOnMainScroll()
@@ -35,17 +35,12 @@ export const NotificationsScreen = withAuthRequired(
     const {screen} = useAnalytics()
     const pal = usePalette('default')
     const {isDesktop} = useWebMediaQueries()
-
-    const hasNew =
-      store.me.notifications.hasNewLatest &&
-      !store.me.notifications.isRefreshing
+    const unreadNotifs = useUnreadNotifications()
+    const queryClient = useQueryClient()
+    const hasNew = !!unreadNotifs
 
     // event handlers
     // =
-    const onPressTryAgain = React.useCallback(() => {
-      store.me.notifications.refresh()
-    }, [store])
-
     const scrollToTop = React.useCallback(() => {
       scrollElRef.current?.scrollToOffset({offset: 0})
       resetMainScroll()
@@ -53,8 +48,8 @@ export const NotificationsScreen = withAuthRequired(
 
     const onPressLoadLatest = React.useCallback(() => {
       scrollToTop()
-      store.me.notifications.refresh()
-    }, [store, scrollToTop])
+      queryClient.invalidateQueries({queryKey: NOTIFS_RQKEY()})
+    }, [scrollToTop, queryClient])
 
     // on-visible setup
     // =
@@ -63,42 +58,14 @@ export const NotificationsScreen = withAuthRequired(
         setMinimalShellMode(false)
         logger.debug('NotificationsScreen: Updating feed')
         const softResetSub = store.onScreenSoftReset(onPressLoadLatest)
-        store.me.notifications.update()
         screen('Notifications')
 
         return () => {
           softResetSub.remove()
-          store.me.notifications.markAllRead()
         }
       }, [store, screen, onPressLoadLatest, setMinimalShellMode]),
     )
 
-    useTabFocusEffect(
-      'Notifications',
-      React.useCallback(
-        isInside => {
-          // on mobile:
-          // fires with `isInside=true` when the user navigates to the root tab
-          // but not when the user goes back to the screen by pressing back
-          // on web:
-          // essentially equivalent to useFocusEffect because we dont used tabbed
-          // navigation
-          if (isInside) {
-            if (isWeb) {
-              store.me.notifications.syncQueue()
-            } else {
-              if (store.me.notifications.unreadCount > 0) {
-                store.me.notifications.refresh()
-              } else {
-                store.me.notifications.syncQueue()
-              }
-            }
-          }
-        },
-        [store],
-      ),
-    )
-
     const ListHeaderComponent = React.useCallback(() => {
       if (isDesktop) {
         return (
@@ -145,8 +112,6 @@ export const NotificationsScreen = withAuthRequired(
       <View testID="notificationsScreen" style={s.hContentRegion}>
         <ViewHeader title="Notifications" canGoBack={false} />
         <Feed
-          view={store.me.notifications}
-          onPressTryAgain={onPressTryAgain}
           onScroll={onMainScroll}
           scrollElRef={scrollElRef}
           ListHeaderComponent={ListHeaderComponent}
@@ -160,5 +125,5 @@ export const NotificationsScreen = withAuthRequired(
         )}
       </View>
     )
-  }),
+  },
 )
diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx
index 609348e4d..8a84a07c6 100644
--- a/src/view/shell/Drawer.tsx
+++ b/src/view/shell/Drawer.tsx
@@ -49,6 +49,7 @@ import {useSetDrawerOpen} from '#/state/shell'
 import {useModalControls} from '#/state/modals'
 import {useSession, SessionAccount} from '#/state/session'
 import {useProfileQuery} from '#/state/queries/profile'
+import {useUnreadNotifications} from '#/state/queries/notifications/unread'
 
 export function DrawerProfileCard({
   account,
@@ -110,8 +111,7 @@ export const DrawerContent = observer(function DrawerContentImpl() {
   const {isAtHome, isAtSearch, isAtFeeds, isAtNotifications, isAtMyProfile} =
     useNavigationTabState()
   const {currentAccount} = useSession()
-
-  const {notifications} = store.me
+  const numUnreadNotifications = useUnreadNotifications()
 
   // events
   // =
@@ -286,11 +286,11 @@ export const DrawerContent = observer(function DrawerContentImpl() {
             label="Notifications"
             accessibilityLabel={_(msg`Notifications`)}
             accessibilityHint={
-              notifications.unreadCountLabel === ''
+              numUnreadNotifications === ''
                 ? ''
-                : `${notifications.unreadCountLabel} unread`
+                : `${numUnreadNotifications} unread`
             }
-            count={notifications.unreadCountLabel}
+            count={numUnreadNotifications}
             bold={isAtNotifications}
             onPress={onPressNotifications}
           />
diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx
index 3dd7f57c5..81552635f 100644
--- a/src/view/shell/bottom-bar/BottomBar.tsx
+++ b/src/view/shell/bottom-bar/BottomBar.tsx
@@ -28,6 +28,7 @@ import {useLingui} from '@lingui/react'
 import {msg} from '@lingui/macro'
 import {useModalControls} from '#/state/modals'
 import {useShellLayout} from '#/state/shell/shell-layout'
+import {useUnreadNotifications} from '#/state/queries/notifications/unread'
 
 type TabOptions = 'Home' | 'Search' | 'Notifications' | 'MyProfile' | 'Feeds'
 
@@ -43,9 +44,8 @@ export const BottomBar = observer(function BottomBarImpl({
   const {footerHeight} = useShellLayout()
   const {isAtHome, isAtSearch, isAtFeeds, isAtNotifications, isAtMyProfile} =
     useNavigationTabState()
-
+  const numUnreadNotifications = useUnreadNotifications()
   const {footerMinimalShellTransform} = useMinimalShellMode()
-  const {notifications} = store.me
 
   const onPressTab = React.useCallback(
     (tab: TabOptions) => {
@@ -178,14 +178,14 @@ export const BottomBar = observer(function BottomBarImpl({
           )
         }
         onPress={onPressNotifications}
-        notificationCount={notifications.unreadCountLabel}
+        notificationCount={numUnreadNotifications}
         accessible={true}
         accessibilityRole="tab"
         accessibilityLabel={_(msg`Notifications`)}
         accessibilityHint={
-          notifications.unreadCountLabel === ''
+          numUnreadNotifications === ''
             ? ''
-            : `${notifications.unreadCountLabel} unread`
+            : `${numUnreadNotifications} unread`
         }
       />
       <Btn
diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx
index 0586323b4..c9a03ce62 100644
--- a/src/view/shell/desktop/LeftNav.tsx
+++ b/src/view/shell/desktop/LeftNav.tsx
@@ -43,6 +43,7 @@ import {useLingui} from '@lingui/react'
 import {Trans, msg} from '@lingui/macro'
 import {useProfileQuery} from '#/state/queries/profile'
 import {useSession} from '#/state/session'
+import {useUnreadNotifications} from '#/state/queries/notifications/unread'
 
 const ProfileCard = observer(function ProfileCardImpl() {
   const {currentAccount} = useSession()
@@ -253,6 +254,7 @@ export const DesktopLeftNav = observer(function DesktopLeftNav() {
   const store = useStores()
   const pal = usePalette('default')
   const {isDesktop, isTablet} = useWebMediaQueries()
+  const numUnread = useUnreadNotifications()
 
   return (
     <View
@@ -314,7 +316,7 @@ export const DesktopLeftNav = observer(function DesktopLeftNav() {
       />
       <NavItem
         href="/notifications"
-        count={store.me.notifications.unreadCountLabel}
+        count={numUnread}
         icon={
           <BellIcon
             strokeWidth={2}