about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/App.native.tsx48
-rw-r--r--src/lib/hooks/useNotificationHandler.ts225
-rw-r--r--src/lib/notifications/notifications.ts74
-rw-r--r--src/lib/routes/types.ts4
-rw-r--r--src/lib/statsig/events.ts7
-rw-r--r--src/screens/Messages/Conversation/index.tsx2
-rw-r--r--src/screens/Messages/List/index.tsx16
-rw-r--r--src/state/session/agent.ts4
-rw-r--r--src/view/shell/index.tsx3
9 files changed, 274 insertions, 109 deletions
diff --git a/src/App.native.tsx b/src/App.native.tsx
index 79104f17c..9356be7a7 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -12,7 +12,6 @@ import {
 import * as SplashScreen from 'expo-splash-screen'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
-import {useQueryClient} from '@tanstack/react-query'
 
 import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
 import {logger} from '#/logger'
@@ -22,7 +21,6 @@ import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
 import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts'
 import {readLastActiveAccount} from '#/state/session/util'
 import {useIntentHandler} from 'lib/hooks/useIntentHandler'
-import {useNotificationsListener} from 'lib/notifications/notifications'
 import {QueryProvider} from 'lib/react-query'
 import {s} from 'lib/styles'
 import {ThemeProvider} from 'lib/ThemeContext'
@@ -96,27 +94,25 @@ function InnerApp() {
                 // Resets the entire tree below when it changes:
                 key={currentAccount?.did}>
                 <QueryProvider currentDid={currentAccount?.did}>
-                  <PushNotificationsListener>
-                    <StatsigProvider>
-                      <MessagesProvider>
-                        {/* LabelDefsProvider MUST come before ModerationOptsProvider */}
-                        <LabelDefsProvider>
-                          <ModerationOptsProvider>
-                            <LoggedOutViewProvider>
-                              <SelectedFeedProvider>
-                                <UnreadNotifsProvider>
-                                  <GestureHandlerRootView style={s.h100pct}>
-                                    <TestCtrls />
-                                    <Shell />
-                                  </GestureHandlerRootView>
-                                </UnreadNotifsProvider>
-                              </SelectedFeedProvider>
-                            </LoggedOutViewProvider>
-                          </ModerationOptsProvider>
-                        </LabelDefsProvider>
-                      </MessagesProvider>
-                    </StatsigProvider>
-                  </PushNotificationsListener>
+                  <StatsigProvider>
+                    <MessagesProvider>
+                      {/* LabelDefsProvider MUST come before ModerationOptsProvider */}
+                      <LabelDefsProvider>
+                        <ModerationOptsProvider>
+                          <LoggedOutViewProvider>
+                            <SelectedFeedProvider>
+                              <UnreadNotifsProvider>
+                                <GestureHandlerRootView style={s.h100pct}>
+                                  <TestCtrls />
+                                  <Shell />
+                                </GestureHandlerRootView>
+                              </UnreadNotifsProvider>
+                            </SelectedFeedProvider>
+                          </LoggedOutViewProvider>
+                        </ModerationOptsProvider>
+                      </LabelDefsProvider>
+                    </MessagesProvider>
+                  </StatsigProvider>
                 </QueryProvider>
               </React.Fragment>
             </RootSiblingParent>
@@ -127,12 +123,6 @@ function InnerApp() {
   )
 }
 
-function PushNotificationsListener({children}: {children: React.ReactNode}) {
-  const queryClient = useQueryClient()
-  useNotificationsListener(queryClient)
-  return children
-}
-
 function App() {
   const [isReady, setReady] = useState(false)
 
diff --git a/src/lib/hooks/useNotificationHandler.ts b/src/lib/hooks/useNotificationHandler.ts
new file mode 100644
index 000000000..12e150572
--- /dev/null
+++ b/src/lib/hooks/useNotificationHandler.ts
@@ -0,0 +1,225 @@
+import React from 'react'
+import * as Notifications from 'expo-notifications'
+import {CommonActions, useNavigation} from '@react-navigation/native'
+import {useQueryClient} from '@tanstack/react-query'
+
+import {logger} from '#/logger'
+import {track} from 'lib/analytics/analytics'
+import {useAccountSwitcher} from 'lib/hooks/useAccountSwitcher'
+import {NavigationProp} from 'lib/routes/types'
+import {logEvent} from 'lib/statsig/statsig'
+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'
+import {truncateAndInvalidate} from 'state/queries/util'
+import {useSession} from 'state/session'
+import {useLoggedOutViewControls} from 'state/shell/logged-out'
+import {useCloseAllActiveElements} from 'state/util'
+import {resetToTab} from '#/Navigation'
+
+type NotificationReason =
+  | 'like'
+  | 'repost'
+  | 'follow'
+  | 'mention'
+  | 'reply'
+  | 'quote'
+  | 'chat-message'
+
+type NotificationPayload =
+  | {
+      reason: Exclude<NotificationReason, 'chat-message'>
+      uri: string
+      subject: string
+    }
+  | {
+      reason: 'chat-message'
+      convoId: string
+      messageId: string
+      recipientDid: string
+    }
+
+const DEFAULT_HANDLER_OPTIONS = {
+  shouldShowAlert: false,
+  shouldPlaySound: false,
+  shouldSetBadge: true,
+}
+
+// This needs to stay outside the hook to persist between account switches
+let storedPayload: NotificationPayload | undefined
+
+export function useNotificationsHandler() {
+  const queryClient = useQueryClient()
+  const {currentAccount, accounts} = useSession()
+  const {onPressSwitchAccount} = useAccountSwitcher()
+  const navigation = useNavigation<NavigationProp>()
+  const {currentConvoId} = useCurrentConvoId()
+  const {setShowLoggedOut} = useLoggedOutViewControls()
+  const closeAllActiveElements = useCloseAllActiveElements()
+
+  // Safety to prevent double handling of the same notification
+  const prevIdentifier = React.useRef('')
+
+  React.useEffect(() => {
+    const handleNotification = (payload?: NotificationPayload) => {
+      if (!payload) return
+
+      if (payload.reason === 'chat-message') {
+        if (payload.recipientDid !== currentAccount?.did && !storedPayload) {
+          storedPayload = payload
+          closeAllActiveElements()
+
+          const account = accounts.find(a => a.did === payload.recipientDid)
+          if (account) {
+            onPressSwitchAccount(account, 'Notification')
+          } else {
+            setShowLoggedOut(true)
+          }
+        } else {
+          navigation.dispatch(state => {
+            if (state.routes[0].name === 'Messages') {
+              return CommonActions.navigate('MessagesConversation', {
+                conversation: payload.convoId,
+              })
+            } else {
+              return CommonActions.navigate('MessagesTab', {
+                screen: 'Messages',
+                params: {
+                  pushToConversation: payload.convoId,
+                },
+              })
+            }
+          })
+        }
+      } else {
+        switch (payload.reason) {
+          case 'like':
+          case 'repost':
+          case 'follow':
+          case 'mention':
+          case 'quote':
+          case 'reply':
+            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)
+        }
+      }
+    }
+
+    Notifications.setNotificationHandler({
+      handleNotification: async e => {
+        if (e.request.trigger.type !== 'push') return DEFAULT_HANDLER_OPTIONS
+
+        logger.debug(
+          'Notifications: received',
+          {e},
+          logger.DebugContext.notifications,
+        )
+
+        const payload = e.request.trigger.payload as NotificationPayload
+        if (
+          payload.reason === 'chat-message' &&
+          payload.recipientDid === currentAccount?.did
+        ) {
+          return {
+            shouldShowAlert: payload.convoId !== currentConvoId,
+            shouldPlaySound: false,
+            shouldSetBadge: false,
+          }
+        }
+
+        // Any notification other than a chat message should invalidate the unread page
+        invalidateCachedUnreadPage()
+        return DEFAULT_HANDLER_OPTIONS
+      },
+    })
+
+    const responseReceivedListener =
+      Notifications.addNotificationResponseReceivedListener(e => {
+        if (e.notification.request.identifier === prevIdentifier.current) {
+          return
+        }
+        prevIdentifier.current = e.notification.request.identifier
+
+        logger.debug(
+          'Notifications: response received',
+          {
+            actionIdentifier: e.actionIdentifier,
+          },
+          logger.DebugContext.notifications,
+        )
+
+        if (
+          e.actionIdentifier === Notifications.DEFAULT_ACTION_IDENTIFIER &&
+          e.notification.request.trigger.type === 'push'
+        ) {
+          logger.debug(
+            'User pressed a notification, opening notifications tab',
+            {},
+            logger.DebugContext.notifications,
+          )
+          track('Notificatons:OpenApp')
+          logEvent('notifications:openApp', {})
+          invalidateCachedUnreadPage()
+          truncateAndInvalidate(queryClient, RQKEY_NOTIFS())
+          logger.debug('Notifications: handleNotification', {
+            content: e.notification.request.content,
+            payload: e.notification.request.trigger.payload,
+          })
+          handleNotification(
+            e.notification.request.trigger.payload as NotificationPayload,
+          )
+          Notifications.dismissAllNotificationsAsync()
+        }
+      })
+
+    // 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
+    ) {
+      handleNotification(storedPayload)
+      storedPayload = undefined
+    }
+
+    return () => {
+      responseReceivedListener.remove()
+    }
+  }, [
+    queryClient,
+    currentAccount,
+    currentConvoId,
+    accounts,
+    closeAllActiveElements,
+    currentAccount?.did,
+    navigation,
+    onPressSwitchAccount,
+    setShowLoggedOut,
+  ])
+}
diff --git a/src/lib/notifications/notifications.ts b/src/lib/notifications/notifications.ts
index b0bbc1bf9..38c18bf3f 100644
--- a/src/lib/notifications/notifications.ts
+++ b/src/lib/notifications/notifications.ts
@@ -1,17 +1,9 @@
-import {useEffect} from 'react'
 import * as Notifications from 'expo-notifications'
 import {BskyAgent} from '@atproto/api'
