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/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}