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
53const logger = Logger.create(Logger.Context.Notifications)
54
55export function useNotificationsHandler() {
56 const queryClient = useQueryClient()
57 const {currentAccount, accounts} = useSession()
58 const {onPressSwitchAccount} = useAccountSwitcher()
59 const navigation = useNavigation<NavigationProp>()
60 const {currentConvoId} = useCurrentConvoId()
61 const {setShowLoggedOut} = useLoggedOutViewControls()
62 const closeAllActiveElements = useCloseAllActiveElements()
63
64 // On Android, we cannot control which sound is used for a notification on Android
65 // 28 or higher. Instead, we have to configure a notification channel ahead of time
66 // which has the sounds we want in the configuration for that channel. These two
67 // channels allow for the mute/unmute functionality we want for the background
68 // handler.
69 React.useEffect(() => {
70 if (!isAndroid) return
71 Notifications.setNotificationChannelAsync('chat-messages', {
72 name: 'Chat',
73 importance: Notifications.AndroidImportance.MAX,
74 sound: 'dm.mp3',
75 showBadge: true,
76 vibrationPattern: [250],
77 lockscreenVisibility: Notifications.AndroidNotificationVisibility.PRIVATE,
78 })
79
80 Notifications.setNotificationChannelAsync('chat-messages-muted', {
81 name: 'Chat - Muted',
82 importance: Notifications.AndroidImportance.MAX,
83 sound: null,
84 showBadge: true,
85 vibrationPattern: [250],
86 lockscreenVisibility: Notifications.AndroidNotificationVisibility.PRIVATE,
87 })
88 }, [])
89
90 React.useEffect(() => {
91 const handleNotification = (payload?: NotificationPayload) => {
92 if (!payload) return
93
94 if (payload.reason === 'chat-message') {
95 if (payload.recipientDid !== currentAccount?.did && !storedPayload) {
96 storedPayload = payload
97 closeAllActiveElements()
98
99 const account = accounts.find(a => a.did === payload.recipientDid)
100 if (account) {
101 onPressSwitchAccount(account, 'Notification')
102 } else {
103 setShowLoggedOut(true)
104 }
105 } else {
106 navigation.dispatch(state => {
107 if (state.routes[0].name === 'Messages') {
108 if (
109 state.routes[state.routes.length - 1].name ===
110 'MessagesConversation'
111 ) {
112 return CommonActions.reset({
113 ...state,
114 routes: [
115 ...state.routes.slice(0, state.routes.length - 1),
116 {
117 name: 'MessagesConversation',
118 params: {
119 conversation: payload.convoId,
120 },
121 },
122 ],
123 })
124 } else {
125 return CommonActions.navigate('MessagesConversation', {
126 conversation: payload.convoId,
127 })
128 }
129 } else {
130 return CommonActions.navigate('MessagesTab', {
131 screen: 'Messages',
132 params: {
133 pushToConversation: payload.convoId,
134 },
135 })
136 }
137 })
138 }
139 } else {
140 switch (payload.reason) {
141 case 'like':
142 case 'repost':
143 case 'follow':
144 case 'mention':
145 case 'quote':
146 case 'reply':
147 case 'starterpack-joined':
148 resetToTab('NotificationsTab')
149 break
150 // TODO implement these after we have an idea of how to handle each individual case
151 // case 'follow':
152 // const uri = new AtUri(payload.uri)
153 // setTimeout(() => {
154 // // @ts-expect-error types are weird here
155 // navigation.navigate('HomeTab', {
156 // screen: 'Profile',
157 // params: {
158 // name: uri.host,
159 // },
160 // })
161 // }, 500)
162 // break
163 // case 'mention':
164 // case 'reply':
165 // const urip = new AtUri(payload.uri)
166 // setTimeout(() => {
167 // // @ts-expect-error types are weird here
168 // navigation.navigate('HomeTab', {
169 // screen: 'PostThread',
170 // params: {
171 // name: urip.host,
172 // rkey: urip.rkey,
173 // },
174 // })
175 // }, 500)
176 }
177 }
178 }
179
180 Notifications.setNotificationHandler({
181 handleNotification: async e => {
182 if (
183 e.request.trigger == null ||
184 typeof e.request.trigger !== 'object' ||
185 !('type' in e.request.trigger) ||
186 e.request.trigger.type !== 'push'
187 ) {
188 return DEFAULT_HANDLER_OPTIONS
189 }
190
191 logger.debug('Notifications: received', {e})
192
193 const payload = e.request.trigger.payload as NotificationPayload
194 if (
195 payload.reason === 'chat-message' &&
196 payload.recipientDid === currentAccount?.did
197 ) {
198 return {
199 shouldShowAlert: payload.convoId !== currentConvoId,
200 shouldPlaySound: false,
201 shouldSetBadge: false,
202 }
203 }
204
205 // Any notification other than a chat message should invalidate the unread page
206 invalidateCachedUnreadPage()
207 return DEFAULT_HANDLER_OPTIONS
208 },
209 })
210
211 const responseReceivedListener =
212 Notifications.addNotificationResponseReceivedListener(e => {
213 if (e.notification.date === prevDate) {
214 return
215 }
216 prevDate = e.notification.date
217
218 logger.debug('Notifications: response received', {
219 actionIdentifier: e.actionIdentifier,
220 })
221
222 if (
223 e.actionIdentifier === Notifications.DEFAULT_ACTION_IDENTIFIER &&
224 e.notification.request.trigger != null &&
225 typeof e.notification.request.trigger === 'object' &&
226 'type' in e.notification.request.trigger &&
227 e.notification.request.trigger.type === 'push'
228 ) {
229 logger.debug(
230 'User pressed a notification, opening notifications tab',
231 {},
232 )
233 logEvent('notifications:openApp', {})
234 invalidateCachedUnreadPage()
235 const payload = e.notification.request.trigger
236 .payload as NotificationPayload
237 truncateAndInvalidate(queryClient, RQKEY_NOTIFS('all'))
238 if (
239 payload.reason === 'mention' ||
240 payload.reason === 'quote' ||
241 payload.reason === 'reply'
242 ) {
243 truncateAndInvalidate(queryClient, RQKEY_NOTIFS('mentions'))
244 }
245 logger.debug('Notifications: handleNotification', {
246 content: e.notification.request.content,
247 payload: e.notification.request.trigger.payload,
248 })
249 handleNotification(payload)
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}