mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import {useEffect} from 'react' 2import * as Notifications from 'expo-notifications' 3import {AtUri} from '@atproto/api' 4import {msg} from '@lingui/macro' 5import {useLingui} from '@lingui/react' 6import {CommonActions, useNavigation} from '@react-navigation/native' 7import {useQueryClient} from '@tanstack/react-query' 8 9import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher' 10import {logger as notyLogger} from '#/lib/notifications/util' 11import {type NavigationProp} from '#/lib/routes/types' 12import {isAndroid, isIOS} 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' 16import {truncateAndInvalidate} from '#/state/queries/util' 17import {useSession} from '#/state/session' 18import {useLoggedOutViewControls} from '#/state/shell/logged-out' 19import {useCloseAllActiveElements} from '#/state/util' 20import {resetToTab} from '#/Navigation' 21import {router} from '#/routes' 22 23export type NotificationReason = 24 | 'like' 25 | 'repost' 26 | 'follow' 27 | 'mention' 28 | 'reply' 29 | 'quote' 30 | 'chat-message' 31 | 'starterpack-joined' 32 | 'like-via-repost' 33 | 'repost-via-repost' 34 | 'verified' 35 | 'unverified' 36 | 'subscribed-post' 37 38/** 39 * Manually overridden type, but retains the possibility of 40 * `notification.request.trigger.payload` being `undefined`, as specified in 41 * the source types. 42 */ 43export 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' 53 convoId: string 54 messageId: string 55 recipientDid: string 56 } 57 58const DEFAULT_HANDLER_OPTIONS = { 59 shouldShowBanner: false, 60 shouldShowList: false, 61 shouldPlaySound: false, 62 shouldSetBadge: true, 63} satisfies Notifications.NotificationBehavior 64 65/** 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 */ 70let storedAccountSwitchPayload: NotificationPayload 71 72/** 73 * Used to ensure we don't handle the same notification twice 74 */ 75let lastHandledNotificationDateDedupe = 0 76 77export function useNotificationsHandler() { 78 const queryClient = useQueryClient() 79 const {currentAccount, accounts} = useSession() 80 const {onPressSwitchAccount} = useAccountSwitcher() 81 const navigation = useNavigation<NavigationProp>() 82 const {currentConvoId} = useCurrentConvoId() 83 const {setShowLoggedOut} = useLoggedOutViewControls() 84 const closeAllActiveElements = useCloseAllActiveElements() 85 const {_} = useLingui() 86 87 // On Android, we cannot control which sound is used for a notification on Android 88 // 28 or higher. Instead, we have to configure a notification channel ahead of time 89 // which has the sounds we want in the configuration for that channel. These two 90 // channels allow for the mute/unmute functionality we want for the background 91 // handler. 92 useEffect(() => { 93 if (!isAndroid) return 94 // assign both chat notifications to a group 95 // NOTE: I don't think that it will retroactively move them into the group 96 // if the channels already exist. no big deal imo -sfn 97 const CHAT_GROUP = 'chat' 98 Notifications.setNotificationChannelGroupAsync(CHAT_GROUP, { 99 name: _(msg`Chat`), 100 description: _( 101 msg`You can choose whether chat notifications have sound in the chat settings within the app`, 102 ), 103 }) 104 Notifications.setNotificationChannelAsync('chat-messages', { 105 name: _(msg`Chat messages - sound`), 106 groupId: CHAT_GROUP, 107 importance: Notifications.AndroidImportance.MAX, 108 sound: 'dm.mp3', 109 showBadge: true, 110 vibrationPattern: [250], 111 lockscreenVisibility: Notifications.AndroidNotificationVisibility.PRIVATE, 112 }) 113 Notifications.setNotificationChannelAsync('chat-messages-muted', { 114 name: _(msg`Chat messages - silent`), 115 groupId: CHAT_GROUP, 116 importance: Notifications.AndroidImportance.MAX, 117 sound: null, 118 showBadge: true, 119 vibrationPattern: [250], 120 lockscreenVisibility: Notifications.AndroidNotificationVisibility.PRIVATE, 121 }) 122 123 Notifications.setNotificationChannelAsync( 124 'like' satisfies NotificationReason, 125 { 126 name: _(msg`Likes`), 127 importance: Notifications.AndroidImportance.HIGH, 128 }, 129 ) 130 Notifications.setNotificationChannelAsync( 131 'repost' satisfies NotificationReason, 132 { 133 name: _(msg`Reposts`), 134 importance: Notifications.AndroidImportance.HIGH, 135 }, 136 ) 137 Notifications.setNotificationChannelAsync( 138 'reply' satisfies NotificationReason, 139 { 140 name: _(msg`Replies`), 141 importance: Notifications.AndroidImportance.HIGH, 142 }, 143 ) 144 Notifications.setNotificationChannelAsync( 145 'mention' satisfies NotificationReason, 146 { 147 name: _(msg`Mentions`), 148 importance: Notifications.AndroidImportance.HIGH, 149 }, 150 ) 151 Notifications.setNotificationChannelAsync( 152 'quote' satisfies NotificationReason, 153 { 154 name: _(msg`Quotes`), 155 importance: Notifications.AndroidImportance.HIGH, 156 }, 157 ) 158 Notifications.setNotificationChannelAsync( 159 'follow' satisfies NotificationReason, 160 { 161 name: _(msg`New followers`), 162 importance: Notifications.AndroidImportance.HIGH, 163 }, 164 ) 165 Notifications.setNotificationChannelAsync( 166 'like-via-repost' satisfies NotificationReason, 167 { 168 name: _(msg`Likes of your reposts`), 169 importance: Notifications.AndroidImportance.HIGH, 170 }, 171 ) 172 Notifications.setNotificationChannelAsync( 173 'repost-via-repost' satisfies NotificationReason, 174 { 175 name: _(msg`Reposts of your reposts`), 176 importance: Notifications.AndroidImportance.HIGH, 177 }, 178 ) 179 Notifications.setNotificationChannelAsync( 180 'subscribed-post' satisfies NotificationReason, 181 { 182 name: _(msg`Activity from others`), 183 importance: Notifications.AndroidImportance.HIGH, 184 }, 185 ) 186 }, [_]) 187 188 useEffect(() => { 189 const handleNotification = (payload?: NotificationPayload) => { 190 if (!payload) return 191 192 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() 203 204 const account = accounts.find(a => a.did === payload.recipientDid) 205 if (account) { 206 onPressSwitchAccount(account, 'Notification') 207 } else { 208 setShowLoggedOut(true) 209 } 210 } else { 211 navigation.dispatch(state => { 212 if (state.routes[0].name === 'Messages') { 213 if ( 214 state.routes[state.routes.length - 1].name === 215 'MessagesConversation' 216 ) { 217 return CommonActions.reset({ 218 ...state, 219 routes: [ 220 ...state.routes.slice(0, state.routes.length - 1), 221 { 222 name: 'MessagesConversation', 223 params: { 224 conversation: payload.convoId, 225 }, 226 }, 227 ], 228 }) 229 } else { 230 return CommonActions.navigate('MessagesConversation', { 231 conversation: payload.convoId, 232 }) 233 } 234 } else { 235 return CommonActions.navigate('MessagesTab', { 236 screen: 'Messages', 237 params: { 238 pushToConversation: payload.convoId, 239 }, 240 }) 241 } 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 }) 257 } 258 } 259 } 260 261 Notifications.setNotificationHandler({ 262 handleNotification: async e => { 263 const payload = getNotificationPayload(e) 264 265 if (!payload) return DEFAULT_HANDLER_OPTIONS 266 267 notyLogger.debug('useNotificationsHandler: incoming', {e, payload}) 268 269 if ( 270 payload.reason === 'chat-message' && 271 payload.recipientDid === currentAccount?.did 272 ) { 273 const shouldAlert = payload.convoId !== currentConvoId 274 return { 275 shouldShowList: shouldAlert, 276 shouldShowBanner: shouldAlert, 277 shouldPlaySound: false, 278 shouldSetBadge: false, 279 } satisfies Notifications.NotificationBehavior 280 } 281 282 // Any notification other than a chat message should invalidate the unread page 283 invalidateCachedUnreadPage() 284 return DEFAULT_HANDLER_OPTIONS 285 }, 286 }) 287 288 const responseReceivedListener = 289 Notifications.addNotificationResponseReceivedListener(e => { 290 if (e.notification.date === lastHandledNotificationDateDedupe) return 291 lastHandledNotificationDateDedupe = e.notification.date 292 293 notyLogger.debug('useNotificationsHandler: response received', { 294 actionIdentifier: e.actionIdentifier, 295 }) 296 297 if (e.actionIdentifier !== Notifications.DEFAULT_ACTION_IDENTIFIER) { 298 return 299 } 300 301 const payload = getNotificationPayload(e.notification) 302 303 if (payload) { 304 notyLogger.debug( 305 'User pressed a notification, opening notifications tab', 306 {}, 307 ) 308 notyLogger.metric( 309 'notifications:openApp', 310 {reason: payload.reason, causedBoot: false}, 311 {statsig: false}, 312 ) 313 314 invalidateCachedUnreadPage() 315 truncateAndInvalidate(queryClient, RQKEY_NOTIFS('all')) 316 317 if ( 318 payload.reason === 'mention' || 319 payload.reason === 'quote' || 320 payload.reason === 'reply' 321 ) { 322 truncateAndInvalidate(queryClient, RQKEY_NOTIFS('mentions')) 323 } 324 325 notyLogger.debug('Notifications: handleNotification', { 326 content: e.notification.request.content, 327 payload: payload, 328 }) 329 330 handleNotification(payload) 331 Notifications.dismissAllNotificationsAsync() 332 } else { 333 notyLogger.error('useNotificationsHandler: received no payload', { 334 identifier: e.notification.request.identifier, 335 }) 336 } 337 }) 338 339 // Whenever there's a stored payload, that means we had to switch accounts before handling the notification. 340 // Whenever currentAccount changes, we should try to handle it again. 341 if ( 342 storedAccountSwitchPayload?.reason === 'chat-message' && 343 currentAccount?.did === storedAccountSwitchPayload.recipientDid 344 ) { 345 handleNotification(storedAccountSwitchPayload) 346 storedAccountSwitchPayload = undefined 347 } 348 349 return () => { 350 responseReceivedListener.remove() 351 } 352 }, [ 353 queryClient, 354 currentAccount, 355 currentConvoId, 356 accounts, 357 closeAllActiveElements, 358 currentAccount?.did, 359 navigation, 360 onPressSwitchAccount, 361 setShowLoggedOut, 362 ]) 363} 364 365export function storePayloadForAccountSwitch(payload: NotificationPayload) { 366 storedAccountSwitchPayload = payload 367} 368 369export function getNotificationPayload( 370 e: Notifications.Notification, 371): NotificationPayload | null { 372 if ( 373 e.request.trigger == null || 374 typeof e.request.trigger !== 'object' || 375 !('type' in e.request.trigger) || 376 e.request.trigger.type !== 'push' 377 ) { 378 return null 379 } 380 381 const payload = ( 382 isIOS ? e.request.trigger.payload : e.request.content.data 383 ) as NotificationPayload 384 385 if (payload && payload.reason) { 386 return payload 387 } else { 388 if (payload) { 389 notyLogger.debug('getNotificationPayload: received unknown payload', { 390 payload, 391 identifier: e.request.identifier, 392 }) 393 } 394 return null 395 } 396} 397 398export function notificationToURL(payload: NotificationPayload): string | null { 399 switch (payload?.reason) { 400 case 'like': 401 case 'repost': 402 case 'like-via-repost': 403 case 'repost-via-repost': { 404 const urip = new AtUri(payload.subject) 405 if (urip.collection === 'app.bsky.feed.post') { 406 return `/profile/${urip.host}/post/${urip.rkey}` 407 } else { 408 return '/notifications' 409 } 410 } 411 case 'reply': 412 case 'quote': 413 case 'mention': 414 case 'subscribed-post': { 415 const urip = new AtUri(payload.uri) 416 if (urip.collection === 'app.bsky.feed.post') { 417 return `/profile/${urip.host}/post/${urip.rkey}` 418 } else { 419 return '/notifications' 420 } 421 } 422 case 'follow': 423 case 'starterpack-joined': { 424 const urip = new AtUri(payload.uri) 425 return `/profile/${urip.host}` 426 } 427 case 'chat-message': 428 // should be handled separately 429 return null 430 case 'verified': 431 case 'unverified': 432 return '/notifications' 433 default: 434 // do nothing if we don't know what to do with it 435 return null 436 } 437}