-import {QueryClient} from '@tanstack/react-query'
 
 import {logger} from '#/logger'
-import {RQKEY as RQKEY_NOTIFS} from '#/state/queries/notifications/feed'
-import {invalidateCachedUnreadPage} from '#/state/queries/notifications/unread'
-import {truncateAndInvalidate} from '#/state/queries/util'
 import {SessionAccount} from '#/state/session'
-import {track} from 'lib/analytics/analytics'
-import {devicePlatform, isIOS} from 'platform/detection'
-import {resetToTab} from '../../Navigation'
-import {logEvent} from '../statsig/statsig'
+import {devicePlatform} from 'platform/detection'
 
 const SERVICE_DID = (serviceUrl?: string) =>
   serviceUrl?.includes('staging')
@@ -85,67 +77,3 @@ export function registerTokenChangeHandler(
     sub.remove()
   }
 }
-
-export function useNotificationsListener(queryClient: QueryClient) {
-  useEffect(() => {
-    // handle notifications that are received, both in the foreground or background
-    // NOTE: currently just here for debug logging
-    const sub1 = Notifications.addNotificationReceivedListener(event => {
-      invalidateCachedUnreadPage()
-      logger.debug(
-        'Notifications: received',
-        {event},
-        logger.DebugContext.notifications,
-      )
-      if (event.request.trigger.type === 'push') {
-        // handle payload-based deeplinks
-        let payload
-        if (isIOS) {
-          payload = event.request.trigger.payload
-        } else {
-          // TODO: handle android payload deeplink
-        }
-        if (payload) {
-          logger.debug(
-            'Notifications: received payload',
-            payload,
-            logger.DebugContext.notifications,
-          )
-          // TODO: deeplink notif here
-        }
-      }
-    })
-
-    // handle notifications that are tapped on
-    const sub2 = Notifications.addNotificationResponseReceivedListener(
-      response => {
-        logger.debug(
-          'Notifications: response received',
-          {
-            actionIdentifier: response.actionIdentifier,
-          },
-          logger.DebugContext.notifications,
-        )
-        if (
-          response.actionIdentifier === Notifications.DEFAULT_ACTION_IDENTIFIER
-        ) {
-          logger.debug(
-            'User pressed a notification, opening notifications tab',
-            {},
-            logger.DebugContext.notifications,
-          )
-          track('Notificatons:OpenApp')
-          logEvent('notifications:openApp', {})
-          invalidateCachedUnreadPage()
-          truncateAndInvalidate(queryClient, RQKEY_NOTIFS())
-          resetToTab('NotificationsTab') // open notifications tab
-        }
-      },
-    )
-
-    return () => {
-      sub1.remove()
-      sub2.remove()
-    }
-  }, [queryClient])
-}
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index f7e8544b8..31133cb1b 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -72,7 +72,7 @@ export type MyProfileTabNavigatorParams = CommonNavigatorParams & {
 }
 
 export type MessagesTabNavigatorParams = CommonNavigatorParams & {
-  Messages: undefined
+  Messages: {pushToConversation?: string}
 }
 
 export type FlatNavigatorParams = CommonNavigatorParams & {
@@ -81,7 +81,7 @@ export type FlatNavigatorParams = CommonNavigatorParams & {
   Feeds: undefined
   Notifications: undefined
   Hashtag: {tag: string; author?: string}
-  Messages: undefined
+  Messages: {pushToConversation?: string}
 }
 
 export type AllNavigatorParams = CommonNavigatorParams & {
diff --git a/src/lib/statsig/events.ts b/src/lib/statsig/events.ts
index 1cfdcbb6a..3371cd140 100644
--- a/src/lib/statsig/events.ts
+++ b/src/lib/statsig/events.ts
@@ -4,7 +4,12 @@ export type LogEvents = {
     initMs: number
   }
   'account:loggedIn': {
-    logContext: 'LoginForm' | 'SwitchAccount' | 'ChooseAccountForm' | 'Settings'
+    logContext:
+      | 'LoginForm'
+      | 'SwitchAccount'
+      | 'ChooseAccountForm'
+      | 'Settings'
+      | 'Notification'
     withPassword: boolean
   }
   'account:loggedOut': {
diff --git a/src/screens/Messages/Conversation/index.tsx b/src/screens/Messages/Conversation/index.tsx
index c6cbf989c..af9064cc3 100644
--- a/src/screens/Messages/Conversation/index.tsx
+++ b/src/screens/Messages/Conversation/index.tsx
@@ -110,7 +110,7 @@ let Header = ({
     if (isWeb) {
       navigation.replace('Messages')
     } else {
-      navigation.pop()
+      navigation.goBack()
     }
   }, [navigation])
 
diff --git a/src/screens/Messages/List/index.tsx b/src/screens/Messages/List/index.tsx
index 4d218bda8..6c07073a8 100644
--- a/src/screens/Messages/List/index.tsx
+++ b/src/screens/Messages/List/index.tsx
@@ -40,11 +40,12 @@ import {ClipClopGate} from '../gate'
 import {useDmServiceUrlStorage} from '../Temp/useDmServiceUrlStorage'
 
 type Props = NativeStackScreenProps<MessagesTabNavigatorParams, 'Messages'>
-export function MessagesScreen({navigation}: Props) {
+export function MessagesScreen({navigation, route}: Props) {
   const {_} = useLingui()
   const t = useTheme()
   const newChatControl = useDialogControl()
   const {gtMobile} = useBreakpoints()
+  const pushToConversation = route.params?.pushToConversation
 
   // TEMP
   const {serviceUrl, setServiceUrl} = useDmServiceUrlStorage()
@@ -57,6 +58,19 @@ export function MessagesScreen({navigation}: Props) {
     )
   }, [serviceUrl])
 
+  // Whenever we have `pushToConversation` set, it means we pressed a notification for a chat without being on
+  // this tab. We should immediately push to the conversation after pressing the notification.
+  // After we push, reset with `setParams` so that this effect will fire next time we press a notification, even if
+  // the conversation is the same as before
+  React.useEffect(() => {
+    if (pushToConversation) {
+      navigation.navigate('MessagesConversation', {
+        conversation: pushToConversation,
+      })
+      navigation.setParams({pushToConversation: undefined})
+    }
+  }, [navigation, pushToConversation])
+
   const renderButton = useCallback(() => {
     return (
       <Link
diff --git a/src/state/session/agent.ts b/src/state/session/agent.ts
index ab7ebc790..9dacf543e 100644
--- a/src/state/session/agent.ts
+++ b/src/state/session/agent.ts
@@ -3,15 +3,15 @@ import {AtpSessionEvent} from '@atproto-labs/api'
 
 import {networkRetry} from '#/lib/async/retry'
 import {PUBLIC_BSKY_SERVICE} from '#/lib/constants'
+import {IS_PROD_SERVICE} from '#/lib/constants'
 import {tryFetchGates} from '#/lib/statsig/statsig'
+import {DEFAULT_PROD_FEEDS} from '../queries/preferences'
 import {
   configureModerationForAccount,
   configureModerationForGuest,
 } from './moderation'
 import {SessionAccount} from './types'
 import {isSessionDeactivated, isSessionExpired} from './util'
-import {IS_PROD_SERVICE} from '#/lib/constants'
-import {DEFAULT_PROD_FEEDS} from '../queries/preferences'
 
 export function createPublicAgent() {
   configureModerationForGuest() // Side effect but only relevant for tests
diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx
index f13a8d7df..425c1b3f8 100644
--- a/src/view/shell/index.tsx
+++ b/src/view/shell/index.tsx
@@ -20,6 +20,7 @@ import {
   useSetDrawerOpen,
 } from '#/state/shell'
 import {useCloseAnyActiveElement} from '#/state/util'
+import {useNotificationsHandler} from 'lib/hooks/useNotificationHandler'
 import {usePalette} from 'lib/hooks/usePalette'
 import * as notifications from 'lib/notifications/notifications'
 import {isStateAtTabRoot} from 'lib/routes/helpers'
@@ -63,6 +64,8 @@ function ShellInner() {
   // start undefined
   const currentAccountDid = React.useRef<string | undefined>(undefined)
 
+  useNotificationsHandler()
+
   React.useEffect(() => {
     let listener = {remove() {}}
     if (isAndroid) {