mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import React from 'react'
2import * as Notifications from 'expo-notifications'
3import {CommonActions, useNavigation} from '@react-navigation/native'
4import {useQueryClient} from '@tanstack/react-query'
5
6import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher'
7import {NavigationProp} from '#/lib/routes/types'
8import {logEvent} from '#/lib/statsig/statsig'
9import {logger} from '#/logger'
10import {isAndroid} from '#/platform/detection'
11import {useCurrentConvoId} from '#/state/messages/current-convo-id'
12import {RQKEY as RQKEY_NOTIFS} from '#/state/queries/notifications/feed'
13import {invalidateCachedUnreadPage} from '#/state/queries/notifications/unread'
14import {truncateAndInvalidate} from '#/state/queries/util'
15import {useSession} from '#/state/session'
16import {useLoggedOutViewControls} from '#/state/shell/logged-out'
17import {useCloseAllActiveElements} from '#/state/util'
18import {resetToTab} from '#/Navigation'
19
20type NotificationReason =
21 | 'like'
22 | 'repost'
23 | 'follow'
24 | 'mention'
25 | 'reply'
26 | 'quote'
27 | 'chat-message'
28 | 'starterpack-joined'
29
30type NotificationPayload =
31 | {
32 reason: Exclude<NotificationReason, 'chat-message'>
33 uri: string
34 subject: string
35 }
36 | {
37 reason: 'chat-message'
38 convoId: string
39 messageId: string
40 recipientDid: string
41 }
42
43const DEFAULT_HANDLER_OPTIONS = {
44 shouldShowAlert: false,
45 shouldPlaySound: false,
46 shouldSetBadge: true,
47}
48
49// These need to stay outside the hook to persist between account switches
50let storedPayload: NotificationPayload | undefined
51let prevDate = 0
52
53export function useNotificationsHandler() {
54 const queryClient = useQueryClient()
55 const {currentAccount, accounts} = useSession()
56 const {onPressSwitchAccount} = useAccountSwitcher()
57 const navigation = useNavigation<NavigationProp>()
58 const {currentConvoId} = useCurrentConvoId()
59 const {setShowLoggedOut} = useLoggedOutViewControls()
60 const closeAllActiveElements = useCloseAllActiveElements()
61
62 // On Android, we cannot control which sound is used for a notification on Android
63 // 28 or higher. Instead, we have to configure a notification channel ahead of time
64 // which has the sounds we want in the configuration for that channel. These two
65 // channels allow for the mute/unmute functionality we want for the background
66 // handler.
67 React.useEffect(() => {
68 if (!isAndroid) return
69 Notifications.setNotificationChannelAsync('chat-messages', {
70 name: 'Chat',
71 importance: Notifications.AndroidImportance.MAX,
72 sound: 'dm.mp3',
73 showBadge: true,
74 vibrationPattern: [250],
75 lockscreenVisibility: Notifications.AndroidNotificationVisibility.PRIVATE,
76 })
77
78 Notifications.setNotificationChannelAsync('chat-messages-muted', {
79 name: 'Chat - Muted',
80 importance: Notifications.AndroidImportance.MAX,
81 sound: null,
82 showBadge: true,
83 vibrationPattern: [250],
84 lockscreenVisibility: Notifications.AndroidNotificationVisibility.PRIVATE,
85 })
86 }, [])
87
88 React.useEffect(() => {
89 const handleNotification = (payload?: NotificationPayload) => {
90 if (!payload) return
91
92 if (payload.reason === 'chat-message') {
93 if (payload.recipientDid !== currentAccount?.did && !storedPayload) {
94 storedPayload = payload
95 closeAllActiveElements()
96
97 const account = accounts.find(a => a.did === payload.recipientDid)
98 if (account) {
99 onPressSwitchAccount(account, 'Notification')
100 } else {
101 setShowLoggedOut(true)
102 }
103 } else {
104 navigation.dispatch(state => {
105 if (state.routes[0].name === 'Messages') {
106 if (
107 state.routes[state.routes.length - 1].name ===
108 'MessagesConversation'
109 ) {
110 return CommonActions.reset({
111 ...state,
112 routes: [
113 ...state.routes.slice(0, state.routes.length - 1),
114 {
115 name: 'MessagesConversation',
116 params: {
117 conversation: payload.convoId,
118 },
119 },
120 ],
121 })
122 } else {
123 return CommonActions.navigate('MessagesConversation', {
124 conversation: payload.convoId,
125 })
126 }
127 } else {
128 return CommonActions.navigate('MessagesTab', {
129 screen: 'Messages',
130 params: {
131 pushToConversation: payload.convoId,
132 },
133 })
134 }
135 })
136 }
137 } else {
138 switch (payload.reason) {
139 case 'like':
140 case 'repost':
141 case 'follow':
142 case 'mention':
143 case 'quote':
144 case 'reply':
145 case 'starterpack-joined':
146 resetToTab('NotificationsTab')
147 break
148 // TODO implement these after we have an idea of how to handle each individual case
149 // case 'follow':
150 // const uri = new AtUri(payload.uri)
151 // setTimeout(() => {
152 // // @ts-expect-error types are weird here
153 // navigation.navigate('HomeTab', {
154 // screen: 'Profile',
155 // params: {
156 // name: uri.host,
157 // },
158 // })
159 // }, 500)
160 // break
161 // case 'mention':
162 // case 'reply':
163 // const urip = new AtUri(payload.uri)
164 // setTimeout(() => {
165 // // @ts-expect-error types are weird here
166 // navigation.navigate('HomeTab', {
167 // screen: 'PostThread',
168 // params: {
169 // name: urip.host,
170 // rkey: urip.rkey,
171 // },
172 // })
173 // }, 500)
174 }
175 }
176 }
177
178 Notifications.setNotificationHandler({
179 handleNotification: async e => {
180 if (
181 e.request.trigger == null ||
182 typeof e.request.trigger !== 'object' ||
183 !('type' in e.request.trigger) ||
184 e.request.trigger.type !== 'push'
185 ) {
186 return DEFAULT_HANDLER_OPTIONS
187 }
188
189 logger.debug(
190 'Notifications: received',
191 {e},
192 logger.DebugContext.notifications,
193 )
194
195 const payload = e.request.trigger.payload as NotificationPayload
196 if (
197 payload.reason === 'chat-message' &&
198 payload.recipientDid === currentAccount?.did
199 ) {
200 return {
201 shouldShowAlert: payload.convoId !== currentConvoId,
202 shouldPlaySound: false,
203 shouldSetBadge: false,
204 }
205 }
206
207 // Any notification other than a chat message should invalidate the unread page
208 invalidateCachedUnreadPage()
209 return DEFAULT_HANDLER_OPTIONS
210 },
211 })
212
213 const responseReceivedListener =
214 Notifications.addNotificationResponseReceivedListener(e => {
215 if (e.notification.date === prevDate) {
216 return
217 }
218 prevDate = e.notification.date
219
220 logger.debug(
221 'Notifications: response received',
222 {
223 actionIdentifier: e.actionIdentifier,
224 },
225 logger.DebugContext.notifications,
226 )
227
228 if (
229 e.actionIdentifier === Notifications.DEFAULT_ACTION_IDENTIFIER &&
230 e.notification.request.trigger != null &&
231 typeof e.notification.request.trigger === 'object' &&
232 'type' in e.notification.request.trigger &&
233 e.notification.request.trigger.type === 'push'
234 ) {
235 logger.debug(
236 'User pressed a notification, opening notifications tab',
237 {},
238 logger.DebugContext.notifications,
239 )
240 logEvent('notifications:openApp', {})
241 invalidateCachedUnreadPage()
242 truncateAndInvalidate(queryClient, RQKEY_NOTIFS())
243 logger.debug('Notifications: handleNotification', {
244 content: e.notification.request.content,
245 payload: e.notification.request.trigger.payload,
246 })
247 handleNotification(
248 e.notification.request.trigger.payload as NotificationPayload,
249 )
250 Notifications.dismissAllNotificationsAsync()
251 }
252 })
253
254 // Whenever there's a stored payload, that means we had to switch accounts before handling the notification.
255 // Whenever currentAccount changes, we should try to handle it again.
256 if (
257 storedPayload?.reason === 'chat-message' &&
258 currentAccount?.did === storedPayload.recipientDid
259 ) {
260 handleNotification(storedPayload)
261 storedPayload = undefined
262 }
263
264 return () => {
265 responseReceivedListener.remove()
266 }
267 }, [
268 queryClient,
269 currentAccount,
270 currentConvoId,
271 accounts,
272 closeAllActiveElements,
273 currentAccount?.did,
274 navigation,
275 onPressSwitchAccount,
276 setShowLoggedOut,
277 ])
278}