From 377d80e21df668c50585b1131bf1b7f0528bacd0 Mon Sep 17 00:00:00 2001
From: Samuel Newman <mozzius@protonmail.com>
Date: Fri, 11 Jul 2025 00:36:53 +0300
Subject: Handle notifications on first load (#8629)

* add getInitialURL

* get deep linking working

* actually handle as push instead

* push to route instead of setting initial path

* pop to on top of notifications

* fix comment

* change chat-message handling

* add metrics

* don't reopen due to notif multiple times

* extract data from notification differently on android

* sorry sorry annoying naming nits, align logger

---------

Co-authored-by: Eric Bailey <git@esb.lol>
---
 src/Navigation.tsx | 146 +++++++++++++++++++++++++++++++++++++++++++++--------
 1 file changed, 124 insertions(+), 22 deletions(-)

(limited to 'src/Navigation.tsx')

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()) {
-- 
cgit 1.4.1

