about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authordan <dan.abramov@gmail.com>2024-12-12 17:37:07 +0000
committerGitHub <noreply@github.com>2024-12-12 17:37:07 +0000
commitf8cdd6b9ae46a9a4efe0be87e55ee1debded4f91 (patch)
tree87884914d17ca8d466bcb57a1a668f77a1d7338f /src
parent10e241e7eb5a45a0c35802618f52b411dcecd0b0 (diff)
downloadvoidsky-f8cdd6b9ae46a9a4efe0be87e55ee1debded4f91.tar.zst
[Notifications] Add a Mentions tab (#7044)
* Split out NotificationsTab

* Remove unused route parameter

* Refine the split between components

* Hoist some logic out of NotificationFeed

* Remove unused option

* Add all|conversations to query, hardcode "all"

* Add a Conversations tab

* Rename to Mentions

* Bump packages

* Rename fields

* Fix oopsie

* Simplify header

* Track active tab

* Fix types

* Separate logic for tabs

* Better border for first unread

* Highlight unread for all only

* Fix spinner races

* Fix fetchPage races

* Fix bottom bar border being obscured by glimmer

* Remember last tab within the session

* One tab at a time

* Fix TS

* Handle all RQKEY usages

* Nit
Diffstat (limited to 'src')
-rw-r--r--src/lib/hooks/useNotificationHandler.ts15
-rw-r--r--src/lib/routes/types.ts6
-rw-r--r--src/screens/Settings/NotificationSettings.tsx8
-rw-r--r--src/state/queries/notifications/feed.ts34
-rw-r--r--src/state/queries/notifications/settings.ts9
-rw-r--r--src/state/queries/notifications/unread.tsx16
-rw-r--r--src/state/queries/notifications/util.ts5
-rw-r--r--src/state/queries/util.ts4
-rw-r--r--src/view/com/notifications/NotificationFeed.tsx33
-rw-r--r--src/view/com/notifications/NotificationFeedItem.tsx13
-rw-r--r--src/view/com/pager/Pager.tsx14
-rw-r--r--src/view/screens/DebugMod.tsx8
-rw-r--r--src/view/screens/Notifications.tsx256
13 files changed, 277 insertions, 144 deletions
diff --git a/src/lib/hooks/useNotificationHandler.ts b/src/lib/hooks/useNotificationHandler.ts
index 69ae536d0..2ec3fcb79 100644
--- a/src/lib/hooks/useNotificationHandler.ts
+++ b/src/lib/hooks/useNotificationHandler.ts
@@ -239,14 +239,21 @@ export function useNotificationsHandler() {
           )
           logEvent('notifications:openApp', {})
           invalidateCachedUnreadPage()
-          truncateAndInvalidate(queryClient, RQKEY_NOTIFS())
+          const payload = e.notification.request.trigger
+            .payload as NotificationPayload
+          truncateAndInvalidate(queryClient, RQKEY_NOTIFS('all'))
+          if (
+            payload.reason === 'mention' ||
+            payload.reason === 'quote' ||
+            payload.reason === 'reply'
+          ) {
+            truncateAndInvalidate(queryClient, RQKEY_NOTIFS('mentions'))
+          }
           logger.debug('Notifications: handleNotification', {
             content: e.notification.request.content,
             payload: e.notification.request.trigger.payload,
           })
-          handleNotification(
-            e.notification.request.trigger.payload as NotificationPayload,
-          )
+          handleNotification(payload)
           Notifications.dismissAllNotificationsAsync()
         }
       })
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index 9e3407261..238e4be4c 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -75,7 +75,7 @@ export type SearchTabNavigatorParams = CommonNavigatorParams & {
 }
 
 export type NotificationsTabNavigatorParams = CommonNavigatorParams & {
-  Notifications: {show?: 'all'}
+  Notifications: undefined
 }
 
 export type MyProfileTabNavigatorParams = CommonNavigatorParams & {
@@ -90,7 +90,7 @@ export type FlatNavigatorParams = CommonNavigatorParams & {
   Home: undefined
   Search: {q?: string}
   Feeds: undefined
-  Notifications: {show?: 'all'}
+  Notifications: undefined
   Hashtag: {tag: string; author?: string}
   Messages: {pushToConversation?: string; animation?: 'push' | 'pop'}
 }
@@ -102,7 +102,7 @@ export type AllNavigatorParams = CommonNavigatorParams & {
   Search: {q?: string}
   Feeds: undefined
   NotificationsTab: undefined
-  Notifications: {show?: 'all'}
+  Notifications: undefined
   MyProfileTab: undefined
   Hashtag: {tag: string; author?: string}
   MessagesTab: undefined
diff --git a/src/screens/Settings/NotificationSettings.tsx b/src/screens/Settings/NotificationSettings.tsx
index 1c77b3148..ebb230c2c 100644
--- a/src/screens/Settings/NotificationSettings.tsx
+++ b/src/screens/Settings/NotificationSettings.tsx
@@ -18,7 +18,13 @@ type Props = NativeStackScreenProps<AllNavigatorParams, 'NotificationSettings'>
 export function NotificationSettingsScreen({}: Props) {
   const {_} = useLingui()
 
-  const {data, isError: isQueryError, refetch} = useNotificationFeedQuery()
+  const {
+    data,
+    isError: isQueryError,
+    refetch,
+  } = useNotificationFeedQuery({
+    filter: 'all',
+  })
   const serverPriority = data?.pages.at(0)?.priority
 
   const {
diff --git a/src/state/queries/notifications/feed.ts b/src/state/queries/notifications/feed.ts
index 19a92fc3c..72100a624 100644
--- a/src/state/queries/notifications/feed.ts
+++ b/src/state/queries/notifications/feed.ts
@@ -52,25 +52,22 @@ const PAGE_SIZE = 30
 type RQPageParam = string | undefined
 
 const RQKEY_ROOT = 'notification-feed'
-export function RQKEY(priority?: false) {
-  return [RQKEY_ROOT, priority]
+export function RQKEY(filter: 'all' | 'mentions') {
+  return [RQKEY_ROOT, filter]
 }
 
-export function useNotificationFeedQuery(opts?: {
+export function useNotificationFeedQuery(opts: {
   enabled?: boolean
-  overridePriorityNotifications?: boolean
+  filter: 'all' | 'mentions'
 }) {
   const agent = useAgent()
   const queryClient = useQueryClient()
   const moderationOpts = useModerationOpts()
   const unreads = useUnreadNotificationsApi()
-  const enabled = opts?.enabled !== false
+  const enabled = opts.enabled !== false
+  const filter = opts.filter
   const {uris: hiddenReplyUris} = useThreadgateHiddenReplyUris()
 
-  // false: force showing all notifications
-  // undefined: let the server decide
-  const priority = opts?.overridePriorityNotifications ? false : undefined
-
   const selectArgs = useMemo(() => {
     return {
       moderationOpts,
@@ -91,14 +88,23 @@ export function useNotificationFeedQuery(opts?: {
     RQPageParam
   >({
     staleTime: STALE.INFINITY,
-    queryKey: RQKEY(priority),
+    queryKey: RQKEY(filter),
     async queryFn({pageParam}: {pageParam: RQPageParam}) {
       let page
-      if (!pageParam) {
+      if (filter === 'all' && !pageParam) {
         // for the first page, we check the cached page held by the unread-checker first
         page = unreads.getCachedUnreadPage()
       }
       if (!page) {
+        let reasons: string[] = []
+        if (filter === 'mentions') {
+          reasons = [
+            // Anything that's a post
+            'mention',
+            'reply',
+            'quote',
+          ]
+        }
         const {page: fetchedPage} = await fetchPage({
           agent,
           limit: PAGE_SIZE,
@@ -106,13 +112,13 @@ export function useNotificationFeedQuery(opts?: {
           queryClient,
           moderationOpts,
           fetchAdditionalData: true,
-          priority,
+          reasons,
         })
         page = fetchedPage
       }
 
-      // if the first page has an unread, mark all read
-      if (!pageParam) {
+      if (filter === 'all' && !pageParam) {
+        // if the first page has an unread, mark all read
         unreads.markAllRead()
       }
 
diff --git a/src/state/queries/notifications/settings.ts b/src/state/queries/notifications/settings.ts
index a17fce832..e552b6520 100644
--- a/src/state/queries/notifications/settings.ts
+++ b/src/state/queries/notifications/settings.ts
@@ -45,7 +45,8 @@ export function useNotificationSettingsMutation() {
     },
     onSettled: () => {
       invalidateCachedUnreadPage()
-      queryClient.invalidateQueries({queryKey: RQKEY_NOTIFS()})
+      queryClient.invalidateQueries({queryKey: RQKEY_NOTIFS('all')})
+      queryClient.invalidateQueries({queryKey: RQKEY_NOTIFS('mentions')})
     },
   })
 }
@@ -54,7 +55,7 @@ function eagerlySetCachedPriority(
   queryClient: ReturnType<typeof useQueryClient>,
   enabled: boolean,
 ) {
-  queryClient.setQueryData(RQKEY_NOTIFS(), (old: any) => {
+  function updateData(old: any) {
     if (!old) return old
     return {
       ...old,
@@ -65,5 +66,7 @@ function eagerlySetCachedPriority(
         }
       }),
     }
-  })
+  }
+  queryClient.setQueryData(RQKEY_NOTIFS('all'), updateData)
+  queryClient.setQueryData(RQKEY_NOTIFS('mentions'), updateData)
 }
diff --git a/src/state/queries/notifications/unread.tsx b/src/state/queries/notifications/unread.tsx
index 2ade04246..ba2377a78 100644
--- a/src/state/queries/notifications/unread.tsx
+++ b/src/state/queries/notifications/unread.tsx
@@ -2,7 +2,7 @@
  * A kind of companion API to ./feed.ts. See that file for more info.
  */
 
-import React from 'react'
+import React, {useRef} from 'react'
 import {AppState} from 'react-native'
 import {useQueryClient} from '@tanstack/react-query'
 import EventEmitter from 'eventemitter3'
@@ -105,6 +105,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
     }
   }, [setNumUnread])
 
+  const isFetchingRef = useRef(false)
+
   // create API
   const api = React.useMemo<ApiContext>(() => {
     return {
@@ -138,6 +140,12 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
             }
           }
 
+          if (isFetchingRef.current) {
+            return
+          }
+          // Do not move this without ensuring it gets a symmetrical reset in the finally block.
+          isFetchingRef.current = true
+
           // count
           const {page, indexedAt: lastIndexed} = await fetchPage({
             agent,
@@ -145,6 +153,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
             limit: 40,
             queryClient,
             moderationOpts,
+            reasons: [],
 
             // only fetch subjects when the page is going to be used
             // in the notifications query, otherwise skip it
@@ -174,11 +183,14 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
           // update & broadcast
           setNumUnread(unreadCountStr)
           if (invalidate) {
-            truncateAndInvalidate(queryClient, RQKEY_NOTIFS())
+            truncateAndInvalidate(queryClient, RQKEY_NOTIFS('all'))
+            truncateAndInvalidate(queryClient, RQKEY_NOTIFS('mentions'))
           }
           broadcast.postMessage({event: unreadCountStr})
         } catch (e) {
           logger.warn('Failed to check unread notifications', {error: e})
+        } finally {
+          isFetchingRef.current = false
         }
       },
 
diff --git a/src/state/queries/notifications/util.ts b/src/state/queries/notifications/util.ts
index a251d170e..0d72e9e92 100644
--- a/src/state/queries/notifications/util.ts
+++ b/src/state/queries/notifications/util.ts
@@ -31,6 +31,7 @@ export async function fetchPage({
   queryClient,
   moderationOpts,
   fetchAdditionalData,
+  reasons,
 }: {
   agent: BskyAgent
   cursor: string | undefined
@@ -38,7 +39,7 @@ export async function fetchPage({
   queryClient: QueryClient
   moderationOpts: ModerationOpts | undefined
   fetchAdditionalData: boolean
-  priority?: boolean
+  reasons: string[]
 }): Promise<{
   page: FeedPage
   indexedAt: string | undefined
@@ -46,7 +47,7 @@ export async function fetchPage({
   const res = await agent.listNotifications({
     limit,
     cursor,
-    // priority,
+    reasons,
   })
 
   const indexedAt = res.data.notifications[0]?.indexedAt
diff --git a/src/state/queries/util.ts b/src/state/queries/util.ts
index 0d6a8e99a..887c1df0a 100644
--- a/src/state/queries/util.ts
+++ b/src/state/queries/util.ts
@@ -8,7 +8,7 @@ import {
 } from '@atproto/api'
 import {InfiniteData, QueryClient, QueryKey} from '@tanstack/react-query'
 
-export function truncateAndInvalidate<T = any>(
+export async function truncateAndInvalidate<T = any>(
   queryClient: QueryClient,
   queryKey: QueryKey,
 ) {
@@ -21,7 +21,7 @@ export function truncateAndInvalidate<T = any>(
     }
     return data
   })
-  queryClient.invalidateQueries({queryKey})
+  return queryClient.invalidateQueries({queryKey})
 }
 
 // Given an AtUri, this function will check if the AtUri matches a
diff --git a/src/view/com/notifications/NotificationFeed.tsx b/src/view/com/notifications/NotificationFeed.tsx
index 5168933ae..0b814e68d 100644
--- a/src/view/com/notifications/NotificationFeed.tsx
+++ b/src/view/com/notifications/NotificationFeed.tsx
@@ -9,13 +9,11 @@ import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
 import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
-import {usePalette} from '#/lib/hooks/usePalette'
 import {cleanError} from '#/lib/strings/errors'
 import {s} from '#/lib/styles'
 import {logger} from '#/logger'
 import {useModerationOpts} from '#/state/preferences/moderation-opts'
 import {useNotificationFeedQuery} from '#/state/queries/notifications/feed'
-import {useUnreadNotificationsApi} from '#/state/queries/notifications/unread'
 import {EmptyState} from '#/view/com/util/EmptyState'
 import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
 import {List, ListRef} from '#/view/com/util/List'
@@ -28,26 +26,26 @@ const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
 const LOADING_ITEM = {_reactKey: '__loading__'}
 
 export function NotificationFeed({
+  filter,
+  enabled,
   scrollElRef,
   onPressTryAgain,
   onScrolledDownChange,
   ListHeaderComponent,
-  overridePriorityNotifications,
+  refreshNotifications,
 }: {
+  filter: 'all' | 'mentions'
+  enabled: boolean
   scrollElRef?: ListRef
   onPressTryAgain?: () => void
   onScrolledDownChange: (isScrolledDown: boolean) => void
   ListHeaderComponent?: () => JSX.Element
-  overridePriorityNotifications?: boolean
+  refreshNotifications: () => Promise<void>
 }) {
   const initialNumToRender = useInitialNumToRender()
-
   const [isPTRing, setIsPTRing] = React.useState(false)
-  const pal = usePalette('default')
-
   const {_} = useLingui()
   const moderationOpts = useModerationOpts()
-  const {checkUnread} = useUnreadNotificationsApi()
   const {
     data,
     isFetching,
@@ -58,8 +56,8 @@ export function NotificationFeed({
     isFetchingNextPage,
     fetchNextPage,
   } = useNotificationFeedQuery({
-    enabled: !!moderationOpts,
-    overridePriorityNotifications,
+    enabled: enabled && !!moderationOpts,
+    filter,
   })
   const isEmpty = !isFetching && !data?.pages[0]?.items.length
 
@@ -85,7 +83,7 @@ export function NotificationFeed({
   const onRefresh = React.useCallback(async () => {
     try {
       setIsPTRing(true)
-      await checkUnread({invalidate: true})
+      await refreshNotifications()
     } catch (err) {
       logger.error('Failed to refresh notifications feed', {
         message: err,
@@ -93,7 +91,7 @@ export function NotificationFeed({
     } finally {
       setIsPTRing(false)
     }
-  }, [checkUnread, setIsPTRing])
+  }, [refreshNotifications, setIsPTRing])
 
   const onEndReached = React.useCallback(async () => {
     if (isFetching || !hasNextPage || isError) return
@@ -129,21 +127,18 @@ export function NotificationFeed({
           />
         )
       } else if (item === LOADING_ITEM) {
-        return (
-          <View style={[pal.border]}>
-            <NotificationFeedLoadingPlaceholder />
-          </View>
-        )
+        return <NotificationFeedLoadingPlaceholder />
       }
       return (
         <NotificationFeedItem
+          highlightUnread={filter === 'all'}
           item={item}
           moderationOpts={moderationOpts!}
-          hideTopBorder={index === 0}
+          hideTopBorder={index === 0 && item.notification.isRead}
         />
       )
     },
-    [moderationOpts, _, onPressRetryLoadMore, pal.border],
+    [moderationOpts, _, onPressRetryLoadMore, filter],
   )
 
   const FeedFooter = React.useCallback(
diff --git a/src/view/com/notifications/NotificationFeedItem.tsx b/src/view/com/notifications/NotificationFeedItem.tsx
index 4902e66bc..1267ce089 100644
--- a/src/view/com/notifications/NotificationFeedItem.tsx
+++ b/src/view/com/notifications/NotificationFeedItem.tsx
@@ -79,10 +79,12 @@ interface Author {
 let NotificationFeedItem = ({
   item,
   moderationOpts,
+  highlightUnread,
   hideTopBorder,
 }: {
   item: FeedNotification
   moderationOpts: ModerationOpts
+  highlightUnread: boolean
   hideTopBorder?: boolean
 }): React.ReactNode => {
   const queryClient = useQueryClient()
@@ -151,6 +153,7 @@ let NotificationFeedItem = ({
     if (!item.subject) {
       return null
     }
+    const isHighlighted = highlightUnread && !item.notification.isRead
     return (
       <Link
         testID={`feedItem-by-${item.notification.author.handle}`}
@@ -160,12 +163,10 @@ let NotificationFeedItem = ({
         <Post
           post={item.subject}
           style={
-            item.notification.isRead
-              ? undefined
-              : {
-                  backgroundColor: pal.colors.unreadNotifBg,
-                  borderColor: pal.colors.unreadNotifBorder,
-                }
+            isHighlighted && {
+              backgroundColor: pal.colors.unreadNotifBg,
+              borderColor: pal.colors.unreadNotifBorder,
+            }
           }
           hideTopBorder={hideTopBorder}
         />
diff --git a/src/view/com/pager/Pager.tsx b/src/view/com/pager/Pager.tsx
index 2c0bbee52..b3f936ddc 100644
--- a/src/view/com/pager/Pager.tsx
+++ b/src/view/com/pager/Pager.tsx
@@ -136,12 +136,14 @@ export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>(
 
     return (
       <View testID={testID} style={[a.flex_1, native(a.overflow_hidden)]}>
-        {renderTabBar({
-          selectedPage,
-          onSelect: onTabBarSelect,
-          dragProgress,
-          dragState,
-        })}
+        <View style={a.z_10 /* Let tabbar bottom border cover the glimmer */}>
+          {renderTabBar({
+            selectedPage,
+            onSelect: onTabBarSelect,
+            dragProgress,
+            dragState,
+          })}
+        </View>
         <GestureDetector gesture={nativeGesture}>
           <AnimatedPagerView
             ref={pagerView}
diff --git a/src/view/screens/DebugMod.tsx b/src/view/screens/DebugMod.tsx
index 74a58a56a..4ff0a4b8b 100644
--- a/src/view/screens/DebugMod.tsx
+++ b/src/view/screens/DebugMod.tsx
@@ -872,7 +872,13 @@ function MockNotifItem({
       </P>
     )
   }
-  return <NotificationFeedItem item={notif} moderationOpts={moderationOpts} />
+  return (
+    <NotificationFeedItem
+      item={notif}
+      moderationOpts={moderationOpts}
+      highlightUnread
+    />
+  )
 }
 
 function MockAccountCard({
diff --git a/src/view/screens/Notifications.tsx b/src/view/screens/Notifications.tsx
index 70ab32db0..82c68dde6 100644
--- a/src/view/screens/Notifications.tsx
+++ b/src/view/screens/Notifications.tsx
@@ -13,7 +13,7 @@ import {
 } from '#/lib/routes/types'
 import {s} from '#/lib/styles'
 import {logger} from '#/logger'
-import {isNative, isWeb} from '#/platform/detection'
+import {isNative} from '#/platform/detection'
 import {emitSoftReset, listenSoftReset} from '#/state/events'
 import {RQKEY as NOTIFS_RQKEY} from '#/state/queries/notifications/feed'
 import {
@@ -24,35 +24,173 @@ import {truncateAndInvalidate} from '#/state/queries/util'
 import {useSetMinimalShellMode} from '#/state/shell'
 import {useComposerControls} from '#/state/shell/composer'
 import {NotificationFeed} from '#/view/com/notifications/NotificationFeed'
+import {Pager} from '#/view/com/pager/Pager'
+import {TabBar} from '#/view/com/pager/TabBar'
 import {FAB} from '#/view/com/util/fab/FAB'
 import {ListMethods} from '#/view/com/util/List'
 import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn'
 import {MainScrollProvider} from '#/view/com/util/MainScrollProvider'
-import {atoms as a, useBreakpoints, useTheme} from '#/alf'
-import {Button, ButtonIcon} from '#/components/Button'
+import {atoms as a} from '#/alf'
+import {web} from '#/alf'
+import {ButtonIcon} from '#/components/Button'
 import {SettingsGear2_Stroke2_Corner0_Rounded as SettingsIcon} from '#/components/icons/SettingsGear2'
 import * as Layout from '#/components/Layout'
 import {Link} from '#/components/Link'
 import {Loader} from '#/components/Loader'
 
+// We don't currently persist this across reloads since
+// you gotta visit All to clear the badge anyway.
+// But let's at least persist it during the sesssion.
+let lastActiveTab = 0
+
 type Props = NativeStackScreenProps<
   NotificationsTabNavigatorParams,
   'Notifications'
 >
-export function NotificationsScreen({route: {params}}: Props) {
-  const t = useTheme()
-  const {gtTablet} = useBreakpoints()
+export function NotificationsScreen({}: Props) {
+  const {_} = useLingui()
+  const {openComposer} = useComposerControls()
+  const unreadNotifs = useUnreadNotifications()
+  const hasNew = !!unreadNotifs
+  const {checkUnread: checkUnreadAll} = useUnreadNotificationsApi()
+  const [isLoadingAll, setIsLoadingAll] = React.useState(false)
+  const [isLoadingMentions, setIsLoadingMentions] = React.useState(false)
+  const initialActiveTab = lastActiveTab
+  const [activeTab, setActiveTab] = React.useState(initialActiveTab)
+  const isLoading = activeTab === 0 ? isLoadingAll : isLoadingMentions
+
+  const onPageSelected = React.useCallback(
+    (index: number) => {
+      setActiveTab(index)
+      lastActiveTab = index
+    },
+    [setActiveTab],
+  )
+
+  const queryClient = useQueryClient()
+  const checkUnreadMentions = React.useCallback(
+    async ({invalidate}: {invalidate: boolean}) => {
+      if (invalidate) {
+        return truncateAndInvalidate(queryClient, NOTIFS_RQKEY('mentions'))
+      } else {
+        // Background polling is not implemented for the mentions tab.
+        // Just ignore it.
+      }
+    },
+    [queryClient],
+  )
+
+  const sections = React.useMemo(() => {
+    return [
+      {
+        title: _(msg`All`),
+        component: (
+          <NotificationsTab
+            filter="all"
+            isActive={activeTab === 0}
+            isLoading={isLoadingAll}
+            hasNew={hasNew}
+            setIsLoadingLatest={setIsLoadingAll}
+            checkUnread={checkUnreadAll}
+          />
+        ),
+      },
+      {
+        title: _(msg`Mentions`),
+        component: (
+          <NotificationsTab
+            filter="mentions"
+            isActive={activeTab === 1}
+            isLoading={isLoadingMentions}
+            hasNew={false /* We don't know for sure */}
+            setIsLoadingLatest={setIsLoadingMentions}
+            checkUnread={checkUnreadMentions}
+          />
+        ),
+      },
+    ]
+  }, [
+    _,
+    hasNew,
+    checkUnreadAll,
+    checkUnreadMentions,
+    activeTab,
+    isLoadingAll,
+    isLoadingMentions,
+  ])
+
+  return (
+    <Layout.Screen testID="notificationsScreen">
+      <Layout.Header.Outer noBottomBorder>
+        <Layout.Header.MenuButton />
+        <Layout.Header.Content>
+          <Layout.Header.TitleText>
+            <Trans>Notifications</Trans>
+          </Layout.Header.TitleText>
+        </Layout.Header.Content>
+        <Layout.Header.Slot>
+          <Link
+            to="/notifications/settings"
+            label={_(msg`Notification settings`)}
+            size="small"
+            variant="ghost"
+            color="secondary"
+            shape="round"
+            style={[a.justify_center]}>
+            <ButtonIcon icon={isLoading ? Loader : SettingsIcon} size="lg" />
+          </Link>
+        </Layout.Header.Slot>
+      </Layout.Header.Outer>
+      <Pager
+        onPageSelected={onPageSelected}
+        renderTabBar={props => (
+          <Layout.Center style={web([a.sticky, a.z_10, {top: 0}])}>
+            <TabBar
+              {...props}
+              items={sections.map(section => section.title)}
+              onPressSelected={() => emitSoftReset()}
+            />
+          </Layout.Center>
+        )}
+        initialPage={initialActiveTab}>
+        {sections.map((section, i) => (
+          <View key={i}>{section.component}</View>
+        ))}
+      </Pager>
+      <FAB
+        testID="composeFAB"
+        onPress={() => openComposer({})}
+        icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
+        accessibilityRole="button"
+        accessibilityLabel={_(msg`New post`)}
+        accessibilityHint=""
+      />
+    </Layout.Screen>
+  )
+}
+
+function NotificationsTab({
+  filter,
+  isActive,
+  isLoading,
+  hasNew,
+  checkUnread,
+  setIsLoadingLatest,
+}: {
+  filter: 'all' | 'mentions'
+  isActive: boolean
+  isLoading: boolean
+  hasNew: boolean
+  checkUnread: ({invalidate}: {invalidate: boolean}) => Promise<void>
+  setIsLoadingLatest: (v: boolean) => void
+}) {
   const {_} = useLingui()
   const setMinimalShellMode = useSetMinimalShellMode()
   const [isScrolledDown, setIsScrolledDown] = React.useState(false)
-  const [isLoadingLatest, setIsLoadingLatest] = React.useState(false)
   const scrollElRef = React.useRef<ListMethods>(null)
   const queryClient = useQueryClient()
-  const unreadNotifs = useUnreadNotifications()
-  const unreadApi = useUnreadNotificationsApi()
-  const hasNew = !!unreadNotifs
   const isScreenFocused = useIsFocused()
-  const {openComposer} = useComposerControls()
+  const isFocusedAndActive = isScreenFocused && isActive
 
   // event handlers
   // =
@@ -65,16 +203,23 @@ export function NotificationsScreen({route: {params}}: Props) {
     scrollToTop()
     if (hasNew) {
       // render what we have now
-      truncateAndInvalidate(queryClient, NOTIFS_RQKEY())
-    } else {
+      truncateAndInvalidate(queryClient, NOTIFS_RQKEY(filter))
+    } else if (!isLoading) {
       // check with the server
       setIsLoadingLatest(true)
-      unreadApi
-        .checkUnread({invalidate: true})
+      checkUnread({invalidate: true})
         .catch(() => undefined)
         .then(() => setIsLoadingLatest(false))
     }
-  }, [scrollToTop, queryClient, unreadApi, hasNew, setIsLoadingLatest])
+  }, [
+    scrollToTop,
+    queryClient,
+    checkUnread,
+    hasNew,
+    isLoading,
+    setIsLoadingLatest,
+    filter,
+  ])
 
   const onFocusCheckLatest = useNonReactiveCallback(() => {
     // on focus, check for latest, but only invalidate if the user
@@ -87,79 +232,36 @@ export function NotificationsScreen({route: {params}}: Props) {
       // we're just going to look it up synchronously.
       currentIsScrolledDown = window.scrollY > 200
     }
-    unreadApi.checkUnread({invalidate: !currentIsScrolledDown})
+    checkUnread({invalidate: !currentIsScrolledDown})
   })
 
   // on-visible setup
   // =
   useFocusEffect(
     React.useCallback(() => {
-      setMinimalShellMode(false)
-      logger.debug('NotificationsScreen: Focus')
-      onFocusCheckLatest()
-    }, [setMinimalShellMode, onFocusCheckLatest]),
+      if (isFocusedAndActive) {
+        setMinimalShellMode(false)
+        logger.debug('NotificationsScreen: Focus')
+        onFocusCheckLatest()
+      }
+    }, [setMinimalShellMode, onFocusCheckLatest, isFocusedAndActive]),
   )
   React.useEffect(() => {
-    if (!isScreenFocused) {
+    if (!isFocusedAndActive) {
       return
     }
     return listenSoftReset(onPressLoadLatest)
-  }, [onPressLoadLatest, isScreenFocused])
+  }, [onPressLoadLatest, isFocusedAndActive])
 
   return (
-    <Layout.Screen testID="notificationsScreen">
-      <Layout.Header.Outer>
-        <Layout.Header.MenuButton />
-        <Layout.Header.Content>
-          <Button
-            label={_(msg`Notifications`)}
-            accessibilityHint={_(msg`Refresh notifications`)}
-            onPress={emitSoftReset}
-            style={[a.justify_start]}>
-            {({hovered}) => (
-              <Layout.Header.TitleText
-                style={[a.w_full, hovered && a.underline]}>
-                <Trans>Notifications</Trans>
-                {isWeb && gtTablet && hasNew && (
-                  <View
-                    style={[
-                      a.rounded_full,
-                      {
-                        width: 8,
-                        height: 8,
-                        bottom: 3,
-                        left: 6,
-                        backgroundColor: t.palette.primary_500,
-                      },
-                    ]}
-                  />
-                )}
-              </Layout.Header.TitleText>
-            )}
-          </Button>
-        </Layout.Header.Content>
-        <Layout.Header.Slot>
-          <Link
-            to="/notifications/settings"
-            label={_(msg`Notification settings`)}
-            size="small"
-            variant="ghost"
-            color="secondary"
-            shape="round"
-            style={[a.justify_center]}>
-            <ButtonIcon
-              icon={isLoadingLatest ? Loader : SettingsIcon}
-              size="lg"
-            />
-          </Link>
-        </Layout.Header.Slot>
-      </Layout.Header.Outer>
-
+    <>
       <MainScrollProvider>
         <NotificationFeed
+          enabled={isFocusedAndActive}
+          filter={filter}
+          refreshNotifications={() => checkUnread({invalidate: true})}
           onScrolledDownChange={setIsScrolledDown}
           scrollElRef={scrollElRef}
-          overridePriorityNotifications={params?.show === 'all'}
         />
       </MainScrollProvider>
       {(isScrolledDown || hasNew) && (
@@ -169,14 +271,6 @@ export function NotificationsScreen({route: {params}}: Props) {
           showIndicator={hasNew}
         />
       )}
-      <FAB
-        testID="composeFAB"
-        onPress={() => openComposer({})}
-        icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
-        accessibilityRole="button"
-        accessibilityLabel={_(msg`New post`)}
-        accessibilityHint=""
-      />
-    </Layout.Screen>
+    </>
   )
 }