about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--package.json4
-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
-rw-r--r--yarn.lock251
15 files changed, 409 insertions, 267 deletions
diff --git a/package.json b/package.json
index bbe46aa7c..23e2bb324 100644
--- a/package.json
+++ b/package.json
@@ -54,7 +54,7 @@
     "icons:optimize": "svgo -f ./assets/icons"
   },
   "dependencies": {
-    "@atproto/api": "^0.13.18",
+    "@atproto/api": "^0.13.20",
     "@bitdrift/react-native": "0.4.0",
     "@braintree/sanitize-url": "^6.0.2",
     "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
@@ -206,7 +206,7 @@
     "zod": "^3.20.2"
   },
   "devDependencies": {
-    "@atproto/dev-env": "^0.3.64",
+    "@atproto/dev-env": "^0.3.67",
     "@babel/core": "^7.26.0",
     "@babel/preset-env": "^7.26.0",
     "@babel/runtime": "^7.26.0",
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>
+    </>
   )
 }
diff --git a/yarn.lock b/yarn.lock
index 16fe9579a..9e2259e0d 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -20,21 +20,21 @@
     "@jridgewell/gen-mapping" "^0.3.0"
     "@jridgewell/trace-mapping" "^0.3.9"
 
-"@atproto-labs/fetch-node@0.1.3":
-  version "0.1.3"
-  resolved "https://registry.yarnpkg.com/@atproto-labs/fetch-node/-/fetch-node-0.1.3.tgz#2581bf4710a4f957c74c75d959961de3304b3595"
-  integrity sha512-KX3ogPJt6dXNppWImQ9omfhrc8t73WrJaxHMphRAqQL8jXxKW5NBCTjSuwroBkJ1pj1aValBrc5NpdYu+H/9Qg==
+"@atproto-labs/fetch-node@0.1.4":
+  version "0.1.4"
+  resolved "https://registry.yarnpkg.com/@atproto-labs/fetch-node/-/fetch-node-0.1.4.tgz#03859a39556eab936e2b3bec2d087585c6408cb3"
+  integrity sha512-hwYx0XpgIl2zydRy13DtWvywruuHk1EX+yCjqjgUIezUm8fi35ZN4QvR6INEm0MpN2MD/kQsImPbd8ZftzZ3zw==
   dependencies:
-    "@atproto-labs/fetch" "0.1.1"
+    "@atproto-labs/fetch" "0.1.2"
     "@atproto-labs/pipe" "0.1.0"
     ipaddr.js "^2.1.0"
     psl "^1.9.0"
     undici "^6.14.1"
 
-"@atproto-labs/fetch@0.1.1":
-  version "0.1.1"
-  resolved "https://registry.yarnpkg.com/@atproto-labs/fetch/-/fetch-0.1.1.tgz#10e7f8c06cf01a63f58e130b95d9ee0d4171902c"
-  integrity sha512-X1zO1MDoJzEurbWXMAe1H8EZ995Xam/aXdxhGVrXmOMyPDuvBa1oxwh/kQNZRCKcMQUbiwkk+Jfq6ZkTuvGbww==
+"@atproto-labs/fetch@0.1.2":
+  version "0.1.2"
+  resolved "https://registry.yarnpkg.com/@atproto-labs/fetch/-/fetch-0.1.2.tgz#e1b9354205fb76f106ae3e1c6b56e7865a39600f"
+  integrity sha512-7mQQIRtVenqtdBQKCqoLjyAhPS2aA56EGEjyz5zB3sramM3qkrvzyusr55GAzGDS0tvB6cy9cDEtSLmfK7LUnA==
   dependencies:
     "@atproto-labs/pipe" "0.1.0"
   optionalDependencies:
@@ -58,28 +58,28 @@
   resolved "https://registry.yarnpkg.com/@atproto-labs/simple-store/-/simple-store-0.1.1.tgz#e743a2722b5d8732166f0a72aca8bd10e9bff106"
   integrity sha512-WKILW2b3QbAYKh+w5U2x6p5FqqLl0nAeLwGeDY+KjX01K4Dq3vQTR9b/qNp0jZm48CabPQVrqCv0PPU9LgRRRg==
 
