diff --git a/src/App.native.tsx b/src/App.native.tsx
index 79104f17c7..9356be7a74 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}>
-
-
-
- {/* LabelDefsProvider MUST come before ModerationOptsProvider */}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+ {/* LabelDefsProvider MUST come before ModerationOptsProvider */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -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 0000000000..12e1505725
--- /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
+ 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()
+ 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 b0bbc1bf99..38c18bf3fe 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 f7e8544b8b..31133cb1bd 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 1cfdcbb6a5..3371cd140d 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 c6cbf989cd..af9064cc30 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 4d218bda88..6c07073a8c 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
-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 (
(undefined)
+ useNotificationsHandler()
+
React.useEffect(() => {
let listener = {remove() {}}
if (isAndroid) {