diff options
author | Eric Bailey <git@esb.lol> | 2025-06-25 21:32:51 -0500 |
---|---|---|
committer | GitHub <noreply@github.com> | 2025-06-25 21:32:51 -0500 |
commit | 92ffe66ae12df05370ddd44d37c23de3daa85775 (patch) | |
tree | 04c4b866d287b1ce926d39a5e098a20514085f94 /src/lib/notifications/notifications.ts | |
parent | bb760400feaf4bab668fc2532a4de64e6833200a (diff) | |
download | voidsky-92ffe66ae12df05370ddd44d37c23de3daa85775.tar.zst |
Notifications registration (#8564)
* Formatting nits * Debounce push token registration by 100ms * Comment * Align handling across native devices * Clean up * Simplify * Use hooks * Update import * Comment * Put app view DIDs in constants * Clarify comment
Diffstat (limited to 'src/lib/notifications/notifications.ts')
-rw-r--r-- | src/lib/notifications/notifications.ts | 231 |
1 files changed, 163 insertions, 68 deletions
diff --git a/src/lib/notifications/notifications.ts b/src/lib/notifications/notifications.ts index ab7fc5708..2c0487ab7 100644 --- a/src/lib/notifications/notifications.ts +++ b/src/lib/notifications/notifications.ts @@ -1,91 +1,185 @@ -import React from 'react' +import {useCallback, useEffect} from 'react' +import {Platform} from 'react-native' import * as Notifications from 'expo-notifications' import {getBadgeCountAsync, setBadgeCountAsync} from 'expo-notifications' -import {BskyAgent} from '@atproto/api' +import {type AtpAgent} from '@atproto/api' +import debounce from 'lodash.debounce' -import {logEvent} from '#/lib/statsig/statsig' +import {PUBLIC_APPVIEW_DID, PUBLIC_STAGING_APPVIEW_DID} from '#/lib/constants' import {Logger} from '#/logger' -import {devicePlatform, isAndroid, isNative} from '#/platform/detection' -import {SessionAccount, useAgent, useSession} from '#/state/session' -import BackgroundNotificationHandler from '../../../modules/expo-background-notification-handler' - -const SERVICE_DID = (serviceUrl?: string) => - serviceUrl?.includes('staging') - ? 'did:web:api.staging.bsky.dev' - : 'did:web:api.bsky.app' +import {isNative} from '#/platform/detection' +import {type SessionAccount, useAgent, useSession} from '#/state/session' +import BackgroundNotificationHandler from '#/../modules/expo-background-notification-handler' const logger = Logger.create(Logger.Context.Notifications) -async function registerPushToken( - agent: BskyAgent, - account: SessionAccount, - token: Notifications.DevicePushToken, -) { +/** + * @private + * Registers the device's push notification token with the Bluesky server. + */ +async function _registerPushToken({ + agent, + currentAccount, + token, +}: { + agent: AtpAgent + currentAccount: SessionAccount + token: Notifications.DevicePushToken +}) { try { - await agent.api.app.bsky.notification.registerPush({ - serviceDid: SERVICE_DID(account.service), - platform: devicePlatform, + await agent.app.bsky.notification.registerPush({ + serviceDid: currentAccount.service?.includes('staging') + ? PUBLIC_STAGING_APPVIEW_DID + : PUBLIC_APPVIEW_DID, + platform: Platform.OS, token: token.data, appId: 'xyz.blueskyweb.app', }) - logger.debug('Notifications: Sent push token (init)', { + + logger.debug(`registerPushToken: success`, { tokenType: token.type, token: token.data, }) } catch (error) { - logger.error('Notifications: Failed to set push token', {message: error}) + logger.error(`registerPushToken: failed`, {safeMessage: error}) } } -async function getPushToken(skipPermissionCheck = false) { - const granted = - skipPermissionCheck || (await Notifications.getPermissionsAsync()).granted +/** + * @private + * Debounced version of `_registerPushToken` to prevent multiple calls. + */ +const _registerPushTokenDebounced = debounce(_registerPushToken, 100) + +/** + * Hook to register the device's push notification token with the Bluesky. If + * the user is not logged in, this will do nothing. + * + * Use this instead of using `_registerPushToken` or + * `_registerPushTokenDebounced` directly. + */ +export function useRegisterPushToken() { + const agent = useAgent() + const {currentAccount} = useSession() + + return useCallback( + ({token}: {token: Notifications.DevicePushToken}) => { + if (!currentAccount) return + return _registerPushTokenDebounced({ + agent, + currentAccount, + token, + }) + }, + [agent, currentAccount], + ) +} + +/** + * Retreive the device's push notification token, if permissions are granted. + */ +async function getPushToken() { + const granted = (await Notifications.getPermissionsAsync()).granted + logger.debug(`getPushToken`, {granted}) if (granted) { return Notifications.getDevicePushTokenAsync() } } -export function useNotificationsRegistration() { - const agent = useAgent() - const {currentAccount} = useSession() - - React.useEffect(() => { - if (!currentAccount) { - return +/** + * Hook to get the device push token and register it with the Bluesky server. + * Should only be called after a user has logged-in, since registration is an + * authed endpoint. + * + * N.B. A previous regression in `expo-notifications` caused + * `addPushTokenListener` to not fire on Android after calling + * `getPushToken()`. Therefore, as insurance, we also call + * `registerPushToken` here. + * + * Because `registerPushToken` is debounced, even if the the listener _does_ + * fire, it's OK to also call `registerPushToken` below since only a single + * call will be made to the server (ideally). This does race the listener (if + * it fires), so there's a possibility that multiple calls will be made, but + * that is acceptable. + * + * @see https://github.com/bluesky-social/social-app/pull/4467 + * @see https://github.com/expo/expo/issues/28656 + * @see https://github.com/expo/expo/issues/29909 + */ +export function useGetAndRegisterPushToken() { + const registerPushToken = useRegisterPushToken() + return useCallback(async () => { + /** + * This will also fire the listener added via `addPushTokenListener`. That + * listener also handles registration. + */ + const token = await getPushToken() + + logger.debug(`useGetAndRegisterPushToken`, {token: token ?? 'undefined'}) + + if (token) { + /** + * The listener should have registered the token already, but just in + * case, call the debounced function again. + */ + registerPushToken({token}) } - // HACK - see https://github.com/bluesky-social/social-app/pull/4467 - // An apparent regression in expo-notifications causes `addPushTokenListener` to not fire on Android whenever the - // token changes by calling `getPushToken()`. This is a workaround to ensure we register the token once it is - // generated on Android. - if (isAndroid) { - ;(async () => { - const token = await getPushToken() - - // Token will be undefined if we don't have notifications permission - if (token) { - registerPushToken(agent, currentAccount, token) - } - })() - } else { - getPushToken() - } + return token + }, [registerPushToken]) +} - // According to the Expo docs, there is a chance that the token will change while the app is open in some rare - // cases. This will fire `registerPushToken` whenever that happens. - const subscription = Notifications.addPushTokenListener(async newToken => { - registerPushToken(agent, currentAccount, newToken) +/** + * Hook to register the device's push notification token with the Bluesky + * server, as well as listen for push token updates, should they occurr. + * + * Registered via the shell, which wraps the navigation stack, meaning if we + * have a current account, this handling will be registered and ready to go. + */ +export function useNotificationsRegistration() { + const {currentAccount} = useSession() + const registerPushToken = useRegisterPushToken() + const getAndRegisterPushToken = useGetAndRegisterPushToken() + + useEffect(() => { + /** + * We want this to init right away _after_ we have a logged in user. + */ + if (!currentAccount) return + + logger.debug(`useNotificationsRegistration`) + + /** + * Init push token, if permissions are granted already. If they weren't, + * they'll be requested by the `useRequestNotificationsPermission` hook + * below. + */ + getAndRegisterPushToken() + + /** + * Register the push token with the Bluesky server, whenever it changes. + * This is also fired any time `getDevicePushTokenAsync` is called. + * + * According to the Expo docs, there is a chance that the token will change + * while the app is open in some rare cases. This will fire + * `registerPushToken` whenever that happens. + * + * @see https://docs.expo.dev/versions/latest/sdk/notifications/#addpushtokenlistenerlistener + */ + const subscription = Notifications.addPushTokenListener(async token => { + registerPushToken({token}) + logger.debug(`addPushTokenListener callback`, {token}) }) return () => { subscription.remove() } - }, [currentAccount, agent]) + }, [currentAccount, getAndRegisterPushToken, registerPushToken]) } export function useRequestNotificationsPermission() { const {currentAccount} = useSession() - const agent = useAgent() + const getAndRegisterPushToken = useGetAndRegisterPushToken() return async ( context: 'StartOnboarding' | 'AfterOnboarding' | 'Login' | 'Home', @@ -107,26 +201,27 @@ export function useRequestNotificationsPermission() { } const res = await Notifications.requestPermissionsAsync() - logEvent('notifications:request', { + + logger.metric(`notifications:request`, { context: context, status: res.status, }) if (res.granted) { - // This will fire a pushTokenEvent, which will handle registration of the token - const token = await getPushToken(true) - - // Same hack as above. We cannot rely on the `addPushTokenListener` to fire on Android due to an Expo bug, so we - // will manually register it here. Note that this will occur only: - // 1. right after the user signs in, leading to no `currentAccount` account being available - this will be instead - // picked up from the useEffect above on `currentAccount` change - // 2. right after onboarding. In this case, we _need_ this registration, since `currentAccount` will not change - // and we need to ensure the token is registered right after permission is granted. `currentAccount` will already - // be available in this case, so the registration will succeed. - // We should remove this once expo-notifications (and possibly FCMv1) is fixed and the `addPushTokenListener` is - // working again. See https://github.com/expo/expo/issues/28656 - if (isAndroid && currentAccount && token) { - registerPushToken(agent, currentAccount, token) + if (currentAccount) { + /** + * If we have an account in scope, we can safely call + * `getAndRegisterPushToken`. + */ + getAndRegisterPushToken() + } else { + /** + * Right after login, `currentAccount` in this scope will be undefined, + * but calling `getPushToken` will result in `addPushTokenListener` + * listeners being called, which will handle the registration with the + * Bluesky server. + */ + getPushToken() } } } |