-"@atproto/api@^0.13.18":
-  version "0.13.18"
-  resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.18.tgz#cc537cc3b4c8d03f258a373f4d893fea11a77cdd"
-  integrity sha512-rrl5HhzGYWZ7fiC965TPBUOVItq9M4dxMb6qz8IvAVQliSkrJrKc7UD0QWL89QiiXaOBuX8w+4i5r4wrfBGddg==
+"@atproto/api@^0.13.20":
+  version "0.13.20"
+  resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.20.tgz#5140db303c3b0981958dfe6a5fa6d7d1cd7bb3cc"
+  integrity sha512-z/+CvG6BEttRHf856tKSe1AeUQNfrobRJldaHAthGmFk7O3wLZQyfcI9DUmBJQ9+4wAt0dZwvKWVGLZOV9eLHA==
   dependencies:
     "@atproto/common-web" "^0.3.1"
-    "@atproto/lexicon" "^0.4.3"
+    "@atproto/lexicon" "^0.4.4"
     "@atproto/syntax" "^0.3.1"
-    "@atproto/xrpc" "^0.6.4"
+    "@atproto/xrpc" "^0.6.5"
     await-lock "^2.2.2"
     multiformats "^9.9.0"
     tlds "^1.234.0"
     zod "^3.23.8"
 
-"@atproto/aws@^0.2.9":
-  version "0.2.9"
-  resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.9.tgz#3539b281b725914b769451ee4afc62315dff1afc"
-  integrity sha512-sc9aXUePcqItkJSOJJnGNVthVfAKjhn3zMDG+RRLzKUBye6Yutrlhpt1yxNZLHQiqIK5fy2Cuc4EX3p3jeWUYw==
+"@atproto/aws@^0.2.10":
+  version "0.2.10"
+  resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.10.tgz#e0b888fd50308cc24b7086cf3ec209587c13bbe4"
+  integrity sha512-zQElKk6wGTQo5aKdXtmx/dINjkVgbJU9+C/xOVTs+M88I8IrrBxPvo1dASLJcMtRb9VjXh5snLJeAjgyx6qC6Q==
   dependencies:
-    "@atproto/common" "^0.4.4"
+    "@atproto/common" "^0.4.5"
     "@atproto/crypto" "^0.4.2"
-    "@atproto/repo" "^0.5.5"
+    "@atproto/repo" "^0.6.0"
     "@aws-sdk/client-cloudfront" "^3.261.0"
     "@aws-sdk/client-kms" "^3.196.0"
     "@aws-sdk/client-s3" "^3.224.0"
@@ -89,20 +89,20 @@
     multiformats "^9.9.0"
     uint8arrays "3.0.0"
 
-"@atproto/bsky@^0.0.96":
-  version "0.0.96"
-  resolved "https://registry.yarnpkg.com/@atproto/bsky/-/bsky-0.0.96.tgz#b89abf2828f57738357beb4efd05539667dd14b3"
-  integrity sha512-Tk0ppiPMKdcnPU3x+uBAVRn92vroznhr2OlqinNSy/PZ39qWViRlKAhG3CLJsU2gjSHxsNfaIwulj7tPvKCmSw==
+"@atproto/bsky@^0.0.98":
+  version "0.0.98"
+  resolved "https://registry.yarnpkg.com/@atproto/bsky/-/bsky-0.0.98.tgz#4c4746e588568df1878647ae80cf4b963bc95924"
+  integrity sha512-Y+un2pD1W1H0s0IWdY6S4vLy8rgR8cpqThz9onn4wDppmGWvOBNXeD8AjNzIWC0iFlYcfR4rwCKSoccUXYzxNg==
   dependencies:
-    "@atproto/api" "^0.13.18"
-    "@atproto/common" "^0.4.4"
+    "@atproto/api" "^0.13.20"
+    "@atproto/common" "^0.4.5"
     "@atproto/crypto" "^0.4.2"
     "@atproto/identity" "^0.4.3"
-    "@atproto/lexicon" "^0.4.3"
-    "@atproto/repo" "^0.5.5"
-    "@atproto/sync" "^0.1.6"
+    "@atproto/lexicon" "^0.4.4"
+    "@atproto/repo" "^0.6.0"
+    "@atproto/sync" "^0.1.7"
     "@atproto/syntax" "^0.3.1"
-    "@atproto/xrpc-server" "^0.7.3"
+    "@atproto/xrpc-server" "^0.7.4"
     "@bufbuild/protobuf" "^1.5.0"
     "@connectrpc/connect" "^1.1.4"
     "@connectrpc/connect-express" "^1.1.4"
