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