forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
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}