@@ -129,12 +129,12 @@
     typed-emitter "^2.1.0"
     uint8arrays "3.0.0"
 
-"@atproto/bsync@^0.0.9":
-  version "0.0.9"
-  resolved "https://registry.yarnpkg.com/@atproto/bsync/-/bsync-0.0.9.tgz#7a6d58ef776404893d3c1139bdfe606fef483612"
-  integrity sha512-N0+TnYOoJz4hTo6/h1jJKh6QzdbwkFuOQ1bdwugzST7ZkwMtjs5FX8o/uqgiD4gSHSqfQSRrew7+qYEHUT61Aw==
+"@atproto/bsync@^0.0.10":
+  version "0.0.10"
+  resolved "https://registry.yarnpkg.com/@atproto/bsync/-/bsync-0.0.10.tgz#fa16acfaf67112449b703778a20c785226c94189"
+  integrity sha512-qviPMyYade/sqhX/9X9eTT4KaQ+FLvOyz+140LCDk/0vbZUCZPuYSEXZDCQkL5nlEXzScsQ3iyVeoYCGvV5kYw==
   dependencies:
-    "@atproto/common" "^0.4.4"
+    "@atproto/common" "^0.4.5"
     "@atproto/syntax" "^0.3.1"
     "@bufbuild/protobuf" "^1.5.0"
     "@connectrpc/connect" "^1.1.4"
@@ -175,10 +175,10 @@
     pino "^8.6.1"
     zod "^3.14.2"
 
-"@atproto/common@^0.4.4":
-  version "0.4.4"
-  resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.4.4.tgz#79096aef920f5ad7cda5c682d7ed7416d0581e1a"
-  integrity sha512-58tMbn6A1Zu296s/l3uIj8z9d7IRHpZvLOfsFRikaQaYrzhJpL2aPY4uFQ8GJcxnsxeUnxBCrQz9we5jVVJI5Q==
+"@atproto/common@^0.4.5":
+  version "0.4.5"
+  resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.4.5.tgz#28fd176a9b5527c723828e725586bc0be9fa9516"
+  integrity sha512-LFAGqHcxCI5+b31Xgk+VQQtZU258iGPpHJzNeHVcdh6teIKZi4C2l6YV+m+3CEz+yYcfP7jjUmgqesx7l9Arsg==
   dependencies:
     "@atproto/common-web" "^0.3.1"
     "@ipld/dag-cbor" "^7.0.3"
@@ -207,23 +207,23 @@
     "@noble/hashes" "^1.3.1"
     uint8arrays "3.0.0"
 
-"@atproto/dev-env@^0.3.64":
-  version "0.3.64"
-  resolved "https://registry.yarnpkg.com/@atproto/dev-env/-/dev-env-0.3.64.tgz#148537785b6a86b0a56d0988e63a1ff8ea7c84e9"
-  integrity sha512-s7mdppgp2BS0uy5ASZwqJ3J8dez14pDGI9uqTGbsOYF/qTCbBGZKw/Vkqjci5bY1UaW+o6n787q63ECDtljM8A==
+"@atproto/dev-env@^0.3.67":
+  version "0.3.67"
+  resolved "https://registry.yarnpkg.com/@atproto/dev-env/-/dev-env-0.3.67.tgz#4f6a20f0aafa8125ed9ec715abceedd11580882e"
+  integrity sha512-7Ize4Y5vdjQjyrxTwjBPbkxKXQdE02KpE7AJLJt6Xpvowd2vbn8l8rDXfha+LtVi6t/613U4s+Slo5c1YD3x9A==
   dependencies:
-    "@atproto/api" "^0.13.18"
-    "@atproto/bsky" "^0.0.96"
-    "@atproto/bsync" "^0.0.9"
+    "@atproto/api" "^0.13.20"
+    "@atproto/bsky" "^0.0.98"
+    "@atproto/bsync" "^0.0.10"
     "@atproto/common-web" "^0.3.1"
     "@atproto/crypto" "^0.4.2"
     "@atproto/identity" "^0.4.3"
-    "@atproto/lexicon" "^0.4.3"
-    "@atproto/ozone" "^0.1.57"
-    "@atproto/pds" "^0.4.73"
-    "@atproto/sync" "^0.1.6"
+    "@atproto/lexicon" "^0.4.4"
+    "@atproto/ozone" "^0.1.59"
+    "@atproto/pds" "^0.4.76"
+    "@atproto/sync" "^0.1.7"
     "@atproto/syntax" "^0.3.1"
