Bluesky app fork with some witchin' additions 💫

Unregister push token on signout (#8661)

* unregister push - WIP

* create agent and submit push token revokation

* mock unregisterpush

* fix import

* Add proxy headers

---------

Co-authored-by: Eric Bailey <git@esb.lol>

authored by samuel.fm

Eric Bailey and committed by
GitHub
65faee4f 94be6617

+129 -6
+4
src/lib/constants.ts
··· 241 241 'atproto-proxy': `${BSKY_LABELER_DID}#atproto_labeler`, 242 242 } 243 243 244 + export const BLUESKY_NOTIF_SERVICE_HEADERS = { 245 + 'atproto-proxy': `${BLUESKY_PROXY_DID}#bsky_notif`, 246 + } 247 + 244 248 export const webLinks = { 245 249 tos: `https://bsky.social/about/support/tos`, 246 250 privacy: `https://bsky.social/about/support/privacy-policy`,
+40 -3
src/lib/notifications/notifications.ts
··· 2 2 import {Platform} from 'react-native' 3 3 import * as Notifications from 'expo-notifications' 4 4 import {getBadgeCountAsync, setBadgeCountAsync} from 'expo-notifications' 5 - import {type AppBskyNotificationRegisterPush, type AtpAgent} from '@atproto/api' 5 + import {type AtpAgent} from '@atproto/api' 6 + import {type AppBskyNotificationRegisterPush} from '@atproto/api' 6 7 import debounce from 'lodash.debounce' 7 8 8 - import {PUBLIC_APPVIEW_DID, PUBLIC_STAGING_APPVIEW_DID} from '#/lib/constants' 9 + import { 10 + BLUESKY_NOTIF_SERVICE_HEADERS, 11 + PUBLIC_APPVIEW_DID, 12 + PUBLIC_STAGING_APPVIEW_DID, 13 + } from '#/lib/constants' 9 14 import {logger as notyLogger} from '#/lib/notifications/util' 10 15 import {isNetworkError} from '#/lib/strings/errors' 11 16 import {isNative} from '#/platform/detection' ··· 44 49 45 50 notyLogger.debug(`registerPushToken: registering`, {...payload}) 46 51 47 - await agent.app.bsky.notification.registerPush(payload) 52 + await agent.app.bsky.notification.registerPush(payload, { 53 + headers: BLUESKY_NOTIF_SERVICE_HEADERS, 54 + }) 48 55 49 56 notyLogger.debug(`registerPushToken: success`) 50 57 } catch (error) { ··· 286 293 await BackgroundNotificationHandler.setBadgeCountAsync(0) 287 294 await setBadgeCountAsync(0) 288 295 } 296 + 297 + export async function unregisterPushToken(agents: AtpAgent[]) { 298 + if (!isNative) 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 + }
+5
src/state/session/__tests__/session-test.ts
··· 12 12 13 13 jest.mock('../../birthdate') 14 14 jest.mock('../../../ageAssurance/data') 15 + jest.mock('#/lib/notifications/notifications', () => ({ 16 + unregisterPushToken(_agents: BskyAgent[]) { 17 + return Promise.resolve() 18 + }, 19 + })) 15 20 16 21 describe('session', () => { 17 22 it('can log in and out', () => {
+49 -3
src/state/session/reducer.ts
··· 1 - import {type AtpSessionEvent, type BskyAgent} from '@atproto/api' 1 + import {type AtpAgent, type AtpSessionEvent} from '@atproto/api' 2 2 3 + import {unregisterPushToken} from '#/lib/notifications/notifications' 4 + import {logger} from '#/lib/notifications/util' 3 5 import {createPublicAgent} from './agent' 4 6 import {wrapSessionReducerForLogging} from './logging' 5 7 import {type SessionAccount} from './types' 8 + import {createTemporaryAgentsAndResume} from './util' 6 9 7 10 // A hack so that the reducer can't read anything from the agent. 8 11 // From the reducer's point of view, it should be a completely opaque object. ··· 137 140 } 138 141 case 'removed-account': { 139 142 const {accountDid} = action 143 + 144 + // side effect 145 + const account = state.accounts.find(a => a.did === accountDid) 146 + if (account) { 147 + createTemporaryAgentsAndResume([account]) 148 + .then(agents => unregisterPushToken(agents)) 149 + .then(() => 150 + logger.debug('Push token unregistered', {did: accountDid}), 151 + ) 152 + .catch(err => { 153 + logger.error('Failed to unregister push token', { 154 + did: accountDid, 155 + error: err, 156 + }) 157 + }) 158 + } 159 + 140 160 return { 141 161 accounts: state.accounts.filter(a => a.did !== accountDid), 142 162 currentAgentState: ··· 148 168 } 149 169 case 'logged-out-current-account': { 150 170 const {currentAgentState} = state 171 + const accountDid = currentAgentState.did 172 + // side effect 173 + const account = state.accounts.find(a => a.did === accountDid) 174 + if (account && accountDid) { 175 + createTemporaryAgentsAndResume([account]) 176 + .then(agents => unregisterPushToken(agents)) 177 + .then(() => 178 + logger.debug('Push token unregistered', {did: accountDid}), 179 + ) 180 + .catch(err => { 181 + logger.error('Failed to unregister push token', { 182 + did: accountDid, 183 + error: err, 184 + }) 185 + }) 186 + } 187 + 151 188 return { 152 189 accounts: state.accounts.map(a => 153 - a.did === currentAgentState.did 190 + a.did === accountDid 154 191 ? { 155 192 ...a, 156 193 refreshJwt: undefined, ··· 163 200 } 164 201 } 165 202 case 'logged-out-every-account': { 203 + createTemporaryAgentsAndResume(state.accounts) 204 + .then(agents => unregisterPushToken(agents)) 205 + .then(() => logger.debug('Push token unregistered')) 206 + .catch(err => { 207 + logger.error('Failed to unregister push token', { 208 + error: err, 209 + }) 210 + }) 211 + 166 212 return { 167 213 accounts: state.accounts.map(a => ({ 168 214 ...a, ··· 187 233 } 188 234 case 'partial-refresh-session': { 189 235 const {accountDid, patch} = action 190 - const agent = state.currentAgentState.agent as BskyAgent 236 + const agent = state.currentAgentState.agent as AtpAgent 191 237 192 238 /* 193 239 * Only mutating values that are safe. Be very careful with this.
+31
src/state/session/util.ts
··· 1 + import AtpAgent from '@atproto/api' 1 2 import {jwtDecode} from 'jwt-decode' 2 3 3 4 import {isJwtExpired} from '#/lib/jwt' 4 5 import {hasProp} from '#/lib/type-guards' 5 6 import * as persisted from '#/state/persisted' 7 + import {sessionAccountToSession} from './agent' 6 8 import {type SessionAccount} from './types' 7 9 8 10 export function readLastActiveAccount() { ··· 28 30 return true 29 31 } 30 32 } 33 + 34 + /** 35 + * Creates and attempted to resumeSession for every stored session. 36 + * Intended to be used to send push token revokations just before logout. 37 + */ 38 + export async function createTemporaryAgentsAndResume( 39 + accounts: SessionAccount[], 40 + ) { 41 + const agents = await Promise.allSettled( 42 + accounts.map(async account => { 43 + const agent: AtpAgent = new AtpAgent({service: account.service}) 44 + if (account.pdsUrl) { 45 + agent.sessionManager.pdsUrl = new URL(account.pdsUrl) 46 + } 47 + 48 + const session = sessionAccountToSession(account) 49 + const res = await agent.resumeSession(session) 50 + if (!res.success) throw new Error('Failed to resume session') 51 + 52 + agent.assertAuthenticated() // confirm auth success 53 + 54 + return agent 55 + }), 56 + ) 57 + 58 + return agents 59 + .filter(x => x.status === 'fulfilled') 60 + .map(promise => promise.value) 61 + }