about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--src/App.native.tsx2
-rw-r--r--src/Navigation.tsx146
-rw-r--r--src/lib/hooks/useNotificationHandler.ts250
-rw-r--r--src/lib/notifications/notifications.ts20
-rw-r--r--src/lib/notifications/util.ts3
-rw-r--r--src/lib/routes/links.ts2
-rw-r--r--src/logger/metrics.ts1
7 files changed, 276 insertions, 148 deletions
diff --git a/src/App.native.tsx b/src/App.native.tsx
index c42b11746..2278b73de 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -17,6 +17,7 @@ import {useLingui} from '@lingui/react'
 import * as Sentry from '@sentry/react-native'
 
 import {KeyboardControllerProvider} from '#/lib/hooks/useEnableKeyboardController'
+import {Provider as HideBottomBarBorderProvider} from '#/lib/hooks/useHideBottomBarBorder'
 import {QueryProvider} from '#/lib/react-query'
 import {Provider as StatsigProvider, tryFetchGates} from '#/lib/statsig/statsig'
 import {s} from '#/lib/styles'
@@ -73,7 +74,6 @@ import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbe
 import {Splash} from '#/Splash'
 import {BottomSheetProvider} from '../modules/bottom-sheet'
 import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
-import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottomBarBorder'
 
 SplashScreen.preventAutoHideAsync()
 if (isIOS) {
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index 26a2b2a2a..e71148a2c 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -1,4 +1,5 @@
-import * as React from 'react'
+import {useCallback, useRef} from 'react'
+import * as Notifications from 'expo-notifications'
 import {i18n, type MessageDescriptor} from '@lingui/core'
 import {msg} from '@lingui/macro'
 import {
@@ -10,13 +11,21 @@ import {
   createNavigationContainerRef,
   DarkTheme,
   DefaultTheme,
+  type LinkingOptions,
   NavigationContainer,
   StackActions,
 } from '@react-navigation/native'
 
 import {timeout} from '#/lib/async/timeout'
 import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
+import {
+  getNotificationPayload,
+  type NotificationPayload,
+  notificationToURL,
+  storePayloadForAccountSwitch,
+} from '#/lib/hooks/useNotificationHandler'
 import {useWebScrollRestoration} from '#/lib/hooks/useWebScrollRestoration'
+import {logger as notyLogger} from '#/lib/notifications/util'
 import {buildStateObject} from '#/lib/routes/helpers'
 import {
   type AllNavigatorParams,
@@ -71,6 +80,7 @@ import {MessagesSettingsScreen} from '#/screens/Messages/Settings'
 import {ModerationScreen} from '#/screens/Moderation'
 import {Screen as ModerationVerificationSettings} from '#/screens/Moderation/VerificationSettings'
 import {Screen as ModerationInteractionSettings} from '#/screens/ModerationInteractionSettings'
+import {NotificationsActivityListScreen} from '#/screens/Notifications/ActivityList'
 import {PostLikedByScreen} from '#/screens/Post/PostLikedBy'
 import {PostQuotesScreen} from '#/screens/Post/PostQuotes'
 import {PostRepostedByScreen} from '#/screens/Post/PostRepostedBy'
@@ -93,6 +103,18 @@ import {ExternalMediaPreferencesScreen} from '#/screens/Settings/ExternalMediaPr
 import {FollowingFeedPreferencesScreen} from '#/screens/Settings/FollowingFeedPreferences'
 import {InterestsSettingsScreen} from '#/screens/Settings/InterestsSettings'
 import {LanguageSettingsScreen} from '#/screens/Settings/LanguageSettings'
+import {LegacyNotificationSettingsScreen} from '#/screens/Settings/LegacyNotificationSettings'
+import {NotificationSettingsScreen} from '#/screens/Settings/NotificationSettings'
+import {ActivityNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/ActivityNotificationSettings'
+import {LikeNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/LikeNotificationSettings'
+import {LikesOnRepostsNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/LikesOnRepostsNotificationSettings'
+import {MentionNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/MentionNotificationSettings'
+import {MiscellaneousNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/MiscellaneousNotificationSettings'
+import {NewFollowerNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/NewFollowerNotificationSettings'
+import {QuoteNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/QuoteNotificationSettings'
+import {ReplyNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/ReplyNotificationSettings'
+import {RepostNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/RepostNotificationSettings'
+import {RepostsOnRepostsNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/RepostsOnRepostsNotificationSettings'
 import {PrivacyAndSecuritySettingsScreen} from '#/screens/Settings/PrivacyAndSecuritySettings'
 import {SettingsScreen} from '#/screens/Settings/Settings'
 import {ThreadPreferencesScreen} from '#/screens/Settings/ThreadPreferences'
@@ -110,19 +132,10 @@ import {
 } from '#/components/dialogs/EmailDialog'
 import {router} from '#/routes'
 import {Referrer} from '../modules/expo-bluesky-swiss-army'
-import {NotificationsActivityListScreen} from './screens/Notifications/ActivityList'
-import {LegacyNotificationSettingsScreen} from './screens/Settings/LegacyNotificationSettings'
-import {NotificationSettingsScreen} from './screens/Settings/NotificationSettings'
-import {ActivityNotificationSettingsScreen} from './screens/Settings/NotificationSettings/ActivityNotificationSettings'
-import {LikeNotificationSettingsScreen} from './screens/Settings/NotificationSettings/LikeNotificationSettings'
-import {LikesOnRepostsNotificationSettingsScreen} from './screens/Settings/NotificationSettings/LikesOnRepostsNotificationSettings'
-import {MentionNotificationSettingsScreen} from './screens/Settings/NotificationSettings/MentionNotificationSettings'
-import {MiscellaneousNotificationSettingsScreen} from './screens/Settings/NotificationSettings/MiscellaneousNotificationSettings'
-import {NewFollowerNotificationSettingsScreen} from './screens/Settings/NotificationSettings/NewFollowerNotificationSettings'
-import {QuoteNotificationSettingsScreen} from './screens/Settings/NotificationSettings/QuoteNotificationSettings'
-import {ReplyNotificationSettingsScreen} from './screens/Settings/NotificationSettings/ReplyNotificationSettings'
-import {RepostNotificationSettingsScreen} from './screens/Settings/NotificationSettings/RepostNotificationSettings'
-import {RepostsOnRepostsNotificationSettingsScreen} from './screens/Settings/NotificationSettings/RepostsOnRepostsNotificationSettings'
+import {useAccountSwitcher} from './lib/hooks/useAccountSwitcher'
+import {useNonReactiveCallback} from './lib/hooks/useNonReactiveCallback'
+import {useLoggedOutViewControls} from './state/shell/logged-out'
+import {useCloseAllActiveElements} from './state/util'
 
 const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
 
@@ -595,7 +608,7 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
  * in 3 distinct tab-stacks with a different root screen on each.
  */
 function TabsNavigator() {
-  const tabBar = React.useCallback(
+  const tabBar = useCallback(
     (props: JSX.IntrinsicAttributes & BottomTabBarProps) => (
       <BottomBar {...props} />
     ),
@@ -762,6 +775,7 @@ const FlatNavigator = () => {
 
 const LINKING = {
   // TODO figure out what we are going to use
+  // note: `bluesky://` is what is used in app.config.js
   prefixes: ['bsky://', 'bluesky://', 'https://bsky.app'],
 
   getPathFromState(state: State) {
@@ -818,13 +832,100 @@ const LINKING = {
       return res
     }
   },
-}
+} satisfies LinkingOptions<AllNavigatorParams>
+
+/**
+ * Used to ensure we don't handle the same notification twice
+ */
+let lastHandledNotificationDateDedupe: number | undefined
 
 function RoutesContainer({children}: React.PropsWithChildren<{}>) {
   const theme = useColorSchemeStyle(DefaultTheme, DarkTheme)
-  const {currentAccount} = useSession()
-  const prevLoggedRouteName = React.useRef<string | undefined>(undefined)
+  const {currentAccount, accounts} = useSession()
+  const {onPressSwitchAccount} = useAccountSwitcher()
+  const {setShowLoggedOut} = useLoggedOutViewControls()
+  const prevLoggedRouteName = useRef<string | undefined>(undefined)
   const emailDialogControl = useEmailDialogControl()
+  const closeAllActiveElements = useCloseAllActiveElements()
+
+  /**
+   * Handle navigation to a conversation, or prepares for account switch.
+   *
+   * Non-reactive because we need the latest data from some hooks
+   * after an async call - sfn
+   */
+  const handleChatMessage = useNonReactiveCallback(
+    (payload: Extract<NotificationPayload, {reason: 'chat-message'}>) => {
+      notyLogger.debug(`handleChatMessage`, {payload})
+
+      if (payload.recipientDid !== currentAccount?.did) {
+        // handled in useNotificationHandler after account switch finishes
+        storePayloadForAccountSwitch(payload)
+        closeAllActiveElements()
+
+        const account = accounts.find(a => a.did === payload.recipientDid)
+        if (account) {
+          onPressSwitchAccount(account, 'Notification')
+        } else {
+          setShowLoggedOut(true)
+        }
+      } else {
+        // @ts-expect-error nested navigators aren't typed -sfn
+        navigate('MessagesTab', {
+          screen: 'MessagesConversation',
+          params: {
+            conversation: payload.convoId,
+          },
+        })
+      }
+    },
+  )
+
+  async function handlePushNotificationEntry() {
+    if (!isNative) return
+
+    /**
+     * The notification that caused the app to open, if applicable
+     */
+    const response = await Notifications.getLastNotificationResponseAsync()
+
+    if (response) {
+      notyLogger.debug(`handlePushNotificationEntry: response`, {response})
+
+      if (response.notification.date === lastHandledNotificationDateDedupe)
+        return
+      lastHandledNotificationDateDedupe = response.notification.date
+
+      const payload = getNotificationPayload(response.notification)
+
+      if (payload) {
+        notyLogger.metric(
+          'notifications:openApp',
+          {reason: payload.reason, causedBoot: true},
+          {statsig: false},
+        )
+
+        if (payload.reason === 'chat-message') {
+          handleChatMessage(payload)
+        } else {
+          const path = notificationToURL(payload)
+
+          if (path === '/notifications') {
+            resetToTab('NotificationsTab')
+            notyLogger.debug(`handlePushNotificationEntry: default navigate`)
+          } else if (path) {
+            const [screen, params] = router.matchPath(path)
+            // @ts-expect-error nested navigators aren't typed -sfn
+            navigate('HomeTab', {screen, params})
+            notyLogger.debug(`handlePushNotificationEntry: navigate`, {
+              screen,
+              params,
+            })
+          }
+        }
+      }
+    }
+  }
 
   function onReady() {
     prevLoggedRouteName.current = getCurrentRouteName()
@@ -845,9 +946,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
         onStateChange={() => {
           logger.metric(
             'router:navigate',
-            {
-              from: prevLoggedRouteName.current,
-            },
+            {from: prevLoggedRouteName.current},
             {statsig: false},
           )
           prevLoggedRouteName.current = getCurrentRouteName()
@@ -857,6 +956,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
           logModuleInitTime()
           onReady()
           logger.metric('router:navigate', {}, {statsig: false})
+          handlePushNotificationEntry()
         }}
         // WARNING: Implicit navigation to nested navigators is depreciated in React Navigation 7.x
         // However, there's a fair amount of places we do that, especially in when popping to the top of stacks.
@@ -906,7 +1006,9 @@ function navigate<K extends keyof AllNavigatorParams>(
   return Promise.resolve()
 }
 
-function resetToTab(tabName: 'HomeTab' | 'SearchTab' | 'NotificationsTab') {
+function resetToTab(
+  tabName: 'HomeTab' | 'SearchTab' | 'MessagesTab' | 'NotificationsTab',
+) {
   if (navigationRef.isReady()) {
     navigate(tabName)
     if (navigationRef.canGoBack()) {
diff --git a/src/lib/hooks/useNotificationHandler.ts b/src/lib/hooks/useNotificationHandler.ts
index 6c3e7deb8..ddee11fb5 100644
--- a/src/lib/hooks/useNotificationHandler.ts
+++ b/src/lib/hooks/useNotificationHandler.ts
@@ -7,9 +7,9 @@ import {CommonActions, useNavigation} from '@react-navigation/native'
 import {useQueryClient} from '@tanstack/react-query'
 
 import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher'
+import {logger as notyLogger} from '#/lib/notifications/util'
 import {type NavigationProp} from '#/lib/routes/types'
-import {Logger} from '#/logger'
-import {isAndroid} from '#/platform/detection'
+import {isAndroid, isIOS} from '#/platform/detection'
 import {useCurrentConvoId} from '#/state/messages/current-convo-id'
 import {RQKEY as RQKEY_NOTIFS} from '#/state/queries/notifications/feed'
 import {invalidateCachedUnreadPage} from '#/state/queries/notifications/unread'
@@ -18,6 +18,7 @@ import {useSession} from '#/state/session'
 import {useLoggedOutViewControls} from '#/state/shell/logged-out'
 import {useCloseAllActiveElements} from '#/state/util'
 import {resetToTab} from '#/Navigation'
+import {router} from '#/routes'
 
 export type NotificationReason =
   | 'like'
@@ -39,12 +40,13 @@ export type NotificationReason =
  * `notification.request.trigger.payload` being `undefined`, as specified in
  * the source types.
  */
-type NotificationPayload =
+export type NotificationPayload =
   | undefined
   | {
       reason: Exclude<NotificationReason, 'chat-message'>
       uri: string
       subject: string
+      recipientDid: string
     }
   | {
       reason: 'chat-message'
@@ -60,11 +62,17 @@ const DEFAULT_HANDLER_OPTIONS = {
   shouldSetBadge: true,
 } satisfies Notifications.NotificationBehavior
 
-// These need to stay outside the hook to persist between account switches
-let storedPayload: NotificationPayload
-let prevDate = 0
+/**
+ * Cached notification payload if we handled a notification while the user was
+ * using a different account. This is consumed after we finish switching
+ * accounts.
+ */
+let storedAccountSwitchPayload: NotificationPayload
 
-const logger = Logger.create(Logger.Context.Notifications)
+/**
+ * Used to ensure we don't handle the same notification twice
+ */
+let lastHandledNotificationDateDedupe = 0
 
 export function useNotificationsHandler() {
   const queryClient = useQueryClient()
@@ -182,8 +190,15 @@ export function useNotificationsHandler() {
       if (!payload) return
 
       if (payload.reason === 'chat-message') {
-        if (payload.recipientDid !== currentAccount?.did && !storedPayload) {
-          storedPayload = payload
+        notyLogger.debug(`useNotificationsHandler: handling chat message`, {
+          payload,
+        })
+
+        if (
+          payload.recipientDid !== currentAccount?.did &&
+          !storedAccountSwitchPayload
+        ) {
+          storePayloadForAccountSwitch(payload)
           closeAllActiveElements()
 
           const account = accounts.find(a => a.did === payload.recipientDid)
@@ -227,86 +242,29 @@ export function useNotificationsHandler() {
           })
         }
       } else {
-        switch (payload.reason) {
-          case 'subscribed-post':
-            const urip = new AtUri(payload.uri)
-            if (urip.collection === 'app.bsky.feed.post') {
-              setTimeout(() => {
-                // @ts-expect-error types are weird here
-                navigation.navigate('HomeTab', {
-                  screen: 'PostThread',
-                  params: {
-                    name: urip.host,
-                    rkey: urip.rkey,
-                  },
-                })
-              }, 500)
-            } else {
-              resetToTab('NotificationsTab')
-            }
-            break
-          case 'like':
-          case 'repost':
-          case 'follow':
-          case 'mention':
-          case 'quote':
-          case 'reply':
-          case 'starterpack-joined':
-          case 'like-via-repost':
-          case 'repost-via-repost':
-          case 'verified':
-          case 'unverified':
-          default:
-            resetToTab('NotificationsTab')
-            break
-          // TODO implement these after we have an idea of how to handle each individual case
-          // case 'follow':
-          //   const uri = new AtUri(payload.uri)
-          //   setTimeout(() => {
-          //     // @ts-expect-error types are weird here
-          //     navigation.navigate('HomeTab', {
-          //       screen: 'Profile',
-          //       params: {
-          //         name: uri.host,
-          //       },
-          //     })
-          //   }, 500)
-          //   break
-          // case 'mention':
-          // case 'reply':
-          //   const urip = new AtUri(payload.uri)
-          //   setTimeout(() => {
-          //     // @ts-expect-error types are weird here
-          //     navigation.navigate('HomeTab', {
-          //       screen: 'PostThread',
-          //       params: {
-          //         name: urip.host,
-          //         rkey: urip.rkey,
-          //       },
-          //     })
-          //   }, 500)
+        const url = notificationToURL(payload)
+
+        if (url === '/notifications') {
+          resetToTab('NotificationsTab')
+        } else if (url) {
+          const [screen, params] = router.matchPath(url)
+          // @ts-expect-error router is not typed :/ -sfn
+          navigation.navigate('HomeTab', {screen, params})
+          notyLogger.debug(`useNotificationsHandler: navigate`, {
+            screen,
+            params,
+          })
         }
       }
     }
 
     Notifications.setNotificationHandler({
       handleNotification: async e => {
-        if (
-          e.request.trigger == null ||
-          typeof e.request.trigger !== 'object' ||
-          !('type' in e.request.trigger) ||
-          e.request.trigger.type !== 'push'
-        ) {
-          return DEFAULT_HANDLER_OPTIONS
-        }
+        const payload = getNotificationPayload(e)
 
-        logger.debug('Notifications: received', {e})
+        if (!payload) return DEFAULT_HANDLER_OPTIONS
 
-        const payload = e.request.trigger.payload as NotificationPayload
-
-        if (!payload) {
-          return DEFAULT_HANDLER_OPTIONS
-        }
+        notyLogger.debug('useNotificationsHandler: incoming', {e, payload})
 
         if (
           payload.reason === 'chat-message' &&
@@ -329,46 +287,38 @@ export function useNotificationsHandler() {
 
     const responseReceivedListener =
       Notifications.addNotificationResponseReceivedListener(e => {
-        if (e.notification.date === prevDate) {
-          return
-        }
-        prevDate = e.notification.date
+        if (e.notification.date === lastHandledNotificationDateDedupe) return
+        lastHandledNotificationDateDedupe = e.notification.date
 
-        logger.debug('Notifications: response received', {
+        notyLogger.debug('useNotificationsHandler: response received', {
           actionIdentifier: e.actionIdentifier,
         })
 
-        if (
-          e.actionIdentifier === Notifications.DEFAULT_ACTION_IDENTIFIER &&
-          e.notification.request.trigger != null &&
-          typeof e.notification.request.trigger === 'object' &&
-          'type' in e.notification.request.trigger &&
-          e.notification.request.trigger.type === 'push'
-        ) {
-          const payload = e.notification.request.trigger
-            .payload as NotificationPayload
+        if (e.actionIdentifier !== Notifications.DEFAULT_ACTION_IDENTIFIER) {
+          return
+        }
 
-          if (!payload) {
-            logger.error('useNotificationsHandler: received no payload', {
-              identifier: e.notification.request.identifier,
-            })
-            return
-          }
+        const payload = getNotificationPayload(e.notification)
+
+        if (payload) {
           if (!payload.reason) {
-            logger.error('useNotificationsHandler: received unknown payload', {
-              payload,
-              identifier: e.notification.request.identifier,
-            })
+            notyLogger.error(
+              'useNotificationsHandler: received unknown payload',
+              {
+                payload,
+                identifier: e.notification.request.identifier,
+              },
+            )
             return
           }
 
-          logger.debug(
+          notyLogger.debug(
             'User pressed a notification, opening notifications tab',
             {},
           )
-          logger.metric(
+          notyLogger.metric(
             'notifications:openApp',
-            {reason: payload.reason},
+            {reason: payload.reason, causedBoot: false},
             {statsig: false},
           )
 
@@ -383,24 +333,28 @@ export function useNotificationsHandler() {
             truncateAndInvalidate(queryClient, RQKEY_NOTIFS('mentions'))
           }
 
-          logger.debug('Notifications: handleNotification', {
+          notyLogger.debug('Notifications: handleNotification', {
             content: e.notification.request.content,
-            payload: e.notification.request.trigger.payload,
+            payload: payload,
           })
 
           handleNotification(payload)
           Notifications.dismissAllNotificationsAsync()
+        } else {
+          notyLogger.error('useNotificationsHandler: received no payload', {
+            identifier: e.notification.request.identifier,
+          })
         }
       })
 
     // Whenever there's a stored payload, that means we had to switch accounts before handling the notification.
     // Whenever currentAccount changes, we should try to handle it again.
     if (
-      storedPayload?.reason === 'chat-message' &&
-      currentAccount?.did === storedPayload.recipientDid
+      storedAccountSwitchPayload?.reason === 'chat-message' &&
+      currentAccount?.did === storedAccountSwitchPayload.recipientDid
     ) {
-      handleNotification(storedPayload)
-      storedPayload = undefined
+      handleNotification(storedAccountSwitchPayload)
+      storedAccountSwitchPayload = undefined
     }
 
     return () => {
@@ -418,3 +372,71 @@ export function useNotificationsHandler() {
     setShowLoggedOut,
   ])
 }
+
+export function storePayloadForAccountSwitch(payload: NotificationPayload) {
+  storedAccountSwitchPayload = payload
+}
+
+export function getNotificationPayload(
+  e: Notifications.Notification,
+): NotificationPayload | null {
+  if (
+    e.request.trigger == null ||
+    typeof e.request.trigger !== 'object' ||
+    !('type' in e.request.trigger) ||
+    e.request.trigger.type !== 'push'
+  ) {
+    return null
+  }
+
+  const payload = (
+    isIOS ? e.request.trigger.payload : e.request.content.data
+  ) as NotificationPayload
+
+  if (payload) {
+    return payload
+  } else {
+    return null
+  }
+}
+
+export function notificationToURL(
+  payload: NotificationPayload,
+): string | undefined {
+  switch (payload?.reason) {
+    case 'like':
+    case 'repost':
+    case 'like-via-repost':
+    case 'repost-via-repost': {
+      const urip = new AtUri(payload.subject)
+      if (urip.collection === 'app.bsky.feed.post') {
+        return `/profile/${urip.host}/post/${urip.rkey}`
+      } else {
+        return '/notifications'
+      }
+    }
+    case 'reply':
+    case 'quote':
+    case 'mention':
+    case 'subscribed-post': {
+      const urip = new AtUri(payload.uri)
+      if (urip.collection === 'app.bsky.feed.post') {
+        return `/profile/${urip.host}/post/${urip.rkey}`
+      } else {
+        return '/notifications'
+      }
+    }
+    case 'follow':
+    case 'starterpack-joined': {
+      const urip = new AtUri(payload.uri)
+      return `/profile/${urip.host}`
+    }
+    case 'chat-message':
+      // should be handled separately
+      return undefined
+    case 'verified':
+    case 'unverified':
+    default:
+      return '/notifications'
+  }
+}
diff --git a/src/lib/notifications/notifications.ts b/src/lib/notifications/notifications.ts
index 2c0487ab7..94b3f6de3 100644
--- a/src/lib/notifications/notifications.ts
+++ b/src/lib/notifications/notifications.ts
@@ -6,13 +6,11 @@ import {type AtpAgent} from '@atproto/api'
 import debounce from 'lodash.debounce'
 
 import {PUBLIC_APPVIEW_DID, PUBLIC_STAGING_APPVIEW_DID} from '#/lib/constants'
-import {Logger} from '#/logger'
+import {logger as notyLogger} from '#/lib/notifications/util'
 import {isNative} from '#/platform/detection'
 import {type SessionAccount, useAgent, useSession} from '#/state/session'
 import BackgroundNotificationHandler from '#/../modules/expo-background-notification-handler'
 
-const logger = Logger.create(Logger.Context.Notifications)
-
 /**
  * @private
  * Registers the device's push notification token with the Bluesky server.
@@ -36,12 +34,12 @@ async function _registerPushToken({
       appId: 'xyz.blueskyweb.app',
     })
 
-    logger.debug(`registerPushToken: success`, {
+    notyLogger.debug(`registerPushToken: success`, {
       tokenType: token.type,
       token: token.data,
     })
   } catch (error) {
-    logger.error(`registerPushToken: failed`, {safeMessage: error})
+    notyLogger.error(`registerPushToken: failed`, {safeMessage: error})
   }
 }
 
@@ -80,7 +78,7 @@ export function useRegisterPushToken() {
  */
 async function getPushToken() {
   const granted = (await Notifications.getPermissionsAsync()).granted
-  logger.debug(`getPushToken`, {granted})
+  notyLogger.debug(`getPushToken`, {granted})
   if (granted) {
     return Notifications.getDevicePushTokenAsync()
   }
@@ -115,7 +113,9 @@ export function useGetAndRegisterPushToken() {
      */
     const token = await getPushToken()
 
-    logger.debug(`useGetAndRegisterPushToken`, {token: token ?? 'undefined'})
+    notyLogger.debug(`useGetAndRegisterPushToken`, {
+      token: token ?? 'undefined',
+    })
 
     if (token) {
       /**
@@ -147,7 +147,7 @@ export function useNotificationsRegistration() {
      */
     if (!currentAccount) return
 
-    logger.debug(`useNotificationsRegistration`)
+    notyLogger.debug(`useNotificationsRegistration`)
 
     /**
      * Init push token, if permissions are granted already. If they weren't,
@@ -168,7 +168,7 @@ export function useNotificationsRegistration() {
      */
     const subscription = Notifications.addPushTokenListener(async token => {
       registerPushToken({token})
-      logger.debug(`addPushTokenListener callback`, {token})
+      notyLogger.debug(`addPushTokenListener callback`, {token})
     })
 
     return () => {
@@ -202,7 +202,7 @@ export function useRequestNotificationsPermission() {
 
     const res = await Notifications.requestPermissionsAsync()
 
-    logger.metric(`notifications:request`, {
+    notyLogger.metric(`notifications:request`, {
       context: context,
       status: res.status,
     })
diff --git a/src/lib/notifications/util.ts b/src/lib/notifications/util.ts
new file mode 100644
index 000000000..6d00aa0e3
--- /dev/null
+++ b/src/lib/notifications/util.ts
@@ -0,0 +1,3 @@
+import {Logger} from '#/logger'
+
+export const logger = Logger.create(Logger.Context.Notifications)
diff --git a/src/lib/routes/links.ts b/src/lib/routes/links.ts
index 10c99b62d..908db7cba 100644
--- a/src/lib/routes/links.ts
+++ b/src/lib/routes/links.ts
@@ -1,4 +1,4 @@
-import {AppBskyGraphDefs, AtUri} from '@atproto/api'
+import {type AppBskyGraphDefs, AtUri} from '@atproto/api'
 
 import {isInvalidHandle} from '#/lib/strings/handles'
 
diff --git a/src/logger/metrics.ts b/src/logger/metrics.ts
index d18e69122..3390c4b4b 100644
--- a/src/logger/metrics.ts
+++ b/src/logger/metrics.ts
@@ -26,6 +26,7 @@ export type MetricEvents = {
   }
   'notifications:openApp': {
     reason: NotificationReason
+    causedBoot: boolean
   }
   'notifications:request': {
     context: 'StartOnboarding' | 'AfterOnboarding' | 'Login' | 'Home'