-    "@atproto/xrpc-server" "^0.7.3"
+    "@atproto/xrpc-server" "^0.7.4"
     "@did-plc/lib" "^0.0.1"
     "@did-plc/server" "^0.0.1"
     axios "^0.27.2"
@@ -258,10 +258,10 @@
     multiformats "^9.9.0"
     zod "^3.23.8"
 
-"@atproto/lexicon@^0.4.3":
-  version "0.4.3"
-  resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.3.tgz#d69f6bb363a6326df7766c48132bfa30e22622d9"
-  integrity sha512-lFVZXe1S1pJP0dcxvJuHP3r/a+EAIBwwU7jUK+r8iLhIja+ml6NmYv8KeFHmIJATh03spEQ9s02duDmFVdCoXg==
+"@atproto/lexicon@^0.4.4":
+  version "0.4.4"
+  resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.4.tgz#0d97314bb57b693b76f2495fa5e02872469dd93a"
+  integrity sha512-QFEmr3rpj/RoAmfX9ALU/asBG/rsVtQZnw+9nOB1/AuIwoxXd+ZyndR6lVUc2+DL4GEjl6W2yvBru5xbQIZWyA==
   dependencies:
     "@atproto/common-web" "^0.3.1"
     "@atproto/syntax" "^0.3.1"
@@ -269,20 +269,20 @@
     multiformats "^9.9.0"
     zod "^3.23.8"
 
-"@atproto/oauth-provider@^0.2.7":
-  version "0.2.7"
-  resolved "https://registry.yarnpkg.com/@atproto/oauth-provider/-/oauth-provider-0.2.7.tgz#38a211c197ee1ce4e92a5b59a92f2e15fcacee0b"
-  integrity sha512-T/cEr7TGs36SqTW8JzLAt9EchumYY48zuI4rqoAepYT29eGpP37SxK+5X0+fQHOKJPKWUGlYocR9fDm4CdzAPQ==
+"@atproto/oauth-provider@^0.2.10":
+  version "0.2.10"
+  resolved "https://registry.yarnpkg.com/@atproto/oauth-provider/-/oauth-provider-0.2.10.tgz#f9820d7f82c33d3b74e81a75873f50e1e654b901"
+  integrity sha512-cF42lo0+Mj+Zq2RXwS2NxmobmtL7YL1vXlYcN6iKflZ8pQ5WvpR/cZKsKEZOT9cEBBTw5MARKTYxbr8CPDKlHg==
   dependencies:
-    "@atproto-labs/fetch" "0.1.1"
-    "@atproto-labs/fetch-node" "0.1.3"
+    "@atproto-labs/fetch" "0.1.2"
+    "@atproto-labs/fetch-node" "0.1.4"
     "@atproto-labs/pipe" "0.1.0"
     "@atproto-labs/simple-store" "0.1.1"
     "@atproto-labs/simple-store-memory" "0.1.1"
-    "@atproto/common" "^0.4.4"
+    "@atproto/common" "^0.4.5"
     "@atproto/jwk" "0.1.1"
     "@atproto/jwk-jose" "0.1.2"
-    "@atproto/oauth-types" "0.2.0"
+    "@atproto/oauth-types" "0.2.1"
     "@hapi/accept" "^6.0.3"
     "@hapi/bourne" "^3.0.0"
     "@hapi/content" "^6.0.0"
@@ -294,27 +294,27 @@
     psl "^1.9.0"
     zod "^3.23.8"
 
-"@atproto/oauth-types@0.2.0":
-  version "0.2.0"
-  resolved "https://registry.yarnpkg.com/@atproto/oauth-types/-/oauth-types-0.2.0.tgz#28bc861b56cba093e6c52603cec1d3d38cd2a1e7"
-  integrity sha512-v/4ht6eRh0yOu2iuuWujZdnJBamPKimdy8k0Xan8cVZ+a2i83UkhIIU+S/XUbbvJ4a64wLPZrS9IDd0K5XYYTQ==
+"@atproto/oauth-types@0.2.1":
+  version "0.2.1"
+  resolved "https://registry.yarnpkg.com/@atproto/oauth-types/-/oauth-types-0.2.1.tgz#a7ace557cc91817fcde6195f023e4e1838e4aef6"
+  integrity sha512-hDisUXzcq5KU1HMuCYZ8Kcz7BePl7V11bFjjgZvND3mdSphiyBpJ8MCNn3QzAa6cXpFo0w9PDcYMAlCCRZHdVw==
   dependencies:
     "@atproto/jwk" "0.1.1"
     zod "^3.23.8"
 
