drop the agent url reads in link meta and push registration
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -23,7 +23,7 @@ import {
|
|||||||
useMaybeProfileShadow,
|
useMaybeProfileShadow,
|
||||||
} from '#/state/cache/profile-shadow'
|
} from '#/state/cache/profile-shadow'
|
||||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
import {useAgent, usePdsClient, useSession} from '#/state/session'
|
import {usePdsClient, useSession} from '#/state/session'
|
||||||
import {useTickEveryMinute} from '#/state/shell'
|
import {useTickEveryMinute} from '#/state/shell'
|
||||||
import {useDialogContext} from '#/components/Dialog'
|
import {useDialogContext} from '#/components/Dialog'
|
||||||
import * as Toast from '#/components/Toast'
|
import * as Toast from '#/components/Toast'
|
||||||
@@ -194,7 +194,6 @@ export function useLiveLinkMetaQuery(url: string | null) {
|
|||||||
const liveNowConfig = useLiveNowConfig()
|
const liveNowConfig = useLiveNowConfig()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
|
|
||||||
const agent = useAgent()
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
enabled: !!url,
|
enabled: !!url,
|
||||||
queryKey: ['link-meta', url],
|
queryKey: ['link-meta', url],
|
||||||
@@ -212,7 +211,7 @@ export function useLiveLinkMetaQuery(url: string | null) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return await getLinkMeta(agent, url)
|
return await getLinkMeta(url)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ export async function resolveLink(
|
|||||||
view: res.data.starterPack,
|
view: res.data.starterPack,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return resolveExternal(agent, uri)
|
return resolveExternal(uri)
|
||||||
|
|
||||||
// Forked from useGetPost. TODO: move into RQ.
|
// Forked from useGetPost. TODO: move into RQ.
|
||||||
async function getPost({uri}: {uri: string}) {
|
async function getPost({uri}: {uri: string}) {
|
||||||
@@ -258,11 +258,8 @@ function getFileSlug(url: string | undefined): string | undefined {
|
|||||||
return dotIndex > 0 ? filename.slice(0, dotIndex) : undefined
|
return dotIndex > 0 ? filename.slice(0, dotIndex) : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveExternal(
|
async function resolveExternal(uri: string): Promise<ResolvedExternalLink> {
|
||||||
agent: AtpAgent,
|
const result = await getLinkMeta(uri)
|
||||||
uri: string,
|
|
||||||
): Promise<ResolvedExternalLink> {
|
|
||||||
const result = await getLinkMeta(agent, uri)
|
|
||||||
return {
|
return {
|
||||||
type: 'external',
|
type: 'external',
|
||||||
uri: result.url,
|
uri: result.url,
|
||||||
|
|||||||
@@ -279,9 +279,14 @@ export const DM_SERVICE_HEADERS = {
|
|||||||
'atproto-proxy': `${CHAT_PROXY_DID}#bsky_chat`,
|
'atproto-proxy': `${CHAT_PROXY_DID}#bsky_chat`,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BLUESKY_NOTIF_SERVICE_HEADERS = {
|
/**
|
||||||
'atproto-proxy': `${BLUESKY_PROXY_DID}#bsky_notif`,
|
* The notification service's proxy target, in the `did#service_id` form a lex
|
||||||
}
|
* client's per-call `service` option takes. Passing it emits `atproto-proxy:
|
||||||
|
* <this value>` on that one request, which is what routes push registration to
|
||||||
|
* the notification service (replaces the old
|
||||||
|
* `BLUESKY_NOTIF_SERVICE_HEADERS`).
|
||||||
|
*/
|
||||||
|
export const NOTIF_SERVICE: Service = `${BLUESKY_PROXY_DID}#bsky_notif`
|
||||||
|
|
||||||
export const webLinks = {
|
export const webLinks = {
|
||||||
tos: `https://bsky.social/about/support/tos`,
|
tos: `https://bsky.social/about/support/tos`,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {type AppBskyEmbedExternal, type AtpAgent} from '@atproto/api'
|
import {type AppBskyEmbedExternal} from '@atproto/api'
|
||||||
|
|
||||||
import {LINK_META_PROXY} from '#/lib/constants'
|
import {LINK_META_PROXY} from '#/lib/constants'
|
||||||
import {getGiphyMetaUri} from '#/lib/strings/embed-player'
|
import {getGiphyMetaUri} from '#/lib/strings/embed-player'
|
||||||
@@ -31,7 +31,6 @@ export interface LinkMeta {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getLinkMeta(
|
export async function getLinkMeta(
|
||||||
agent: AtpAgent,
|
|
||||||
url: string,
|
url: string,
|
||||||
timeout = 15e3,
|
timeout = 15e3,
|
||||||
): Promise<LinkMeta> {
|
): Promise<LinkMeta> {
|
||||||
@@ -80,9 +79,7 @@ export async function getLinkMeta(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${LINK_META_PROXY(agent.serviceUrl.toString() || '')}${encodeURIComponent(
|
`${LINK_META_PROXY('')}${encodeURIComponent(url)}`,
|
||||||
url,
|
|
||||||
)}`,
|
|
||||||
{signal: controller.signal},
|
{signal: controller.signal},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -2,33 +2,47 @@ import {useCallback, useEffect} from 'react'
|
|||||||
import {Platform} from 'react-native'
|
import {Platform} from 'react-native'
|
||||||
import * as Notifications from 'expo-notifications'
|
import * as Notifications from 'expo-notifications'
|
||||||
import {getBadgeCountAsync, setBadgeCountAsync} from 'expo-notifications'
|
import {getBadgeCountAsync, setBadgeCountAsync} from 'expo-notifications'
|
||||||
import {type AppBskyNotificationRegisterPush, type AtpAgent} from '@atproto/api'
|
import {type Client} from '@atproto/lex'
|
||||||
import debounce from 'lodash.debounce'
|
import debounce from 'lodash.debounce'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BLUESKY_NOTIF_SERVICE_HEADERS,
|
NOTIF_SERVICE,
|
||||||
PUBLIC_APPVIEW_DID,
|
PUBLIC_APPVIEW_DID,
|
||||||
PUBLIC_STAGING_APPVIEW_DID,
|
PUBLIC_STAGING_APPVIEW_DID,
|
||||||
} from '#/lib/constants'
|
} from '#/lib/constants'
|
||||||
import {logger as notyLogger} from '#/lib/notifications/util'
|
import {logger as notyLogger} from '#/lib/notifications/util'
|
||||||
import {isNetworkError} from '#/lib/strings/errors'
|
import {isNetworkError} from '#/lib/strings/errors'
|
||||||
import {type SessionAccount, useAgent, useSession} from '#/state/session'
|
import {type SessionAccount, usePdsClient, useSession} from '#/state/session'
|
||||||
import BackgroundNotificationHandler from '#/../modules/expo-background-notification-handler'
|
import BackgroundNotificationHandler from '#/../modules/expo-background-notification-handler'
|
||||||
import {useAgeAssurance} from '#/ageAssurance'
|
import {useAgeAssurance} from '#/ageAssurance'
|
||||||
import {useAnalytics} from '#/analytics'
|
import {useAnalytics} from '#/analytics'
|
||||||
import {IS_DEV, IS_NATIVE} from '#/env'
|
import {IS_DEV, IS_NATIVE} from '#/env'
|
||||||
|
import {app} from '#/lexicons'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A resumed single-use account client paired with the account's service origin
|
||||||
|
* and handle. Produced by `createTemporaryClientsAndResume` (session util) and
|
||||||
|
* consumed by {@link unregisterPushToken}, which needs the service host to pick
|
||||||
|
* the appview DID and the handle for a debug log line without reaching into the
|
||||||
|
* session internals.
|
||||||
|
*/
|
||||||
|
export type TemporaryPushClient = {
|
||||||
|
client: Client
|
||||||
|
service: string
|
||||||
|
handle: string
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @private
|
* @private
|
||||||
* Registers the device's push notification token with the Bluesky server.
|
* Registers the device's push notification token with the Bluesky server.
|
||||||
*/
|
*/
|
||||||
async function _registerPushToken({
|
async function _registerPushToken({
|
||||||
agent,
|
client,
|
||||||
currentAccount,
|
currentAccount,
|
||||||
token,
|
token,
|
||||||
extra = {},
|
extra = {},
|
||||||
}: {
|
}: {
|
||||||
agent: AtpAgent
|
client: Client
|
||||||
currentAccount: SessionAccount
|
currentAccount: SessionAccount
|
||||||
token: Notifications.DevicePushToken
|
token: Notifications.DevicePushToken
|
||||||
extra?: {
|
extra?: {
|
||||||
@@ -36,7 +50,7 @@ async function _registerPushToken({
|
|||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
const payload: AppBskyNotificationRegisterPush.InputSchema = {
|
const payload: app.bsky.notification.registerPush.$InputBody = {
|
||||||
serviceDid: currentAccount.service?.includes('staging')
|
serviceDid: currentAccount.service?.includes('staging')
|
||||||
? PUBLIC_STAGING_APPVIEW_DID
|
? PUBLIC_STAGING_APPVIEW_DID
|
||||||
: PUBLIC_APPVIEW_DID,
|
: PUBLIC_APPVIEW_DID,
|
||||||
@@ -48,8 +62,8 @@ async function _registerPushToken({
|
|||||||
|
|
||||||
notyLogger.debug(`registerPushToken: registering`, {...payload})
|
notyLogger.debug(`registerPushToken: registering`, {...payload})
|
||||||
|
|
||||||
await agent.app.bsky.notification.registerPush(payload, {
|
await client.call(app.bsky.notification.registerPush, payload, {
|
||||||
headers: BLUESKY_NOTIF_SERVICE_HEADERS,
|
service: NOTIF_SERVICE,
|
||||||
})
|
})
|
||||||
|
|
||||||
notyLogger.debug(`registerPushToken: success`)
|
notyLogger.debug(`registerPushToken: success`)
|
||||||
@@ -74,7 +88,7 @@ const _registerPushTokenDebounced = debounce(_registerPushToken, 100)
|
|||||||
* `_registerPushTokenDebounced` directly.
|
* `_registerPushTokenDebounced` directly.
|
||||||
*/
|
*/
|
||||||
export function useRegisterPushToken() {
|
export function useRegisterPushToken() {
|
||||||
const agent = useAgent()
|
const client = usePdsClient()
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
|
|
||||||
return useCallback(
|
return useCallback(
|
||||||
@@ -87,7 +101,7 @@ export function useRegisterPushToken() {
|
|||||||
}) => {
|
}) => {
|
||||||
if (!currentAccount) return
|
if (!currentAccount) return
|
||||||
return _registerPushTokenDebounced({
|
return _registerPushTokenDebounced({
|
||||||
agent,
|
client,
|
||||||
currentAccount,
|
currentAccount,
|
||||||
token,
|
token,
|
||||||
extra: {
|
extra: {
|
||||||
@@ -95,7 +109,7 @@ export function useRegisterPushToken() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[agent, currentAccount],
|
[client, currentAccount],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,16 +340,17 @@ export async function resetBadgeCount() {
|
|||||||
await setBadgeCountAsync(0)
|
await setBadgeCountAsync(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function unregisterPushToken(agents: AtpAgent[]) {
|
export async function unregisterPushToken(clients: TemporaryPushClient[]) {
|
||||||
if (!IS_NATIVE) return
|
if (!IS_NATIVE) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const token = await getPushToken()
|
const token = await getPushToken()
|
||||||
if (token) {
|
if (token) {
|
||||||
for (const agent of agents) {
|
for (const {client, service, handle} of clients) {
|
||||||
await agent.app.bsky.notification.unregisterPush(
|
await client.call(
|
||||||
|
app.bsky.notification.unregisterPush,
|
||||||
{
|
{
|
||||||
serviceDid: agent.serviceUrl.hostname.includes('staging')
|
serviceDid: service.includes('staging')
|
||||||
? PUBLIC_STAGING_APPVIEW_DID
|
? PUBLIC_STAGING_APPVIEW_DID
|
||||||
: PUBLIC_APPVIEW_DID,
|
: PUBLIC_APPVIEW_DID,
|
||||||
platform: Platform.OS,
|
platform: Platform.OS,
|
||||||
@@ -343,10 +358,10 @@ export async function unregisterPushToken(agents: AtpAgent[]) {
|
|||||||
appId: 'xyz.blueskyweb.app',
|
appId: 'xyz.blueskyweb.app',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
headers: BLUESKY_NOTIF_SERVICE_HEADERS,
|
service: NOTIF_SERVICE,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
notyLogger.debug(`Push token unregistered for ${agent.session?.handle}`)
|
notyLogger.debug(`Push token unregistered for ${handle}`)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
notyLogger.debug('Tried to unregister push token, but could not find one')
|
notyLogger.debug('Tried to unregister push token, but could not find one')
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import {type AtpAgent} from '@atproto/api'
|
|
||||||
import {type SessionData} from '@atproto/lex-password-session'
|
import {type SessionData} from '@atproto/lex-password-session'
|
||||||
import {describe, expect, it, jest} from '@jest/globals'
|
import {describe, expect, it, jest} from '@jest/globals'
|
||||||
|
|
||||||
@@ -18,21 +17,21 @@ jest.mock('../../../ageAssurance/state', () => ({
|
|||||||
unsafeGetAndComputeAgeAssurance: () => ({state: {}}),
|
unsafeGetAndComputeAgeAssurance: () => ({state: {}}),
|
||||||
}))
|
}))
|
||||||
jest.mock('#/lib/notifications/notifications', () => ({
|
jest.mock('#/lib/notifications/notifications', () => ({
|
||||||
unregisterPushToken(_agents: AtpAgent[]) {
|
unregisterPushToken(_clients: unknown[]) {
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
/*
|
/*
|
||||||
* The logout and account-removal reducer cases fire a push-token side effect
|
* The logout and account-removal reducer cases fire a push-token side effect
|
||||||
* whose first step, `createTemporaryAgentsAndResume`, builds real `AtpAgent`s
|
* whose first step, `createTemporaryClientsAndResume`, resumes real
|
||||||
* and resumes them over the real network. Under jest that request outlives the
|
* `PasswordSession`s over the real network. Under jest that request outlives the
|
||||||
* suite: it rejects after teardown, and the resulting `logger.error` reaches
|
* suite: it rejects after teardown, and the resulting `logger.error` reaches
|
||||||
* for `nanoid` in an environment that no longer has it, failing whichever suite
|
* for `nanoid` in an environment that no longer has it, failing whichever suite
|
||||||
* happens to be running at that moment. Stubbing the module keeps the side
|
* happens to be running at that moment. Stubbing the module keeps the side
|
||||||
* effect synchronous and offline.
|
* effect synchronous and offline.
|
||||||
*/
|
*/
|
||||||
jest.mock('../util', () => ({
|
jest.mock('../util', () => ({
|
||||||
createTemporaryAgentsAndResume: () => Promise.resolve([]),
|
createTemporaryClientsAndResume: () => Promise.resolve([]),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Reuse a bundle within each test: session events are scoped by bundle identity.
|
// Reuse a bundle within each test: session events are scoped by bundle identity.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {logger} from '#/lib/notifications/util'
|
|||||||
import {wrapSessionReducerForLogging} from './logging'
|
import {wrapSessionReducerForLogging} from './logging'
|
||||||
import {createPublicSessionBundle} from './session-core'
|
import {createPublicSessionBundle} from './session-core'
|
||||||
import {type AtpSessionEvent, type SessionAccount} from './types'
|
import {type AtpSessionEvent, type SessionAccount} from './types'
|
||||||
import {createTemporaryAgentsAndResume} from './util'
|
import {createTemporaryClientsAndResume} from './util'
|
||||||
|
|
||||||
// Keep session internals outside the reducer's static view of a bundle.
|
// Keep session internals outside the reducer's static view of a bundle.
|
||||||
type OpaqueSessionBundle = {
|
type OpaqueSessionBundle = {
|
||||||
@@ -155,8 +155,8 @@ let reducer = (state: State, action: Action): State => {
|
|||||||
// side effect
|
// side effect
|
||||||
const account = state.accounts.find(a => a.did === accountDid)
|
const account = state.accounts.find(a => a.did === accountDid)
|
||||||
if (account) {
|
if (account) {
|
||||||
createTemporaryAgentsAndResume([account])
|
createTemporaryClientsAndResume([account])
|
||||||
.then(agents => unregisterPushToken(agents))
|
.then(clients => unregisterPushToken(clients))
|
||||||
.then(() =>
|
.then(() =>
|
||||||
logger.debug('Push token unregistered', {did: accountDid}),
|
logger.debug('Push token unregistered', {did: accountDid}),
|
||||||
)
|
)
|
||||||
@@ -183,8 +183,8 @@ let reducer = (state: State, action: Action): State => {
|
|||||||
// side effect
|
// side effect
|
||||||
const account = state.accounts.find(a => a.did === accountDid)
|
const account = state.accounts.find(a => a.did === accountDid)
|
||||||
if (account && accountDid) {
|
if (account && accountDid) {
|
||||||
createTemporaryAgentsAndResume([account])
|
createTemporaryClientsAndResume([account])
|
||||||
.then(agents => unregisterPushToken(agents))
|
.then(clients => unregisterPushToken(clients))
|
||||||
.then(() =>
|
.then(() =>
|
||||||
logger.debug('Push token unregistered', {did: accountDid}),
|
logger.debug('Push token unregistered', {did: accountDid}),
|
||||||
)
|
)
|
||||||
@@ -211,8 +211,8 @@ let reducer = (state: State, action: Action): State => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
case 'logged-out-every-account': {
|
case 'logged-out-every-account': {
|
||||||
createTemporaryAgentsAndResume(state.accounts)
|
createTemporaryClientsAndResume(state.accounts)
|
||||||
.then(agents => unregisterPushToken(agents))
|
.then(clients => unregisterPushToken(clients))
|
||||||
.then(() => logger.debug('Push token unregistered'))
|
.then(() => logger.debug('Push token unregistered'))
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
logger.error('Failed to unregister push token', {
|
logger.error('Failed to unregister push token', {
|
||||||
|
|||||||
+34
-20
@@ -1,7 +1,10 @@
|
|||||||
import AtpAgent from '@atproto/api'
|
import {PasswordSession} from '@atproto/lex-password-session'
|
||||||
|
|
||||||
|
import {createLexClient} from '#/lib/lexClient'
|
||||||
|
import {type TemporaryPushClient} from '#/lib/notifications/notifications'
|
||||||
import * as persisted from '#/state/persisted'
|
import * as persisted from '#/state/persisted'
|
||||||
import {sessionAccountToSession} from './session-data'
|
import {networkAwareFetch} from './network'
|
||||||
|
import {sessionAccountToSessionData} from './session-data'
|
||||||
import {type SessionAccount} from './types'
|
import {type SessionAccount} from './types'
|
||||||
|
|
||||||
export {isSessionExpired, isSignupQueued} from './session-data'
|
export {isSessionExpired, isSignupQueued} from './session-data'
|
||||||
@@ -12,30 +15,41 @@ export function readLastActiveAccount() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates and attempted to resumeSession for every stored session.
|
* Resume a single-use session per stored account, for the push-token revocation
|
||||||
* Intended to be used to send push token revokations just before logout.
|
* sent just before logout.
|
||||||
|
*
|
||||||
|
* The sessions carry no lifecycle hooks - no `onUpdated`, no `onDeleted` - so a
|
||||||
|
* rotation one of them performs can neither persist over nor race the live
|
||||||
|
* session's tokens. That isolation is load-bearing: each exists only long enough
|
||||||
|
* to authenticate one `unregisterPush` call.
|
||||||
|
*
|
||||||
|
* PDS routing is left to the session rather than pinned from the stored
|
||||||
|
* `pdsUrl`, because `resume` refreshes (and fills in a missing didDoc from
|
||||||
|
* `getSession`) before the client issues anything, so the request already goes
|
||||||
|
* to the didDoc PDS.
|
||||||
|
*
|
||||||
|
* `resume` rejects only when a session is definitively dead; a transient network
|
||||||
|
* failure resolves with the stored tokens, which are the same ones the old agent
|
||||||
|
* path would have sent. Definitively dead sessions drop out of the settled list.
|
||||||
*/
|
*/
|
||||||
export async function createTemporaryAgentsAndResume(
|
export async function createTemporaryClientsAndResume(
|
||||||
accounts: SessionAccount[],
|
accounts: SessionAccount[],
|
||||||
) {
|
): Promise<TemporaryPushClient[]> {
|
||||||
const agents = await Promise.allSettled(
|
const settled = await Promise.allSettled(
|
||||||
accounts.map(async account => {
|
accounts.map(async account => {
|
||||||
const agent: AtpAgent = new AtpAgent({service: account.service})
|
const session = await PasswordSession.resume(
|
||||||
if (account.pdsUrl) {
|
sessionAccountToSessionData(account),
|
||||||
agent.sessionManager.pdsUrl = new URL(account.pdsUrl)
|
{fetch: networkAwareFetch},
|
||||||
}
|
)
|
||||||
|
return {
|
||||||
const session = sessionAccountToSession(account)
|
client: createLexClient(session),
|
||||||
const res = await agent.resumeSession(session)
|
service: session.session.service,
|
||||||
if (!res.success) throw new Error('Failed to resume session')
|
handle: session.session.handle,
|
||||||
|
} satisfies TemporaryPushClient
|
||||||
agent.assertAuthenticated() // confirm auth success
|
|
||||||
|
|
||||||
return agent
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
return agents
|
return settled
|
||||||
.filter(x => x.status === 'fulfilled')
|
.filter(x => x.status === 'fulfilled')
|
||||||
.map(promise => promise.value)
|
.map(promise => promise.value)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user