Bluesky app fork with some witchin' additions 馃挮 witchsky.app
bluesky fork client
119
fork

Configure Feed

Select the types of activity you want to include in your feed.

at a876aae44ea07494ebea9727350aa060b81f317b 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: 'app.witchsky', 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}