-"@atproto/ozone@^0.1.57":
-  version "0.1.57"
-  resolved "https://registry.yarnpkg.com/@atproto/ozone/-/ozone-0.1.57.tgz#141d66b213710575c7859d691586fd44c731f7ca"
-  integrity sha512-P2YKeRFPbxKc2e2yftUoMTTcWYuFV0qU1/Nkd4GxuHnBnDJcbtMPglXd7kyLf0p8plCCFau/wZ8QdY8KSDLM9Q==
+"@atproto/ozone@^0.1.59":
+  version "0.1.59"
+  resolved "https://registry.yarnpkg.com/@atproto/ozone/-/ozone-0.1.59.tgz#219984a46617b0ac039f2f02767290eaa0b4cfc3"
+  integrity sha512-AD03Ocb3fZW+grxO/VwMld5iNdCLgbahFzku6xh1qEw0tLOBKp3GXSfepVd9XWu5fb1yPhGPd2JgjApV5hbJvw==
   dependencies:
-    "@atproto/api" "^0.13.18"
-    "@atproto/common" "^0.4.4"
+    "@atproto/api" "^0.13.20"
+    "@atproto/common" "^0.4.5"
     "@atproto/crypto" "^0.4.2"
     "@atproto/identity" "^0.4.3"
-    "@atproto/lexicon" "^0.4.3"
+    "@atproto/lexicon" "^0.4.4"
     "@atproto/syntax" "^0.3.1"
-    "@atproto/xrpc" "^0.6.4"
-    "@atproto/xrpc-server" "^0.7.3"
+    "@atproto/xrpc" "^0.6.5"
+    "@atproto/xrpc-server" "^0.7.4"
     "@did-plc/lib" "^0.0.1"
     axios "^1.6.7"
     compression "^1.7.4"
@@ -331,29 +331,30 @@
     typed-emitter "^2.1.0"
     uint8arrays "3.0.0"
 
-"@atproto/pds@^0.4.73":
-  version "0.4.73"
-  resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.4.73.tgz#49b7625d9b40a5a24be1cdd7cdb56faab9e25707"
-  integrity sha512-fzrKlgKVF5JvTTmhfvofXT9Ok1KFTfAjCzTrLJivbOcqQSqBagNTuz5CiQxAAAo/JTlSxmnyr3e7OrlJdrph1w==
+"@atproto/pds@^0.4.76":
+  version "0.4.76"
+  resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.4.76.tgz#cd7b3f13359a7c31dc9362a5e4309419512c4102"
+  integrity sha512-+cFVpqlgpCS0BuGac5fCQPZUugpS1r7ghnSQLVdjnTnvQJCqLRA++BlJWYbGgRP6FJrumCY2jtuwG8t59Rjt8Q==
   dependencies:
-    "@atproto-labs/fetch-node" "0.1.3"
-    "@atproto/api" "^0.13.18"
-    "@atproto/aws" "^0.2.9"
-    "@atproto/common" "^0.4.4"
+    "@atproto-labs/fetch-node" "0.1.4"
+    "@atproto/api" "^0.13.20"
+    "@atproto/aws" "^0.2.10"
+    "@atproto/common" "^0.4.5"
     "@atproto/crypto" "^0.4.2"
     "@atproto/identity" "^0.4.3"
-    "@atproto/lexicon" "^0.4.3"
-    "@atproto/oauth-provider" "^0.2.7"
-    "@atproto/repo" "^0.5.5"
+    "@atproto/lexicon" "^0.4.4"
+    "@atproto/oauth-provider" "^0.2.10"
+    "@atproto/repo" "^0.6.0"
     "@atproto/syntax" "^0.3.1"
-    "@atproto/xrpc" "^0.6.4"
-    "@atproto/xrpc-server" "^0.7.3"
+    "@atproto/xrpc" "^0.6.5"
+    "@atproto/xrpc-server" "^0.7.4"
     "@did-plc/lib" "^0.0.4"
