···17import * as Sentry from '@sentry/react-native'
1819import {KeyboardControllerProvider} from '#/lib/hooks/useEnableKeyboardController'
020import {QueryProvider} from '#/lib/react-query'
21import {Provider as StatsigProvider, tryFetchGates} from '#/lib/statsig/statsig'
22import {s} from '#/lib/styles'
···73import {Splash} from '#/Splash'
74import {BottomSheetProvider} from '../modules/bottom-sheet'
75import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
76-import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottomBarBorder'
7778SplashScreen.preventAutoHideAsync()
79if (isIOS) {
···17import * as Sentry from '@sentry/react-native'
1819import {KeyboardControllerProvider} from '#/lib/hooks/useEnableKeyboardController'
20+import {Provider as HideBottomBarBorderProvider} from '#/lib/hooks/useHideBottomBarBorder'
21import {QueryProvider} from '#/lib/react-query'
22import {Provider as StatsigProvider, tryFetchGates} from '#/lib/statsig/statsig'
23import {s} from '#/lib/styles'
···74import {Splash} from '#/Splash'
75import {BottomSheetProvider} from '../modules/bottom-sheet'
76import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
07778SplashScreen.preventAutoHideAsync()
79if (isIOS) {
+124-22
src/Navigation.tsx
···1-import * as React from 'react'
02import {i18n, type MessageDescriptor} from '@lingui/core'
3import {msg} from '@lingui/macro'
4import {
···10 createNavigationContainerRef,
11 DarkTheme,
12 DefaultTheme,
013 NavigationContainer,
14 StackActions,
15} from '@react-navigation/native'
1617import {timeout} from '#/lib/async/timeout'
18import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
00000019import {useWebScrollRestoration} from '#/lib/hooks/useWebScrollRestoration'
020import {buildStateObject} from '#/lib/routes/helpers'
21import {
22 type AllNavigatorParams,
···71import {ModerationScreen} from '#/screens/Moderation'
72import {Screen as ModerationVerificationSettings} from '#/screens/Moderation/VerificationSettings'
73import {Screen as ModerationInteractionSettings} from '#/screens/ModerationInteractionSettings'
074import {PostLikedByScreen} from '#/screens/Post/PostLikedBy'
75import {PostQuotesScreen} from '#/screens/Post/PostQuotes'
76import {PostRepostedByScreen} from '#/screens/Post/PostRepostedBy'
···94import {FollowingFeedPreferencesScreen} from '#/screens/Settings/FollowingFeedPreferences'
95import {InterestsSettingsScreen} from '#/screens/Settings/InterestsSettings'
96import {LanguageSettingsScreen} from '#/screens/Settings/LanguageSettings'
00000000000097import {PrivacyAndSecuritySettingsScreen} from '#/screens/Settings/PrivacyAndSecuritySettings'
98import {SettingsScreen} from '#/screens/Settings/Settings'
99import {ThreadPreferencesScreen} from '#/screens/Settings/ThreadPreferences'
···111} from '#/components/dialogs/EmailDialog'
112import {router} from '#/routes'
113import {Referrer} from '../modules/expo-bluesky-swiss-army'
114-import {NotificationsActivityListScreen} from './screens/Notifications/ActivityList'
115-import {LegacyNotificationSettingsScreen} from './screens/Settings/LegacyNotificationSettings'
116-import {NotificationSettingsScreen} from './screens/Settings/NotificationSettings'
117-import {ActivityNotificationSettingsScreen} from './screens/Settings/NotificationSettings/ActivityNotificationSettings'
118-import {LikeNotificationSettingsScreen} from './screens/Settings/NotificationSettings/LikeNotificationSettings'
119-import {LikesOnRepostsNotificationSettingsScreen} from './screens/Settings/NotificationSettings/LikesOnRepostsNotificationSettings'
120-import {MentionNotificationSettingsScreen} from './screens/Settings/NotificationSettings/MentionNotificationSettings'
121-import {MiscellaneousNotificationSettingsScreen} from './screens/Settings/NotificationSettings/MiscellaneousNotificationSettings'
122-import {NewFollowerNotificationSettingsScreen} from './screens/Settings/NotificationSettings/NewFollowerNotificationSettings'
123-import {QuoteNotificationSettingsScreen} from './screens/Settings/NotificationSettings/QuoteNotificationSettings'
124-import {ReplyNotificationSettingsScreen} from './screens/Settings/NotificationSettings/ReplyNotificationSettings'
125-import {RepostNotificationSettingsScreen} from './screens/Settings/NotificationSettings/RepostNotificationSettings'
126-import {RepostsOnRepostsNotificationSettingsScreen} from './screens/Settings/NotificationSettings/RepostsOnRepostsNotificationSettings'
127128const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
129···604 * in 3 distinct tab-stacks with a different root screen on each.
605 */
606function TabsNavigator() {
607- const tabBar = React.useCallback(
608 (props: JSX.IntrinsicAttributes & BottomTabBarProps) => (
609 <BottomBar {...props} />
610 ),
···771772const LINKING = {
773 // TODO figure out what we are going to use
0774 prefixes: ['bsky://', 'bluesky://', 'https://bsky.app'],
775776 getPathFromState(state: State) {
···827 return res
828 }
829 },
830-}
00000831832function RoutesContainer({children}: React.PropsWithChildren<{}>) {
833 const theme = useColorSchemeStyle(DefaultTheme, DarkTheme)
834- const {currentAccount} = useSession()
835- const prevLoggedRouteName = React.useRef<string | undefined>(undefined)
00836 const emailDialogControl = useEmailDialogControl()
00000000000000000000000000000000000000000000000000000000000000000000000000000000837838 function onReady() {
839 prevLoggedRouteName.current = getCurrentRouteName()
···854 onStateChange={() => {
855 logger.metric(
856 'router:navigate',
857- {
858- from: prevLoggedRouteName.current,
859- },
860 {statsig: false},
861 )
862 prevLoggedRouteName.current = getCurrentRouteName()
···866 logModuleInitTime()
867 onReady()
868 logger.metric('router:navigate', {}, {statsig: false})
0869 }}
870 // WARNING: Implicit navigation to nested navigators is depreciated in React Navigation 7.x
871 // However, there's a fair amount of places we do that, especially in when popping to the top of stacks.
···915 return Promise.resolve()
916}
917918-function resetToTab(tabName: 'HomeTab' | 'SearchTab' | 'NotificationsTab') {
00919 if (navigationRef.isReady()) {
920 navigate(tabName)
921 if (navigationRef.canGoBack()) {
···1+import {useCallback, useRef} from 'react'
2+import * as Notifications from 'expo-notifications'
3import {i18n, type MessageDescriptor} from '@lingui/core'
4import {msg} from '@lingui/macro'
5import {
···11 createNavigationContainerRef,
12 DarkTheme,
13 DefaultTheme,
14+ type LinkingOptions,
15 NavigationContainer,
16 StackActions,
17} from '@react-navigation/native'
1819import {timeout} from '#/lib/async/timeout'
20import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
21+import {
22+ getNotificationPayload,
23+ type NotificationPayload,
24+ notificationToURL,
25+ storePayloadForAccountSwitch,
26+} from '#/lib/hooks/useNotificationHandler'
27import {useWebScrollRestoration} from '#/lib/hooks/useWebScrollRestoration'
28+import {logger as notyLogger} from '#/lib/notifications/util'
29import {buildStateObject} from '#/lib/routes/helpers'
30import {
31 type AllNavigatorParams,
···80import {ModerationScreen} from '#/screens/Moderation'
81import {Screen as ModerationVerificationSettings} from '#/screens/Moderation/VerificationSettings'
82import {Screen as ModerationInteractionSettings} from '#/screens/ModerationInteractionSettings'
83+import {NotificationsActivityListScreen} from '#/screens/Notifications/ActivityList'
84import {PostLikedByScreen} from '#/screens/Post/PostLikedBy'
85import {PostQuotesScreen} from '#/screens/Post/PostQuotes'
86import {PostRepostedByScreen} from '#/screens/Post/PostRepostedBy'
···104import {FollowingFeedPreferencesScreen} from '#/screens/Settings/FollowingFeedPreferences'
105import {InterestsSettingsScreen} from '#/screens/Settings/InterestsSettings'
106import {LanguageSettingsScreen} from '#/screens/Settings/LanguageSettings'
107+import {LegacyNotificationSettingsScreen} from '#/screens/Settings/LegacyNotificationSettings'
108+import {NotificationSettingsScreen} from '#/screens/Settings/NotificationSettings'
109+import {ActivityNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/ActivityNotificationSettings'
110+import {LikeNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/LikeNotificationSettings'
111+import {LikesOnRepostsNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/LikesOnRepostsNotificationSettings'
112+import {MentionNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/MentionNotificationSettings'
113+import {MiscellaneousNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/MiscellaneousNotificationSettings'
114+import {NewFollowerNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/NewFollowerNotificationSettings'
115+import {QuoteNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/QuoteNotificationSettings'
116+import {ReplyNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/ReplyNotificationSettings'
117+import {RepostNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/RepostNotificationSettings'
118+import {RepostsOnRepostsNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/RepostsOnRepostsNotificationSettings'
119import {PrivacyAndSecuritySettingsScreen} from '#/screens/Settings/PrivacyAndSecuritySettings'
120import {SettingsScreen} from '#/screens/Settings/Settings'
121import {ThreadPreferencesScreen} from '#/screens/Settings/ThreadPreferences'
···133} from '#/components/dialogs/EmailDialog'
134import {router} from '#/routes'
135import {Referrer} from '../modules/expo-bluesky-swiss-army'
136+import {useAccountSwitcher} from './lib/hooks/useAccountSwitcher'
137+import {useNonReactiveCallback} from './lib/hooks/useNonReactiveCallback'
138+import {useLoggedOutViewControls} from './state/shell/logged-out'
139+import {useCloseAllActiveElements} from './state/util'
000000000140141const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
142···617 * in 3 distinct tab-stacks with a different root screen on each.
618 */
619function TabsNavigator() {
620+ const tabBar = useCallback(
621 (props: JSX.IntrinsicAttributes & BottomTabBarProps) => (
622 <BottomBar {...props} />
623 ),
···784785const LINKING = {
786 // TODO figure out what we are going to use
787+ // note: `bluesky://` is what is used in app.config.js
788 prefixes: ['bsky://', 'bluesky://', 'https://bsky.app'],
789790 getPathFromState(state: State) {
···841 return res
842 }
843 },
844+} satisfies LinkingOptions<AllNavigatorParams>
845+846+/**
847+ * Used to ensure we don't handle the same notification twice
848+ */
849+let lastHandledNotificationDateDedupe: number | undefined
850851function RoutesContainer({children}: React.PropsWithChildren<{}>) {
852 const theme = useColorSchemeStyle(DefaultTheme, DarkTheme)
853+ const {currentAccount, accounts} = useSession()
854+ const {onPressSwitchAccount} = useAccountSwitcher()
855+ const {setShowLoggedOut} = useLoggedOutViewControls()
856+ const prevLoggedRouteName = useRef<string | undefined>(undefined)
857 const emailDialogControl = useEmailDialogControl()
858+ const closeAllActiveElements = useCloseAllActiveElements()
859+860+ /**
861+ * Handle navigation to a conversation, or prepares for account switch.
862+ *
863+ * Non-reactive because we need the latest data from some hooks
864+ * after an async call - sfn
865+ */
866+ const handleChatMessage = useNonReactiveCallback(
867+ (payload: Extract<NotificationPayload, {reason: 'chat-message'}>) => {
868+ notyLogger.debug(`handleChatMessage`, {payload})
869+870+ if (payload.recipientDid !== currentAccount?.did) {
871+ // handled in useNotificationHandler after account switch finishes
872+ storePayloadForAccountSwitch(payload)
873+ closeAllActiveElements()
874+875+ const account = accounts.find(a => a.did === payload.recipientDid)
876+ if (account) {
877+ onPressSwitchAccount(account, 'Notification')
878+ } else {
879+ setShowLoggedOut(true)
880+ }
881+ } else {
882+ // @ts-expect-error nested navigators aren't typed -sfn
883+ navigate('MessagesTab', {
884+ screen: 'MessagesConversation',
885+ params: {
886+ conversation: payload.convoId,
887+ },
888+ })
889+ }
890+ },
891+ )
892+893+ async function handlePushNotificationEntry() {
894+ if (!isNative) return
895+896+ /**
897+ * The notification that caused the app to open, if applicable
898+ */
899+ const response = await Notifications.getLastNotificationResponseAsync()
900+901+ if (response) {
902+ notyLogger.debug(`handlePushNotificationEntry: response`, {response})
903+904+ if (response.notification.date === lastHandledNotificationDateDedupe)
905+ return
906+ lastHandledNotificationDateDedupe = response.notification.date
907+908+ const payload = getNotificationPayload(response.notification)
909+910+ if (payload) {
911+ notyLogger.metric(
912+ 'notifications:openApp',
913+ {reason: payload.reason, causedBoot: true},
914+ {statsig: false},
915+ )
916+917+ if (payload.reason === 'chat-message') {
918+ handleChatMessage(payload)
919+ } else {
920+ const path = notificationToURL(payload)
921+922+ if (path === '/notifications') {
923+ resetToTab('NotificationsTab')
924+ notyLogger.debug(`handlePushNotificationEntry: default navigate`)
925+ } else if (path) {
926+ const [screen, params] = router.matchPath(path)
927+ // @ts-expect-error nested navigators aren't typed -sfn
928+ navigate('HomeTab', {screen, params})
929+ notyLogger.debug(`handlePushNotificationEntry: navigate`, {
930+ screen,
931+ params,
932+ })
933+ }
934+ }
935+ }
936+ }
937+ }
938939 function onReady() {
940 prevLoggedRouteName.current = getCurrentRouteName()
···955 onStateChange={() => {
956 logger.metric(
957 'router:navigate',
958+ {from: prevLoggedRouteName.current},
00959 {statsig: false},
960 )
961 prevLoggedRouteName.current = getCurrentRouteName()
···965 logModuleInitTime()
966 onReady()
967 logger.metric('router:navigate', {}, {statsig: false})
968+ handlePushNotificationEntry()
969 }}
970 // WARNING: Implicit navigation to nested navigators is depreciated in React Navigation 7.x
971 // However, there's a fair amount of places we do that, especially in when popping to the top of stacks.
···1015 return Promise.resolve()
1016}
10171018+function resetToTab(
1019+ tabName: 'HomeTab' | 'SearchTab' | 'MessagesTab' | 'NotificationsTab',
1020+) {
1021 if (navigationRef.isReady()) {
1022 navigate(tabName)
1023 if (navigationRef.canGoBack()) {
+136-114
src/lib/hooks/useNotificationHandler.ts
···7import {useQueryClient} from '@tanstack/react-query'
89import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher'
010import {type NavigationProp} from '#/lib/routes/types'
11-import {Logger} from '#/logger'
12-import {isAndroid} from '#/platform/detection'
13import {useCurrentConvoId} from '#/state/messages/current-convo-id'
14import {RQKEY as RQKEY_NOTIFS} from '#/state/queries/notifications/feed'
15import {invalidateCachedUnreadPage} from '#/state/queries/notifications/unread'
···18import {useLoggedOutViewControls} from '#/state/shell/logged-out'
19import {useCloseAllActiveElements} from '#/state/util'
20import {resetToTab} from '#/Navigation'
02122export type NotificationReason =
23 | 'like'
···39 * `notification.request.trigger.payload` being `undefined`, as specified in
40 * the source types.
41 */
42-type NotificationPayload =
43 | undefined
44 | {
45 reason: Exclude<NotificationReason, 'chat-message'>
46 uri: string
47 subject: string
048 }
49 | {
50 reason: 'chat-message'
···60 shouldSetBadge: true,
61} satisfies Notifications.NotificationBehavior
6263-// These need to stay outside the hook to persist between account switches
64-let storedPayload: NotificationPayload
65-let prevDate = 0
0006667-const logger = Logger.create(Logger.Context.Notifications)
0006869export function useNotificationsHandler() {
70 const queryClient = useQueryClient()
···182 if (!payload) return
183184 if (payload.reason === 'chat-message') {
185- if (payload.recipientDid !== currentAccount?.did && !storedPayload) {
186- storedPayload = payload
0000000187 closeAllActiveElements()
188189 const account = accounts.find(a => a.did === payload.recipientDid)
···227 })
228 }
229 } else {
230- switch (payload.reason) {
231- case 'subscribed-post':
232- const urip = new AtUri(payload.uri)
233- if (urip.collection === 'app.bsky.feed.post') {
234- setTimeout(() => {
235- // @ts-expect-error types are weird here
236- navigation.navigate('HomeTab', {
237- screen: 'PostThread',
238- params: {
239- name: urip.host,
240- rkey: urip.rkey,
241- },
242- })
243- }, 500)
244- } else {
245- resetToTab('NotificationsTab')
246- }
247- break
248- case 'like':
249- case 'repost':
250- case 'follow':
251- case 'mention':
252- case 'quote':
253- case 'reply':
254- case 'starterpack-joined':
255- case 'like-via-repost':
256- case 'repost-via-repost':
257- case 'verified':
258- case 'unverified':
259- default:
260- resetToTab('NotificationsTab')
261- break
262- // TODO implement these after we have an idea of how to handle each individual case
263- // case 'follow':
264- // const uri = new AtUri(payload.uri)
265- // setTimeout(() => {
266- // // @ts-expect-error types are weird here
267- // navigation.navigate('HomeTab', {
268- // screen: 'Profile',
269- // params: {
270- // name: uri.host,
271- // },
272- // })
273- // }, 500)
274- // break
275- // case 'mention':
276- // case 'reply':
277- // const urip = new AtUri(payload.uri)
278- // setTimeout(() => {
279- // // @ts-expect-error types are weird here
280- // navigation.navigate('HomeTab', {
281- // screen: 'PostThread',
282- // params: {
283- // name: urip.host,
284- // rkey: urip.rkey,
285- // },
286- // })
287- // }, 500)
288 }
289 }
290 }
291292 Notifications.setNotificationHandler({
293 handleNotification: async e => {
294- if (
295- e.request.trigger == null ||
296- typeof e.request.trigger !== 'object' ||
297- !('type' in e.request.trigger) ||
298- e.request.trigger.type !== 'push'
299- ) {
300- return DEFAULT_HANDLER_OPTIONS
301- }
302303- logger.debug('Notifications: received', {e})
304-305- const payload = e.request.trigger.payload as NotificationPayload
306307- if (!payload) {
308- return DEFAULT_HANDLER_OPTIONS
309- }
310311 if (
312 payload.reason === 'chat-message' &&
···329330 const responseReceivedListener =
331 Notifications.addNotificationResponseReceivedListener(e => {
332- if (e.notification.date === prevDate) {
333- return
334- }
335- prevDate = e.notification.date
336337- logger.debug('Notifications: response received', {
338 actionIdentifier: e.actionIdentifier,
339 })
340341- if (
342- e.actionIdentifier === Notifications.DEFAULT_ACTION_IDENTIFIER &&
343- e.notification.request.trigger != null &&
344- typeof e.notification.request.trigger === 'object' &&
345- 'type' in e.notification.request.trigger &&
346- e.notification.request.trigger.type === 'push'
347- ) {
348- const payload = e.notification.request.trigger
349- .payload as NotificationPayload
350351- if (!payload) {
352- logger.error('useNotificationsHandler: received no payload', {
353- identifier: e.notification.request.identifier,
354- })
355- return
356- }
357 if (!payload.reason) {
358- logger.error('useNotificationsHandler: received unknown payload', {
359- payload,
360- identifier: e.notification.request.identifier,
361- })
000362 return
363 }
364365- logger.debug(
366 'User pressed a notification, opening notifications tab',
367 {},
368 )
369- logger.metric(
370 'notifications:openApp',
371- {reason: payload.reason},
372 {statsig: false},
373 )
374···383 truncateAndInvalidate(queryClient, RQKEY_NOTIFS('mentions'))
384 }
385386- logger.debug('Notifications: handleNotification', {
387 content: e.notification.request.content,
388- payload: e.notification.request.trigger.payload,
389 })
390391 handleNotification(payload)
392 Notifications.dismissAllNotificationsAsync()
0000393 }
394 })
395396 // Whenever there's a stored payload, that means we had to switch accounts before handling the notification.
397 // Whenever currentAccount changes, we should try to handle it again.
398 if (
399- storedPayload?.reason === 'chat-message' &&
400- currentAccount?.did === storedPayload.recipientDid
401 ) {
402- handleNotification(storedPayload)
403- storedPayload = undefined
404 }
405406 return () => {
···418 setShowLoggedOut,
419 ])
420}
00000000000000000000000000000000000000000000000000000000000000000000
···7import {useQueryClient} from '@tanstack/react-query'
89import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher'
10+import {logger as notyLogger} from '#/lib/notifications/util'
11import {type NavigationProp} from '#/lib/routes/types'
12+import {isAndroid, isIOS} from '#/platform/detection'
013import {useCurrentConvoId} from '#/state/messages/current-convo-id'
14import {RQKEY as RQKEY_NOTIFS} from '#/state/queries/notifications/feed'
15import {invalidateCachedUnreadPage} from '#/state/queries/notifications/unread'
···18import {useLoggedOutViewControls} from '#/state/shell/logged-out'
19import {useCloseAllActiveElements} from '#/state/util'
20import {resetToTab} from '#/Navigation'
21+import {router} from '#/routes'
2223export type NotificationReason =
24 | 'like'
···40 * `notification.request.trigger.payload` being `undefined`, as specified in
41 * the source types.
42 */
43+export type NotificationPayload =
44 | undefined
45 | {
46 reason: Exclude<NotificationReason, 'chat-message'>
47 uri: string
48 subject: string
49+ recipientDid: string
50 }
51 | {
52 reason: 'chat-message'
···62 shouldSetBadge: true,
63} satisfies Notifications.NotificationBehavior
6465+/**
66+ * Cached notification payload if we handled a notification while the user was
67+ * using a different account. This is consumed after we finish switching
68+ * accounts.
69+ */
70+let storedAccountSwitchPayload: NotificationPayload
7172+/**
73+ * Used to ensure we don't handle the same notification twice
74+ */
75+let lastHandledNotificationDateDedupe = 0
7677export function useNotificationsHandler() {
78 const queryClient = useQueryClient()
···190 if (!payload) return
191192 if (payload.reason === 'chat-message') {
193+ notyLogger.debug(`useNotificationsHandler: handling chat message`, {
194+ payload,
195+ })
196+197+ if (
198+ payload.recipientDid !== currentAccount?.did &&
199+ !storedAccountSwitchPayload
200+ ) {
201+ storePayloadForAccountSwitch(payload)
202 closeAllActiveElements()
203204 const account = accounts.find(a => a.did === payload.recipientDid)
···242 })
243 }
244 } else {
245+ const url = notificationToURL(payload)
246+247+ if (url === '/notifications') {
248+ resetToTab('NotificationsTab')
249+ } else if (url) {
250+ const [screen, params] = router.matchPath(url)
251+ // @ts-expect-error router is not typed :/ -sfn
252+ navigation.navigate('HomeTab', {screen, params})
253+ notyLogger.debug(`useNotificationsHandler: navigate`, {
254+ screen,
255+ params,
256+ })
0000000000000000000000000000000000000000000000257 }
258 }
259 }
260261 Notifications.setNotificationHandler({
262 handleNotification: async e => {
263+ const payload = getNotificationPayload(e)
0000000264265+ if (!payload) return DEFAULT_HANDLER_OPTIONS
00266267+ notyLogger.debug('useNotificationsHandler: incoming', {e, payload})
00268269 if (
270 payload.reason === 'chat-message' &&
···287288 const responseReceivedListener =
289 Notifications.addNotificationResponseReceivedListener(e => {
290+ if (e.notification.date === lastHandledNotificationDateDedupe) return
291+ lastHandledNotificationDateDedupe = e.notification.date
00292293+ notyLogger.debug('useNotificationsHandler: response received', {
294 actionIdentifier: e.actionIdentifier,
295 })
296297+ if (e.actionIdentifier !== Notifications.DEFAULT_ACTION_IDENTIFIER) {
298+ return
299+ }
000000300301+ const payload = getNotificationPayload(e.notification)
302+303+ if (payload) {
000304 if (!payload.reason) {
305+ notyLogger.error(
306+ 'useNotificationsHandler: received unknown payload',
307+ {
308+ payload,
309+ identifier: e.notification.request.identifier,
310+ },
311+ )
312 return
313 }
314315+ notyLogger.debug(
316 'User pressed a notification, opening notifications tab',
317 {},
318 )
319+ notyLogger.metric(
320 'notifications:openApp',
321+ {reason: payload.reason, causedBoot: false},
322 {statsig: false},
323 )
324···333 truncateAndInvalidate(queryClient, RQKEY_NOTIFS('mentions'))
334 }
335336+ notyLogger.debug('Notifications: handleNotification', {
337 content: e.notification.request.content,
338+ payload: payload,
339 })
340341 handleNotification(payload)
342 Notifications.dismissAllNotificationsAsync()
343+ } else {
344+ notyLogger.error('useNotificationsHandler: received no payload', {
345+ identifier: e.notification.request.identifier,
346+ })
347 }
348 })
349350 // Whenever there's a stored payload, that means we had to switch accounts before handling the notification.
351 // Whenever currentAccount changes, we should try to handle it again.
352 if (
353+ storedAccountSwitchPayload?.reason === 'chat-message' &&
354+ currentAccount?.did === storedAccountSwitchPayload.recipientDid
355 ) {
356+ handleNotification(storedAccountSwitchPayload)
357+ storedAccountSwitchPayload = undefined
358 }
359360 return () => {
···372 setShowLoggedOut,
373 ])
374}
375+376+export function storePayloadForAccountSwitch(payload: NotificationPayload) {
377+ storedAccountSwitchPayload = payload
378+}
379+380+export function getNotificationPayload(
381+ e: Notifications.Notification,
382+): NotificationPayload | null {
383+ if (
384+ e.request.trigger == null ||
385+ typeof e.request.trigger !== 'object' ||
386+ !('type' in e.request.trigger) ||
387+ e.request.trigger.type !== 'push'
388+ ) {
389+ return null
390+ }
391+392+ const payload = (
393+ isIOS ? e.request.trigger.payload : e.request.content.data
394+ ) as NotificationPayload
395+396+ if (payload) {
397+ return payload
398+ } else {
399+ return null
400+ }
401+}
402+403+export function notificationToURL(
404+ payload: NotificationPayload,
405+): string | undefined {
406+ switch (payload?.reason) {
407+ case 'like':
408+ case 'repost':
409+ case 'like-via-repost':
410+ case 'repost-via-repost': {
411+ const urip = new AtUri(payload.subject)
412+ if (urip.collection === 'app.bsky.feed.post') {
413+ return `/profile/${urip.host}/post/${urip.rkey}`
414+ } else {
415+ return '/notifications'
416+ }
417+ }
418+ case 'reply':
419+ case 'quote':
420+ case 'mention':
421+ case 'subscribed-post': {
422+ const urip = new AtUri(payload.uri)
423+ if (urip.collection === 'app.bsky.feed.post') {
424+ return `/profile/${urip.host}/post/${urip.rkey}`
425+ } else {
426+ return '/notifications'
427+ }
428+ }
429+ case 'follow':
430+ case 'starterpack-joined': {
431+ const urip = new AtUri(payload.uri)
432+ return `/profile/${urip.host}`
433+ }
434+ case 'chat-message':
435+ // should be handled separately
436+ return undefined
437+ case 'verified':
438+ case 'unverified':
439+ default:
440+ return '/notifications'
441+ }
442+}
···59 enabled: enabled && !!moderationOpts,
60 filter,
61 })
62+ // previously, this was `!isFetching && !data?.pages[0]?.items.length`
63+ // however, if the first page had no items (can happen in the mentions tab!)
64+ // it would flicker the empty state whenever it was loading.
65+ // therefore, we need to find if *any* page has items. in 99.9% of cases,
66+ // the `.find()` won't need to go any further than the first page -sfn
67+ const isEmpty =
68+ !isFetching && !data?.pages.find(page => page.items.length > 0)
6970 const items = React.useMemo(() => {
71 let arr: any[] = []