mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
at tooltip 13 kB view raw
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}