Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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