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