Bluesky app fork with some witchin' additions 馃挮 witchsky.app
bluesky fork client
119
fork

Configure Feed

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

at a876aae44ea07494ebea9727350aa060b81f317b 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/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 | '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`Reposts`), 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 reposts`), 172 importance: Notifications.AndroidImportance.HIGH, 173 }, 174 ) 175 Notifications.setNotificationChannelAsync( 176 'repost-via-repost' satisfies NotificationReason, 177 { 178 name: _(msg`Reposts of your reposts`), 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}