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