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:
+1
-1
@@ -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
@@ -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()) {
|
||||||
|
|||||||
@@ -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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import {Logger} from '#/logger'
|
||||||
|
|
||||||
|
export const logger = Logger.create(Logger.Context.Notifications)
|
||||||
@@ -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'
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user