+    "@hapi/address" "^5.1.1"
     better-sqlite3 "^10.0.0"
     bytes "^3.1.2"
     compression "^1.7.4"
     cors "^2.8.5"
-    disposable-email "^0.2.3"
+    disposable-email-domains-js "^1.5.0"
     express "^4.17.2"
     express-async-errors "^3.1.1"
     file-type "^16.5.4"
@@ -376,49 +377,50 @@
     undici "^6.19.8"
     zod "^3.23.8"
 
-"@atproto/repo@^0.5.5":
-  version "0.5.5"
-  resolved "https://registry.yarnpkg.com/@atproto/repo/-/repo-0.5.5.tgz#73eaf1a0b35cfc4fc1c837f4e3ddeb6768d29c20"
-  integrity sha512-Zu1tw42KBVyFzIh1XYSIvm8V+V9oEKWJR7NnHBgeSMwCc9QwM32jO7uqgvEjZYEXgdYKanGhv/YHLyxtZa5Ckg==
+"@atproto/repo@^0.6.0":
+  version "0.6.0"
+  resolved "https://registry.yarnpkg.com/@atproto/repo/-/repo-0.6.0.tgz#29e698731e6df63636b0f7c91ce106a9de50ad19"
+  integrity sha512-6YGVhjiHKmqCW5Ce4oY49E3NCEfbvAGowJ5ETXX2sx2l4D2bOL7a2hn5zWqsPHYpWSLjrPfnj7PVpApK0kmL7A==
   dependencies:
-    "@atproto/common" "^0.4.4"
+    "@atproto/common" "^0.4.5"
     "@atproto/common-web" "^0.3.1"
     "@atproto/crypto" "^0.4.2"
-    "@atproto/lexicon" "^0.4.3"
+    "@atproto/lexicon" "^0.4.4"
     "@ipld/car" "^3.2.3"
     "@ipld/dag-cbor" "^7.0.0"
     multiformats "^9.9.0"
     uint8arrays "3.0.0"
     zod "^3.23.8"
 
-"@atproto/sync@^0.1.6":
-  version "0.1.6"
-  resolved "https://registry.yarnpkg.com/@atproto/sync/-/sync-0.1.6.tgz#fb3e61147c05caf2c3d1cd597ff94fef68abbc02"
-  integrity sha512-9lqe6E6fIns28TJyQufLCVefMxmK3bvEfQBhmXJBGZMHuKlH8+F5P9DfnHv6vs6ygfmHIUIjYDWqJu/rpt8pzw==
+"@atproto/sync@^0.1.7":
+  version "0.1.7"
+  resolved "https://registry.yarnpkg.com/@atproto/sync/-/sync-0.1.7.tgz#c7f78d99bb40eacf93ca13fdd04134a0985bf421"
+  integrity sha512-liJH2EsD4AbWA8G0oRDURgbHW6Uq4NnM2rNfbrTlqgtj0kyGRY3FcVEyqeRcaQYfCuscChIg5DQKHqY421/7Mw==
   dependencies:
-    "@atproto/common" "^0.4.4"
+    "@atproto/common" "^0.4.5"
     "@atproto/identity" "^0.4.3"
-    "@atproto/lexicon" "^0.4.3"
-    "@atproto/repo" "^0.5.5"
+    "@atproto/lexicon" "^0.4.4"
+    "@atproto/repo" "^0.6.0"
     "@atproto/syntax" "^0.3.1"
-    "@atproto/xrpc-server" "^0.7.3"
+    "@atproto/xrpc-server" "^0.7.4"
     multiformats "^9.9.0"
     p-queue "^6.6.2"
+    ws "^8.12.0"
 
 "@atproto/syntax@^0.3.1":
   version "0.3.1"
   resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.3.1.tgz#4346418728f9643d783d2ffcf7c77e132e1f53d4"
   integrity sha512-fzW0Mg1QUOVCWUD3RgEsDt6d1OZ6DdFmbKcDdbzUfh0t4rhtRAC05KbZYmxuMPWDAiJ4BbbQ5dkAc/mNypMXkw==
 
