Something went wrong. Try again.
Bluesky app fork with some witchin' additions 💫 forked from jollywhoppers.com/witchsky.app
Something went wrong. Try again.
14 kB · 441 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442import {useEffect} from 'react'import * as Notifications from 'expo-notifications'import {AtUri} from '@atproto/api'import {msg} from '@lingui/macro'import {useLingui} from '@lingui/react'import {CommonActions, useNavigation} from '@react-navigation/native'import {useQueryClient} from '@tanstack/react-query'
import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher'import {logger as notyLogger} from '#/lib/notifications/util'import {type NavigationProp} from '#/lib/routes/types'import {useCurrentConvoId} from '#/state/messages/current-convo-id'import {RQKEY as RQKEY_NOTIFS} from '#/state/queries/notifications/feed'import {invalidateCachedUnreadPage} from '#/state/queries/notifications/unread'import {truncateAndInvalidate} from '#/state/queries/util'import {useSession} from '#/state/session'import {useLoggedOutViewControls} from '#/state/shell/logged-out'import {useCloseAllActiveElements} from '#/state/util'import {useAnalytics} from '#/analytics'import {IS_ANDROID, IS_IOS} from '#/env'import {resetToTab} from '#/Navigation'import {router} from '#/routes'
export type NotificationReason = | 'like' | 'repost' | 'follow' | 'mention' | 'reply' | 'quote' | 'chat-message' | 'starterpack-joined' | 'like-via-repost' | 'repost-via-repost' | 'verified' | 'unverified' | 'subscribed-post'
/** * Manually overridden type, but retains the possibility of * `notification.request.trigger.payload` being `undefined`, as specified in * the source types. */export type NotificationPayload = | undefined | { reason: Exclude<NotificationReason, 'chat-message'> uri: string subject: string recipientDid: string } | { reason: 'chat-message' convoId: string messageId: string recipientDid: string }
const DEFAULT_HANDLER_OPTIONS = { shouldShowBanner: false, shouldShowList: false, shouldPlaySound: false, shouldSetBadge: true,} satisfies Notifications.NotificationBehavior
/** * Cached notification payload if we handled a notification while the user was * using a different account. This is consumed after we finish switching * accounts. */let storedAccountSwitchPayload: NotificationPayload
/** * Used to ensure we don't handle the same notification twice */let lastHandledNotificationDateDedupe = 0
export function useNotificationsHandler() { const ax = useAnalytics() const logger = ax.logger.useChild(ax.logger.Context.Notifications) const queryClient = useQueryClient() const {currentAccount, accounts} = useSession() const {onPressSwitchAccount} = useAccountSwitcher() const navigation = useNavigation<NavigationProp>() const {currentConvoId} = useCurrentConvoId() const {setShowLoggedOut} = useLoggedOutViewControls() const closeAllActiveElements = useCloseAllActiveElements() const {_} = useLingui()
// On Android, we cannot control which sound is used for a notification on Android // 28 or higher. Instead, we have to configure a notification channel ahead of time // which has the sounds we want in the configuration for that channel. These two // channels allow for the mute/unmute functionality we want for the background // handler. useEffect(() => { if (!IS_ANDROID) return // assign both chat notifications to a group // NOTE: I don't think that it will retroactively move them into the group // if the channels already exist. no big deal imo -sfn const CHAT_GROUP = 'chat' Notifications.setNotificationChannelGroupAsync(CHAT_GROUP, { name: _(msg`Chat`), description: _( msg`You can choose whether chat notifications have sound in the chat settings within the app`, ), }) Notifications.setNotificationChannelAsync('chat-messages', { name: _(msg`Chat messages - sound`), groupId: CHAT_GROUP, importance: Notifications.AndroidImportance.MAX, sound: 'dm.mp3', showBadge: true, vibrationPattern: [250], lockscreenVisibility: Notifications.AndroidNotificationVisibility.PRIVATE, }) Notifications.setNotificationChannelAsync('chat-messages-muted', { name: _(msg`Chat messages - silent`), groupId: CHAT_GROUP, importance: Notifications.AndroidImportance.MAX, sound: null, showBadge: true, vibrationPattern: [250], lockscreenVisibility: Notifications.AndroidNotificationVisibility.PRIVATE, })
Notifications.setNotificationChannelAsync( 'like' satisfies NotificationReason, { name: _(msg`Likes`), importance: Notifications.AndroidImportance.HIGH, }, ) Notifications.setNotificationChannelAsync( 'repost' satisfies NotificationReason, { name: _(msg`Reposts`), importance: Notifications.AndroidImportance.HIGH, }, ) Notifications.setNotificationChannelAsync( 'reply' satisfies NotificationReason, { name: _(msg`Replies`), importance: Notifications.AndroidImportance.HIGH, }, ) Notifications.setNotificationChannelAsync( 'mention' satisfies NotificationReason, { name: _(msg`Mentions`), importance: Notifications.AndroidImportance.HIGH, }, ) Notifications.setNotificationChannelAsync( 'quote' satisfies NotificationReason, { name: _(msg`Quotes`), importance: Notifications.AndroidImportance.HIGH, }, ) Notifications.setNotificationChannelAsync( 'follow' satisfies NotificationReason, { name: _(msg`New followers`), importance: Notifications.AndroidImportance.HIGH, }, ) Notifications.setNotificationChannelAsync( 'like-via-repost' satisfies NotificationReason, { name: _(msg`Likes of your reposts`), importance: Notifications.AndroidImportance.HIGH, }, ) Notifications.setNotificationChannelAsync( 'repost-via-repost' satisfies NotificationReason, { name: _(msg`Reposts of your reposts`), importance: Notifications.AndroidImportance.HIGH, }, ) Notifications.setNotificationChannelAsync( 'subscribed-post' satisfies NotificationReason, { name: _(msg`Activity from others`), importance: Notifications.AndroidImportance.HIGH, }, ) }, [_])
useEffect(() => { const handleNotification = (payload?: NotificationPayload) => { if (!payload) return
if (payload.reason === 'chat-message') { logger.debug(`useNotificationsHandler: handling chat message`, { payload, })
if ( payload.recipientDid !== currentAccount?.did && !storedAccountSwitchPayload ) { storePayloadForAccountSwitch(payload) closeAllActiveElements()
const account = accounts.find(a => a.did === payload.recipientDid) if (account) { onPressSwitchAccount(account, 'Notification') } else { setShowLoggedOut(true) } } else { navigation.dispatch(state => { if (state.routes[0].name === 'Messages') { if ( state.routes[state.routes.length - 1].name === 'MessagesConversation' ) { return CommonActions.reset({ ...state, routes: [ ...state.routes.slice(0, state.routes.length - 1), { name: 'MessagesConversation', params: { conversation: payload.convoId, }, }, ], }) } else { return CommonActions.navigate('MessagesConversation', { conversation: payload.convoId, }) } } else { return CommonActions.navigate('MessagesTab', { screen: 'Messages', params: { pushToConversation: payload.convoId, }, }) } }) } } else { const url = notificationToURL(payload)
if (url === '/notifications') { resetToTab('NotificationsTab') } else if (url) { const [screen, params] = router.matchPath(url) // @ts-expect-error router is not typed :/ -sfn navigation.navigate('HomeTab', {screen, params}) logger.debug(`useNotificationsHandler: navigate`, { screen, params, }) } } }
Notifications.setNotificationHandler({ handleNotification: async e => { const payload = getNotificationPayload(e)
if (!payload) return DEFAULT_HANDLER_OPTIONS
logger.debug('useNotificationsHandler: incoming', {e, payload})
if ( payload.reason === 'chat-message' && payload.recipientDid === currentAccount?.did ) { const shouldAlert = payload.convoId !== currentConvoId return { shouldShowList: shouldAlert, shouldShowBanner: shouldAlert, shouldPlaySound: false, shouldSetBadge: false, } satisfies Notifications.NotificationBehavior }
// Any notification other than a chat message should invalidate the unread page invalidateCachedUnreadPage() return DEFAULT_HANDLER_OPTIONS }, })
const responseReceivedListener = Notifications.addNotificationResponseReceivedListener(e => { if (e.notification.date === lastHandledNotificationDateDedupe) return lastHandledNotificationDateDedupe = e.notification.date
logger.debug('useNotificationsHandler: response received', { actionIdentifier: e.actionIdentifier, })
if (e.actionIdentifier !== Notifications.DEFAULT_ACTION_IDENTIFIER) { return }
const payload = getNotificationPayload(e.notification)
if (payload) { logger.debug( 'User pressed a notification, opening notifications tab', {}, ) ax.metric('notifications:openApp', { reason: payload.reason, causedBoot: false, })
invalidateCachedUnreadPage() truncateAndInvalidate(queryClient, RQKEY_NOTIFS('all'))
if ( payload.reason === 'mention' || payload.reason === 'quote' || payload.reason === 'reply' ) { truncateAndInvalidate(queryClient, RQKEY_NOTIFS('mentions')) }
logger.debug('Notifications: handleNotification', { content: e.notification.request.content, payload: payload, })
handleNotification(payload) Notifications.dismissAllNotificationsAsync() } else { logger.error('useNotificationsHandler: received no payload', { identifier: e.notification.request.identifier, }) } })
// Whenever there's a stored payload, that means we had to switch accounts before handling the notification. // Whenever currentAccount changes, we should try to handle it again. if ( storedAccountSwitchPayload?.reason === 'chat-message' && currentAccount?.did === storedAccountSwitchPayload.recipientDid ) { handleNotification(storedAccountSwitchPayload) storedAccountSwitchPayload = undefined }
return () => { responseReceivedListener.remove() } }, [ ax, logger, queryClient, currentAccount, currentConvoId, accounts, closeAllActiveElements, currentAccount?.did, navigation, onPressSwitchAccount, setShowLoggedOut, ])}
export function storePayloadForAccountSwitch(payload: NotificationPayload) { storedAccountSwitchPayload = payload}
export function getNotificationPayload( e: Notifications.Notification,): NotificationPayload | null { if ( e.request.trigger == null || typeof e.request.trigger !== 'object' || !('type' in e.request.trigger) || e.request.trigger.type !== 'push' ) { return null }
const payload = ( IS_IOS ? e.request.trigger.payload : e.request.content.data ) as NotificationPayload
if (payload && payload.reason) { return payload } else { if (payload) { notyLogger.debug('getNotificationPayload: received unknown payload', { payload, identifier: e.request.identifier, }) } return null }}
export function notificationToURL(payload: NotificationPayload): string | null { switch (payload?.reason) { case 'like': case 'repost': case 'like-via-repost': case 'repost-via-repost': { const urip = new AtUri(payload.subject) if (urip.collection === 'app.bsky.feed.post') { return `/profile/${urip.host}/post/${urip.rkey}` } else { return '/notifications' } } case 'reply': case 'quote': case 'mention': case 'subscribed-post': { const urip = new AtUri(payload.uri) if (urip.collection === 'app.bsky.feed.post') { return `/profile/${urip.host}/post/${urip.rkey}` } else { return '/notifications' } } case 'follow': case 'starterpack-joined': { const urip = new AtUri(payload.uri) return `/profile/${urip.host}` } case 'chat-message': // should be handled separately return null case 'verified': case 'unverified': return '/notifications' default: // do nothing if we don't know what to do with it return null }}