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 (e.request.trigger.type !== 'push') return DEFAULT_HANDLER_OPTIONS
181
182 logger.debug(
183 'Notifications: received',
184 {e},
185 logger.DebugContext.notifications,
186 )
187
188 const payload = e.request.trigger.payload as NotificationPayload
189 if (
190 payload.reason === 'chat-message' &&
191 payload.recipientDid === currentAccount?.did
192 ) {
193 return {
194 shouldShowAlert: payload.convoId !== currentConvoId,
195 shouldPlaySound: false,
196 shouldSetBadge: false,
197 }
198 }
199
200 // Any notification other than a chat message should invalidate the unread page
201 invalidateCachedUnreadPage()
202 return DEFAULT_HANDLER_OPTIONS
203 },
204 })
205
206 const responseReceivedListener =
207 Notifications.addNotificationResponseReceivedListener(e => {
208 if (e.notification.date === prevDate) {
209 return
210 }
211 prevDate = e.notification.date
212
213 logger.debug(
214 'Notifications: response received',
215 {
216 actionIdentifier: e.actionIdentifier,
217 },
218 logger.DebugContext.notifications,
219 )
220
221 if (
222 e.actionIdentifier === Notifications.DEFAULT_ACTION_IDENTIFIER &&
223 e.notification.request.trigger.type === 'push'
224 ) {
225 logger.debug(
226 'User pressed a notification, opening notifications tab',
227 {},
228 logger.DebugContext.notifications,
229 )
230 logEvent('notifications:openApp', {})
231 invalidateCachedUnreadPage()
232 truncateAndInvalidate(queryClient, RQKEY_NOTIFS())
233 logger.debug('Notifications: handleNotification', {
234 content: e.notification.request.content,
235 payload: e.notification.request.trigger.payload,
236 })
237 handleNotification(
238 e.notification.request.trigger.payload as NotificationPayload,
239 )
240 Notifications.dismissAllNotificationsAsync()
241 }
242 })
243
244 // Whenever there's a stored payload, that means we had to switch accounts before handling the notification.
245 // Whenever currentAccount changes, we should try to handle it again.
246 if (
247 storedPayload?.reason === 'chat-message' &&
248 currentAccount?.did === storedPayload.recipientDid
249 ) {
250 handleNotification(storedPayload)
251 storedPayload = undefined
252 }
253
254 return () => {
255 responseReceivedListener.remove()
256 }
257 }, [
258 queryClient,
259 currentAccount,
260 currentConvoId,
261 accounts,
262 closeAllActiveElements,
263 currentAccount?.did,
264 navigation,
265 onPressSwitchAccount,
266 setShowLoggedOut,
267 ])
268}