Bluesky app fork with some witchin' additions 馃挮 witchsky.app
bluesky fork client
at main 325 lines 9.8 kB view raw
1import {useCallback, useEffect} from 'react' 2import {Platform} from 'react-native' 3import * as Notifications from 'expo-notifications' 4import {getBadgeCountAsync, setBadgeCountAsync} from 'expo-notifications' 5import {type AppBskyNotificationRegisterPush, type AtpAgent} from '@atproto/api' 6import debounce from 'lodash.debounce' 7 8import { 9 BLUESKY_NOTIF_SERVICE_HEADERS, 10 PUBLIC_APPVIEW_DID, 11 PUBLIC_STAGING_APPVIEW_DID, 12} from '#/lib/constants' 13import {logger as notyLogger} from '#/lib/notifications/util' 14import {isNetworkError} from '#/lib/strings/errors' 15import {type SessionAccount, useAgent, useSession} from '#/state/session' 16import BackgroundNotificationHandler from '#/../modules/expo-background-notification-handler' 17import {useAgeAssurance} from '#/ageAssurance' 18import {useAnalytics} from '#/analytics' 19import {IS_DEV, IS_NATIVE} from '#/env' 20 21/** 22 * @private 23 * Registers the device's push notification token with the Bluesky server. 24 */ 25async function _registerPushToken({ 26 agent, 27 currentAccount, 28 token, 29 // extra = {}, 30}: { 31 agent: AtpAgent 32 currentAccount: SessionAccount 33 token: Notifications.DevicePushToken 34 extra?: { 35 ageRestricted?: boolean 36 } 37}) { 38 try { 39 const payload: AppBskyNotificationRegisterPush.InputSchema = { 40 serviceDid: currentAccount.service?.includes('staging') 41 ? PUBLIC_STAGING_APPVIEW_DID 42 : PUBLIC_APPVIEW_DID, 43 platform: Platform.OS, 44 token: token.data, 45 appId: 'app.witchsky', 46 // ageRestricted: extra.ageRestricted ?? false, 47 } 48 49 notyLogger.debug(`registerPushToken: registering`, {...payload}) 50 51 await agent.app.bsky.notification.registerPush(payload, { 52 headers: BLUESKY_NOTIF_SERVICE_HEADERS, 53 }) 54 55 notyLogger.debug(`registerPushToken: success`) 56 } catch (error) { 57 if (!isNetworkError(error)) { 58 notyLogger.error(`registerPushToken: failed`, {safeMessage: error}) 59 } 60 } 61} 62 63/** 64 * @private 65 * Debounced version of `_registerPushToken` to prevent multiple calls. 66 */ 67const _registerPushTokenDebounced = debounce(_registerPushToken, 100) 68 69/** 70 * Hook to register the device's push notification token with the Bluesky. If 71 * the user is not logged in, this will do nothing. 72 * 73 * Use this instead of using `_registerPushToken` or 74 * `_registerPushTokenDebounced` directly. 75 */ 76export function useRegisterPushToken() { 77 const agent = useAgent() 78 const {currentAccount} = useSession() 79 80 return useCallback( 81 ({ 82 token, 83 isAgeRestricted, 84 }: { 85 token: Notifications.DevicePushToken 86 isAgeRestricted: boolean 87 }) => { 88 if (!currentAccount) return 89 return _registerPushTokenDebounced({ 90 agent, 91 currentAccount, 92 token, 93 extra: { 94 ageRestricted: isAgeRestricted, 95 }, 96 }) 97 }, 98 [agent, currentAccount], 99 ) 100} 101 102/** 103 * Retreive the device's push notification token, if permissions are granted. 104 */ 105async function getPushToken() { 106 const granted = (await Notifications.getPermissionsAsync()).granted 107 notyLogger.debug(`getPushToken`, {granted}) 108 if (granted) { 109 return Notifications.getDevicePushTokenAsync() 110 } 111} 112 113/** 114 * Hook to get the device push token and register it with the Bluesky server. 115 * Should only be called after a user has logged-in, since registration is an 116 * authed endpoint. 117 * 118 * N.B. A previous regression in `expo-notifications` caused 119 * `addPushTokenListener` to not fire on Android after calling 120 * `getPushToken()`. Therefore, as insurance, we also call 121 * `registerPushToken` here. 122 * 123 * Because `registerPushToken` is debounced, even if the the listener _does_ 124 * fire, it's OK to also call `registerPushToken` below since only a single 125 * call will be made to the server (ideally). This does race the listener (if 126 * it fires), so there's a possibility that multiple calls will be made, but 127 * that is acceptable. 128 * 129 * @see https://github.com/expo/expo/issues/28656 130 * @see https://github.com/expo/expo/issues/29909 131 * @see https://github.com/bluesky-social/social-app/pull/4467 132 */ 133export function useGetAndRegisterPushToken() { 134 const aa = useAgeAssurance() 135 const registerPushToken = useRegisterPushToken() 136 return useCallback( 137 async ({ 138 isAgeRestricted: isAgeRestrictedOverride, 139 }: { 140 isAgeRestricted?: boolean 141 } = {}) => { 142 if (!IS_NATIVE || IS_DEV) return 143 144 /** 145 * This will also fire the listener added via `addPushTokenListener`. That 146 * listener also handles registration. 147 */ 148 const token = await getPushToken() 149 150 notyLogger.debug(`useGetAndRegisterPushToken`, { 151 token: token ?? 'undefined', 152 }) 153 154 if (token) { 155 /** 156 * The listener should have registered the token already, but just in 157 * case, call the debounced function again. 158 */ 159 registerPushToken({ 160 token, 161 isAgeRestricted: 162 isAgeRestrictedOverride ?? aa.state.access !== aa.Access.Full, 163 }) 164 } 165 166 return token 167 }, 168 [registerPushToken, aa], 169 ) 170} 171 172/** 173 * Hook to register the device's push notification token with the Bluesky 174 * server, as well as listen for push token updates, should they occurr. 175 * 176 * Registered via the shell, which wraps the navigation stack, meaning if we 177 * have a current account, this handling will be registered and ready to go. 178 */ 179export function useNotificationsRegistration() { 180 const {currentAccount} = useSession() 181 const registerPushToken = useRegisterPushToken() 182 const getAndRegisterPushToken = useGetAndRegisterPushToken() 183 const aa = useAgeAssurance() 184 185 useEffect(() => { 186 /** 187 * We want this to init right away _after_ we have a logged in user, and 188 * _after_ we've loaded their age assurance state. 189 */ 190 if (!currentAccount) return 191 192 notyLogger.debug(`useNotificationsRegistration`) 193 194 /** 195 * Init push token, if permissions are granted already. If they weren't, 196 * they'll be requested by the `useRequestNotificationsPermission` hook 197 * below. 198 */ 199 getAndRegisterPushToken() 200 201 /** 202 * Register the push token with the Bluesky server, whenever it changes. 203 * This is also fired any time `getDevicePushTokenAsync` is called. 204 * 205 * Since this is registered immediately after `getAndRegisterPushToken`, it 206 * should also detect that getter and be fired almost immediately after this. 207 * 208 * According to the Expo docs, there is a chance that the token will change 209 * while the app is open in some rare cases. This will fire 210 * `registerPushToken` whenever that happens. 211 * 212 * @see https://docs.expo.dev/versions/latest/sdk/notifications/#addpushtokenlistenerlistener 213 */ 214 const subscription = Notifications.addPushTokenListener(async token => { 215 registerPushToken({ 216 token, 217 isAgeRestricted: aa.state.access !== aa.Access.Full, 218 }) 219 notyLogger.debug(`addPushTokenListener callback`, {token}) 220 }) 221 222 return () => { 223 subscription.remove() 224 } 225 }, [currentAccount, getAndRegisterPushToken, registerPushToken, aa]) 226} 227 228export function useRequestNotificationsPermission() { 229 const ax = useAnalytics() 230 const {currentAccount} = useSession() 231 const getAndRegisterPushToken = useGetAndRegisterPushToken() 232 233 return async ( 234 context: 'StartOnboarding' | 'AfterOnboarding' | 'Login' | 'Home', 235 ) => { 236 const permissions = await Notifications.getPermissionsAsync() 237 238 if ( 239 !IS_NATIVE || 240 permissions?.status === 'granted' || 241 (permissions?.status === 'denied' && !permissions.canAskAgain) 242 ) { 243 return 244 } 245 if (context === 'AfterOnboarding') { 246 return 247 } 248 if (context === 'Home' && !currentAccount) { 249 return 250 } 251 252 const res = await Notifications.requestPermissionsAsync() 253 254 ax.metric(`notifications:request`, { 255 context: context, 256 status: res.status, 257 }) 258 259 if (res.granted) { 260 if (currentAccount) { 261 /** 262 * If we have an account in scope, we can safely call 263 * `getAndRegisterPushToken`. 264 */ 265 getAndRegisterPushToken() 266 } else { 267 /** 268 * Right after login, `currentAccount` in this scope will be undefined, 269 * but calling `getPushToken` will result in `addPushTokenListener` 270 * listeners being called, which will handle the registration with the 271 * Bluesky server. 272 */ 273 getPushToken() 274 } 275 } 276 } 277} 278 279export async function decrementBadgeCount(by: number) { 280 if (!IS_NATIVE) return 281 282 let count = await getBadgeCountAsync() 283 count -= by 284 if (count < 0) { 285 count = 0 286 } 287 288 await BackgroundNotificationHandler.setBadgeCountAsync(count) 289 await setBadgeCountAsync(count) 290} 291 292export async function resetBadgeCount() { 293 await BackgroundNotificationHandler.setBadgeCountAsync(0) 294 await setBadgeCountAsync(0) 295} 296 297export async function unregisterPushToken(agents: AtpAgent[]) { 298 if (!IS_NATIVE) return 299 300 try { 301 const token = await getPushToken() 302 if (token) { 303 for (const agent of agents) { 304 await agent.app.bsky.notification.unregisterPush( 305 { 306 serviceDid: agent.serviceUrl.hostname.includes('staging') 307 ? PUBLIC_STAGING_APPVIEW_DID 308 : PUBLIC_APPVIEW_DID, 309 platform: Platform.OS, 310 token: token.data, 311 appId: 'xyz.blueskyweb.app', 312 }, 313 { 314 headers: BLUESKY_NOTIF_SERVICE_HEADERS, 315 }, 316 ) 317 notyLogger.debug(`Push token unregistered for ${agent.session?.handle}`) 318 } 319 } else { 320 notyLogger.debug('Tried to unregister push token, but could not find one') 321 } 322 } catch (error) { 323 notyLogger.debug('Failed to unregister push token', {message: error}) 324 } 325}