Bluesky app fork with some witchin' additions 💫

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

authored by

Eric Bailey and committed by
GitHub
92ffe66a bb760400

+163 -66
+3
src/lib/constants.ts
··· 201 201 }, 202 202 }, 203 203 } 204 + 205 + export const PUBLIC_APPVIEW_DID = 'did:web:api.bsky.app' 206 + export const PUBLIC_STAGING_APPVIEW_DID = 'did:web:api.staging.bsky.dev'
+160 -65
src/lib/notifications/notifications.ts
··· 1 - import React from 'react' 1 + import {useCallback, useEffect} from 'react' 2 + import {Platform} from 'react-native' 2 3 import * as Notifications from 'expo-notifications' 3 4 import {getBadgeCountAsync, setBadgeCountAsync} from 'expo-notifications' 4 - import {BskyAgent} from '@atproto/api' 5 + import {type AtpAgent} from '@atproto/api' 6 + import debounce from 'lodash.debounce' 5 7 6 - import {logEvent} from '#/lib/statsig/statsig' 8 + import {PUBLIC_APPVIEW_DID, PUBLIC_STAGING_APPVIEW_DID} from '#/lib/constants' 7 9 import {Logger} from '#/logger' 8 - import {devicePlatform, isAndroid, isNative} from '#/platform/detection' 9 - import {SessionAccount, useAgent, useSession} from '#/state/session' 10 - import BackgroundNotificationHandler from '../../../modules/expo-background-notification-handler' 11 - 12 - const SERVICE_DID = (serviceUrl?: string) => 13 - serviceUrl?.includes('staging') 14 - ? 'did:web:api.staging.bsky.dev' 15 - : 'did:web:api.bsky.app' 10 + import {isNative} from '#/platform/detection' 11 + import {type SessionAccount, useAgent, useSession} from '#/state/session' 12 + import BackgroundNotificationHandler from '#/../modules/expo-background-notification-handler' 16 13 17 14 const logger = Logger.create(Logger.Context.Notifications) 18 15 19 - async function registerPushToken( 20 - agent: BskyAgent, 21 - account: SessionAccount, 22 - token: Notifications.DevicePushToken, 23 - ) { 16 + /** 17 + * @private 18 + * Registers the device's push notification token with the Bluesky server. 19 + */ 20 + async function _registerPushToken({ 21 + agent, 22 + currentAccount, 23 + token, 24 + }: { 25 + agent: AtpAgent 26 + currentAccount: SessionAccount 27 + token: Notifications.DevicePushToken 28 + }) { 24 29 try { 25 - await agent.api.app.bsky.notification.registerPush({ 26 - serviceDid: SERVICE_DID(account.service), 27 - platform: devicePlatform, 30 + await agent.app.bsky.notification.registerPush({ 31 + serviceDid: currentAccount.service?.includes('staging') 32 + ? PUBLIC_STAGING_APPVIEW_DID 33 + : PUBLIC_APPVIEW_DID, 34 + platform: Platform.OS, 28 35 token: token.data, 29 36 appId: 'xyz.blueskyweb.app', 30 37 }) 31 - logger.debug('Notifications: Sent push token (init)', { 38 + 39 + logger.debug(`registerPushToken: success`, { 32 40 tokenType: token.type, 33 41 token: token.data, 34 42 }) 35 43 } catch (error) { 36 - logger.error('Notifications: Failed to set push token', {message: error}) 44 + logger.error(`registerPushToken: failed`, {safeMessage: error}) 37 45 } 38 46 } 39 47 40 - async function getPushToken(skipPermissionCheck = false) { 41 - const granted = 42 - skipPermissionCheck || (await Notifications.getPermissionsAsync()).granted 48 + /** 49 + * @private 50 + * Debounced version of `_registerPushToken` to prevent multiple calls. 51 + */ 52 + const _registerPushTokenDebounced = debounce(_registerPushToken, 100) 53 + 54 + /** 55 + * Hook to register the device's push notification token with the Bluesky. If 56 + * the user is not logged in, this will do nothing. 57 + * 58 + * Use this instead of using `_registerPushToken` or 59 + * `_registerPushTokenDebounced` directly. 60 + */ 61 + export function useRegisterPushToken() { 62 + const agent = useAgent() 63 + const {currentAccount} = useSession() 64 + 65 + return useCallback( 66 + ({token}: {token: Notifications.DevicePushToken}) => { 67 + if (!currentAccount) return 68 + return _registerPushTokenDebounced({ 69 + agent, 70 + currentAccount, 71 + token, 72 + }) 73 + }, 74 + [agent, currentAccount], 75 + ) 76 + } 77 + 78 + /** 79 + * Retreive the device's push notification token, if permissions are granted. 80 + */ 81 + async function getPushToken() { 82 + const granted = (await Notifications.getPermissionsAsync()).granted 83 + logger.debug(`getPushToken`, {granted}) 43 84 if (granted) { 44 85 return Notifications.getDevicePushTokenAsync() 45 86 } 46 87 } 47 88 89 + /** 90 + * Hook to get the device push token and register it with the Bluesky server. 91 + * Should only be called after a user has logged-in, since registration is an 92 + * authed endpoint. 93 + * 94 + * N.B. A previous regression in `expo-notifications` caused 95 + * `addPushTokenListener` to not fire on Android after calling 96 + * `getPushToken()`. Therefore, as insurance, we also call 97 + * `registerPushToken` here. 98 + * 99 + * Because `registerPushToken` is debounced, even if the the listener _does_ 100 + * fire, it's OK to also call `registerPushToken` below since only a single 101 + * call will be made to the server (ideally). This does race the listener (if 102 + * it fires), so there's a possibility that multiple calls will be made, but 103 + * that is acceptable. 104 + * 105 + * @see https://github.com/bluesky-social/social-app/pull/4467 106 + * @see https://github.com/expo/expo/issues/28656 107 + * @see https://github.com/expo/expo/issues/29909 108 + */ 109 + export function useGetAndRegisterPushToken() { 110 + const registerPushToken = useRegisterPushToken() 111 + return useCallback(async () => { 112 + /** 113 + * This will also fire the listener added via `addPushTokenListener`. That 114 + * listener also handles registration. 115 + */ 116 + const token = await getPushToken() 117 + 118 + logger.debug(`useGetAndRegisterPushToken`, {token: token ?? 'undefined'}) 119 + 120 + if (token) { 121 + /** 122 + * The listener should have registered the token already, but just in 123 + * case, call the debounced function again. 124 + */ 125 + registerPushToken({token}) 126 + } 127 + 128 + return token 129 + }, [registerPushToken]) 130 + } 131 + 132 + /** 133 + * Hook to register the device's push notification token with the Bluesky 134 + * server, as well as listen for push token updates, should they occurr. 135 + * 136 + * Registered via the shell, which wraps the navigation stack, meaning if we 137 + * have a current account, this handling will be registered and ready to go. 138 + */ 48 139 export function useNotificationsRegistration() { 49 - const agent = useAgent() 50 140 const {currentAccount} = useSession() 141 + const registerPushToken = useRegisterPushToken() 142 + const getAndRegisterPushToken = useGetAndRegisterPushToken() 51 143 52 - React.useEffect(() => { 53 - if (!currentAccount) { 54 - return 55 - } 144 + useEffect(() => { 145 + /** 146 + * We want this to init right away _after_ we have a logged in user. 147 + */ 148 + if (!currentAccount) return 56 149 57 - // HACK - see https://github.com/bluesky-social/social-app/pull/4467 58 - // An apparent regression in expo-notifications causes `addPushTokenListener` to not fire on Android whenever the 59 - // token changes by calling `getPushToken()`. This is a workaround to ensure we register the token once it is 60 - // generated on Android. 61 - if (isAndroid) { 62 - ;(async () => { 63 - const token = await getPushToken() 150 + logger.debug(`useNotificationsRegistration`) 64 151 65 - // Token will be undefined if we don't have notifications permission 66 - if (token) { 67 - registerPushToken(agent, currentAccount, token) 68 - } 69 - })() 70 - } else { 71 - getPushToken() 72 - } 152 + /** 153 + * Init push token, if permissions are granted already. If they weren't, 154 + * they'll be requested by the `useRequestNotificationsPermission` hook 155 + * below. 156 + */ 157 + getAndRegisterPushToken() 73 158 74 - // According to the Expo docs, there is a chance that the token will change while the app is open in some rare 75 - // cases. This will fire `registerPushToken` whenever that happens. 76 - const subscription = Notifications.addPushTokenListener(async newToken => { 77 - registerPushToken(agent, currentAccount, newToken) 159 + /** 160 + * Register the push token with the Bluesky server, whenever it changes. 161 + * This is also fired any time `getDevicePushTokenAsync` is called. 162 + * 163 + * According to the Expo docs, there is a chance that the token will change 164 + * while the app is open in some rare cases. This will fire 165 + * `registerPushToken` whenever that happens. 166 + * 167 + * @see https://docs.expo.dev/versions/latest/sdk/notifications/#addpushtokenlistenerlistener 168 + */ 169 + const subscription = Notifications.addPushTokenListener(async token => { 170 + registerPushToken({token}) 171 + logger.debug(`addPushTokenListener callback`, {token}) 78 172 }) 79 173 80 174 return () => { 81 175 subscription.remove() 82 176 } 83 - }, [currentAccount, agent]) 177 + }, [currentAccount, getAndRegisterPushToken, registerPushToken]) 84 178 } 85 179 86 180 export function useRequestNotificationsPermission() { 87 181 const {currentAccount} = useSession() 88 - const agent = useAgent() 182 + const getAndRegisterPushToken = useGetAndRegisterPushToken() 89 183 90 184 return async ( 91 185 context: 'StartOnboarding' | 'AfterOnboarding' | 'Login' | 'Home', ··· 107 201 } 108 202 109 203 const res = await Notifications.requestPermissionsAsync() 110 - logEvent('notifications:request', { 204 + 205 + logger.metric(`notifications:request`, { 111 206 context: context, 112 207 status: res.status, 113 208 }) 114 209 115 210 if (res.granted) { 116 - // This will fire a pushTokenEvent, which will handle registration of the token 117 - const token = await getPushToken(true) 118 - 119 - // Same hack as above. We cannot rely on the `addPushTokenListener` to fire on Android due to an Expo bug, so we 120 - // will manually register it here. Note that this will occur only: 121 - // 1. right after the user signs in, leading to no `currentAccount` account being available - this will be instead 122 - // picked up from the useEffect above on `currentAccount` change 123 - // 2. right after onboarding. In this case, we _need_ this registration, since `currentAccount` will not change 124 - // and we need to ensure the token is registered right after permission is granted. `currentAccount` will already 125 - // be available in this case, so the registration will succeed. 126 - // We should remove this once expo-notifications (and possibly FCMv1) is fixed and the `addPushTokenListener` is 127 - // working again. See https://github.com/expo/expo/issues/28656 128 - if (isAndroid && currentAccount && token) { 129 - registerPushToken(agent, currentAccount, token) 211 + if (currentAccount) { 212 + /** 213 + * If we have an account in scope, we can safely call 214 + * `getAndRegisterPushToken`. 215 + */ 216 + getAndRegisterPushToken() 217 + } else { 218 + /** 219 + * Right after login, `currentAccount` in this scope will be undefined, 220 + * but calling `getPushToken` will result in `addPushTokenListener` 221 + * listeners being called, which will handle the registration with the 222 + * Bluesky server. 223 + */ 224 + getPushToken() 130 225 } 131 226 } 132 227 }
-1
src/platform/detection.ts
··· 3 3 export const isIOS = Platform.OS === 'ios' 4 4 export const isAndroid = Platform.OS === 'android' 5 5 export const isNative = isIOS || isAndroid 6 - export const devicePlatform = isIOS ? 'ios' : isAndroid ? 'android' : 'web' 7 6 export const isWeb = !isNative 8 7 export const isMobileWebMediaQuery = 'only screen and (max-width: 1300px)' 9 8 export const isMobileWeb =