diff --git a/src/lib/react-query.tsx b/src/lib/react-query.tsx index be507216..5abfccd7 100644 --- a/src/lib/react-query.tsx +++ b/src/lib/react-query.tsx @@ -2,18 +2,83 @@ import React, {useRef, useState} from 'react' import {AppState, AppStateStatus} from 'react-native' import AsyncStorage from '@react-native-async-storage/async-storage' import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister' -import {focusManager, QueryClient} from '@tanstack/react-query' +import {focusManager, onlineManager, QueryClient} from '@tanstack/react-query' import { PersistQueryClientProvider, PersistQueryClientProviderProps, } from '@tanstack/react-query-persist-client' import {isNative} from '#/platform/detection' +import {listenNetworkConfirmed, listenNetworkLost} from '#/state/events' // any query keys in this array will be persisted to AsyncStorage export const labelersDetailedInfoQueryKeyRoot = 'labelers-detailed-info' const STORED_CACHE_QUERY_KEY_ROOTS = [labelersDetailedInfoQueryKeyRoot] +async function checkIsOnline(): Promise { + try { + const controller = new AbortController() + setTimeout(() => { + controller.abort() + }, 15e3) + const res = await fetch('https://public.api.bsky.app/xrpc/_health', { + cache: 'no-store', + signal: controller.signal, + }) + const json = await res.json() + if (json.version) { + return true + } else { + return false + } + } catch (e) { + return false + } +} + +let receivedNetworkLost = false +let receivedNetworkConfirmed = false +let isNetworkStateUnclear = false + +listenNetworkLost(() => { + receivedNetworkLost = true + onlineManager.setOnline(false) +}) + +listenNetworkConfirmed(() => { + receivedNetworkConfirmed = true + onlineManager.setOnline(true) +}) + +let checkPromise: Promise | undefined +function checkIsOnlineIfNeeded() { + if (checkPromise) { + return + } + receivedNetworkLost = false + receivedNetworkConfirmed = false + checkPromise = checkIsOnline().then(nextIsOnline => { + checkPromise = undefined + if (nextIsOnline && receivedNetworkLost) { + isNetworkStateUnclear = true + } + if (!nextIsOnline && receivedNetworkConfirmed) { + isNetworkStateUnclear = true + } + if (!isNetworkStateUnclear) { + onlineManager.setOnline(nextIsOnline) + } + }) +} + +setInterval(() => { + if (AppState.currentState === 'active') { + if (!onlineManager.isOnline() || isNetworkStateUnclear) { + checkIsOnlineIfNeeded() + } + } +}, 2000) + focusManager.setEventListener(onFocus => { if (isNative) { const subscription = AppState.addEventListener( diff --git a/src/state/events.ts b/src/state/events.ts index 1384abde..dcd36464 100644 --- a/src/state/events.ts +++ b/src/state/events.ts @@ -22,6 +22,22 @@ export function listenSessionDropped(fn: () => void): UnlistenFn { return () => emitter.off('session-dropped', fn) } +export function emitNetworkConfirmed() { + emitter.emit('network-confirmed') +} +export function listenNetworkConfirmed(fn: () => void): UnlistenFn { + emitter.on('network-confirmed', fn) + return () => emitter.off('network-confirmed', fn) +} + +export function emitNetworkLost() { + emitter.emit('network-lost') +} +export function listenNetworkLost(fn: () => void): UnlistenFn { + emitter.on('network-lost', fn) + return () => emitter.off('network-lost', fn) +} + export function emitPostCreated() { emitter.emit('post-created') } diff --git a/src/state/queries/feed.ts b/src/state/queries/feed.ts index 2b6751e8..e5ce19a9 100644 --- a/src/state/queries/feed.ts +++ b/src/state/queries/feed.ts @@ -454,7 +454,8 @@ export function usePinnedFeedsInfos() { }), ) - await Promise.allSettled([feedsPromise, ...listsPromises]) + await feedsPromise // Fail the whole query if it fails. + await Promise.allSettled(listsPromises) // Ignore individual failing ones. // order the feeds/lists in the order they were pinned const result: SavedFeedSourceInfo[] = [] diff --git a/src/state/session/__tests__/session-test.ts b/src/state/session/__tests__/session-test.ts index 731b66b0..cb4c6a35 100644 --- a/src/state/session/__tests__/session-test.ts +++ b/src/state/session/__tests__/session-test.ts @@ -1184,7 +1184,7 @@ describe('session', () => { expect(state.currentAgentState.did).toBe('bob-did') }) - it('does soft logout on network error', () => { + it('ignores network errors', () => { let state = getInitialState([]) const agent1 = new BskyAgent({service: 'https://alice.com'}) @@ -1217,11 +1217,9 @@ describe('session', () => { }, ]) expect(state.accounts.length).toBe(1) - // Network error should reset current user but not reset the tokens. - // TODO: We might want to remove or change this behavior? expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1') expect(state.accounts[0].refreshJwt).toBe('alice-refresh-jwt-1') - expect(state.currentAgentState.did).toBe(undefined) + expect(state.currentAgentState.did).toBe('alice-did') expect(printState(state)).toMatchInlineSnapshot(` { "accounts": [ @@ -1242,9 +1240,9 @@ describe('session', () => { ], "currentAgentState": { "agent": { - "service": "https://public.api.bsky.app/", + "service": "https://alice.com/", }, - "did": undefined, + "did": "alice-did", }, "needsPersist": true, } diff --git a/src/state/session/agent.ts b/src/state/session/agent.ts index ea6af677..8a48cf95 100644 --- a/src/state/session/agent.ts +++ b/src/state/session/agent.ts @@ -12,6 +12,7 @@ import {tryFetchGates} from '#/lib/statsig/statsig' import {getAge} from '#/lib/strings/time' import {logger} from '#/logger' import {snoozeEmailConfirmationPrompt} from '#/state/shell/reminders' +import {emitNetworkConfirmed, emitNetworkLost} from '../events' import {addSessionErrorLog} from './logging' import { configureModerationForAccount, @@ -227,6 +228,7 @@ export function sessionAccountToSession( } // Not exported. Use factories above to create it. +let realFetch = globalThis.fetch class BskyAppAgent extends BskyAgent { persistSessionHandler: ((event: AtpSessionEvent) => void) | undefined = undefined @@ -234,6 +236,23 @@ class BskyAppAgent extends BskyAgent { constructor({service}: {service: string}) { super({ service, + async fetch(...args) { + let success = false + try { + const result = await realFetch(...args) + success = true + return result + } catch (e) { + success = false + throw e + } finally { + if (success) { + emitNetworkConfirmed() + } else { + emitNetworkLost() + } + } + }, persistSession: (event: AtpSessionEvent) => { if (this.persistSessionHandler) { this.persistSessionHandler(event) @@ -257,7 +276,15 @@ class BskyAppAgent extends BskyAgent { // Now the agent is ready. const account = agentToSessionAccountOrThrow(this) + let lastSession = this.sessionManager.session this.persistSessionHandler = event => { + if (this.sessionManager.session) { + lastSession = this.sessionManager.session + } else if (event === 'network-error') { + // Put it back, we'll try again later. + this.sessionManager.session = lastSession + } + onSessionChange(this, account.did, event) if (event !== 'create' && event !== 'update') { addSessionErrorLog(account.did, event) diff --git a/src/state/session/reducer.ts b/src/state/session/reducer.ts index 0a537b42..b4919851 100644 --- a/src/state/session/reducer.ts +++ b/src/state/session/reducer.ts @@ -79,12 +79,8 @@ let reducer = (state: State, action: Action): State => { return state } if (sessionEvent === 'network-error') { - // Don't change stored accounts but kick to the choose account screen. - return { - accounts: state.accounts, - currentAgentState: createPublicAgentState(), - needsPersist: true, - } + // Assume it's transient. + return state } const existingAccount = state.accounts.find(a => a.did === accountDid) if (