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