about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/state/queries/notifications/feed.ts217
-rw-r--r--src/state/queries/notifications/types.ts34
-rw-r--r--src/state/queries/notifications/unread.tsx129
-rw-r--r--src/state/queries/notifications/util.ts182
-rw-r--r--src/view/com/notifications/Feed.tsx31
-rw-r--r--src/view/screens/Notifications.tsx22
6 files changed, 369 insertions, 246 deletions
diff --git a/src/state/queries/notifications/feed.ts b/src/state/queries/notifications/feed.ts
index 68396143c..5c519d045 100644
--- a/src/state/queries/notifications/feed.ts
+++ b/src/state/queries/notifications/feed.ts
@@ -1,11 +1,22 @@
-import {
-  AppBskyFeedDefs,
-  AppBskyFeedPost,
-  AppBskyFeedRepost,
-  AppBskyFeedLike,
-  AppBskyNotificationListNotifications,
-} from '@atproto/api'
-import chunk from 'lodash.chunk'
+/**
+ * NOTE
+ * The ./unread.ts API:
+ *
+ * - Provides a `checkUnread()` function to sync with the server,
+ * - Periodically calls `checkUnread()`, and
+ * - Caches the first page of notifications.
+ *
+ * IMPORTANT: This query uses ./unread.ts's cache as its first page,
+ * IMPORTANT: which means the cache-freshness of this query is driven by the unread API.
+ *
+ * Follow these rules:
+ *
+ * 1. Call `checkUnread()` if you want to fetch latest in the background.
+ * 2. Call `checkUnread({invalidate: true})` if you want latest to sync into this query's results immediately.
+ * 3. Don't call this query's `refetch()` if you're trying to sync latest; call `checkUnread()` instead.
+ */
+
+import {AppBskyFeedDefs} from '@atproto/api'
 import {
   useInfiniteQuery,
   InfiniteData,
@@ -13,50 +24,27 @@ import {
   useQueryClient,
   QueryClient,
 } from '@tanstack/react-query'
-import {getAgent} from '../../session'
 import {useModerationOpts} from '../preferences'
-import {shouldFilterNotif} from './util'
+import {useUnreadNotificationsApi} from './unread'
+import {fetchPage} from './util'
+import {FeedPage} from './types'
 import {useMutedThreads} from '#/state/muted-threads'
-import {precacheProfile as precacheResolvedUri} from '../resolve-uri'
 
-const GROUPABLE_REASONS = ['like', 'repost', 'follow']
+export type {NotificationType, FeedNotification, FeedPage} from './types'
+
 const PAGE_SIZE = 30
-const MS_1HR = 1e3 * 60 * 60
-const MS_2DAY = MS_1HR * 48
 
 type RQPageParam = string | undefined
-type NotificationType =
-  | 'post-like'
-  | 'feedgen-like'
-  | 'repost'
-  | 'mention'
-  | 'reply'
-  | 'quote'
-  | 'follow'
-  | 'unknown'
 
 export function RQKEY() {
   return ['notification-feed']
 }
 
-export interface FeedNotification {
-  _reactKey: string
-  type: NotificationType
-  notification: AppBskyNotificationListNotifications.Notification
-  additional?: AppBskyNotificationListNotifications.Notification[]
-  subjectUri?: string
-  subject?: AppBskyFeedDefs.PostView
-}
-
-export interface FeedPage {
-  cursor: string | undefined
-  items: FeedNotification[]
-}
-
 export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
   const queryClient = useQueryClient()
   const moderationOpts = useModerationOpts()
   const threadMutes = useMutedThreads()
+  const unreads = useUnreadNotificationsApi()
   const enabled = opts?.enabled !== false
 
   return useInfiniteQuery<
@@ -68,40 +56,21 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
   >({
     queryKey: RQKEY(),
     async queryFn({pageParam}: {pageParam: RQPageParam}) {
-      const res = await getAgent().listNotifications({
+      // for the first page, we check the cached page held by the unread-checker first
+      if (!pageParam) {
+        const cachedPage = unreads.getCachedUnreadPage()
+        if (cachedPage) {
+          return cachedPage
+        }
+      }
+      // do a normal fetch
+      return fetchPage({
         limit: PAGE_SIZE,
         cursor: pageParam,
+        queryClient,
+        moderationOpts,
+        threadMutes,
       })
-
-      // filter out notifs by mod rules
-      const notifs = res.data.notifications.filter(
-        notif => !shouldFilterNotif(notif, moderationOpts),
-      )
-
-      // group notifications which are essentially similar (follows, likes on a post)
-      let notifsGrouped = groupNotifications(notifs)
-
-      // we fetch subjects of notifications (usually posts) now instead of lazily
-      // in the UI to avoid relayouts
-      const subjects = await fetchSubjects(notifsGrouped)
-      for (const notif of notifsGrouped) {
-        if (notif.subjectUri) {
-          notif.subject = subjects.get(notif.subjectUri)
-          if (notif.subject) {
-            precacheResolvedUri(queryClient, notif.subject.author) // precache the handle->did resolution
-          }
-        }
-      }
-
-      // apply thread muting
-      notifsGrouped = notifsGrouped.filter(
-        notif => !isThreadMuted(notif, threadMutes),
-      )
-
-      return {
-        cursor: res.data.cursor,
-        items: notifsGrouped,
-      }
     },
     initialPageParam: undefined,
     getNextPageParam: lastPage => lastPage.cursor,
@@ -134,115 +103,3 @@ export function findPostInQueryData(
   }
   return undefined
 }
-
-function groupNotifications(
-  notifs: AppBskyNotificationListNotifications.Notification[],
-): FeedNotification[] {
-  const groupedNotifs: FeedNotification[] = []
-  for (const notif of notifs) {
-    const ts = +new Date(notif.indexedAt)
-    let grouped = false
-    if (GROUPABLE_REASONS.includes(notif.reason)) {
-      for (const groupedNotif of groupedNotifs) {
-        const ts2 = +new Date(groupedNotif.notification.indexedAt)
-        if (
-          Math.abs(ts2 - ts) < MS_2DAY &&
-          notif.reason === groupedNotif.notification.reason &&
-          notif.reasonSubject === groupedNotif.notification.reasonSubject &&
-          notif.author.did !== groupedNotif.notification.author.did
-        ) {
-          groupedNotif.additional = groupedNotif.additional || []
-          groupedNotif.additional.push(notif)
-          grouped = true
-          break
-        }
-      }
-    }
-    if (!grouped) {
-      const type = toKnownType(notif)
-      groupedNotifs.push({
-        _reactKey: `notif-${notif.uri}`,
-        type,
-        notification: notif,
-        subjectUri: getSubjectUri(type, notif),
-      })
-    }
-  }
-  return groupedNotifs
-}
-
-async function fetchSubjects(
-  groupedNotifs: FeedNotification[],
-): Promise<Map<string, AppBskyFeedDefs.PostView>> {
-  const uris = new Set<string>()
-  for (const notif of groupedNotifs) {
-    if (notif.subjectUri) {
-      uris.add(notif.subjectUri)
-    }
-  }
-  const uriChunks = chunk(Array.from(uris), 25)
-  const postsChunks = await Promise.all(
-    uriChunks.map(uris =>
-      getAgent()
-        .app.bsky.feed.getPosts({uris})
-        .then(res => res.data.posts),
-    ),
-  )
-  const map = new Map<string, AppBskyFeedDefs.PostView>()
-  for (const post of postsChunks.flat()) {
-    if (
-      AppBskyFeedPost.isRecord(post.record) &&
-      AppBskyFeedPost.validateRecord(post.record).success
-    ) {
-      map.set(post.uri, post)
-    }
-  }
-  return map
-}
-
-function toKnownType(
-  notif: AppBskyNotificationListNotifications.Notification,
-): NotificationType {
-  if (notif.reason === 'like') {
-    if (notif.reasonSubject?.includes('feed.generator')) {
-      return 'feedgen-like'
-    }
-    return 'post-like'
-  }
-  if (
-    notif.reason === 'repost' ||
-    notif.reason === 'mention' ||
-    notif.reason === 'reply' ||
-    notif.reason === 'quote' ||
-    notif.reason === 'follow'
-  ) {
-    return notif.reason as NotificationType
-  }
-  return 'unknown'
-}
-
-function getSubjectUri(
-  type: NotificationType,
-  notif: AppBskyNotificationListNotifications.Notification,
-): string | undefined {
-  if (type === 'reply' || type === 'quote' || type === 'mention') {
-    return notif.uri
-  } else if (type === 'post-like' || type === 'repost') {
-    if (
-      AppBskyFeedRepost.isRecord(notif.record) ||
-      AppBskyFeedLike.isRecord(notif.record)
-    ) {
-      return typeof notif.record.subject?.uri === 'string'
-        ? notif.record.subject?.uri
-        : undefined
-    }
-  }
-}
-
-function isThreadMuted(notif: FeedNotification, mutes: string[]): boolean {
-  if (!notif.subject) {
-    return false
-  }
-  const record = notif.subject.record as AppBskyFeedPost.Record // assured in fetchSubjects()
-  return mutes.includes(record.reply?.root.uri || notif.subject.uri)
-}
diff --git a/src/state/queries/notifications/types.ts b/src/state/queries/notifications/types.ts
new file mode 100644
index 000000000..0e88f1071
--- /dev/null
+++ b/src/state/queries/notifications/types.ts
@@ -0,0 +1,34 @@
+import {
+  AppBskyNotificationListNotifications,
+  AppBskyFeedDefs,
+} from '@atproto/api'
+
+export type NotificationType =
+  | 'post-like'
+  | 'feedgen-like'
+  | 'repost'
+  | 'mention'
+  | 'reply'
+  | 'quote'
+  | 'follow'
+  | 'unknown'
+
+export interface FeedNotification {
+  _reactKey: string
+  type: NotificationType
+  notification: AppBskyNotificationListNotifications.Notification
+  additional?: AppBskyNotificationListNotifications.Notification[]
+  subjectUri?: string
+  subject?: AppBskyFeedDefs.PostView
+}
+
+export interface FeedPage {
+  cursor: string | undefined
+  items: FeedNotification[]
+}
+
+export interface CachedFeedPage {
+  sessDid: string // used to invalidate on session changes
+  syncedAt: Date
+  data: FeedPage | undefined
+}
diff --git a/src/state/queries/notifications/unread.tsx b/src/state/queries/notifications/unread.tsx
index b93e1dc81..d41cfee2e 100644
--- a/src/state/queries/notifications/unread.tsx
+++ b/src/state/queries/notifications/unread.tsx
@@ -1,10 +1,19 @@
+/**
+ * A kind of companion API to ./feed.ts. See that file for more info.
+ */
+
 import React from 'react'
 import * as Notifications from 'expo-notifications'
+import {useQueryClient} from '@tanstack/react-query'
 import BroadcastChannel from '#/lib/broadcast'
 import {useSession, getAgent} from '#/state/session'
 import {useModerationOpts} from '../preferences'
-import {shouldFilterNotif} from './util'
+import {fetchPage} from './util'
+import {CachedFeedPage, FeedPage} from './types'
 import {isNative} from '#/platform/detection'
+import {useMutedThreads} from '#/state/muted-threads'
+import {RQKEY as RQKEY_NOTIFS} from './feed'
+import {logger} from '#/logger'
 
 const UPDATE_INTERVAL = 30 * 1e3 // 30sec
 
@@ -14,7 +23,8 @@ type StateContext = string
 
 interface ApiContext {
   markAllRead: () => Promise<void>
-  checkUnread: () => Promise<void>
+  checkUnread: (opts?: {invalidate?: boolean}) => Promise<void>
+  getCachedUnreadPage: () => FeedPage | undefined
 }
 
 const stateContext = React.createContext<StateContext>('')
@@ -22,16 +32,23 @@ const stateContext = React.createContext<StateContext>('')
 const apiContext = React.createContext<ApiContext>({
   async markAllRead() {},
   async checkUnread() {},
+  getCachedUnreadPage: () => undefined,
 })
 
 export function Provider({children}: React.PropsWithChildren<{}>) {
-  const {hasSession} = useSession()
+  const {hasSession, currentAccount} = useSession()
+  const queryClient = useQueryClient()
   const moderationOpts = useModerationOpts()
+  const threadMutes = useMutedThreads()
 
   const [numUnread, setNumUnread] = React.useState('')
 
-  const checkUnreadRef = React.useRef<(() => Promise<void>) | null>(null)
-  const lastSyncRef = React.useRef<Date>(new Date())
+  const checkUnreadRef = React.useRef<ApiContext['checkUnread'] | null>(null)
+  const cacheRef = React.useRef<CachedFeedPage>({
+    sessDid: currentAccount?.did || '',
+    syncedAt: new Date(),
+    data: undefined,
+  })
 
   // periodic sync
   React.useEffect(() => {
@@ -46,14 +63,18 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
   // listen for broadcasts
   React.useEffect(() => {
     const listener = ({data}: MessageEvent) => {
-      lastSyncRef.current = new Date()
+      cacheRef.current = {
+        sessDid: currentAccount?.did || '',
+        syncedAt: new Date(),
+        data: undefined,
+      }
       setNumUnread(data.event)
     }
     broadcast.addEventListener('message', listener)
     return () => {
       broadcast.removeEventListener('message', listener)
     }
-  }, [setNumUnread])
+  }, [setNumUnread, currentAccount])
 
   // create API
   const api = React.useMemo<ApiContext>(() => {
@@ -61,7 +82,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
       async markAllRead() {
         // update server
         await getAgent().updateSeenNotifications(
-          lastSyncRef.current.toISOString(),
+          cacheRef.current.syncedAt.toISOString(),
         )
 
         // update & broadcast
@@ -69,36 +90,59 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
         broadcast.postMessage({event: ''})
       },
 
-      async checkUnread() {
-        if (!getAgent().session) return
-
-        // count
-        const res = await getAgent().listNotifications({limit: 40})
-        const filtered = res.data.notifications.filter(
-          notif => !notif.isRead && !shouldFilterNotif(notif, moderationOpts),
-        )
-        const num =
-          filtered.length >= 30
-            ? '30+'
-            : filtered.length === 0
-            ? ''
-            : String(filtered.length)
-        if (isNative) {
-          Notifications.setBadgeCountAsync(Math.min(filtered.length, 30))
+      async checkUnread({invalidate}: {invalidate?: boolean} = {}) {
+        try {
+          if (!getAgent().session) return
+
+          // count
+          const page = await fetchPage({
+            cursor: undefined,
+            limit: 40,
+            queryClient,
+            moderationOpts,
+            threadMutes,
+          })
+          const unreadCount = countUnread(page)
+          const unreadCountStr =
+            unreadCount >= 30
+              ? '30+'
+              : unreadCount === 0
+              ? ''
+              : String(unreadCount)
+          if (isNative) {
+            Notifications.setBadgeCountAsync(Math.min(unreadCount, 30))
+          }
+
+          // track last sync
+          const now = new Date()
+          const lastIndexed =
+            page.items[0] && new Date(page.items[0].notification.indexedAt)
+          cacheRef.current = {
+            sessDid: currentAccount?.did || '',
+            data: page,
+            syncedAt: !lastIndexed || now > lastIndexed ? now : lastIndexed,
+          }
+
+          // update & broadcast
+          setNumUnread(unreadCountStr)
+          if (invalidate) {
+            queryClient.invalidateQueries({queryKey: RQKEY_NOTIFS()})
+          }
+          broadcast.postMessage({event: unreadCountStr})
+        } catch (e) {
+          logger.error('Failed to check unread notifications', {error: e})
         }
+      },
 
-        // track last sync
-        const now = new Date()
-        const lastIndexed = filtered[0] && new Date(filtered[0].indexedAt)
-        lastSyncRef.current =
-          !lastIndexed || now > lastIndexed ? now : lastIndexed
-
-        // update & broadcast
-        setNumUnread(num)
-        broadcast.postMessage({event: num})
+      getCachedUnreadPage() {
+        // return cached page if was for the current user
+        // (protects against session changes serving data from the past session)
+        if (cacheRef.current.sessDid === currentAccount?.did) {
+          return cacheRef.current.data
+        }
       },
     }
-  }, [setNumUnread, moderationOpts])
+  }, [setNumUnread, queryClient, moderationOpts, threadMutes, currentAccount])
   checkUnreadRef.current = api.checkUnread
 
   return (
@@ -115,3 +159,20 @@ export function useUnreadNotifications() {
 export function useUnreadNotificationsApi() {
   return React.useContext(apiContext)
 }
+
+function countUnread(page: FeedPage) {
+  let num = 0
+  for (const item of page.items) {
+    if (!item.notification.isRead) {
+      num++
+    }
+    if (item.additional) {
+      for (const item2 of item.additional) {
+        if (!item2.isRead) {
+          num++
+        }
+      }
+    }
+  }
+  return num
+}
diff --git a/src/state/queries/notifications/util.ts b/src/state/queries/notifications/util.ts
index c49d1851a..48e1b8dd8 100644
--- a/src/state/queries/notifications/util.ts
+++ b/src/state/queries/notifications/util.ts
@@ -3,10 +3,78 @@ import {
   ModerationOpts,
   moderateProfile,
   moderatePost,
+  AppBskyFeedDefs,
+  AppBskyFeedPost,
+  AppBskyFeedRepost,
+  AppBskyFeedLike,
 } from '@atproto/api'
+import chunk from 'lodash.chunk'
+import {QueryClient} from '@tanstack/react-query'
+import {getAgent} from '../../session'
+import {precacheProfile as precacheResolvedUri} from '../resolve-uri'
+import {NotificationType, FeedNotification, FeedPage} from './types'
+
+const GROUPABLE_REASONS = ['like', 'repost', 'follow']
+const MS_1HR = 1e3 * 60 * 60
+const MS_2DAY = MS_1HR * 48
+
+// exported api
+// =
+
+export async function fetchPage({
+  cursor,
+  limit,
+  queryClient,
+  moderationOpts,
+  threadMutes,
+}: {
+  cursor: string | undefined
+  limit: number
+  queryClient: QueryClient
+  moderationOpts: ModerationOpts | undefined
+  threadMutes: string[]
+}): Promise<FeedPage> {
+  const res = await getAgent().listNotifications({
+    limit,
+    cursor,
+  })
+
+  // filter out notifs by mod rules
+  const notifs = res.data.notifications.filter(
+    notif => !shouldFilterNotif(notif, moderationOpts),
+  )
+
+  // group notifications which are essentially similar (follows, likes on a post)
+  let notifsGrouped = groupNotifications(notifs)
+
+  // we fetch subjects of notifications (usually posts) now instead of lazily
+  // in the UI to avoid relayouts
+  const subjects = await fetchSubjects(notifsGrouped)
+  for (const notif of notifsGrouped) {
+    if (notif.subjectUri) {
+      notif.subject = subjects.get(notif.subjectUri)
+      if (notif.subject) {
+        precacheResolvedUri(queryClient, notif.subject.author) // precache the handle->did resolution
+      }
+    }
+  }
+
+  // apply thread muting
+  notifsGrouped = notifsGrouped.filter(
+    notif => !isThreadMuted(notif, threadMutes),
+  )
+
+  return {
+    cursor: res.data.cursor,
+    items: notifsGrouped,
+  }
+}
+
+// internal methods
+// =
 
 // TODO this should be in the sdk as moderateNotification -prf
-export function shouldFilterNotif(
+function shouldFilterNotif(
   notif: AppBskyNotificationListNotifications.Notification,
   moderationOpts: ModerationOpts | undefined,
 ): boolean {
@@ -36,3 +104,115 @@ export function shouldFilterNotif(
   // (this requires fetching the post)
   return false
 }
+
+function groupNotifications(
+  notifs: AppBskyNotificationListNotifications.Notification[],
+): FeedNotification[] {
+  const groupedNotifs: FeedNotification[] = []
+  for (const notif of notifs) {
+    const ts = +new Date(notif.indexedAt)
+    let grouped = false
+    if (GROUPABLE_REASONS.includes(notif.reason)) {
+      for (const groupedNotif of groupedNotifs) {
+        const ts2 = +new Date(groupedNotif.notification.indexedAt)
+        if (
+          Math.abs(ts2 - ts) < MS_2DAY &&
+          notif.reason === groupedNotif.notification.reason &&
+          notif.reasonSubject === groupedNotif.notification.reasonSubject &&
+          notif.author.did !== groupedNotif.notification.author.did
+        ) {
+          groupedNotif.additional = groupedNotif.additional || []
+          groupedNotif.additional.push(notif)
+          grouped = true
+          break
+        }
+      }
+    }
+    if (!grouped) {
+      const type = toKnownType(notif)
+      groupedNotifs.push({
+        _reactKey: `notif-${notif.uri}`,
+        type,
+        notification: notif,
+        subjectUri: getSubjectUri(type, notif),
+      })
+    }
+  }
+  return groupedNotifs
+}
+
+async function fetchSubjects(
+  groupedNotifs: FeedNotification[],
+): Promise<Map<string, AppBskyFeedDefs.PostView>> {
+  const uris = new Set<string>()
+  for (const notif of groupedNotifs) {
+    if (notif.subjectUri) {
+      uris.add(notif.subjectUri)
+    }
+  }
+  const uriChunks = chunk(Array.from(uris), 25)
+  const postsChunks = await Promise.all(
+    uriChunks.map(uris =>
+      getAgent()
+        .app.bsky.feed.getPosts({uris})
+        .then(res => res.data.posts),
+    ),
+  )
+  const map = new Map<string, AppBskyFeedDefs.PostView>()
+  for (const post of postsChunks.flat()) {
+    if (
+      AppBskyFeedPost.isRecord(post.record) &&
+      AppBskyFeedPost.validateRecord(post.record).success
+    ) {
+      map.set(post.uri, post)
+    }
+  }
+  return map
+}
+
+function toKnownType(
+  notif: AppBskyNotificationListNotifications.Notification,
+): NotificationType {
+  if (notif.reason === 'like') {
+    if (notif.reasonSubject?.includes('feed.generator')) {
+      return 'feedgen-like'
+    }
+    return 'post-like'
+  }
+  if (
+    notif.reason === 'repost' ||
+    notif.reason === 'mention' ||
+    notif.reason === 'reply' ||
+    notif.reason === 'quote' ||
+    notif.reason === 'follow'
+  ) {
+    return notif.reason as NotificationType
+  }
+  return 'unknown'
+}
+
+function getSubjectUri(
+  type: NotificationType,
+  notif: AppBskyNotificationListNotifications.Notification,
+): string | undefined {
+  if (type === 'reply' || type === 'quote' || type === 'mention') {
+    return notif.uri
+  } else if (type === 'post-like' || type === 'repost') {
+    if (
+      AppBskyFeedRepost.isRecord(notif.record) ||
+      AppBskyFeedLike.isRecord(notif.record)
+    ) {
+      return typeof notif.record.subject?.uri === 'string'
+        ? notif.record.subject?.uri
+        : undefined
+    }
+  }
+}
+
+function isThreadMuted(notif: FeedNotification, mutes: string[]): boolean {
+  if (!notif.subject) {
+    return false
+  }
+  const record = notif.subject.record as AppBskyFeedPost.Record // assured in fetchSubjects()
+  return mutes.includes(record.reply?.root.uri || notif.subject.uri)
+}
diff --git a/src/view/com/notifications/Feed.tsx b/src/view/com/notifications/Feed.tsx
index ba88f78c0..c496d5f7c 100644
--- a/src/view/com/notifications/Feed.tsx
+++ b/src/view/com/notifications/Feed.tsx
@@ -35,15 +35,13 @@ export function Feed({
   const [isPTRing, setIsPTRing] = React.useState(false)
 
   const moderationOpts = useModerationOpts()
-  const {markAllRead} = useUnreadNotificationsApi()
+  const {markAllRead, checkUnread} = useUnreadNotificationsApi()
   const {
     data,
-    isLoading,
     isFetching,
     isFetched,
     isError,
     error,
-    refetch,
     hasNextPage,
     isFetchingNextPage,
     fetchNextPage,
@@ -52,13 +50,11 @@ export function Feed({
   const firstItem = data?.pages[0]?.items[0]
 
   // mark all read on fresh data
+  // (this will fire each time firstItem changes)
   React.useEffect(() => {
-    let cleanup
     if (firstItem) {
-      const to = setTimeout(() => markAllRead(), 250)
-      cleanup = () => clearTimeout(to)
+      markAllRead()
     }
-    return cleanup
   }, [firstItem, markAllRead])
 
   const items = React.useMemo(() => {
@@ -83,7 +79,7 @@ export function Feed({
   const onRefresh = React.useCallback(async () => {
     try {
       setIsPTRing(true)
-      await refetch()
+      await checkUnread({invalidate: true})
     } catch (err) {
       logger.error('Failed to refresh notifications feed', {
         error: err,
@@ -91,7 +87,7 @@ export function Feed({
     } finally {
       setIsPTRing(false)
     }
-  }, [refetch, setIsPTRing])
+  }, [checkUnread, setIsPTRing])
 
   const onEndReached = React.useCallback(async () => {
     if (isFetching || !hasNextPage || isError) return
@@ -136,21 +132,6 @@ export function Feed({
     [onPressRetryLoadMore, moderationOpts],
   )
 
-  const showHeaderSpinner = !isPTRing && isFetching && !isLoading
-  const FeedHeader = React.useCallback(
-    () => (
-      <View>
-        {ListHeaderComponent ? <ListHeaderComponent /> : null}
-        {showHeaderSpinner ? (
-          <View style={{padding: 10}}>
-            <ActivityIndicator />
-          </View>
-        ) : null}
-      </View>
-    ),
-    [ListHeaderComponent, showHeaderSpinner],
-  )
-
   const FeedFooter = React.useCallback(
     () =>
       isFetchingNextPage ? (
@@ -180,7 +161,7 @@ export function Feed({
         data={items}
         keyExtractor={item => item._reactKey}
         renderItem={renderItem}
-        ListHeaderComponent={FeedHeader}
+        ListHeaderComponent={ListHeaderComponent}
         ListFooterComponent={FeedFooter}
         refreshControl={
           <RefreshControl
diff --git a/src/view/screens/Notifications.tsx b/src/view/screens/Notifications.tsx
index 8516d1667..a5226bb67 100644
--- a/src/view/screens/Notifications.tsx
+++ b/src/view/screens/Notifications.tsx
@@ -19,7 +19,10 @@ import {logger} from '#/logger'
 import {useSetMinimalShellMode} from '#/state/shell'
 import {Trans, msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
-import {useUnreadNotifications} from '#/state/queries/notifications/unread'
+import {
+  useUnreadNotifications,
+  useUnreadNotificationsApi,
+} from '#/state/queries/notifications/unread'
 import {RQKEY as NOTIFS_RQKEY} from '#/state/queries/notifications/feed'
 import {listenSoftReset, emitSoftReset} from '#/state/events'
 
@@ -35,8 +38,9 @@ export function NotificationsScreen({}: Props) {
   const {screen} = useAnalytics()
   const pal = usePalette('default')
   const {isDesktop} = useWebMediaQueries()
-  const unreadNotifs = useUnreadNotifications()
   const queryClient = useQueryClient()
+  const unreadNotifs = useUnreadNotifications()
+  const unreadApi = useUnreadNotificationsApi()
   const hasNew = !!unreadNotifs
 
   // event handlers
@@ -48,10 +52,16 @@ export function NotificationsScreen({}: Props) {
 
   const onPressLoadLatest = React.useCallback(() => {
     scrollToTop()
-    queryClient.invalidateQueries({
-      queryKey: NOTIFS_RQKEY(),
-    })
-  }, [scrollToTop, queryClient])
+    if (hasNew) {
+      // render what we have now
+      queryClient.invalidateQueries({
+        queryKey: NOTIFS_RQKEY(),
+      })
+    } else {
+      // check with the server
+      unreadApi.checkUnread({invalidate: true})
+    }
+  }, [scrollToTop, queryClient, unreadApi, hasNew])
 
   // on-visible setup
   // =