diff --git a/src/ageAssurance/components/RedirectOverlay.tsx b/src/ageAssurance/components/RedirectOverlay.tsx index 1885990f95..e3b93ffeb4 100644 --- a/src/ageAssurance/components/RedirectOverlay.tsx +++ b/src/ageAssurance/components/RedirectOverlay.tsx @@ -178,18 +178,28 @@ function Inner() { const {_} = useLingui() const {hasSession} = useSession() const appviewClient = useAppviewClient() - const polling = useRef(false) - const unmounted = useRef(false) + /* + * The poll effect is mount-only so a session-bundle rebuild (web cross-tab + * token sync, which swaps the appview client identity) does not restart the + * flow or permanently latch it. Read the volatile values through refs kept + * fresh each render so the next retry attempt picks up the current client. + */ + const clientRef = useRef(appviewClient) + clientRef.current = appviewClient + const hasSessionRef = useRef(hasSession) + hasSessionRef.current = hasSession + const openMetricFired = useRef(false) const [error, setError] = useState(false) const [success, setSuccess] = useState(false) const {close} = useRedirectOverlayContext() useEffect(() => { - if (polling.current) return + let cancelled = false - polling.current = true - - ax.metric('ageAssurance:redirectDialogOpen', {}) + if (!openMetricFired.current) { + openMetricFired.current = true + ax.metric('ageAssurance:redirectDialogOpen', {}) + } wait( 3e3, @@ -197,10 +207,12 @@ function Inner() { 5, () => true, async () => { - if (!hasSession) return - if (unmounted.current) return + if (!hasSessionRef.current) return + if (cancelled) return - const data = await refetchAgeAssuranceServerState({appviewClient}) + const data = await refetchAgeAssuranceServerState({ + appviewClient: clientRef.current, + }) if (data?.state.status !== 'assured') { throw new Error( @@ -215,23 +227,24 @@ function Inner() { ) .then(async data => { if (!data) return - if (!hasSession) return - if (unmounted.current) return + if (!hasSessionRef.current) return + if (cancelled) return setSuccess(true) ax.metric('ageAssurance:redirectDialogSuccess', {}) }) .catch(() => { - if (unmounted.current) return + if (cancelled) return setError(true) ax.metric('ageAssurance:redirectDialogFail', {}) }) return () => { - unmounted.current = true + cancelled = true } - }, [ax, hasSession, appviewClient]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) if (success) { return ( diff --git a/src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx b/src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx index 8a29238780..653b00cdc9 100644 --- a/src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx +++ b/src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx @@ -86,18 +86,28 @@ export function Inner({}: {optimisticState?: AgeAssuranceRedirectDialogState}) { const {_} = useLingui() const {hasSession} = useSession() const appviewClient = useAppviewClient() - const polling = useRef(false) - const unmounted = useRef(false) const control = useAgeAssuranceRedirectDialogControl() + /* + * The poll effect is mount-only so a session-bundle rebuild (web cross-tab + * token sync, which swaps the appview client identity) does not restart the + * flow or permanently latch it. Read the volatile values through refs kept + * fresh each render so the next retry attempt picks up the current client. + */ + const clientRef = useRef(appviewClient) + clientRef.current = appviewClient + const hasSessionRef = useRef(hasSession) + hasSessionRef.current = hasSession + const openMetricFired = useRef(false) const [error, setError] = useState(false) const [success, setSuccess] = useState(false) useEffect(() => { - if (polling.current) return + let cancelled = false - polling.current = true - - ax.metric('ageAssurance:redirectDialogOpen', {}) + if (!openMetricFired.current) { + openMetricFired.current = true + ax.metric('ageAssurance:redirectDialogOpen', {}) + } wait( 3e3, @@ -105,10 +115,12 @@ export function Inner({}: {optimisticState?: AgeAssuranceRedirectDialogState}) { 5, () => true, async () => { - if (!hasSession) return - if (unmounted.current) return + if (!hasSessionRef.current) return + if (cancelled) return - const data = await refetchAgeAssuranceServerState({appviewClient}) + const data = await refetchAgeAssuranceServerState({ + appviewClient: clientRef.current, + }) if (data?.state.status !== 'assured') { throw new Error( @@ -123,23 +135,24 @@ export function Inner({}: {optimisticState?: AgeAssuranceRedirectDialogState}) { ) .then(async data => { if (!data) return - if (!hasSession) return - if (unmounted.current) return + if (!hasSessionRef.current) return + if (cancelled) return setSuccess(true) ax.metric('ageAssurance:redirectDialogSuccess', {}) }) .catch(() => { - if (unmounted.current) return + if (cancelled) return setError(true) ax.metric('ageAssurance:redirectDialogFail', {}) }) return () => { - unmounted.current = true + cancelled = true } - }, [ax, hasSession, appviewClient, control]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) if (success) { return ( diff --git a/src/lib/api/feed/author.ts b/src/lib/api/feed/author.ts index 456c0d6a56..36a9d20dd2 100644 --- a/src/lib/api/feed/author.ts +++ b/src/lib/api/feed/author.ts @@ -19,6 +19,10 @@ export class AuthorFeedAPI implements FeedAPI { this._params = feedParams } + setClient(client: Client) { + this.client = client + } + get params() { const params = {...this._params} params.includePins = params.filter === 'posts_and_author_threads' diff --git a/src/lib/api/feed/custom.ts b/src/lib/api/feed/custom.ts index 038c76140c..2cc94df8d8 100644 --- a/src/lib/api/feed/custom.ts +++ b/src/lib/api/feed/custom.ts @@ -37,6 +37,10 @@ export class CustomFeedAPI implements FeedAPI { this.userInterests = userInterests } + setClient(client: Client) { + this.client = client + } + async peekLatest(): Promise { const contentLangs = getContentLanguages().join(',') const res = await this.client.call( diff --git a/src/lib/api/feed/demo.ts b/src/lib/api/feed/demo.ts index d4a480aea1..4c8937cf00 100644 --- a/src/lib/api/feed/demo.ts +++ b/src/lib/api/feed/demo.ts @@ -11,6 +11,10 @@ export class DemoFeedAPI implements FeedAPI { this.client = client } + setClient(client: Client) { + this.client = client + } + // eslint-disable-next-line @typescript-eslint/require-await async peekLatest(): Promise { return DEMO_FEED.feed[0] diff --git a/src/lib/api/feed/following.ts b/src/lib/api/feed/following.ts index 56c96b777b..ea03f8ce17 100644 --- a/src/lib/api/feed/following.ts +++ b/src/lib/api/feed/following.ts @@ -10,6 +10,10 @@ export class FollowingFeedAPI implements FeedAPI { this.client = client } + setClient(client: Client) { + this.client = client + } + async peekLatest(): Promise { const res = await this.client.call(app.bsky.feed.getTimeline, { limit: 1, diff --git a/src/lib/api/feed/home.ts b/src/lib/api/feed/home.ts index ffac360ca9..fd122889ff 100644 --- a/src/lib/api/feed/home.ts +++ b/src/lib/api/feed/home.ts @@ -56,6 +56,12 @@ export class HomeFeedAPI implements FeedAPI { this.userInterests = userInterests } + setClient(client: Client) { + this.client = client + this.following.setClient(client) + this.discover.setClient(client) + } + reset() { this.following = new FollowingFeedAPI({client: this.client}) this.discover = new CustomFeedAPI({ diff --git a/src/lib/api/feed/likes.ts b/src/lib/api/feed/likes.ts index 3531a58f44..7a0702bd52 100644 --- a/src/lib/api/feed/likes.ts +++ b/src/lib/api/feed/likes.ts @@ -18,6 +18,10 @@ export class LikesFeedAPI implements FeedAPI { this.params = feedParams } + setClient(client: Client) { + this.client = client + } + async peekLatest(): Promise { const res = await this.client.call(app.bsky.feed.getActorLikes, { ...this.params, diff --git a/src/lib/api/feed/list.ts b/src/lib/api/feed/list.ts index 5278e7dae4..35d35ec1e7 100644 --- a/src/lib/api/feed/list.ts +++ b/src/lib/api/feed/list.ts @@ -18,6 +18,10 @@ export class ListFeedAPI implements FeedAPI { this.params = feedParams } + setClient(client: Client) { + this.client = client + } + async peekLatest(): Promise { const res = await this.client.call(app.bsky.feed.getListFeed, { ...this.params, diff --git a/src/lib/api/feed/merge.ts b/src/lib/api/feed/merge.ts index 789c060afc..0c1d2782b4 100644 --- a/src/lib/api/feed/merge.ts +++ b/src/lib/api/feed/merge.ts @@ -64,6 +64,14 @@ export class MergeFeedAPI implements FeedAPI { }) } + setClient(client: Client) { + this.client = client + this.following.setClient(client) + for (const feed of this.customFeeds) { + feed.setClient(client) + } + } + reset() { this.following = new MergeFeedSource_Following({ client: this.client, @@ -203,6 +211,10 @@ class MergeFeedSource { this.feedTuners = feedTuners } + setClient(client: Client) { + this.client = client + } + get numReady() { return this.queue.length } diff --git a/src/lib/api/feed/posts.ts b/src/lib/api/feed/posts.ts index 6093ff6698..0c35e98245 100644 --- a/src/lib/api/feed/posts.ts +++ b/src/lib/api/feed/posts.ts @@ -27,6 +27,10 @@ export class PostListFeedAPI implements FeedAPI { } } + setClient(client: Client) { + this.client = client + } + async peekLatest(): Promise { if (this.peek) return this.peek throw new Error('Has not fetched yet') diff --git a/src/lib/api/feed/types.ts b/src/lib/api/feed/types.ts index 59803a992a..9725566113 100644 --- a/src/lib/api/feed/types.ts +++ b/src/lib/api/feed/types.ts @@ -1,3 +1,5 @@ +import {type Client} from '@atproto/lex-client' + import {type app} from '#/lexicons' export interface FeedAPIResponse { @@ -6,6 +8,13 @@ export interface FeedAPIResponse { } export interface FeedAPI { + /** + * Swap in a fresh client. Feed pages retain their FeedAPI across paginations, + * so the client captured at construction goes stale after a session-bundle + * rebuild (web cross-tab token sync). Callers re-point the api at the current + * client before each fetch/peek so a disposed client is never used. + */ + setClient(client: Client): void peekLatest(): Promise fetch({ cursor, diff --git a/src/state/messages/convo/agent.ts b/src/state/messages/convo/agent.ts index 5427d72a3e..c8fc3b8457 100644 --- a/src/state/messages/convo/agent.ts +++ b/src/state/messages/convo/agent.ts @@ -21,7 +21,6 @@ import { ACTIVE_POLL_INTERVAL, BACKGROUND_POLL_INTERVAL, INACTIVE_TIMEOUT, - NETWORK_FAILURE_STATUSES, } from '#/state/messages/convo/const' import { type ConvoDispatch, @@ -176,6 +175,7 @@ export class Convo { this.setupPlaceholderData(params.placeholderData) } + this.updateClient = this.updateClient.bind(this) this.setConvo = this.setConvo.bind(this) this.subscribe = this.subscribe.bind(this) this.getSnapshot = this.getSnapshot.bind(this) @@ -194,6 +194,18 @@ export class Convo { this.updateLockStatus = this.updateLockStatus.bind(this) } + /** + * Swap in a fresh chat client. On web, a cross-tab token sync rebuilds the + * session bundle (new client identities, same DID) and disposes the old one, + * whose fetch then throws. Every request reads `this.chatClient` per call, so + * reassigning the field keeps the convo alive without a reset (which would + * drop optimistic `pendingMessages`). Same-DID rebuild keeps `senderUserDid` + * valid, so it is intentionally left untouched. + */ + updateClient(client: Client) { + this.chatClient = client + } + private commit() { this.snapshot = undefined this.subscribers.forEach(subscriber => subscriber()) @@ -1198,14 +1210,13 @@ export class Convo { const status = getErrorStatus(e) if (isXrpcError(e)) { /* - * A status-less xrpc error is a network/transport failure (lex throws - * `XrpcInternalError`, which carries no HTTP status). The old bridge - * represented the same case with a sentinel `status` of `1`, which is a - * member of `NETWORK_FAILURE_STATUSES` - so a network failure was - * `recoverable`. Preserve that by treating `undefined` status the same - * as a network-failure status here. + * Defer to lex's own retry classification: transient statuses + * (408/425/429/5xx) and transport/fetch failures are recoverable, while + * permanent statuses and internal errors are not. This is the lex-native + * analogue of the old `NETWORK_FAILURE_STATUSES` check, and correctly + * excludes status-less internal/validation errors that are not retryable. */ - if (status === undefined || NETWORK_FAILURE_STATUSES.includes(status)) { + if (e.shouldRetry()) { this.pendingMessageFailure = 'recoverable' } else { this.pendingMessageFailure = 'unrecoverable' diff --git a/src/state/messages/convo/const.ts b/src/state/messages/convo/const.ts index ee08684013..3a691dc6e7 100644 --- a/src/state/messages/convo/const.ts +++ b/src/state/messages/convo/const.ts @@ -2,7 +2,3 @@ export const ACTIVE_POLL_INTERVAL = 4e3 export const MESSAGE_SCREEN_POLL_INTERVAL = 30e3 export const BACKGROUND_POLL_INTERVAL = 60e3 export const INACTIVE_TIMEOUT = 60e3 * 5 - -export const NETWORK_FAILURE_STATUSES = [ - 1, 408, 425, 429, 500, 502, 503, 504, 522, 524, -] diff --git a/src/state/messages/convo/index.tsx b/src/state/messages/convo/index.tsx index 2d2ca5e108..bb91152359 100644 --- a/src/state/messages/convo/index.tsx +++ b/src/state/messages/convo/index.tsx @@ -101,6 +101,10 @@ export function ConvoProvider({ const service = useSyncExternalStore(convo.subscribe, convo.getSnapshot) const {mutate: markAsRead} = useMarkAsReadMutation() + useEffect(() => { + convo.updateClient(chatClient) + }, [convo, chatClient]) + const appState = useAppState() const isActive = appState === 'active' useFocusEffect( diff --git a/src/state/messages/events/agent.ts b/src/state/messages/events/agent.ts index 82282e10ef..b061d7b390 100644 --- a/src/state/messages/events/agent.ts +++ b/src/state/messages/events/agent.ts @@ -42,6 +42,17 @@ export class MessagesEventBus { this.init() } + /** + * Swap in a fresh chat client. On web, a cross-tab token sync rebuilds the + * session bundle (new client identities, same DID) and disposes the old one, + * whose fetch then throws. Every request reads `this.chatClient` per call, so + * reassigning the field is enough to keep polling alive without tearing down + * the bus and its in-memory poll cursor. + */ + updateClient(client: Client) { + this.chatClient = client + } + requestPollInterval(interval: number) { const id = nanoid() this.requestedPollIntervals.set(id, interval) diff --git a/src/state/messages/events/index.tsx b/src/state/messages/events/index.tsx index 2e58f48494..172cdc7f69 100644 --- a/src/state/messages/events/index.tsx +++ b/src/state/messages/events/index.tsx @@ -50,6 +50,10 @@ export function MessagesEventBusProviderInner({ }), ) + useEffect(() => { + bus.updateClient(chatClient) + }, [bus, chatClient]) + useEffect(() => { bus.resume() diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts index d687ed7767..a198aa8669 100644 --- a/src/state/queries/post-feed.ts +++ b/src/state/queries/post-feed.ts @@ -201,6 +201,15 @@ export function usePostFeedQuery( cursor: undefined, } + /* + * A page-held api captured its client at construction. On web, a + * cross-tab token sync rebuilds the session bundle and disposes the old + * client (whose fetch then throws), so re-point the api at the current + * client before fetching. Safe because the swap is same-DID (fresh + * tokens only), so the stateful Merge/Home api's pagination is unaffected. + */ + api.setClient(client) + const res = await api.fetch({cursor, limit: fetchLimit}) /* @@ -422,7 +431,7 @@ export function usePostFeedQuery( return query } -export async function pollLatest(page: FeedPage | undefined) { +export async function pollLatest(page: FeedPage | undefined, client: Client) { if (!page) { return false } @@ -431,6 +440,9 @@ export async function pollLatest(page: FeedPage | undefined) { } logger.debug('usePostFeedQuery: pollLatest') + // The page-held api may carry a disposed client after a session-bundle + // rebuild - re-point it at the current client before peeking. + page.api.setClient(client) const post = await page.api.peekLatest() if (post) { const slices = page.tuner.tune([post], { diff --git a/src/state/queries/preferences/index.ts b/src/state/queries/preferences/index.ts index 203ae3635c..b92120dcd6 100644 --- a/src/state/queries/preferences/index.ts +++ b/src/state/queries/preferences/index.ts @@ -38,8 +38,9 @@ import { type UsePreferencesQueryResponse, } from '#/state/queries/preferences/types' import {createQueryKey} from '#/state/queries/util' -import {usePdsClient} from '#/state/session' +import {useAppviewClient, usePdsClient} from '#/state/session' import {saveLabelers} from '#/state/session/agent-config' +import {applyLabelersToClient} from '#/state/session/moderation' import {useAgeAssurance} from '#/ageAssurance' import {makeAgeRestrictedModerationPrefs} from '#/ageAssurance/util' import {useAnalytics} from '#/analytics' @@ -57,6 +58,7 @@ export const preferencesQueryKey = createQueryKey( export function usePreferencesQuery() { const client = usePdsClient() + const appviewClient = useAppviewClient() const aa = useAgeAssurance() const query = useQuery({ @@ -71,11 +73,19 @@ export function usePreferencesQuery() { } else { const res = await client.call(getPreferences) + const labelerDids = res.moderationPrefs.labelers.map(l => l.did) + // save to local storage to ensure there are labels on initial requests - void saveLabelers( - client.did, - res.moderationPrefs.labelers.map(l => l.did), - ) + void saveLabelers(client.did, labelerDids) + + /* + * Sync the subscribed labelers to the live appview client, mirroring the + * old `BskyAgent.getPreferences` side effect (which called + * `configureLabelersHeader`). Without this, subscribing/unsubscribing to + * a labeler would not affect server-attached labels until the session + * bundle is rebuilt. + */ + applyLabelersToClient(appviewClient, labelerDids) const preferences: UsePreferencesQueryResponse = { ...res, diff --git a/src/state/session/moderation.ts b/src/state/session/moderation.ts index 57e092c99b..b0b085576e 100644 --- a/src/state/session/moderation.ts +++ b/src/state/session/moderation.ts @@ -24,6 +24,27 @@ function configureGlobalAppLabelers(dids: string[]) { Client.configure({appLabelers: dids as `did:${string}:${string}`[]}) } +/** + * Apply an account's subscribed labeler DIDs to a live appview client. The lex + * `Client` rebuilds the `atproto-accept-labelers` header per request, so this + * takes effect on the very next request without a client rebuild. + * + * The Bluesky moderation labeler is always re-asserted as the base: sending ANY + * `atproto-accept-labelers` header replaces the server-side default, and + * `setLabelers` clears then re-adds, so the moderation DID must be included + * explicitly to stay active. + */ +export function applyLabelersToClient( + client: Client, + subscribedDids: string[], +) { + const perAccount = subscribedDids.filter(did => did !== api.moderation.did) + client.setLabelers([ + api.moderation.did, + ...perAccount, + ] as `did:${string}:${string}`[]) +} + export function configureModerationForGuest() { // This global mutation is *only* OK because this code is only relevant for testing. // Don't add any other global behavior here! @@ -51,18 +72,8 @@ export async function configureModerationForAccount( // The code below is actually relevant to production (and isn't global). const labelerDids = await readLabelers(account.did).catch(_ => {}) if (labelerDids) { - const perAccount = labelerDids.filter(did => did !== api.moderation.did) - /* - * Apply the per-account labelers to the appview client. It re-asserts the - * Bluesky moderation labeler as its base because sending ANY - * `atproto-accept-labelers` header replaces the server-side default - - * `setLabelers` clears then re-adds, so the moderation DID must be included - * explicitly to stay active. - */ - bundle.appviewClient.setLabelers([ - api.moderation.did, - ...perAccount, - ] as `did:${string}:${string}`[]) + // Apply the per-account labelers to the appview client. + applyLabelersToClient(bundle.appviewClient, labelerDids) } else { // If there are no headers in the storage, we'll not send them on the initial requests. // If we wanted to fix this, we could block on the preferences query here. diff --git a/src/view/com/posts/PostFeed.tsx b/src/view/com/posts/PostFeed.tsx index dbda8d0c41..9fe1bf6b29 100644 --- a/src/view/com/posts/PostFeed.tsx +++ b/src/view/com/posts/PostFeed.tsx @@ -44,7 +44,7 @@ import { usePostFeedQuery, } from '#/state/queries/post-feed' import {truncateAndInvalidate} from '#/state/queries/util' -import {useSession} from '#/state/session' +import {useAppviewClient, useSession} from '#/state/session' import {useProgressGuide} from '#/state/shell/progress-guide' import {useSelectedFeed} from '#/state/shell/selected-feed' import {List, type ListRef} from '#/view/com/util/List' @@ -260,6 +260,7 @@ let PostFeed = ({ const t = useTheme() const {t: l} = useLingui() const queryClient = useQueryClient() + const client = useAppviewClient() const {currentAccount, hasSession} = useSession() const initialNumToRender = useInitialNumToRender() const feedFeedback = useFeedFeedbackContext() @@ -333,7 +334,7 @@ let PostFeed = ({ } try { - if (await pollLatest(data.pages[0])) { + if (await pollLatest(data.pages[0], client)) { if (isEmpty) { void refetch() } else {