mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import React from 'react' 2import * as Notifications from 'expo-notifications' 3import {CommonActions, useNavigation} from '@react-navigation/native' 4import {useQueryClient} from '@tanstack/react-query' 5 6import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher' 7import {NavigationProp} from '#/lib/routes/types' 8import {logEvent} from '#/lib/statsig/statsig' 9import {Logger} from '#/logger' 10import {isAndroid} from '#/platform/detection' 11import {useCurrentConvoId} from '#/state/messages/current-convo-id' 12import {RQKEY as RQKEY_NOTIFS} from '#/state/queries/notifications/feed' 13import {invalidateCachedUnreadPage} from '#/state/queries/notifications/unread' 14import {truncateAndInvalidate} from '#/state/queries/util' 15import {useSession} from '#/state/session' 16import {useLoggedOutViewControls} from '#/state/shell/logged-out' 17import {useCloseAllActiveElements} from '#/state/util' 18import {resetToTab} from '#/Navigation' 19 20type NotificationReason = 21 | 'like' 22 | 'repost' 23 | 'follow' 24 | 'mention' 25 | 'reply' 26 | 'quote' 27 | 'chat-message' 28 | 'starterpack-joined' 29 30type NotificationPayload = 31 | { 32 reason: Exclude<NotificationReason, 'chat-message'> 33 uri: string 34 subject: string 35 } 36 | { 37 reason: 'chat-message' 38 convoId: string 39 messageId: string 40 recipientDid: string 41 } 42 43const DEFAULT_HANDLER_OPTIONS = { 44 shouldShowAlert: false, 45 shouldPlaySound: false, 46 shouldSetBadge: true, 47} 48 49// These need to stay outside the hook to persist between account switches 50let storedPayload: NotificationPayload | undefined 51let prevDate = 0 52 53const logger = Logger.create(Logger.Context.Notifications) 54 55export function useNotificationsHandler() { 56 const queryClient = useQueryClient() 57 const {currentAccount, accounts} = useSession() 58 const {onPressSwitchAccount} = useAccountSwitcher() 59 const navigation = useNavigation<NavigationProp>() 60 const {currentConvoId} = useCurrentConvoId() 61 const {setShowLoggedOut} = useLoggedOutViewControls() 62 const closeAllActiveElements = useCloseAllActiveElements() 63 64 // On Android, we cannot control which sound is used for a notification on Android 65 // 28 or higher. Instead, we have to configure a notification channel ahead of time 66 // which has the sounds we want in the configuration for that channel. These two 67 // channels allow for the mute/unmute functionality we want for the background 68 // handler. 69 React.useEffect(() => { 70 if (!isAndroid) return 71 Notifications.setNotificationChannelAsync('chat-messages', { 72 name: 'Chat', 73 importance: Notifications.AndroidImportance.MAX, 74 sound: 'dm.mp3', 75 showBadge: true, 76 vibrationPattern: [250], 77 lockscreenVisibility: Notifications.AndroidNotificationVisibility.PRIVATE, 78 }) 79 80 Notifications.setNotificationChannelAsync('chat-messages-muted', { 81 name: 'Chat - Muted', 82 importance: Notifications.AndroidImportance.MAX, 83 sound: null, 84 showBadge: true, 85 vibrationPattern: [250], 86 lockscreenVisibility: Notifications.AndroidNotificationVisibility.PRIVATE, 87 }) 88 }, []) 89 90 React.useEffect(() => { 91 const handleNotification = (payload?: NotificationPayload) => { 92 if (!payload) return 93 94 if (payload.reason === 'chat-message') { 95 if (payload.recipientDid !== currentAccount?.did && !storedPayload) { 96 storedPayload = payload 97 closeAllActiveElements() 98 99 const account = accounts.find(a => a.did === payload.recipientDid) 100 if (account) { 101 onPressSwitchAccount(account, 'Notification') 102 } else { 103 setShowLoggedOut(true) 104 } 105 } else { 106 navigation.dispatch(state => { 107 if (state.routes[0].name === 'Messages') { 108 if ( 109 state.routes[state.routes.length - 1].name === 110 'MessagesConversation' 111 ) { 112 return CommonActions.reset({ 113 ...state, 114 routes: [ 115 ...state.routes.slice(0, state.routes.length - 1), 116 { 117 name: 'MessagesConversation', 118 params: { 119 conversation: payload.convoId, 120 }, 121 }, 122 ], 123 }) 124 } else { 125 return CommonActions.navigate('MessagesConversation', { 126 conversation: payload.convoId, 127 }) 128 } 129 } else { 130 return CommonActions.navigate('MessagesTab', { 131 screen: 'Messages', 132 params: { 133 pushToConversation: payload.convoId, 134 }, 135 }) 136 } 137 }) 138 } 139 } else { 140 switch (payload.reason) { 141 case 'like': 142 case 'repost': 143 case 'follow': 144 case 'mention': 145 case 'quote': 146 case 'reply': 147 case 'starterpack-joined': 148 resetToTab('NotificationsTab') 149 break 150 // TODO implement these after we have an idea of how to handle each individual case 151 // case 'follow': 152 // const uri = new AtUri(payload.uri) 153 // setTimeout(() => { 154 // // @ts-expect-error types are weird here 155 // navigation.navigate('HomeTab', { 156 // screen: 'Profile', 157 // params: { 158 // name: uri.host, 159 // }, 160 // }) 161 // }, 500) 162 // break 163 // case 'mention': 164 // case 'reply': 165 // const urip = new AtUri(payload.uri) 166 // setTimeout(() => { 167 // // @ts-expect-error types are weird here 168 // navigation.navigate('HomeTab', { 169 // screen: 'PostThread', 170 // params: { 171 // name: urip.host, 172 // rkey: urip.rkey, 173 // }, 174 // }) 175 // }, 500) 176 } 177 } 178 } 179 180 Notifications.setNotificationHandler({ 181 handleNotification: async e => { 182 if ( 183 e.request.trigger == null || 184 typeof e.request.trigger !== 'object' || 185 !('type' in e.request.trigger) || 186 e.request.trigger.type !== 'push' 187 ) { 188 return DEFAULT_HANDLER_OPTIONS 189 } 190 191 logger.debug('Notifications: received', {e}) 192 193 const payload = e.request.trigger.payload as NotificationPayload 194 if ( 195 payload.reason === 'chat-message' && 196 payload.recipientDid === currentAccount?.did 197 ) { 198 return { 199 shouldShowAlert: payload.convoId !== currentConvoId, 200 shouldPlaySound: false, 201 shouldSetBadge: false, 202 } 203 } 204 205 // Any notification other than a chat message should invalidate the unread page 206 invalidateCachedUnreadPage() 207 return DEFAULT_HANDLER_OPTIONS 208 }, 209 }) 210 211 const responseReceivedListener = 212 Notifications.addNotificationResponseReceivedListener(e => { 213 if (e.notification.date === prevDate) { 214 return 215 } 216 prevDate = e.notification.date 217 218 logger.debug('Notifications: response received', { 219 actionIdentifier: e.actionIdentifier, 220 }) 221 222 if ( 223 e.actionIdentifier === Notifications.DEFAULT_ACTION_IDENTIFIER && 224 e.notification.request.trigger != null && 225 typeof e.notification.request.trigger === 'object' && 226 'type' in e.notification.request.trigger && 227 e.notification.request.trigger.type === 'push' 228 ) { 229 logger.debug( 230 'User pressed a notification, opening notifications tab', 231 {}, 232 ) 233 logEvent('notifications:openApp', {}) 234 invalidateCachedUnreadPage() 235 const payload = e.notification.request.trigger 236 .payload as NotificationPayload 237 truncateAndInvalidate(queryClient, RQKEY_NOTIFS('all')) 238 if ( 239 payload.reason === 'mention' || 240 payload.reason === 'quote' || 241 payload.reason === 'reply' 242 ) { 243 truncateAndInvalidate(queryClient, RQKEY_NOTIFS('mentions')) 244 } 245 logger.debug('Notifications: handleNotification', { 246 content: e.notification.request.content, 247 payload: e.notification.request.trigger.payload, 248 }) 249 handleNotification(payload) 250 Notifications.dismissAllNotificationsAsync() 251 } 252 }) 253 254 // Whenever there's a stored payload, that means we had to switch accounts before handling the notification. 255 // Whenever currentAccount changes, we should try to handle it again. 256 if ( 257 storedPayload?.reason === 'chat-message' && 258 currentAccount?.did === storedPayload.recipientDid 259 ) { 260 handleNotification(storedPayload) 261 storedPayload = undefined 262 } 263 264 return () => { 265 responseReceivedListener.remove() 266 } 267 }, [ 268 queryClient, 269 currentAccount, 270 currentConvoId, 271 accounts, 272 closeAllActiveElements, 273 currentAccount?.did, 274 navigation, 275 onPressSwitchAccount, 276 setShowLoggedOut, 277 ]) 278}