-"@atproto/xrpc-server@^0.7.3":
-  version "0.7.3"
-  resolved "https://registry.yarnpkg.com/@atproto/xrpc-server/-/xrpc-server-0.7.3.tgz#d09b36d00edb7aacca48675d1ebb7fa796fa11bd"
-  integrity sha512-x0qegkN6snrbXJO3v9h2kuh9e90g6ZZkDXv3COiraGS3yRTzIm6i4bMvDSfCI50+0xCNtPKOkpn8taRoRgkyiw==
+"@atproto/xrpc-server@^0.7.4":
+  version "0.7.4"
+  resolved "https://registry.yarnpkg.com/@atproto/xrpc-server/-/xrpc-server-0.7.4.tgz#dfac8f7276c1c971a35eaba627eb6372088441c3"
+  integrity sha512-MrAwxfJBQm/kCol3D8qc+vpQzBMzLqvtUbauSSfVVJ10PlGtxg4LlXqcjkAuhrjyrqp3dQH9LHuhDpgVQK+G3w==
   dependencies:
-    "@atproto/common" "^0.4.4"
+    "@atproto/common" "^0.4.5"
     "@atproto/crypto" "^0.4.2"
-    "@atproto/lexicon" "^0.4.3"
-    "@atproto/xrpc" "^0.6.4"
+    "@atproto/lexicon" "^0.4.4"
+    "@atproto/xrpc" "^0.6.5"
     cbor-x "^1.5.1"
     express "^4.17.2"
     http-errors "^2.0.0"
@@ -428,12 +430,12 @@
     ws "^8.12.0"
     zod "^3.23.8"
 
-"@atproto/xrpc@^0.6.4":
-  version "0.6.4"
-  resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.4.tgz#4cf59774f7c72e5bc821bc5f1d57f0a6ae2014db"
-  integrity sha512-9ZAJ8nsXTqC4XFyS0E1Wlg7bAvonhXQNQ3Ocs1L1LIwFLXvsw/4fNpIHXxvXvqTCVeyHLbImOnE9UiO1c/qIYA==
+"@atproto/xrpc@^0.6.5":
+  version "0.6.5"
+  resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.5.tgz#8b180fc5f6b8374fd00c41b9e4cd7b24ead48e6b"
+  integrity sha512-t6u8iPEVbWge5RhzKZDahSzNDYIAxUtop6Q/X/apAZY1rgreVU0/1sSvvRoRFH19d3UIKjYdLuwFqMi9w8nY3Q==
   dependencies:
-    "@atproto/lexicon" "^0.4.3"
+    "@atproto/lexicon" "^0.4.4"
     zod "^3.23.8"
 
 "@aws-crypto/crc32@3.0.0":
@@ -4378,6 +4380,13 @@
     "@hapi/boom" "^10.0.1"
     "@hapi/hoek" "^11.0.2"
 
+"@hapi/address@^5.1.1":
+  version "5.1.1"
+  resolved "https://registry.yarnpkg.com/@hapi/address/-/address-5.1.1.tgz#e9925fc1b65f5cc3fbea821f2b980e4652e84cb6"
+  integrity sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==
+  dependencies:
+    "@hapi/hoek" "^11.0.2"
+
 "@hapi/boom@^10.0.0", "@hapi/boom@^10.0.1":
   version "10.0.1"
   resolved "https://registry.yarnpkg.com/@hapi/boom/-/boom-10.0.1.tgz#ebb14688275ae150aa6af788dbe482e6a6062685"
@@ -9593,10 +9602,10 @@ dir-glob@^3.0.1:
   dependencies:
     path-type "^4.0.0"
 
-disposable-email@^0.2.3:
-  version "0.2.3"
-  resolved "https://registry.yarnpkg.com/disposable-email/-/disposable-email-0.2.3.tgz#a21a49717f6034a8ff777dc8eae3b4d994a7b988"
-  integrity sha512-gkBQQ5Res431ZXqLlAafrXHizG7/1FWmi8U2RTtriD78Vc10HhBUvdJun3R4eSF0KRIQQJs+wHlxjkED/Hr1EQ==
+disposable-email-domains-js@^1.5.0:
+  version "1.7.0"
+  resolved "https://registry.yarnpkg.com/disposable-email-domains-js/-/disposable-email-domains-js-1.7.0.tgz#2bf859bccf7a2eb697025577e18f0434409713ec"
+  integrity sha512-qcIJcnXjDvH8EEt0tyAesk1sZVGU5ZFtW6Wys2wKCAcbUf5nJYfwZfT7Z0PVA/LBMlqd/Xgk9dXN2Q3fx7NFAg==
 
 dns-equal@^1.0.0:
   version "1.0.0"