import React from 'react' import * as Notifications from 'expo-notifications' import {CommonActions, useNavigation} from '@react-navigation/native' import {useQueryClient} from '@tanstack/react-query' import {logger} from '#/logger' import {track} from 'lib/analytics/analytics' import {useAccountSwitcher} from 'lib/hooks/useAccountSwitcher' import {NavigationProp} from 'lib/routes/types' import {logEvent} from 'lib/statsig/statsig' import {isAndroid} from 'platform/detection' import {useCurrentConvoId} from 'state/messages/current-convo-id' import {RQKEY as RQKEY_NOTIFS} from 'state/queries/notifications/feed' import {invalidateCachedUnreadPage} from 'state/queries/notifications/unread' import {truncateAndInvalidate} from 'state/queries/util' import {useSession} from 'state/session' import {useLoggedOutViewControls} from 'state/shell/logged-out' import {useCloseAllActiveElements} from 'state/util' import {resetToTab} from '#/Navigation' type NotificationReason = | 'like' | 'repost' | 'follow' | 'mention' | 'reply' | 'quote' | 'chat-message' | 'starterpack-joined' type NotificationPayload = | { reason: Exclude uri: string subject: string } | { reason: 'chat-message' convoId: string messageId: string recipientDid: string } const DEFAULT_HANDLER_OPTIONS = { shouldShowAlert: false, shouldPlaySound: false, shouldSetBadge: true, } // These need to stay outside the hook to persist between account switches let storedPayload: NotificationPayload | undefined let prevDate = 0 export function useNotificationsHandler() { const queryClient = useQueryClient() const {currentAccount, accounts} = useSession() const {onPressSwitchAccount} = useAccountSwitcher() const navigation = useNavigation() const {currentConvoId} = useCurrentConvoId() const {setShowLoggedOut} = useLoggedOutViewControls() const closeAllActiveElements = useCloseAllActiveElements() // On Android, we cannot control which sound is used for a notification on Android // 28 or higher. Instead, we have to configure a notification channel ahead of time // which has the sounds we want in the configuration for that channel. These two // channels allow for the mute/unmute functionality we want for the background // handler. React.useEffect(() => { if (!isAndroid) return Notifications.setNotificationChannelAsync('chat-messages', { name: 'Chat', importance: Notifications.AndroidImportance.MAX, sound: 'dm.mp3', showBadge: true, vibrationPattern: [250], lockscreenVisibility: Notifications.AndroidNotificationVisibility.PRIVATE, }) Notifications.setNotificationChannelAsync('chat-messages-muted', { name: 'Chat - Muted', importance: Notifications.AndroidImportance.MAX, sound: null, showBadge: true, vibrationPattern: [250], lockscreenVisibility: Notifications.AndroidNotificationVisibility.PRIVATE, }) }, []) React.useEffect(() => { const handleNotification = (payload?: NotificationPayload) => { if (!payload) return if (payload.reason === 'chat-message') { if (payload.recipientDid !== currentAccount?.did && !storedPayload) { storedPayload = payload closeAllActiveElements() const account = accounts.find(a => a.did === payload.recipientDid) if (account) { onPressSwitchAccount(account, 'Notification') } else { setShowLoggedOut(true) } } else { navigation.dispatch(state => { if (state.routes[0].name === 'Messages') { if ( state.routes[state.routes.length - 1].name === 'MessagesConversation' ) { return CommonActions.reset({ ...state, routes: [ ...state.routes.slice(0, state.routes.length - 1), { name: 'MessagesConversation', params: { conversation: payload.convoId, }, }, ], }) } else { return CommonActions.navigate('MessagesConversation', { conversation: payload.convoId, }) } } else { return CommonActions.navigate('MessagesTab', { screen: 'Messages', params: { pushToConversation: payload.convoId, }, }) } }) } } else { switch (payload.reason) { case 'like': case 'repost': case 'follow': case 'mention': case 'quote': case 'reply': case 'starterpack-joined': resetToTab('NotificationsTab') break // TODO implement these after we have an idea of how to handle each individual case // case 'follow': // const uri = new AtUri(payload.uri) // setTimeout(() => { // // @ts-expect-error types are weird here // navigation.navigate('HomeTab', { // screen: 'Profile', // params: { // name: uri.host, // }, // }) // }, 500) // break // case 'mention': // case 'reply': // const urip = new AtUri(payload.uri) // setTimeout(() => { // // @ts-expect-error types are weird here // navigation.navigate('HomeTab', { // screen: 'PostThread', // params: { // name: urip.host, // rkey: urip.rkey, // }, // }) // }, 500) } } } Notifications.setNotificationHandler({ handleNotification: async e => { if (e.request.trigger.type !== 'push') return DEFAULT_HANDLER_OPTIONS logger.debug( 'Notifications: received', {e}, logger.DebugContext.notifications, ) const payload = e.request.trigger.payload as NotificationPayload if ( payload.reason === 'chat-message' && payload.recipientDid === currentAccount?.did ) { return { shouldShowAlert: payload.convoId !== currentConvoId, shouldPlaySound: false, shouldSetBadge: false, } } // Any notification other than a chat message should invalidate the unread page invalidateCachedUnreadPage() return DEFAULT_HANDLER_OPTIONS }, }) const responseReceivedListener = Notifications.addNotificationResponseReceivedListener(e => { if (e.notification.date === prevDate) { return } prevDate = e.notification.date logger.debug( 'Notifications: response received', { actionIdentifier: e.actionIdentifier, }, logger.DebugContext.notifications, ) if ( e.actionIdentifier === Notifications.DEFAULT_ACTION_IDENTIFIER && e.notification.request.trigger.type === 'push' ) { logger.debug( 'User pressed a notification, opening notifications tab', {}, logger.DebugContext.notifications, ) track('Notificatons:OpenApp') logEvent('notifications:openApp', {}) invalidateCachedUnreadPage() truncateAndInvalidate(queryClient, RQKEY_NOTIFS()) logger.debug('Notifications: handleNotification', { content: e.notification.request.content, payload: e.notification.request.trigger.payload, }) handleNotification( e.notification.request.trigger.payload as NotificationPayload, ) Notifications.dismissAllNotificationsAsync() } }) // Whenever there's a stored payload, that means we had to switch accounts before handling the notification. // Whenever currentAccount changes, we should try to handle it again. if ( storedPayload?.reason === 'chat-message' && currentAccount?.did === storedPayload.recipientDid ) { handleNotification(storedPayload) storedPayload = undefined } return () => { responseReceivedListener.remove() } }, [ queryClient, currentAccount, currentConvoId, accounts, closeAllActiveElements, currentAccount?.did, navigation, onPressSwitchAccount, setShowLoggedOut, ]) }