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>
This commit is contained in:
Samuel Newman
2025-07-11 00:36:53 +03:00
parent 1fcd313424
commit 29401aa5b3
7 changed files with 276 additions and 148 deletions
+1 -1
View File
@@ -17,6 +17,7 @@ import {useLingui} from '@lingui/react'
import * as Sentry from '@sentry/react-native' import * as Sentry from '@sentry/react-native'
import {KeyboardControllerProvider} from '#/lib/hooks/useEnableKeyboardController' import {KeyboardControllerProvider} from '#/lib/hooks/useEnableKeyboardController'
import {Provider as HideBottomBarBorderProvider} from '#/lib/hooks/useHideBottomBarBorder'
import {QueryProvider} from '#/lib/react-query' import {QueryProvider} from '#/lib/react-query'
import {Provider as StatsigProvider, tryFetchGates} from '#/lib/statsig/statsig' import {Provider as StatsigProvider, tryFetchGates} from '#/lib/statsig/statsig'
import {s} from '#/lib/styles' import {s} from '#/lib/styles'
@@ -73,7 +74,6 @@ import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbe
import {Splash} from '#/Splash' import {Splash} from '#/Splash'
import {BottomSheetProvider} from '../modules/bottom-sheet' import {BottomSheetProvider} from '../modules/bottom-sheet'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottomBarBorder'
SplashScreen.preventAutoHideAsync() SplashScreen.preventAutoHideAsync()
if (isIOS) { if (isIOS) {
+124 -22
View File
@@ -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 {i18n, type MessageDescriptor} from '@lingui/core'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import { import {
@@ -10,13 +11,21 @@ import {
createNavigationContainerRef, createNavigationContainerRef,
DarkTheme, DarkTheme,
DefaultTheme, DefaultTheme,
type LinkingOptions,
NavigationContainer, NavigationContainer,
StackActions, StackActions,
} from '@react-navigation/native' } from '@react-navigation/native'
import {timeout} from '#/lib/async/timeout' import {timeout} from '#/lib/async/timeout'
import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle' import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
import {
getNotificationPayload,
type NotificationPayload,
notificationToURL,
storePayloadForAccountSwitch,
} from '#/lib/hooks/useNotificationHandler'
import {useWebScrollRestoration} from '#/lib/hooks/useWebScrollRestoration' import {useWebScrollRestoration} from '#/lib/hooks/useWebScrollRestoration'
import {logger as notyLogger} from '#/lib/notifications/util'
import {buildStateObject} from '#/lib/routes/helpers' import {buildStateObject} from '#/lib/routes/helpers'
import { import {
type AllNavigatorParams, type AllNavigatorParams,
@@ -71,6 +80,7 @@ import {MessagesSettingsScreen} from '#/screens/Messages/Settings'
import {ModerationScreen} from '#/screens/Moderation' import {ModerationScreen} from '#/screens/Moderation'
import {Screen as ModerationVerificationSettings} from '#/screens/Moderation/VerificationSettings' import {Screen as ModerationVerificationSettings} from '#/screens/Moderation/VerificationSettings'
import {Screen as ModerationInteractionSettings} from '#/screens/ModerationInteractionSettings' import {Screen as ModerationInteractionSettings} from '#/screens/ModerationInteractionSettings'
import {NotificationsActivityListScreen} from '#/screens/Notifications/ActivityList'
import {PostLikedByScreen} from '#/screens/Post/PostLikedBy' import {PostLikedByScreen} from '#/screens/Post/PostLikedBy'
import {PostQuotesScreen} from '#/screens/Post/PostQuotes' import {PostQuotesScreen} from '#/screens/Post/PostQuotes'
import {PostRepostedByScreen} from '#/screens/Post/PostRepostedBy' import {PostRepostedByScreen} from '#/screens/Post/PostRepostedBy'
@@ -93,6 +103,18 @@ import {ExternalMediaPreferencesScreen} from '#/screens/Settings/ExternalMediaPr
import {FollowingFeedPreferencesScreen} from '#/screens/Settings/FollowingFeedPreferences' import {FollowingFeedPreferencesScreen} from '#/screens/Settings/FollowingFeedPreferences'
import {InterestsSettingsScreen} from '#/screens/Settings/InterestsSettings' import {InterestsSettingsScreen} from '#/screens/Settings/InterestsSettings'
import {LanguageSettingsScreen} from '#/screens/Settings/LanguageSettings' 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 {PrivacyAndSecuritySettingsScreen} from '#/screens/Settings/PrivacyAndSecuritySettings'
import {SettingsScreen} from '#/screens/Settings/Settings' import {SettingsScreen} from '#/screens/Settings/Settings'
import {ThreadPreferencesScreen} from '#/screens/Settings/ThreadPreferences' import {ThreadPreferencesScreen} from '#/screens/Settings/ThreadPreferences'
@@ -110,19 +132,10 @@ import {
} from '#/components/dialogs/EmailDialog' } from '#/components/dialogs/EmailDialog'
import {router} from '#/routes' import {router} from '#/routes'
import {Referrer} from '../modules/expo-bluesky-swiss-army' import {Referrer} from '../modules/expo-bluesky-swiss-army'
import {NotificationsActivityListScreen} from './screens/Notifications/ActivityList' import {useAccountSwitcher} from './lib/hooks/useAccountSwitcher'
import {LegacyNotificationSettingsScreen} from './screens/Settings/LegacyNotificationSettings' import {useNonReactiveCallback} from './lib/hooks/useNonReactiveCallback'
import {NotificationSettingsScreen} from './screens/Settings/NotificationSettings' import {useLoggedOutViewControls} from './state/shell/logged-out'
import {ActivityNotificationSettingsScreen} from './screens/Settings/NotificationSettings/ActivityNotificationSettings' import {useCloseAllActiveElements} from './state/util'
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'
const navigationRef = createNavigationContainerRef<AllNavigatorParams>() 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. * in 3 distinct tab-stacks with a different root screen on each.
*/ */
function TabsNavigator() { function TabsNavigator() {
const tabBar = React.useCallback( const tabBar = useCallback(
(props: JSX.IntrinsicAttributes & BottomTabBarProps) => ( (props: JSX.IntrinsicAttributes & BottomTabBarProps) => (
<BottomBar {...props} /> <BottomBar {...props} />
), ),
@@ -762,6 +775,7 @@ const FlatNavigator = () => {
const LINKING = { const LINKING = {
// TODO figure out what we are going to use // 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'], prefixes: ['bsky://', 'bluesky://', 'https://bsky.app'],
getPathFromState(state: State) { getPathFromState(state: State) {
@@ -818,13 +832,100 @@ const LINKING = {
return res 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<{}>) { function RoutesContainer({children}: React.PropsWithChildren<{}>) {
const theme = useColorSchemeStyle(DefaultTheme, DarkTheme) const theme = useColorSchemeStyle(DefaultTheme, DarkTheme)
const {currentAccount} = useSession() const {currentAccount, accounts} = useSession()
const prevLoggedRouteName = React.useRef<string | undefined>(undefined) const {onPressSwitchAccount} = useAccountSwitcher()
const {setShowLoggedOut} = useLoggedOutViewControls()
const prevLoggedRouteName = useRef<string | undefined>(undefined)
const emailDialogControl = useEmailDialogControl() 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() { function onReady() {
prevLoggedRouteName.current = getCurrentRouteName() prevLoggedRouteName.current = getCurrentRouteName()
@@ -845,9 +946,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
onStateChange={() => { onStateChange={() => {
logger.metric( logger.metric(
'router:navigate', 'router:navigate',
{ {from: prevLoggedRouteName.current},
from: prevLoggedRouteName.current,
},
{statsig: false}, {statsig: false},
) )
prevLoggedRouteName.current = getCurrentRouteName() prevLoggedRouteName.current = getCurrentRouteName()
@@ -857,6 +956,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
logModuleInitTime() logModuleInitTime()
onReady() onReady()
logger.metric('router:navigate', {}, {statsig: false}) logger.metric('router:navigate', {}, {statsig: false})
handlePushNotificationEntry()
}} }}
// WARNING: Implicit navigation to nested navigators is depreciated in React Navigation 7.x // 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. // 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() return Promise.resolve()
} }
function resetToTab(tabName: 'HomeTab' | 'SearchTab' | 'NotificationsTab') { function resetToTab(
tabName: 'HomeTab' | 'SearchTab' | 'MessagesTab' | 'NotificationsTab',
) {
if (navigationRef.isReady()) { if (navigationRef.isReady()) {
navigate(tabName) navigate(tabName)
if (navigationRef.canGoBack()) { if (navigationRef.canGoBack()) {
+132 -110
View File
@@ -7,9 +7,9 @@ import {CommonActions, useNavigation} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query' import {useQueryClient} from '@tanstack/react-query'
import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher' import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher'
import {logger as notyLogger} from '#/lib/notifications/util'
import {type NavigationProp} from '#/lib/routes/types' import {type NavigationProp} from '#/lib/routes/types'
import {Logger} from '#/logger' import {isAndroid, isIOS} from '#/platform/detection'
import {isAndroid} from '#/platform/detection'
import {useCurrentConvoId} from '#/state/messages/current-convo-id' import {useCurrentConvoId} from '#/state/messages/current-convo-id'
import {RQKEY as RQKEY_NOTIFS} from '#/state/queries/notifications/feed' import {RQKEY as RQKEY_NOTIFS} from '#/state/queries/notifications/feed'
import {invalidateCachedUnreadPage} from '#/state/queries/notifications/unread' import {invalidateCachedUnreadPage} from '#/state/queries/notifications/unread'
@@ -18,6 +18,7 @@ import {useSession} from '#/state/session'
import {useLoggedOutViewControls} from '#/state/shell/logged-out' import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {useCloseAllActiveElements} from '#/state/util' import {useCloseAllActiveElements} from '#/state/util'
import {resetToTab} from '#/Navigation' import {resetToTab} from '#/Navigation'
import {router} from '#/routes'
export type NotificationReason = export type NotificationReason =
| 'like' | 'like'
@@ -39,12 +40,13 @@ export type NotificationReason =
* `notification.request.trigger.payload` being `undefined`, as specified in * `notification.request.trigger.payload` being `undefined`, as specified in
* the source types. * the source types.
*/ */
type NotificationPayload = export type NotificationPayload =
| undefined | undefined
| { | {
reason: Exclude<NotificationReason, 'chat-message'> reason: Exclude<NotificationReason, 'chat-message'>
uri: string uri: string
subject: string subject: string
recipientDid: string
} }
| { | {
reason: 'chat-message' reason: 'chat-message'
@@ -60,11 +62,17 @@ const DEFAULT_HANDLER_OPTIONS = {
shouldSetBadge: true, shouldSetBadge: true,
} satisfies Notifications.NotificationBehavior } satisfies Notifications.NotificationBehavior
// These need to stay outside the hook to persist between account switches /**
let storedPayload: NotificationPayload * Cached notification payload if we handled a notification while the user was
let prevDate = 0 * 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() { export function useNotificationsHandler() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
@@ -182,8 +190,15 @@ export function useNotificationsHandler() {
if (!payload) return if (!payload) return
if (payload.reason === 'chat-message') { if (payload.reason === 'chat-message') {
if (payload.recipientDid !== currentAccount?.did && !storedPayload) { notyLogger.debug(`useNotificationsHandler: handling chat message`, {
storedPayload = payload payload,
})
if (
payload.recipientDid !== currentAccount?.did &&
!storedAccountSwitchPayload
) {
storePayloadForAccountSwitch(payload)
closeAllActiveElements() closeAllActiveElements()
const account = accounts.find(a => a.did === payload.recipientDid) const account = accounts.find(a => a.did === payload.recipientDid)
@@ -227,86 +242,29 @@ export function useNotificationsHandler() {
}) })
} }
} else { } else {
switch (payload.reason) { const url = notificationToURL(payload)
case 'subscribed-post':
const urip = new AtUri(payload.uri) if (url === '/notifications') {
if (urip.collection === 'app.bsky.feed.post') { resetToTab('NotificationsTab')
setTimeout(() => { } else if (url) {
// @ts-expect-error types are weird here const [screen, params] = router.matchPath(url)
navigation.navigate('HomeTab', { // @ts-expect-error router is not typed :/ -sfn
screen: 'PostThread', navigation.navigate('HomeTab', {screen, params})
params: { notyLogger.debug(`useNotificationsHandler: navigate`, {
name: urip.host, screen,
rkey: urip.rkey, params,
},
}) })
}, 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)
} }
} }
} }
Notifications.setNotificationHandler({ Notifications.setNotificationHandler({
handleNotification: async e => { handleNotification: async e => {
if ( const payload = getNotificationPayload(e)
e.request.trigger == null ||
typeof e.request.trigger !== 'object' ||
!('type' in e.request.trigger) ||
e.request.trigger.type !== 'push'
) {
return DEFAULT_HANDLER_OPTIONS
}
logger.debug('Notifications: received', {e}) if (!payload) return DEFAULT_HANDLER_OPTIONS
const payload = e.request.trigger.payload as NotificationPayload notyLogger.debug('useNotificationsHandler: incoming', {e, payload})
if (!payload) {
return DEFAULT_HANDLER_OPTIONS
}
if ( if (
payload.reason === 'chat-message' && payload.reason === 'chat-message' &&
@@ -329,46 +287,38 @@ export function useNotificationsHandler() {
const responseReceivedListener = const responseReceivedListener =
Notifications.addNotificationResponseReceivedListener(e => { Notifications.addNotificationResponseReceivedListener(e => {
if (e.notification.date === prevDate) { if (e.notification.date === lastHandledNotificationDateDedupe) return
return lastHandledNotificationDateDedupe = e.notification.date
}
prevDate = e.notification.date
logger.debug('Notifications: response received', { notyLogger.debug('useNotificationsHandler: response received', {
actionIdentifier: e.actionIdentifier, actionIdentifier: e.actionIdentifier,
}) })
if ( if (e.actionIdentifier !== Notifications.DEFAULT_ACTION_IDENTIFIER) {
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 (!payload) {
logger.error('useNotificationsHandler: received no payload', {
identifier: e.notification.request.identifier,
})
return return
} }
const payload = getNotificationPayload(e.notification)
if (payload) {
if (!payload.reason) { if (!payload.reason) {
logger.error('useNotificationsHandler: received unknown payload', { notyLogger.error(
'useNotificationsHandler: received unknown payload',
{
payload, payload,
identifier: e.notification.request.identifier, identifier: e.notification.request.identifier,
}) },
)
return return
} }
logger.debug( notyLogger.debug(
'User pressed a notification, opening notifications tab', 'User pressed a notification, opening notifications tab',
{}, {},
) )
logger.metric( notyLogger.metric(
'notifications:openApp', 'notifications:openApp',
{reason: payload.reason}, {reason: payload.reason, causedBoot: false},
{statsig: false}, {statsig: false},
) )
@@ -383,24 +333,28 @@ export function useNotificationsHandler() {
truncateAndInvalidate(queryClient, RQKEY_NOTIFS('mentions')) truncateAndInvalidate(queryClient, RQKEY_NOTIFS('mentions'))
} }
logger.debug('Notifications: handleNotification', { notyLogger.debug('Notifications: handleNotification', {
content: e.notification.request.content, content: e.notification.request.content,
payload: e.notification.request.trigger.payload, payload: payload,
}) })
handleNotification(payload) handleNotification(payload)
Notifications.dismissAllNotificationsAsync() 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 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. // Whenever currentAccount changes, we should try to handle it again.
if ( if (
storedPayload?.reason === 'chat-message' && storedAccountSwitchPayload?.reason === 'chat-message' &&
currentAccount?.did === storedPayload.recipientDid currentAccount?.did === storedAccountSwitchPayload.recipientDid
) { ) {
handleNotification(storedPayload) handleNotification(storedAccountSwitchPayload)
storedPayload = undefined storedAccountSwitchPayload = undefined
} }
return () => { return () => {
@@ -418,3 +372,71 @@ export function useNotificationsHandler() {
setShowLoggedOut, 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'
}
}
+10 -10
View File
@@ -6,13 +6,11 @@ import {type AtpAgent} from '@atproto/api'
import debounce from 'lodash.debounce' import debounce from 'lodash.debounce'
import {PUBLIC_APPVIEW_DID, PUBLIC_STAGING_APPVIEW_DID} from '#/lib/constants' 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 {isNative} from '#/platform/detection'
import {type SessionAccount, useAgent, useSession} from '#/state/session' import {type SessionAccount, useAgent, useSession} from '#/state/session'
import BackgroundNotificationHandler from '#/../modules/expo-background-notification-handler' import BackgroundNotificationHandler from '#/../modules/expo-background-notification-handler'
const logger = Logger.create(Logger.Context.Notifications)
/** /**
* @private * @private
* Registers the device's push notification token with the Bluesky server. * Registers the device's push notification token with the Bluesky server.
@@ -36,12 +34,12 @@ async function _registerPushToken({
appId: 'xyz.blueskyweb.app', appId: 'xyz.blueskyweb.app',
}) })
logger.debug(`registerPushToken: success`, { notyLogger.debug(`registerPushToken: success`, {
tokenType: token.type, tokenType: token.type,
token: token.data, token: token.data,
}) })
} catch (error) { } catch (error) {
logger.error(`registerPushToken: failed`, {safeMessage: error}) notyLogger.error(`registerPushToken: failed`, {safeMessage: error})
} }
} }
@@ -80,7 +78,7 @@ export function useRegisterPushToken() {
*/ */
async function getPushToken() { async function getPushToken() {
const granted = (await Notifications.getPermissionsAsync()).granted const granted = (await Notifications.getPermissionsAsync()).granted
logger.debug(`getPushToken`, {granted}) notyLogger.debug(`getPushToken`, {granted})
if (granted) { if (granted) {
return Notifications.getDevicePushTokenAsync() return Notifications.getDevicePushTokenAsync()
} }
@@ -115,7 +113,9 @@ export function useGetAndRegisterPushToken() {
*/ */
const token = await getPushToken() const token = await getPushToken()
logger.debug(`useGetAndRegisterPushToken`, {token: token ?? 'undefined'}) notyLogger.debug(`useGetAndRegisterPushToken`, {
token: token ?? 'undefined',
})
if (token) { if (token) {
/** /**
@@ -147,7 +147,7 @@ export function useNotificationsRegistration() {
*/ */
if (!currentAccount) return if (!currentAccount) return
logger.debug(`useNotificationsRegistration`) notyLogger.debug(`useNotificationsRegistration`)
/** /**
* Init push token, if permissions are granted already. If they weren't, * 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 => { const subscription = Notifications.addPushTokenListener(async token => {
registerPushToken({token}) registerPushToken({token})
logger.debug(`addPushTokenListener callback`, {token}) notyLogger.debug(`addPushTokenListener callback`, {token})
}) })
return () => { return () => {
@@ -202,7 +202,7 @@ export function useRequestNotificationsPermission() {
const res = await Notifications.requestPermissionsAsync() const res = await Notifications.requestPermissionsAsync()
logger.metric(`notifications:request`, { notyLogger.metric(`notifications:request`, {
context: context, context: context,
status: res.status, status: res.status,
}) })
+3
View File
@@ -0,0 +1,3 @@
import {Logger} from '#/logger'
export const logger = Logger.create(Logger.Context.Notifications)
+1 -1
View File
@@ -1,4 +1,4 @@
import {AppBskyGraphDefs, AtUri} from '@atproto/api' import {type AppBskyGraphDefs, AtUri} from '@atproto/api'
import {isInvalidHandle} from '#/lib/strings/handles' import {isInvalidHandle} from '#/lib/strings/handles'
+1
View File
@@ -26,6 +26,7 @@ export type MetricEvents = {
} }
'notifications:openApp': { 'notifications:openApp': {
reason: NotificationReason reason: NotificationReason
causedBoot: boolean
} }
'notifications:request': { 'notifications:request': {
context: 'StartOnboarding' | 'AfterOnboarding' | 'Login' | 'Home' context: 'StartOnboarding' | 'AfterOnboarding' | 'Login' | 'Home'