From 798afa9e4934ec3eedcc9a839c6e9fa6f5fd9c31 Mon Sep 17 00:00:00 2001 From: Joshua Blum Date: Thu, 17 Sep 2026 11:22:24 -0400 Subject: [PATCH] hold inbox and thread RPCs until the switched user is ready --- shared/chat/conversation/thread-load.tsx | 2 +- shared/chat/conversation/thread-rpc.tsx | 4 + shared/chat/inbox/layout-state.test.ts | 8 +- shared/chat/inbox/layout-state.tsx | 13 +- shared/chat/inbox/metadata.test.tsx | 38 +- shared/chat/inbox/metadata.tsx | 13 +- shared/chat/inbox/session-rpc.test.tsx | 77 ++ shared/chat/inbox/session-rpc.tsx | 34 + shared/constants/init/shared.tsx | 936 +++++++++++++---------- shared/engine/index.tsx | 4 + shared/router-v2/router.tsx | 15 +- shared/stores/config.tsx | 730 ++++++++++-------- 12 files changed, 1123 insertions(+), 751 deletions(-) create mode 100644 shared/chat/inbox/session-rpc.test.tsx create mode 100644 shared/chat/inbox/session-rpc.tsx diff --git a/shared/chat/conversation/thread-load.tsx b/shared/chat/conversation/thread-load.tsx index 1798882eb82f..b037d84c7530 100644 --- a/shared/chat/conversation/thread-load.tsx +++ b/shared/chat/conversation/thread-load.tsx @@ -448,7 +448,7 @@ export const loadConversationThreadMessages = ( reason: threadLoadReasonToRPCReason(reason), waitingKey: loadingKey, }) - if (!isCurrentThreadLoad()) { + if (!isCurrentThreadLoad() || !results) { return } updateInboxConversationMeta(conversationIDKey, {offline: results.offline}) diff --git a/shared/chat/conversation/thread-rpc.tsx b/shared/chat/conversation/thread-rpc.tsx index 619465cd1dd9..ed5e3cd67f28 100644 --- a/shared/chat/conversation/thread-rpc.tsx +++ b/shared/chat/conversation/thread-rpc.tsx @@ -1,5 +1,6 @@ import * as T from '@/constants/types' import {enumKeys} from '@/constants/utils' +import {isChatSessionReady} from '@/stores/config' type WaitingKey = string | ReadonlyArray @@ -54,6 +55,9 @@ export const loadThreadNonblock = async (p: { reason?: T.RPCChat.GetThreadReason waitingKey?: WaitingKey }) => { + if (!isChatSessionReady()) { + return + } const incomingCallMap: T.RPCChat.IncomingCallMapType = {} if (p.onCachedThread) { incomingCallMap['chat.1.chatUi.chatThreadCached'] = params => p.onCachedThread?.(params.thread || '') diff --git a/shared/chat/inbox/layout-state.test.ts b/shared/chat/inbox/layout-state.test.ts index 336f694314a8..04e7b8c9de4e 100644 --- a/shared/chat/inbox/layout-state.test.ts +++ b/shared/chat/inbox/layout-state.test.ts @@ -2,7 +2,8 @@ let mockIsPhone = false let mockLoggedIn = true -let mockUsername = 'alice' +let mockUserSwitching = false +let mockUsername = 'testuser' const mockLoggerInfo = jest.fn() const mockLoggerWarn = jest.fn() @@ -22,9 +23,11 @@ jest.mock('@/logger', () => ({ })) jest.mock('@/stores/config', () => ({ + isChatSessionReady: () => mockLoggedIn && !mockUserSwitching, useConfigState: { getState: () => ({ loggedIn: mockLoggedIn, + userSwitching: mockUserSwitching, }), }, })) @@ -55,7 +58,8 @@ const layoutWithRows: T.RPCChat.UIInboxLayout = { beforeEach(() => { mockIsPhone = false mockLoggedIn = true - mockUsername = 'alice' + mockUserSwitching = false + mockUsername = 'testuser' mockLoggerInfo.mockClear() mockLoggerWarn.mockClear() useInboxLayoutState.getState().dispatch.resetState() diff --git a/shared/chat/inbox/layout-state.tsx b/shared/chat/inbox/layout-state.tsx index 0299d57ab043..ebda5f89bfe1 100644 --- a/shared/chat/inbox/layout-state.tsx +++ b/shared/chat/inbox/layout-state.tsx @@ -3,10 +3,10 @@ import * as Z from '@/util/zustand' import isEqual from 'lodash/isEqual' import logger from '@/logger' import {isPhone} from '@/constants/platform' -import {useConfigState} from '@/stores/config' -import {useCurrentUserState} from '@/stores/current-user' +import {isChatSessionReady} from '@/stores/config' import {ignorePromise} from '@/constants/utils' import {registerInboxRefresh} from './inbox-refresh' +import {withChatSessionRetry} from './session-rpc' type Store = T.Immutable<{ hasLoaded: boolean @@ -65,18 +65,17 @@ const recycleLayoutRows = ( export const useInboxLayoutState = Z.createZustand('chat-inbox-layout', (set, get) => { const requestInboxLayout = async (reason: T.Chat.RefreshReason) => { - const {username} = useCurrentUserState.getState() - const {loggedIn} = useConfigState.getState() - if (!loggedIn || !username) { + if (!isChatSessionReady()) { return } - logger.info(`Inbox refresh due to ${reason}`) const reselectMode = get().hasLoaded || isPhone ? T.RPCChat.InboxLayoutReselectMode.default : T.RPCChat.InboxLayoutReselectMode.force - await T.RPCChat.localRequestInboxLayoutRpcPromise({reselectMode}) + await withChatSessionRetry(async () => + T.RPCChat.localRequestInboxLayoutRpcPromise({reselectMode}) + ) } const dispatch: State['dispatch'] = { diff --git a/shared/chat/inbox/metadata.test.tsx b/shared/chat/inbox/metadata.test.tsx index 8a25e64fac85..69af67da1ea4 100644 --- a/shared/chat/inbox/metadata.test.tsx +++ b/shared/chat/inbox/metadata.test.tsx @@ -3,6 +3,7 @@ import * as Meta from '@/constants/chat/meta' import * as T from '@/constants/types' import {resetAllStores} from '@/util/zustand' import {useConfigState} from '@/stores/config' +import {useCurrentUserState} from '@/stores/current-user' import { ensureConversationMetaLoaded, forceUnboxRowsForService, @@ -23,7 +24,8 @@ const flushPromises = async () => { } beforeEach(() => { - useConfigState.setState({loggedIn: true}) + useConfigState.setState({loggedIn: true, userSwitching: false}) + useCurrentUserState.setState({username: 'testuser'}) }) afterEach(() => { @@ -302,3 +304,37 @@ test('ensure does not run while logged out and can re-arm after login', async () await jest.advanceTimersByTimeAsync(0) expect(rpc).toHaveBeenCalledTimes(1) }) + +test('userSwitching skips inbox unbox', async () => { + jest.spyOn(T.RPCChat, 'localRequestInboxUnboxRpcPromise').mockResolvedValue(undefined) + useConfigState.setState({loggedIn: true, userSwitching: true}) + + unboxRows([convID]) + await flushPromises() + + expect(T.RPCChat.localRequestInboxUnboxRpcPromise).not.toHaveBeenCalled() +}) + +test('setUserSwitching abandons further unbox until switch completes', async () => { + const resolvers = new Array<() => void>() + jest.spyOn(T.RPCChat, 'localRequestInboxUnboxRpcPromise').mockImplementation( + async () => + new Promise(resolve => { + resolvers.push(() => { + resolve(undefined) + }) + }) + ) + + unboxRows([convID]) + await flushPromises() + expect(T.RPCChat.localRequestInboxUnboxRpcPromise).toHaveBeenCalledTimes(1) + + useConfigState.getState().dispatch.setUserSwitching(true) + resolvers[0]?.() + await flushPromises() + + unboxRows([convID]) + await flushPromises() + expect(T.RPCChat.localRequestInboxUnboxRpcPromise).toHaveBeenCalledTimes(1) +}) diff --git a/shared/chat/inbox/metadata.tsx b/shared/chat/inbox/metadata.tsx index 8c5d1b3b24a0..a3ba7f8f1dcf 100644 --- a/shared/chat/inbox/metadata.tsx +++ b/shared/chat/inbox/metadata.tsx @@ -15,7 +15,8 @@ import logger from '@/logger' import {ignorePromise, timeoutPromise} from '@/constants/utils' import {RPCError} from '@/util/errors' import * as Z from '@/util/zustand' -import {useConfigState} from '@/stores/config' +import {useConfigState, isChatSessionReady} from '@/stores/config' +import {withChatSessionRetry} from './session-rpc' import {useCurrentUserState} from '@/stores/current-user' import {useUsersState} from '@/stores/users' @@ -405,7 +406,7 @@ async function runMetaQueueWorker(generation: number) { const requestInboxUnboxRows = (ids: ReadonlyArray, force: boolean) => { const f = async () => { - if (!useConfigState.getState().loggedIn) { + if (!isChatSessionReady()) { return } @@ -431,9 +432,11 @@ const requestInboxUnboxRows = (ids: ReadonlyArray, for `unboxRows: unboxing len: ${conversationIDKeys.length} convs: ${conversationIDKeys.join(',')}` ) try { - await T.RPCChat.localRequestInboxUnboxRpcPromise({ - convIDs: conversationIDKeys.map(k => T.Chat.keyToConversationID(k)), - }) + await withChatSessionRetry(async () => + T.RPCChat.localRequestInboxUnboxRpcPromise({ + convIDs: conversationIDKeys.map(k => T.Chat.keyToConversationID(k)), + }) + ) } catch (error) { if (error instanceof RPCError) { logger.info(`unboxRows: failed ${error.desc}`) diff --git a/shared/chat/inbox/session-rpc.test.tsx b/shared/chat/inbox/session-rpc.test.tsx new file mode 100644 index 000000000000..bcef280510bd --- /dev/null +++ b/shared/chat/inbox/session-rpc.test.tsx @@ -0,0 +1,77 @@ +/// +import * as T from '@/constants/types' +import {resetAllStores} from '@/util/zustand' +import {useConfigState} from '@/stores/config' +import {useCurrentUserState} from '@/stores/current-user' +import {RPCError} from '@/util/errors' +import {withChatSessionRetry} from './session-rpc' + +beforeEach(() => { + useConfigState.setState({loggedIn: true, userSwitching: false}) + useCurrentUserState.setState({username: 'testuser'}) +}) + +afterEach(() => { + resetAllStores() + jest.useRealTimers() +}) + +test('returns the first success', async () => { + const run = jest.fn().mockResolvedValue('ok') + await expect(withChatSessionRetry(run)).resolves.toBe('ok') + expect(run).toHaveBeenCalledTimes(1) +}) + +test('gives up when the username is empty', async () => { + useCurrentUserState.setState({username: ''}) + const run = jest.fn().mockResolvedValue('ok') + await expect(withChatSessionRetry(run)).resolves.toBeUndefined() + expect(run).not.toHaveBeenCalled() +}) + +test('retries login-required while still logged in', async () => { + jest.useFakeTimers() + const run = jest + .fn() + .mockRejectedValueOnce(new RPCError('chat session not ready', T.RPCGen.StatusCode.scloginrequired)) + .mockResolvedValueOnce('ok') + + const pending = withChatSessionRetry(run) + await jest.advanceTimersByTimeAsync(250) + await expect(pending).resolves.toBe('ok') + expect(run).toHaveBeenCalledTimes(2) +}) + +test('does not retry other errors', async () => { + const run = jest.fn().mockRejectedValue(new RPCError('nope', T.RPCGen.StatusCode.scgeneric)) + await expect(withChatSessionRetry(run)).rejects.toMatchObject({code: T.RPCGen.StatusCode.scgeneric}) + expect(run).toHaveBeenCalledTimes(1) +}) + +test('gives up when the chat session is no longer ready', async () => { + jest.useFakeTimers() + const run = jest + .fn() + .mockRejectedValue(new RPCError('chat session not ready', T.RPCGen.StatusCode.scloginrequired)) + + const pending = withChatSessionRetry(run) + await Promise.resolve() + useConfigState.setState({loggedIn: true, userSwitching: true}) + await jest.advanceTimersByTimeAsync(250) + await expect(pending).resolves.toBeUndefined() + expect(run).toHaveBeenCalledTimes(1) +}) + +test('gives up when the username changes during retry', async () => { + jest.useFakeTimers() + const run = jest + .fn() + .mockRejectedValue(new RPCError('chat session not ready', T.RPCGen.StatusCode.scloginrequired)) + + const pending = withChatSessionRetry(run) + await Promise.resolve() + useCurrentUserState.setState({username: 'otheruser'}) + await jest.advanceTimersByTimeAsync(250) + await expect(pending).resolves.toBeUndefined() + expect(run).toHaveBeenCalledTimes(1) +}) diff --git a/shared/chat/inbox/session-rpc.tsx b/shared/chat/inbox/session-rpc.tsx new file mode 100644 index 000000000000..ea7036aaf655 --- /dev/null +++ b/shared/chat/inbox/session-rpc.tsx @@ -0,0 +1,34 @@ +import * as T from '@/constants/types' +import logger from '@/logger' +import {isChatSessionReady} from '@/stores/config' +import {useCurrentUserState} from '@/stores/current-user' +import {timeoutPromise} from '@/constants/utils' +import {RPCError} from '@/util/errors' + +const retryDelaysMs = [250, 750] + +export const withChatSessionRetry = async (run: () => Promise): Promise => { + const username = useCurrentUserState.getState().username + const sameSession = () => + !!username && isChatSessionReady() && useCurrentUserState.getState().username === username + for (let attempt = 0; attempt <= retryDelaysMs.length; attempt++) { + if (!sameSession()) { + return + } + try { + return await run() + } catch (error) { + const delay = retryDelaysMs[attempt] + if (!(error instanceof RPCError) || error.code !== T.RPCGen.StatusCode.scloginrequired) { + throw error + } + if (delay === undefined || !sameSession()) { + logger.info('chat session not ready, giving up') + return + } + logger.info(`chat session not ready, retrying in ${delay}ms`) + await timeoutPromise(delay) + } + } + return +} diff --git a/shared/constants/init/shared.tsx b/shared/constants/init/shared.tsx index 59cc64630a94..bb82d9078529 100644 --- a/shared/constants/init/shared.tsx +++ b/shared/constants/init/shared.tsx @@ -1,44 +1,47 @@ -import type * as EngineGen from '@/constants/rpc' -import * as T from '../types' -import * as S from '@/constants/strings' -import isEqual from 'lodash/isEqual' -import logger from '@/logger' -import * as Tabs from '@/constants/tabs' +import type * as EngineGen from "@/constants/rpc"; +import * as T from "../types"; +import * as S from "@/constants/strings"; +import isEqual from "lodash/isEqual"; +import logger from "@/logger"; +import * as Tabs from "@/constants/tabs"; declare global { - var __hmr_sharedUnsubs: Array<() => void> | undefined + var __hmr_sharedUnsubs: Array<() => void> | undefined; - var __hmr_platformUnsubs: Array<() => void> | undefined + var __hmr_platformUnsubs: Array<() => void> | undefined; - var __hmr_oneTimeInitDone: boolean | undefined + var __hmr_oneTimeInitDone: boolean | undefined; - var __hmr_TBstores: Map | undefined + var __hmr_TBstores: Map | undefined; } -import {useBlockButtonsState} from '@/chat/blocking/block-buttons-state' -import {useNotifState} from '@/stores/notifications' -import {notifyEngineActionListeners} from '@/engine/action-listener' -import {serviceStaticConfigToStaticConfig} from '@/constants/chat/static-config' -import {emitDeepLink} from '@/router-v2/linking' -import {enqueuePushTapRoute} from '@/router-v2/deep-link-emitter' -import {ignorePromise, timeoutPromise} from '../utils' -import {isPhone, serverConfigFileName} from '../platform' -import {useAvatarState} from '@/common-adapters/avatar/store' -import {useInboxLayoutState} from '@/chat/inbox/layout-state' -import {getPinnedConvIDs} from '@/chat/inbox/pinned-convs' -import {useConfigState} from '@/stores/config' -import {useCurrentUserState} from '@/stores/current-user' -import {setPushTapAck, useNavigationIntentsState} from '@/stores/navigation-intents' -import {useDaemonState, type BootstrapStep} from '@/stores/daemon' -import {useDarkModeState} from '@/stores/darkmode' -import {useFollowerState} from '@/stores/followers' -import {useShellState} from '@/stores/shell' -import {useSettingsEmailState} from '@/stores/settings-email' -import {useSettingsPhoneState} from '@/stores/settings-phone' -import {useSettingsContactsState} from '@/stores/settings-contacts' -import {useUsersState} from '@/stores/users' -import {useWaitingState} from '@/stores/waiting' -import {useRouterState} from '@/stores/router' -import * as Util from '@/constants/router' -import {handleConvoEngineIncoming} from '@/chat/inbox/engine' +import { useBlockButtonsState } from "@/chat/blocking/block-buttons-state"; +import { useNotifState } from "@/stores/notifications"; +import { notifyEngineActionListeners } from "@/engine/action-listener"; +import { serviceStaticConfigToStaticConfig } from "@/constants/chat/static-config"; +import { emitDeepLink } from "@/router-v2/linking"; +import { enqueuePushTapRoute } from "@/router-v2/deep-link-emitter"; +import { ignorePromise, timeoutPromise } from "../utils"; +import { isPhone, serverConfigFileName } from "../platform"; +import { useAvatarState } from "@/common-adapters/avatar/store"; +import { useInboxLayoutState } from "@/chat/inbox/layout-state"; +import { getPinnedConvIDs } from "@/chat/inbox/pinned-convs"; +import { useConfigState } from "@/stores/config"; +import { useCurrentUserState } from "@/stores/current-user"; +import { + setPushTapAck, + useNavigationIntentsState, +} from "@/stores/navigation-intents"; +import { useDaemonState, type BootstrapStep } from "@/stores/daemon"; +import { useDarkModeState } from "@/stores/darkmode"; +import { useFollowerState } from "@/stores/followers"; +import { useShellState } from "@/stores/shell"; +import { useSettingsEmailState } from "@/stores/settings-email"; +import { useSettingsPhoneState } from "@/stores/settings-phone"; +import { useSettingsContactsState } from "@/stores/settings-contacts"; +import { useUsersState } from "@/stores/users"; +import { useWaitingState } from "@/stores/waiting"; +import { useRouterState } from "@/stores/router"; +import * as Util from "@/constants/router"; +import { handleConvoEngineIncoming } from "@/chat/inbox/engine"; import { onChatRouteChanged, onChatInboxSynced, @@ -46,11 +49,11 @@ import { onGetInboxUnverifiedConvs, onInboxLayoutChanged, onIncomingInboxUIItem, -} from '@/chat/inbox/metadata' -import {syncInboxBadgeState} from '@/chat/inbox/badge-state' -import {clearSignupEmail} from '@/people/signup-email' -import {clearSignupDeviceNameDraft} from '@/signup/device-name-draft' -import {clearNavBadges} from '@/teams/actions' +} from "@/chat/inbox/metadata"; +import { syncInboxBadgeState } from "@/chat/inbox/badge-state"; +import { clearSignupEmail } from "@/people/signup-email"; +import { clearSignupDeviceNameDraft } from "@/signup/device-name-draft"; +import { clearNavBadges } from "@/teams/actions"; import { ackPushTap, addAppLifecycleListener, @@ -58,56 +61,68 @@ import { getAppLifecycleState, peekPushTap, type AppLifecycleState, -} from 'react-native-kb' -import {parsePushTapPayload, pushTapField, resolvePushTap, type PushTapPayload} from './push-tap-resolve' +} from "react-native-kb"; +import { + parsePushTapPayload, + pushTapField, + resolvePushTap, + type PushTapPayload, +} from "./push-tap-resolve"; -const _sharedUnsubs: Array<() => void> = __DEV__ ? (globalThis.__hmr_sharedUnsubs ??= []) : [] +const _sharedUnsubs: Array<() => void> = __DEV__ + ? (globalThis.__hmr_sharedUnsubs ??= []) + : []; type SubscribeStore = { - subscribe: (listener: (state: State, previousState: State) => void) => () => void -} + subscribe: ( + listener: (state: State, previousState: State) => void, + ) => () => void; +}; const subscribeValue = ( store: SubscribeStore, select: (state: State) => Value, - onChange: (value: Value, previous: Value) => void + onChange: (value: Value, previous: Value) => void, ) => store.subscribe((state, previousState) => { - const value = select(state) - const previous = select(previousState) + const value = select(state); + const previous = select(previousState); if (value !== previous) { - onChange(value, previous) + onChange(value, previous); } - }) + }); -type ConfigState = ReturnType -type DaemonState = ReturnType -type RouterState = ReturnType +type ConfigState = ReturnType; +type DaemonState = ReturnType; +type RouterState = ReturnType; // ─── Bootstrap steps ────────────────────────────────────────────────────────── // Gating steps for the daemon handshake, run by useDaemonState.dispatch.startHandshake after // bootstrapStatus loads. Throwing fails the attempt and triggers a retry. const loadDarkPrefsStep = async () => { - useDarkModeState.getState().dispatch.loadDarkPrefs() - return Promise.resolve() -} + useDarkModeState.getState().dispatch.loadDarkPrefs(); + return Promise.resolve(); +}; const loadChatStaticConfigStep = async () => { - const {chatBuiltinCommands, chatDeletableByDeleteHistory} = useConfigState.getState() + const { chatBuiltinCommands, chatDeletableByDeleteHistory } = + useConfigState.getState(); if (chatBuiltinCommands && chatDeletableByDeleteHistory) { - return + return; } - const staticConfig = serviceStaticConfigToStaticConfig(await T.RPCChat.localGetStaticConfigRpcPromise()) + const staticConfig = serviceStaticConfigToStaticConfig( + await T.RPCChat.localGetStaticConfigRpcPromise(), + ); if (!staticConfig) { - logger.error('chat.loadStaticConfig: missing required static config') - return + logger.error("chat.loadStaticConfig: missing required static config"); + return; } - useConfigState.getState().dispatch.setChatStaticConfig(staticConfig) -} + useConfigState.getState().dispatch.setChatStaticConfig(staticConfig); +}; export const loadAccountsStep = async () => { - const refreshAccounts = useConfigState.getState().dispatch.refreshAccounts + const refreshAccounts = useConfigState.getState().dispatch.refreshAccounts; // refreshAccounts is local (config/keychain + offline uidmap). Handshake must // not await it while logged in or switching; the logged-out picker still // awaits so the list is not empty. @@ -115,203 +130,247 @@ export const loadAccountsStep = async () => { useDaemonState.getState().bootstrapStatus?.loggedIn || useConfigState.getState().userSwitching ) { - ignorePromise(refreshAccounts().catch(() => {})) - return + ignorePromise(refreshAccounts().catch(() => {})); + return; } try { - await refreshAccounts() + await refreshAccounts(); } catch { - throw new Error("Can't get accounts") + throw new Error("Can't get accounts"); } -} +}; const requestFollowerInfoForStartup = () => { - const {uid} = useCurrentUserState.getState() - logger.info(`getFollowerInfo: init; uid=${uid}`) + const { uid } = useCurrentUserState.getState(); + logger.info(`getFollowerInfo: init; uid=${uid}`); if (uid) { // request follower info in the background T.RPCGen.configRequestFollowingAndUnverifiedFollowersRpcPromise() .then(() => {}) - .catch(() => {}) + .catch(() => {}); } -} +}; const updateServerConfigForStartup = async () => { if (useConfigState.getState().loggedIn) { try { await T.RPCGen.configUpdateLastLoggedInAndServerConfigRpcPromise({ serverConfigPath: serverConfigFileName, - }) + }); } catch {} } -} +}; const loadStartupSettings = () => { - useSettingsContactsState.getState().dispatch.loadContactImportEnabled() -} + useSettingsContactsState.getState().dispatch.loadContactImportEnabled(); +}; const refreshStartupChat = () => { // On phone, let the focused inbox screen trigger the first refresh so hidden chatRoot // mounts behind a pushed conversation do not pay inbox startup cost. if (!isPhone && useCurrentUserState.getState().username) { - ignorePromise(useInboxLayoutState.getState().dispatch.refresh('bootstrap')) + ignorePromise(useInboxLayoutState.getState().dispatch.refresh("bootstrap")); } -} +}; // Loads that want a logged-in user but shouldn't compete with first paint const scheduleStartupOrReloginWork = () => { const f = async () => { - await timeoutPromise(1000) + await timeoutPromise(1000); requestAnimationFrame(() => { - requestFollowerInfoForStartup() - ignorePromise(updateServerConfigForStartup()) - loadStartupSettings() - refreshStartupChat() - }) - } - ignorePromise(f()) -} + requestFollowerInfoForStartup(); + ignorePromise(updateServerConfigForStartup()); + loadStartupSettings(); + refreshStartupChat(); + }); + }; + ignorePromise(f()); +}; // Go reads pins from gregor while building the inbox layout, but gregor state only exists once // the service connects, and items that arrive in the connect-time sync don't reach the in-band // handlers. The GUI gets the synced state pushed afterwards, so rebuild when the pins in it change. const onGregorPushStateChanged = ( - pushState: ConfigState['gregorPushState'], - previous: ConfigState['gregorPushState'] + pushState: ConfigState["gregorPushState"], + previous: ConfigState["gregorPushState"], ) => { - if (!useConfigState.getState().loggedIn) return - if (isEqual(getPinnedConvIDs(pushState), getPinnedConvIDs(previous))) return + if (!useConfigState.getState().loggedIn) return; + if (isEqual(getPinnedConvIDs(pushState), getPinnedConvIDs(previous))) return; ignorePromise( - T.RPCChat.localRequestInboxLayoutRpcPromise({reselectMode: T.RPCChat.InboxLayoutReselectMode.default}) - ) -} + T.RPCChat.localRequestInboxLayoutRpcPromise({ + reselectMode: T.RPCChat.InboxLayoutReselectMode.default, + }), + ); +}; // After an offline stretch, reread the session to pick up what the service learned while we could // not reach it. `previous === undefined` is the first reading of the network at startup, which the // handshake's own read already covers. -export const onNetworkOnlineChanged = (online?: boolean, previous?: boolean) => { +export const onNetworkOnlineChanged = ( + online?: boolean, + previous?: boolean, +) => { if (!online || previous !== false) { - return + return; } - if (useDaemonState.getState().handshakeState === 'done' && !useConfigState.getState().userSwitching) { - useDaemonState.getState().dispatch.refreshSessionFromDaemon('back online') + if ( + useDaemonState.getState().handshakeState === "done" && + !useConfigState.getState().userSwitching + ) { + useDaemonState.getState().dispatch.refreshSessionFromDaemon("back online"); } -} +}; -const onLoggedInChanged = (loggedIn: ConfigState['loggedIn']) => { +const onLoggedInChanged = (loggedIn: ConfigState["loggedIn"]) => { if (loggedIn) { - scheduleStartupOrReloginWork() + scheduleStartupOrReloginWork(); } else { - clearSignupEmail() - clearSignupDeviceNameDraft() - useBlockButtonsState.getState().dispatch.resetState() + clearSignupEmail(); + clearSignupDeviceNameDraft(); + useBlockButtonsState.getState().dispatch.resetState(); } - ignorePromise(useConfigState.getState().dispatch.refreshAccounts()) -} + ignorePromise(useConfigState.getState().dispatch.refreshAccounts()); +}; const onRevokedTriggerChanged = () => { - ignorePromise(useConfigState.getState().dispatch.refreshAccounts()) -} + ignorePromise(useConfigState.getState().dispatch.refreshAccounts()); +}; -const onConfiguredAccountsChanged = (configuredAccounts: ConfigState['configuredAccounts']) => { - const updates = configuredAccounts.map(account => ({ - info: {fullname: account.fullname ?? ''}, +const onConfiguredAccountsChanged = ( + configuredAccounts: ConfigState["configuredAccounts"], +) => { + const updates = configuredAccounts.map((account) => ({ + info: { fullname: account.fullname ?? "" }, name: account.username, - })) + })); if (updates.length > 0) { - useUsersState.getState().dispatch.updates(updates) + useUsersState.getState().dispatch.updates(updates); } -} +}; -const onBootstrapStatusChanged = (bootstrap: DaemonState['bootstrapStatus']) => { +const onBootstrapStatusChanged = ( + bootstrap: DaemonState["bootstrapStatus"], +) => { if (!bootstrap) { - return + return; } - const {deviceID, deviceName, loggedIn, uid, username} = bootstrap - const configDispatch = useConfigState.getState().dispatch + const { deviceID, deviceName, loggedIn, uid, username } = bootstrap; + useCurrentUserState + .getState() + .dispatch.setBootstrap({ deviceID, deviceName, uid, username }); - // Before the identity: the user we hold is what tells the new account's session from the old. - // onUserSwitchingChanged applies the status once the switch ends. - if (!loggedIn && useConfigState.getState().userSwitching) { - logger.info('[Bootstrap] ignoring loggedIn=false result during account switch') - return + const { + dispatch: configDispatch, + defaultUsername: intendedUsername, + userSwitching, + } = useConfigState.getState(); + if (username && (!userSwitching || username === intendedUsername)) { + configDispatch.setDefaultUsername(username); + } + if (!loggedIn && userSwitching) { + logger.info( + "[Bootstrap] ignoring loggedIn=false result during account switch", + ); + return; } // Logged in as someone else than the user we hold is a logout and then a login, however the // notifications in between reached us. Logging out clears the previous account's stores, the // daemon's status among them, so put this status back and let that change apply it. - const currentUid = useCurrentUserState.getState().uid - if (loggedIn && useConfigState.getState().loggedIn && currentUid && uid !== currentUid) { - logger.info('[Bootstrap] the session is another user now, logging out the previous one') - configDispatch.setLoggedIn(false) - useDaemonState.getState().dispatch.setBootstrapStatus(bootstrap) - return + const currentUid = useCurrentUserState.getState().uid; + if ( + loggedIn && + useConfigState.getState().loggedIn && + currentUid && + uid !== currentUid + ) { + logger.info( + "[Bootstrap] the session is another user now, logging out the previous one", + ); + configDispatch.setLoggedIn(false); + useDaemonState.getState().dispatch.setBootstrapStatus(bootstrap); + return; } - useCurrentUserState.getState().dispatch.setBootstrap({deviceID, deviceName, uid, username}) + useCurrentUserState + .getState() + .dispatch.setBootstrap({ deviceID, deviceName, uid, username }); if (username) { - configDispatch.setDefaultUsername(username) + configDispatch.setDefaultUsername(username); + } + configDispatch.setLoggedIn(loggedIn); + + if (loggedIn && username && username === intendedUsername) { + configDispatch.setUserSwitching(false); } - configDispatch.setLoggedIn(loggedIn) if (bootstrap.httpSrvInfo) { - configDispatch.setHTTPSrvInfo(bootstrap.httpSrvInfo.address, bootstrap.httpSrvInfo.token) + configDispatch.setHTTPSrvInfo( + bootstrap.httpSrvInfo.address, + bootstrap.httpSrvInfo.token, + ); } -} +}; // A switch that failed after the service logged out has a logged-out status nothing applied, and // a read after the switch returns the same status, which does not count as a change. -const onUserSwitchingChanged = (userSwitching: ConfigState['userSwitching']) => { +const onUserSwitchingChanged = ( + userSwitching: ConfigState["userSwitching"], +) => { if (!userSwitching) { - onBootstrapStatusChanged(useDaemonState.getState().bootstrapStatus) + onBootstrapStatusChanged(useDaemonState.getState().bootstrapStatus); } -} +}; // Native reports the app state from the same callbacks that report it to Go, and this is the only // writer of mobileAppState. Desktop has no lifecycle; its window focus goes straight to appFocused. export const applyMobileAppState = (state: AppLifecycleState) => { - if (!isMobile) return - useShellState.getState().dispatch.setMobileAppState(state) -} + if (!isMobile) return; + useShellState.getState().dispatch.setMobileAppState(state); +}; const isMobileAppState = (s: string): s is AppLifecycleState => - s === 'active' || s === 'inactive' || s === 'background' + s === "active" || s === "inactive" || s === "background"; const onNativeAppLifecycle = (state: string) => { - logger.info(`[AppState] native: ${state}`) + logger.info(`[AppState] native: ${state}`); if (isMobileAppState(state)) { - applyMobileAppState(state) + applyMobileAppState(state); } else { - logger.warn(`[AppState] unmapped state ${state}, leaving the app state as it was`) + logger.warn( + `[AppState] unmapped state ${state}, leaving the app state as it was`, + ); } -} +}; // Subscribe before seeding: events emitted while no listener existed are only in the seed, and one // emitted after the seed read reaches the listener later on this thread. export const listenForAppLifecycle = (): (() => void) => { - const stop = addAppLifecycleListener(onNativeAppLifecycle) - const initial: string = getAppLifecycleState() + const stop = addAppLifecycleListener(onNativeAppLifecycle); + const initial: string = getAppLifecycleState(); if (isMobileAppState(initial)) { - applyMobileAppState(initial) + applyMobileAppState(initial); } - return stop -} + return stop; +}; -const membersTypeOf = (t: string): T.RPCChat.ConversationMembersType | undefined => { +const membersTypeOf = ( + t: string, +): T.RPCChat.ConversationMembersType | undefined => { switch (parseInt(t, 10)) { case T.RPCChat.ConversationMembersType.kbfs: - return T.RPCChat.ConversationMembersType.kbfs + return T.RPCChat.ConversationMembersType.kbfs; case T.RPCChat.ConversationMembersType.team: - return T.RPCChat.ConversationMembersType.team + return T.RPCChat.ConversationMembersType.team; case T.RPCChat.ConversationMembersType.impteamnative: - return T.RPCChat.ConversationMembersType.impteamnative + return T.RPCChat.ConversationMembersType.impteamnative; case T.RPCChat.ConversationMembersType.impteamupgrade: - return T.RPCChat.ConversationMembersType.impteamupgrade + return T.RPCChat.ConversationMembersType.impteamupgrade; default: - return undefined + return undefined; } -} +}; // An Android push is a data message Go displayed itself, so a tapped chat push's message is unboxed // into the thread here. It waits for the account the push names, which after a cold tap or an @@ -319,81 +378,107 @@ const membersTypeOf = (t: string): T.RPCChat.ConversationMembersType | undefined // dropped (expired, superseded, its switch failed, logged out) never unboxes later. let pendingPushTapUnbox: | { - params: {convID: string; membersType: T.RPCChat.ConversationMembersType; payload: string} - tapID: number - uid: string + params: { + convID: string; + membersType: T.RPCChat.ConversationMembersType; + payload: string; + }; + tapID: number; + uid: string; } - | undefined + | undefined; const settlePushTapUnbox = () => { - const pending = pendingPushTapUnbox - if (!pending) return - const {uid} = useCurrentUserState.getState() + const pending = pendingPushTapUnbox; + if (!pending) return; + const { uid } = useCurrentUserState.getState(); if (uid && (!pending.uid || pending.uid === uid)) { - pendingPushTapUnbox = undefined - T.RPCChat.localUnboxMobilePushNotificationRpcPromise(pending.params).catch(() => { - logger.info('[PushTap] failed to unbox message from payload') - }) - return + pendingPushTapUnbox = undefined; + T.RPCChat.localUnboxMobilePushNotificationRpcPromise(pending.params).catch( + () => { + logger.info("[PushTap] failed to unbox message from payload"); + }, + ); + return; } // Checked after the account: a consumed tap leaves the queue as its account becomes current. - if (useNavigationIntentsState.getState().intent?.pushTapID !== pending.tapID) { - pendingPushTapUnbox = undefined + if ( + useNavigationIntentsState.getState().intent?.pushTapID !== pending.tapID + ) { + pendingPushTapUnbox = undefined; } -} +}; const queuePushTapUnbox = (tapID: number, payload: PushTapPayload) => { - const get = (key: string) => pushTapField(payload, key) - const convID = get('convID') - const boxed = get('m') - const membersType = membersTypeOf(get('t')) - if (get('type') !== 'chat.newmessage' || !convID || !boxed || membersType === undefined) return - pendingPushTapUnbox = {params: {convID, membersType, payload: boxed}, tapID, uid: get('uid')} - settlePushTapUnbox() -} + const get = (key: string) => pushTapField(payload, key); + const convID = get("convID"); + const boxed = get("m"); + const membersType = membersTypeOf(get("t")); + if ( + get("type") !== "chat.newmessage" || + !convID || + !boxed || + membersType === undefined + ) + return; + pendingPushTapUnbox = { + params: { convID, membersType, payload: boxed }, + tapID, + uid: get("uid"), + }; + settlePushTapUnbox(); +}; // Native holds a tapped notification until it is acked by id, so a peek never loses one: the // same tap peeked again (a repeated event, a JS reload) carries the same id, which the intent // store turns away. A tap that opens nothing is acked here; one that does is acked by whatever // consumes or drops its intent. -let lastTakenPushTapID: number | undefined +let lastTakenPushTapID: number | undefined; const takePushTap = () => { - const tap = peekPushTap() - if (!tap) return - const payload = parsePushTapPayload(tap.payload) - const route = payload && resolvePushTap(payload) + const tap = peekPushTap(); + if (!tap) return; + const payload = parsePushTapPayload(tap.payload); + const route = payload && resolvePushTap(payload); if (!payload || !route) { - logger.info('[PushTap] a tap with no route, only opening the app') - ackPushTap(tap.id) - return - } - const firstTake = lastTakenPushTapID !== tap.id - lastTakenPushTapID = tap.id - enqueuePushTapRoute({id: tap.id, targetUid: route.targetUid, url: route.url}) + logger.info("[PushTap] a tap with no route, only opening the app"); + ackPushTap(tap.id); + return; + } + const firstTake = lastTakenPushTapID !== tap.id; + lastTakenPushTapID = tap.id; + enqueuePushTapRoute({ + id: tap.id, + targetUid: route.targetUid, + url: route.url, + }); if (firstTake && isAndroid) { - queuePushTapUnbox(tap.id, payload) + queuePushTapUnbox(tap.id, payload); } -} +}; // Subscribe before peeking: a tap held before JS listened is only seen by the peek, and one that // lands after the peek reaches the listener. export const listenForPushTaps = (): (() => void) => { - setPushTapAck(ackPushTap) - const stopTaps = addPushTapListener(takePushTap) - const stopUnboxOnAccount = useCurrentUserState.subscribe(settlePushTapUnbox) - const stopUnboxOnIntent = useNavigationIntentsState.subscribe(settlePushTapUnbox) - takePushTap() + setPushTapAck(ackPushTap); + const stopTaps = addPushTapListener(takePushTap); + const stopUnboxOnAccount = useCurrentUserState.subscribe(settlePushTapUnbox); + const stopUnboxOnIntent = + useNavigationIntentsState.subscribe(settlePushTapUnbox); + takePushTap(); return () => { - stopTaps() - stopUnboxOnAccount() - stopUnboxOnIntent() - } -} - -const onNavStateChanged =(nextNavState: RouterState['navState'], previousNavState: RouterState['navState']) => { - const next = nextNavState as Util.NavState - const prev = previousNavState as Util.NavState - if (prev === next) return + stopTaps(); + stopUnboxOnAccount(); + stopUnboxOnIntent(); + }; +}; + +const onNavStateChanged = ( + nextNavState: RouterState["navState"], + previousNavState: RouterState["navState"], +) => { + const next = nextNavState as Util.NavState; + const prev = previousNavState as Util.NavState; + if (prev === next) return; // Clear critical update when we nav away from tab if ( @@ -403,40 +488,45 @@ const onNavStateChanged =(nextNavState: RouterState['navState'], previousNavStat Util.getTab(next) !== Tabs.fsTab && useShellState.getState().fsCriticalUpdate ) { - const {dispatch} = useShellState.getState() - dispatch.setFsCriticalUpdate(false) + const { dispatch } = useShellState.getState(); + dispatch.setFsCriticalUpdate(false); } - if (prev && Util.getTab(prev) === Tabs.teamsTab && next && Util.getTab(next) !== Tabs.teamsTab) { - clearNavBadges() + if ( + prev && + Util.getTab(prev) === Tabs.teamsTab && + next && + Util.getTab(next) !== Tabs.teamsTab + ) { + clearNavBadges(); } - onChatRouteChanged(prev, next) -} + onChatRouteChanged(prev, next); +}; export const onEngineConnected = () => { { const registerUIs = async () => { try { - await T.RPCGen.delegateUiCtlRegisterChatUIRpcPromise() - await T.RPCGen.delegateUiCtlRegisterLogUIRpcPromise() - logger.info('Registered Chat UI') - await T.RPCGen.delegateUiCtlRegisterHomeUIRpcPromise() - logger.info('Registered home UI') - await T.RPCGen.delegateUiCtlRegisterSecretUIRpcPromise() - logger.info('Registered secret ui') - await T.RPCGen.delegateUiCtlRegisterIdentify3UIRpcPromise() - logger.info('Registered identify ui') - await T.RPCGen.delegateUiCtlRegisterRekeyUIRpcPromise() - logger.info('Registered rekey ui') + await T.RPCGen.delegateUiCtlRegisterChatUIRpcPromise(); + await T.RPCGen.delegateUiCtlRegisterLogUIRpcPromise(); + logger.info("Registered Chat UI"); + await T.RPCGen.delegateUiCtlRegisterHomeUIRpcPromise(); + logger.info("Registered home UI"); + await T.RPCGen.delegateUiCtlRegisterSecretUIRpcPromise(); + logger.info("Registered secret ui"); + await T.RPCGen.delegateUiCtlRegisterIdentify3UIRpcPromise(); + logger.info("Registered identify ui"); + await T.RPCGen.delegateUiCtlRegisterRekeyUIRpcPromise(); + logger.info("Registered rekey ui"); } catch (error) { - logger.error('Error in registering UIs:', error) + logger.error("Error in registering UIs:", error); } - } - ignorePromise(registerUIs()) + }; + ignorePromise(registerUIs()); } - useConfigState.getState().dispatch.onEngineConnected() - useDaemonState.getState().dispatch.startHandshake() + useConfigState.getState().dispatch.onEngineConnected(); + useDaemonState.getState().dispatch.startHandshake(); { const notifyCtl = async () => { try { @@ -453,23 +543,25 @@ export const onEngineConnected = () => { }) } catch (error) { if (error) { - logger.warn('error in toggling notifications: ', error) + logger.warn("error in toggling notifications: ", error); } } - } - ignorePromise(notifyCtl()) + }; + ignorePromise(notifyCtl()); } -} +}; export const onEngineDisconnected = () => { const f = async () => { - await logger.dump() - } - ignorePromise(f()) - useDaemonState.getState().dispatch.setError(new Error('Disconnected')) -} - -export const initSharedSubscriptions = (platformBootstrapSteps: Array = []) => { + await logger.dump(); + }; + ignorePromise(f()); + useDaemonState.getState().dispatch.setError(new Error("Disconnected")); +}; + +export const initSharedSubscriptions = ( + platformBootstrapSteps: Array = [], +) => { useDaemonState .getState() .dispatch.initBootstrapSteps([ @@ -477,203 +569,265 @@ export const initSharedSubscriptions = (platformBootstrapSteps: Array s.gregorPushState, onGregorPushStateChanged), - subscribeValue(useConfigState, s => s.loggedIn, onLoggedInChanged), - subscribeValue(useConfigState, s => s.revokedTrigger, onRevokedTriggerChanged), - subscribeValue(useConfigState, s => s.configuredAccounts, onConfiguredAccountsChanged), - subscribeValue(useConfigState, s => s.userSwitching, onUserSwitchingChanged) - ) + subscribeValue( + useConfigState, + (s) => s.gregorPushState, + onGregorPushStateChanged, + ), + subscribeValue(useConfigState, (s) => s.loggedIn, onLoggedInChanged), + subscribeValue( + useConfigState, + (s) => s.revokedTrigger, + onRevokedTriggerChanged, + ), + subscribeValue( + useConfigState, + (s) => s.configuredAccounts, + onConfiguredAccountsChanged, + ), + subscribeValue( + useConfigState, + (s) => s.userSwitching, + onUserSwitchingChanged, + ), + ); - _sharedUnsubs.push(subscribeValue(useDaemonState, s => s.bootstrapStatus, onBootstrapStatusChanged)) - _sharedUnsubs.push(subscribeValue(useShellState, s => s.networkStatus?.online, onNetworkOnlineChanged)) + _sharedUnsubs.push( + subscribeValue( + useDaemonState, + (s) => s.bootstrapStatus, + onBootstrapStatusChanged, + ), + ); + _sharedUnsubs.push( + subscribeValue( + useShellState, + (s) => s.networkStatus?.online, + onNetworkOnlineChanged, + ), + ); _sharedUnsubs.push( - subscribeValue(useRouterState, s => s.navState, onNavStateChanged) - ) -} + subscribeValue(useRouterState, (s) => s.navState, onNavStateChanged), + ); +}; // This is to defer loading stores we don't need immediately. export const _onEngineIncoming = (action: EngineGen.Actions) => { const routeConvoEngineIncoming = (engineAction: EngineGen.Actions) => { - const result = handleConvoEngineIncoming(engineAction) + const result = handleConvoEngineIncoming(engineAction); if (result.inboxUIItem) { - onIncomingInboxUIItem(result.inboxUIItem) + onIncomingInboxUIItem(result.inboxUIItem); } if (result.userReacjis) { - useDaemonState.getState().dispatch.updateUserReacjis(result.userReacjis) + useDaemonState.getState().dispatch.updateUserReacjis(result.userReacjis); } - } + }; switch (action.type) { // These can reach us out of order with each other, so none of them sets the session: each only // says it changed, and the daemon's reply to the latest read is what applies. - case 'keybase.1.NotifySession.loggedIn': - case 'keybase.1.NotifySession.loggedOut': - useDaemonState.getState().dispatch.refreshSessionFromDaemon(action.type) - break - case 'keybase.1.NotifyBadges.badgeState': + case "keybase.1.NotifySession.loggedIn": + case "keybase.1.NotifySession.loggedOut": + useDaemonState.getState().dispatch.refreshSessionFromDaemon(action.type); + break; + case "keybase.1.NotifyBadges.badgeState": { - const {badgeState} = action.payload.params - syncInboxBadgeState(badgeState) - useNotifState.getState().dispatch.onEngineIncomingImpl(action) + const { badgeState } = action.payload.params; + syncInboxBadgeState(badgeState); + useNotifState.getState().dispatch.onEngineIncomingImpl(action); } - break - case 'keybase.1.gregorUI.pushState': { - const {state} = action.payload.params - const items = state.items || [] - const goodState = items.reduce>( - (arr, {md, item}) => { - if (md && item) { - arr.push({item, md}) - } - return arr - }, - [] - ) + break; + case "keybase.1.gregorUI.pushState": { + const { state } = action.payload.params; + const items = state.items || []; + const goodState = items.reduce< + Array<{ md: T.RPCGen.Gregor1.Metadata; item: T.RPCGen.Gregor1.Item }> + >((arr, { md, item }) => { + if (md && item) { + arr.push({ item, md }); + } + return arr; + }, []); if (goodState.length !== items.length) { - logger.warn('Lost some messages in filtering out nonNull gregor items') + logger.warn("Lost some messages in filtering out nonNull gregor items"); } - useBlockButtonsState.getState().dispatch.updateFromGregorItems(state.items) + useBlockButtonsState + .getState() + .dispatch.updateFromGregorItems(state.items); - useNotifState.getState().dispatch.onEngineIncomingImpl(action) - break + useNotifState.getState().dispatch.onEngineIncomingImpl(action); + break; } - case 'chat.1.NotifyChat.ChatSetTeamRetention': + case "chat.1.NotifyChat.ChatSetTeamRetention": { - routeConvoEngineIncoming(action) + routeConvoEngineIncoming(action); } - break - case 'keybase.1.NotifyEmailAddress.emailAddressVerified': + break; + case "keybase.1.NotifyEmailAddress.emailAddressVerified": { - const emailAddress = action.payload.params.emailAddress + const emailAddress = action.payload.params.emailAddress; if (emailAddress) { - useSettingsEmailState.getState().dispatch.notifyEmailVerified(emailAddress) + useSettingsEmailState + .getState() + .dispatch.notifyEmailVerified(emailAddress); } - clearSignupEmail() + clearSignupEmail(); } - break - case 'keybase.1.NotifyPhoneNumber.phoneNumbersChanged': { - const {list} = action.payload.params - useSettingsPhoneState.getState().dispatch.notifyPhoneNumberPhoneNumbersChanged(list ?? undefined) - break + break; + case "keybase.1.NotifyPhoneNumber.phoneNumbersChanged": { + const { list } = action.payload.params; + useSettingsPhoneState + .getState() + .dispatch.notifyPhoneNumberPhoneNumbersChanged(list ?? undefined); + break; } - case 'keybase.1.NotifyEmailAddress.emailsChanged': { - const list = action.payload.params.list ?? [] - useSettingsEmailState.getState().dispatch.notifyEmailAddressEmailsChanged(list) - break + case "keybase.1.NotifyEmailAddress.emailsChanged": { + const list = action.payload.params.list ?? []; + useSettingsEmailState + .getState() + .dispatch.notifyEmailAddressEmailsChanged(list); + break; } - case 'chat.1.chatUi.chatInboxFailed': - case 'chat.1.NotifyChat.ChatSetConvSettings': - case 'chat.1.NotifyChat.ChatAttachmentUploadStart': - case 'chat.1.NotifyChat.ChatPromptUnfurl': - case 'chat.1.NotifyChat.ChatPaymentInfo': - case 'chat.1.NotifyChat.ChatRequestInfo': - case 'chat.1.NotifyChat.ChatAttachmentDownloadProgress': - case 'chat.1.NotifyChat.ChatAttachmentDownloadComplete': - case 'chat.1.NotifyChat.ChatAttachmentUploadProgress': - case 'chat.1.chatUi.chatCommandMarkdown': - case 'chat.1.chatUi.chatGiphyToggleResultWindow': - case 'chat.1.chatUi.chatCommandStatus': - case 'chat.1.chatUi.chatGiphySearchResults': - case 'chat.1.NotifyChat.ChatParticipantsInfo': - case 'chat.1.NotifyChat.ChatConvUpdate': - case 'chat.1.chatUi.chatCoinFlipStatus': - case 'chat.1.NotifyChat.ChatThreadsStale': - case 'chat.1.NotifyChat.ChatSubteamRename': - case 'chat.1.NotifyChat.ChatTLFFinalize': - case 'chat.1.NotifyChat.NewChatActivity': - case 'chat.1.NotifyChat.ChatTypingUpdate': - case 'chat.1.NotifyChat.ChatSetConvRetention': - routeConvoEngineIncoming(action) - break - case 'chat.1.NotifyChat.ChatIdentifyUpdate': { - const {update} = action.payload.params - const usernames = update.CanonicalName.split(',') - const broken = (update.breaks.breaks || []).map(b => b.user.username) - const updates = usernames.map(name => ({info: {broken: broken.includes(name)}, name})) - useUsersState.getState().dispatch.updates(updates) - break + case "chat.1.chatUi.chatInboxFailed": + case "chat.1.NotifyChat.ChatSetConvSettings": + case "chat.1.NotifyChat.ChatAttachmentUploadStart": + case "chat.1.NotifyChat.ChatPromptUnfurl": + case "chat.1.NotifyChat.ChatPaymentInfo": + case "chat.1.NotifyChat.ChatRequestInfo": + case "chat.1.NotifyChat.ChatAttachmentDownloadProgress": + case "chat.1.NotifyChat.ChatAttachmentDownloadComplete": + case "chat.1.NotifyChat.ChatAttachmentUploadProgress": + case "chat.1.chatUi.chatCommandMarkdown": + case "chat.1.chatUi.chatGiphyToggleResultWindow": + case "chat.1.chatUi.chatCommandStatus": + case "chat.1.chatUi.chatGiphySearchResults": + case "chat.1.NotifyChat.ChatParticipantsInfo": + case "chat.1.NotifyChat.ChatConvUpdate": + case "chat.1.chatUi.chatCoinFlipStatus": + case "chat.1.NotifyChat.ChatThreadsStale": + case "chat.1.NotifyChat.ChatSubteamRename": + case "chat.1.NotifyChat.ChatTLFFinalize": + case "chat.1.NotifyChat.NewChatActivity": + case "chat.1.NotifyChat.ChatTypingUpdate": + case "chat.1.NotifyChat.ChatSetConvRetention": + routeConvoEngineIncoming(action); + break; + case "chat.1.NotifyChat.ChatIdentifyUpdate": { + const { update } = action.payload.params; + const usernames = update.CanonicalName.split(","); + const broken = (update.breaks.breaks || []).map((b) => b.user.username); + const updates = usernames.map((name) => ({ + info: { broken: broken.includes(name) }, + name, + })); + useUsersState.getState().dispatch.updates(updates); + break; } - case 'chat.1.NotifyChat.ChatInboxStale': - ignorePromise(useInboxLayoutState.getState().dispatch.refresh('inboxStale')) - break - case 'chat.1.chatUi.chatInboxUnverified': - onGetInboxUnverifiedConvs(action) - break - case 'chat.1.NotifyChat.ChatInboxSyncStarted': - useWaitingState.getState().dispatch.increment(S.waitingKeyChatInboxSyncStarted) - break - case 'chat.1.NotifyChat.ChatInboxSynced': - useWaitingState.getState().dispatch.clear(S.waitingKeyChatInboxSyncStarted) + case "chat.1.NotifyChat.ChatInboxStale": ignorePromise( - onChatInboxSynced(action, async reason => useInboxLayoutState.getState().dispatch.refresh(reason)) - ) - break - case 'chat.1.chatUi.chatInboxLayout': { - const {hasLoaded, dispatch} = useInboxLayoutState.getState() - dispatch.updateLayout(action.payload.params.layout) - const {layout} = useInboxLayoutState.getState() + useInboxLayoutState.getState().dispatch.refresh("inboxStale"), + ); + break; + case "chat.1.chatUi.chatInboxUnverified": + onGetInboxUnverifiedConvs(action); + break; + case "chat.1.NotifyChat.ChatInboxSyncStarted": + useWaitingState + .getState() + .dispatch.increment(S.waitingKeyChatInboxSyncStarted); + break; + case "chat.1.NotifyChat.ChatInboxSynced": + useWaitingState + .getState() + .dispatch.clear(S.waitingKeyChatInboxSyncStarted); + ignorePromise( + onChatInboxSynced(action, async (reason) => + useInboxLayoutState.getState().dispatch.refresh(reason), + ), + ); + break; + case "chat.1.chatUi.chatInboxLayout": { + const { hasLoaded, dispatch } = useInboxLayoutState.getState(); + dispatch.updateLayout(action.payload.params.layout); + const { layout } = useInboxLayoutState.getState(); if (layout) { - onInboxLayoutChanged(layout, hasLoaded) + onInboxLayoutChanged(layout, hasLoaded); } - break + break; } - case 'chat.1.chatUi.chatInboxConversation': - onGetInboxConvsUnboxed(action) - break - case 'keybase.1.NotifyService.handleKeybaseLink': + case "chat.1.chatUi.chatInboxConversation": + onGetInboxConvsUnboxed(action); + break; + case "keybase.1.NotifyService.handleKeybaseLink": { - const {link, deferred} = action.payload.params - if (deferred && !link.startsWith('keybase://team-invite-link/')) { - return + const { link, deferred } = action.payload.params; + if (deferred && !link.startsWith("keybase://team-invite-link/")) { + return; } // Route through the linking config; it falls back to handleAppLink // for URL patterns not handled declaratively. - const fullUrl = link.startsWith('keybase://') ? link : `keybase://${link}` - emitDeepLink(fullUrl) + const fullUrl = link.startsWith("keybase://") + ? link + : `keybase://${link}`; + emitDeepLink(fullUrl); } - break - case 'keybase.1.NotifyTeam.avatarUpdated': { - const {name} = action.payload.params - useAvatarState.getState().dispatch.updated(name) - break + break; + case "keybase.1.NotifyTeam.avatarUpdated": { + const { name } = action.payload.params; + useAvatarState.getState().dispatch.updated(name); + break; } - case 'keybase.1.NotifyTracking.trackingChanged': { - const {isTracking, username} = action.payload.params - useFollowerState.getState().dispatch.updateFollowing(username, isTracking) - break + case "keybase.1.NotifyTracking.trackingChanged": { + const { isTracking, username } = action.payload.params; + useFollowerState + .getState() + .dispatch.updateFollowing(username, isTracking); + break; } - case 'keybase.1.NotifyTracking.trackingInfo': { - const {uid, followers: _newFollowers, followees: _newFollowing} = action.payload.params + case "keybase.1.NotifyTracking.trackingInfo": { + const { + uid, + followers: _newFollowers, + followees: _newFollowing, + } = action.payload.params; if (useCurrentUserState.getState().uid !== uid) { - break + break; } - const newFollowers = new Set(_newFollowers) - const newFollowing = new Set(_newFollowing) - const {following: oldFollowing, followers: oldFollowers, dispatch} = useFollowerState.getState() - const following = isEqual(newFollowing, oldFollowing) ? oldFollowing : newFollowing - const followers = isEqual(newFollowers, oldFollowers) ? oldFollowers : newFollowers - dispatch.replace(followers, following) - break + const newFollowers = new Set(_newFollowers); + const newFollowing = new Set(_newFollowing); + const { + following: oldFollowing, + followers: oldFollowers, + dispatch, + } = useFollowerState.getState(); + const following = isEqual(newFollowing, oldFollowing) + ? oldFollowing + : newFollowing; + const followers = isEqual(newFollowers, oldFollowers) + ? oldFollowers + : newFollowers; + dispatch.replace(followers, following); + break; } - case 'keybase.1.NotifyTracking.notifyUserBlocked': + case "keybase.1.NotifyTracking.notifyUserBlocked": { - useUsersState.getState().dispatch.onEngineIncomingImpl(action) + useUsersState.getState().dispatch.onEngineIncomingImpl(action); } - break - case 'keybase.1.NotifyUsers.identifyUpdate': + break; + case "keybase.1.NotifyUsers.identifyUpdate": { - useUsersState.getState().dispatch.onEngineIncomingImpl(action) + useUsersState.getState().dispatch.onEngineIncomingImpl(action); } - break + break; default: } - useConfigState.getState().dispatch.onEngineIncoming(action) - notifyEngineActionListeners(action) -} + useConfigState.getState().dispatch.onEngineIncoming(action); + notifyEngineActionListeners(action); +}; diff --git a/shared/engine/index.tsx b/shared/engine/index.tsx index 1f3d89edc41f..f8d3d0f9bb24 100644 --- a/shared/engine/index.tsx +++ b/shared/engine/index.tsx @@ -301,6 +301,10 @@ class Engine { this._sessionsMap.get(sessionID)?.cancel() } + cancelOutstandingSessions() { + this._cancelOutstandingSessions() + } + // Reset the engine reset() { if (isMobile) { diff --git a/shared/router-v2/router.tsx b/shared/router-v2/router.tsx index e550302f2784..33bbfed02154 100644 --- a/shared/router-v2/router.tsx +++ b/shared/router-v2/router.tsx @@ -263,12 +263,6 @@ function DesktopRouter() { const isDarkMode = useDarkModeState(s => s.isDarkMode()) const navKey = Common.useUserSwitchNavKey() const currentUid = useCurrentUserState(s => s.uid) - const {setUserSwitching, userSwitching} = useConfigState( - C.useShallow(s => ({ - setUserSwitching: s.dispatch.setUserSwitching, - userSwitching: s.userSwitching, - })) - ) const setNavigationReady = useNavigationIntentsState(s => s.dispatch.setNavigationReady) React.useEffect( @@ -298,9 +292,6 @@ function DesktopRouter() { onReady={() => { onStateChange() setNavigationReady(true, currentUid) - if (userSwitching) { - setUserSwitching(false) - } }} onStateChange={onStateChange} onUnhandledAction={onUnhandledAction} @@ -664,10 +655,9 @@ function NativeRouter() { const theme = Kb.Styles.useTheme() const loggedInLoaded = useHandshakeEverDone() - const {loggedIn, setUserSwitching, startupLoaded, userSwitching} = useConfigState( + const {loggedIn, startupLoaded, userSwitching} = useConfigState( C.useShallow(s => ({ loggedIn: s.loggedIn, - setUserSwitching: s.dispatch.setUserSwitching, startupLoaded: s.startup.loaded, userSwitching: s.userSwitching, })) @@ -713,9 +703,6 @@ function NativeRouter() { C.Router2.switchTab(tab) } setNavigationReady(true, currentUid) - if (userSwitching) { - setUserSwitching(false) - } } if (!loggedInLoaded || (loggedIn && !startupLoaded)) { diff --git a/shared/stores/config.tsx b/shared/stores/config.tsx index af87cfe73d3e..e2647e305a4c 100644 --- a/shared/stores/config.tsx +++ b/shared/stores/config.tsx @@ -1,55 +1,64 @@ -import * as T from '@/constants/types' -import {ignorePromise, timeoutPromise} from '@/constants/utils' -import {waitingKeyConfigLogin, waitingKeyConfigLoginAsOther} from '@/constants/strings' -import type * as EngineGen from '@/constants/rpc' -import * as Z from '@/util/zustand' -import {noConversationIDKey} from '@/constants/types/chat/common' -import isEqual from 'lodash/isEqual' -import logger from '@/logger' -import type {Tab} from '@/constants/tabs' -import {RPCError, convertToError, isErrorTransient, niceError} from '@/util/errors' -import {type CommonResponseHandler} from '@/engine/types' -import {invalidPasswordErrorString} from '@/constants/config' -import {navigateAppend} from '@/constants/router' -import {onEngineConnected as onEngineConnectedInPlatform} from '@/util/storeless-actions' -import {useDaemonState} from '@/stores/daemon' +import * as T from "@/constants/types"; +import { ignorePromise, timeoutPromise } from "@/constants/utils"; +import { + waitingKeyConfigLogin, + waitingKeyConfigLoginAsOther, +} from "@/constants/strings"; +import type * as EngineGen from "@/constants/rpc"; +import * as Z from "@/util/zustand"; +import { noConversationIDKey } from "@/constants/types/chat/common"; +import isEqual from "lodash/isEqual"; +import logger from "@/logger"; +import type { Tab } from "@/constants/tabs"; +import { + RPCError, + convertToError, + isErrorTransient, + niceError, +} from "@/util/errors"; +import { type CommonResponseHandler } from "@/engine/types"; +import { invalidPasswordErrorString } from "@/constants/config"; +import { navigateAppend } from "@/constants/router"; +import { onEngineConnected as onEngineConnectedInPlatform } from "@/util/storeless-actions"; +import { useDaemonState } from "@/stores/daemon"; +import { getEngine, hasEngine } from "@/engine/require"; type Store = T.Immutable<{ - allowAnimatedEmojis: boolean + allowAnimatedEmojis: boolean; androidShare?: - | {type: T.RPCGen.IncomingShareType.file; urls: Array} - | {type: T.RPCGen.IncomingShareType.text; text: string} - badgeState?: T.RPCGen.BadgeState - chatBuiltinCommands?: T.Chat.StaticConfig['builtinCommands'] - chatDeletableByDeleteHistory?: Set - configuredAccounts: Array - defaultUsername: string - globalError?: Error | RPCError - gregorPushState: Array<{md: T.RPCGregor.Metadata; item: T.RPCGregor.Item}> - loginError?: RPCError + | { type: T.RPCGen.IncomingShareType.file; urls: Array } + | { type: T.RPCGen.IncomingShareType.text; text: string }; + badgeState?: T.RPCGen.BadgeState; + chatBuiltinCommands?: T.Chat.StaticConfig["builtinCommands"]; + chatDeletableByDeleteHistory?: Set; + configuredAccounts: Array; + defaultUsername: string; + globalError?: Error | RPCError; + gregorPushState: Array<{ md: T.RPCGregor.Metadata; item: T.RPCGregor.Item }>; + loginError?: RPCError; httpSrv: { - address: string - token: string - } - installerRanCount: number - isOnline: boolean - justDeletedSelf: string - justRevokedSelf: string - loggedIn: boolean - outOfDate: T.Config.OutOfDate - revokedTrigger: number - runtimeStats?: T.RPCGen.RuntimeStats + address: string; + token: string; + }; + installerRanCount: number; + isOnline: boolean; + justDeletedSelf: string; + justRevokedSelf: string; + loggedIn: boolean; + outOfDate: T.Config.OutOfDate; + revokedTrigger: number; + runtimeStats?: T.RPCGen.RuntimeStats; startup: { - loaded: boolean - conversation: T.Chat.ConversationIDKey + loaded: boolean; + conversation: T.Chat.ConversationIDKey; // uid of the account that persisted `conversation` (from ui.routeState2). // Used to avoid replaying a conversation under a different account. - conversationUid?: string - tab?: Tab - } - userSwitching: boolean - windowShownCount: Map -}> + conversationUid?: string; + tab?: Tab; + }; + userSwitching: boolean; + windowShownCount: Map; +}>; const initialStore: Store = { allowAnimatedEmojis: true, @@ -58,22 +67,22 @@ const initialStore: Store = { chatBuiltinCommands: undefined, chatDeletableByDeleteHistory: undefined, configuredAccounts: [], - defaultUsername: '', + defaultUsername: "", globalError: undefined, gregorPushState: [], httpSrv: { - address: '', - token: '', + address: "", + token: "", }, installerRanCount: 0, isOnline: true, - justDeletedSelf: '', - justRevokedSelf: '', + justDeletedSelf: "", + justRevokedSelf: "", loggedIn: false, loginError: undefined, outOfDate: { critical: false, - message: '', + message: "", outOfDate: false, updating: false, }, @@ -84,331 +93,377 @@ const initialStore: Store = { }, userSwitching: false, windowShownCount: new Map(), -} +}; export type State = Store & { dispatch: { - checkForUpdate: () => void - initAppUpdateLoop: () => void - installerRan: () => void - loadIsOnline: () => void - login: (username: string, password: string) => void - setLoginError: (error?: RPCError) => void - logoutToLoggedOutFlow: () => void - logoutAndTryToLogInAs: (username: string) => void - onEngineConnected: () => void - onEngineIncoming: (action: EngineGen.Actions) => void - powerMonitorEvent: (event: string) => void - resetState: (isDebug?: boolean) => void - resetRevokedSelf: () => void - revoke: (deviceName: string, wasCurrentDevice: boolean) => void - refreshAccounts: () => Promise - setAccounts: (a: Store['configuredAccounts']) => void - setAndroidShare: (s: Store['androidShare']) => void - setBadgeState: (b: State['badgeState']) => void - setChatStaticConfig: (s: T.Chat.StaticConfig) => void - setDefaultUsername: (u: string) => void - setGlobalError: (e?: unknown) => void - setHTTPSrvInfo: (address: string, token: string) => void - setJustDeletedSelf: (s: string) => void - setLoggedIn: (l: boolean) => void - setStartupDetails: (st: Omit) => void - setOutOfDate: (outOfDate: T.Config.OutOfDate) => void - setUpdating: () => void - setUserSwitching: (sw: boolean) => void - toggleRuntimeStats: () => void - updateGregorCategory: (category: string, body: string, dtime?: {offset: number; time: number}) => void - } -} + checkForUpdate: () => void; + initAppUpdateLoop: () => void; + installerRan: () => void; + loadIsOnline: () => void; + login: (username: string, password: string) => void; + setLoginError: (error?: RPCError) => void; + logoutToLoggedOutFlow: () => void; + logoutAndTryToLogInAs: (username: string) => void; + onEngineConnected: () => void; + onEngineIncoming: (action: EngineGen.Actions) => void; + powerMonitorEvent: (event: string) => void; + resetState: (isDebug?: boolean) => void; + resetRevokedSelf: () => void; + revoke: (deviceName: string, wasCurrentDevice: boolean) => void; + refreshAccounts: () => Promise; + setAccounts: (a: Store["configuredAccounts"]) => void; + setAndroidShare: (s: Store["androidShare"]) => void; + setBadgeState: (b: State["badgeState"]) => void; + setChatStaticConfig: (s: T.Chat.StaticConfig) => void; + setDefaultUsername: (u: string) => void; + setGlobalError: (e?: unknown) => void; + setHTTPSrvInfo: (address: string, token: string) => void; + setJustDeletedSelf: (s: string) => void; + setLoggedIn: (l: boolean) => void; + setStartupDetails: (st: Omit) => void; + setOutOfDate: (outOfDate: T.Config.OutOfDate) => void; + setUpdating: () => void; + setUserSwitching: (sw: boolean) => void; + toggleRuntimeStats: () => void; + updateGregorCategory: ( + category: string, + body: string, + dtime?: { offset: number; time: number }, + ) => void; + }; +}; -export const useConfigState = Z.createZustand('config', (set, get) => { - let inflightRefreshAccounts: Promise | undefined +export const useConfigState = Z.createZustand("config", (set, get) => { + let inflightRefreshAccounts: Promise | undefined; const _checkForUpdate = async () => { try { - const {status, message} = await T.RPCGen.configGetUpdateInfoRpcPromise() + const { status, message } = + await T.RPCGen.configGetUpdateInfoRpcPromise(); get().dispatch.setOutOfDate( status !== T.RPCGen.UpdateInfoStatus.upToDate ? { - critical: status === T.RPCGen.UpdateInfoStatus.criticallyOutOfDate, + critical: + status === T.RPCGen.UpdateInfoStatus.criticallyOutOfDate, message, outOfDate: true, updating: false, } : { critical: false, - message: '', + message: "", outOfDate: false, updating: false, - } - ) + }, + ); } catch (err) { - logger.warn('error getting update info: ', err) + logger.warn("error getting update info: ", err); } - } + }; const setGregorPushState = (state: T.RPCGen.Gregor1.State) => { - const items = state.items || [] - const goodState = items.reduce>( - (arr, {md, item}) => { - if (md && item) { - arr.push({item, md}) - } - return arr - }, - [] - ) + const items = state.items || []; + const goodState = items.reduce< + Array<{ md: T.RPCGregor.Metadata; item: T.RPCGregor.Item }> + >((arr, { md, item }) => { + if (md && item) { + arr.push({ item, md }); + } + return arr; + }, []); if (goodState.length !== items.length) { - logger.warn('Lost some messages in filtering out nonNull gregor items') + logger.warn("Lost some messages in filtering out nonNull gregor items"); } - set(s => { - s.gregorPushState = T.castDraft(goodState) - s.allowAnimatedEmojis = !goodState.find(i => i.item.category === 'emojianimations') - }) - } + set((s) => { + s.gregorPushState = T.castDraft(goodState); + s.allowAnimatedEmojis = !goodState.find( + (i) => i.item.category === "emojianimations", + ); + }); + }; const updateRuntimeStats = (stats?: T.RPCGen.RuntimeStats) => { - set(s => { - s.runtimeStats = stats ? T.castDraft({...s.runtimeStats, ...stats}) : undefined - }) - } + set((s) => { + s.runtimeStats = stats + ? T.castDraft({ ...s.runtimeStats, ...stats }) + : undefined; + }); + }; - const dispatch: State['dispatch'] = { + const dispatch: State["dispatch"] = { checkForUpdate: () => { const f = async () => { - await _checkForUpdate() - } - ignorePromise(f()) + await _checkForUpdate(); + }; + ignorePromise(f()); }, initAppUpdateLoop: () => { const f = async () => { while (true) { try { - await _checkForUpdate() + await _checkForUpdate(); } catch {} - await timeoutPromise(3_600_000) // 1 hr + await timeoutPromise(3_600_000); // 1 hr } - } - ignorePromise(f()) + }; + ignorePromise(f()); }, installerRan: () => { - set(s => { - s.installerRanCount++ - }) + set((s) => { + s.installerRanCount++; + }); }, loadIsOnline: () => { const f = async () => { try { - const isOnline = await T.RPCGen.loginIsOnlineRpcPromise(undefined) - set(s => { - s.isOnline = isOnline - }) + const isOnline = await T.RPCGen.loginIsOnlineRpcPromise(undefined); + set((s) => { + s.isOnline = isOnline; + }); } catch (err) { - logger.warn('Error in checking whether we are online', err) + logger.warn("Error in checking whether we are online", err); } - } - ignorePromise(f()) + }; + ignorePromise(f()); }, login: (username, passphrase) => { - const cancelDesc = 'Canceling RPC' - const cancelOnCallback = (_: unknown, response: CommonResponseHandler) => { - response.error({code: T.RPCGen.StatusCode.scgeneric, desc: cancelDesc}) - } - const ignoreCallback = () => {} + const cancelDesc = "Canceling RPC"; + const cancelOnCallback = ( + _: unknown, + response: CommonResponseHandler, + ) => { + response.error({ + code: T.RPCGen.StatusCode.scgeneric, + desc: cancelDesc, + }); + }; + const ignoreCallback = () => {}; const f = async () => { try { await T.RPCGen.loginLoginRpcListener({ customResponseIncomingCallMap: { - 'keybase.1.gpgUi.selectKey': cancelOnCallback, - 'keybase.1.loginUi.getEmailOrUsername': cancelOnCallback, - 'keybase.1.provisionUi.DisplayAndPromptSecret': cancelOnCallback, - 'keybase.1.provisionUi.PromptNewDeviceName': (_, response) => { - cancelOnCallback(undefined, response) + "keybase.1.gpgUi.selectKey": cancelOnCallback, + "keybase.1.loginUi.getEmailOrUsername": cancelOnCallback, + "keybase.1.provisionUi.DisplayAndPromptSecret": cancelOnCallback, + "keybase.1.provisionUi.PromptNewDeviceName": (_, response) => { + cancelOnCallback(undefined, response); // this account needs provisioning; hand off to the provision flow - navigateAppend({name: 'username', params: {autoSubmit: true, username}}) + navigateAppend({ + name: "username", + params: { autoSubmit: true, username }, + }); }, - 'keybase.1.provisionUi.chooseDevice': cancelOnCallback, - 'keybase.1.provisionUi.chooseGPGMethod': cancelOnCallback, - 'keybase.1.secretUi.getPassphrase': (params, response) => { - if (params.pinentry.type === T.RPCGen.PassphraseType.passPhrase) { + "keybase.1.provisionUi.chooseDevice": cancelOnCallback, + "keybase.1.provisionUi.chooseGPGMethod": cancelOnCallback, + "keybase.1.secretUi.getPassphrase": (params, response) => { + if ( + params.pinentry.type === T.RPCGen.PassphraseType.passPhrase + ) { // Service asking us again due to a bad passphrase? if (params.pinentry.retryLabel) { - cancelOnCallback(params, response) - let retryLabel = params.pinentry.retryLabel + cancelOnCallback(params, response); + let retryLabel = params.pinentry.retryLabel; if (retryLabel === invalidPasswordErrorString) { - retryLabel = 'Incorrect password.' + retryLabel = "Incorrect password."; } - const error = new RPCError(retryLabel, T.RPCGen.StatusCode.scinputerror) - get().dispatch.setLoginError(error) + const error = new RPCError( + retryLabel, + T.RPCGen.StatusCode.scinputerror, + ); + get().dispatch.setLoginError(error); } else { - response.result({passphrase, storeSecret: false}) + response.result({ passphrase, storeSecret: false }); } } else { - cancelOnCallback(params, response) + cancelOnCallback(params, response); } }, }, // cancel if we get any of these callbacks, we're logging in, not provisioning incomingCallMap: { - 'keybase.1.loginUi.displayPrimaryPaperKey': ignoreCallback, - 'keybase.1.provisionUi.DisplaySecretExchanged': ignoreCallback, - 'keybase.1.provisionUi.ProvisioneeSuccess': ignoreCallback, - 'keybase.1.provisionUi.ProvisionerSuccess': ignoreCallback, + "keybase.1.loginUi.displayPrimaryPaperKey": ignoreCallback, + "keybase.1.provisionUi.DisplaySecretExchanged": ignoreCallback, + "keybase.1.provisionUi.ProvisioneeSuccess": ignoreCallback, + "keybase.1.provisionUi.ProvisionerSuccess": ignoreCallback, }, params: { clientType: T.RPCGen.ClientType.guiMain, - deviceName: '', - deviceType: isMobile ? 'mobile' : 'desktop', + deviceName: "", + deviceType: isMobile ? "mobile" : "desktop", doUserSwitch: true, - paperKey: '', + paperKey: "", username, }, waitingKey: waitingKeyConfigLogin, - }) - logger.info('login call succeeded') + }); + logger.info("login call succeeded"); } catch (error) { // Nothing else ends a cancelled switch, and the logged-out status it withheld applies only then if (!(error instanceof RPCError) || error.desc === cancelDesc) { - get().dispatch.setUserSwitching(false) + get().dispatch.setUserSwitching(false); } if (!(error instanceof RPCError)) { - return + return; } // Already logged in: the daemon's session says so. Canceling: nothing to report. - if (error.code !== T.RPCGen.StatusCode.scalreadyloggedin && error.desc !== cancelDesc) { - error.desc = niceError(error) - get().dispatch.setLoginError(error) + if ( + error.code !== T.RPCGen.StatusCode.scalreadyloggedin && + error.desc !== cancelDesc + ) { + error.desc = niceError(error); + get().dispatch.setLoginError(error); } } finally { // After the switch ends: a failed switch must apply a logged-out session. - useDaemonState.getState().dispatch.refreshSessionFromDaemon('login returned') + useDaemonState + .getState() + .dispatch.refreshSessionFromDaemon("login returned"); } - } - get().dispatch.setLoginError() - ignorePromise(f()) + }; + get().dispatch.setLoginError(); + get().dispatch.setDefaultUsername(username); + ignorePromise(f()); }, - logoutAndTryToLogInAs: username => { + logoutAndTryToLogInAs: (username) => { const f = async () => { if (get().loggedIn) { - await T.RPCGen.loginLogoutRpcPromise({force: false, keepSecrets: true}, waitingKeyConfigLogin) + await T.RPCGen.loginLogoutRpcPromise( + { force: false, keepSecrets: true }, + waitingKeyConfigLogin, + ); } - get().dispatch.setDefaultUsername(username) - } - ignorePromise(f()) + get().dispatch.setDefaultUsername(username); + }; + ignorePromise(f()); }, logoutToLoggedOutFlow: () => { const f = async () => { if (get().loggedIn) { await T.RPCGen.loginLogoutRpcPromise( - {force: false, keepSecrets: true}, - waitingKeyConfigLoginAsOther - ) + { force: false, keepSecrets: true }, + waitingKeyConfigLoginAsOther, + ); } - } - ignorePromise(f()) + }; + ignorePromise(f()); }, onEngineConnected: () => { // An engine reset drops in-flight RPCs without settling their promises; a refresh // caught by that would poison the dedupe cache forever - inflightRefreshAccounts = undefined + inflightRefreshAccounts = undefined; // If ever you want to get OOBMs for a different system, then you need to enter it here. const registerForGregorNotifications = async () => { try { - await T.RPCGen.delegateUiCtlRegisterGregorFirehoseFilteredRpcPromise({systems: []}) - logger.info('Registered gregor listener') + await T.RPCGen.delegateUiCtlRegisterGregorFirehoseFilteredRpcPromise({ + systems: [], + }); + logger.info("Registered gregor listener"); } catch (error) { - logger.warn('error in registering gregor listener: ', error) + logger.warn("error in registering gregor listener: ", error); } - } - ignorePromise(registerForGregorNotifications()) + }; + ignorePromise(registerForGregorNotifications()); - onEngineConnectedInPlatform() + onEngineConnectedInPlatform(); }, - onEngineIncoming: action => { + onEngineIncoming: (action) => { switch (action.type) { - case 'keybase.1.NotifyAudit.rootAuditError': + case "keybase.1.NotifyAudit.rootAuditError": get().dispatch.setGlobalError( - new Error(`Keybase is buggy, please report this: ${action.payload.params.message}`) - ) - break - case 'keybase.1.NotifyAudit.boxAuditError': + new Error( + `Keybase is buggy, please report this: ${action.payload.params.message}`, + ), + ); + break; + case "keybase.1.NotifyAudit.boxAuditError": get().dispatch.setGlobalError( new Error( - `Keybase had a problem loading a team, please report this with \`keybase log send\`: ${action.payload.params.message}` - ) - ) - break - case 'keybase.1.NotifyBadges.badgeState': - get().dispatch.setBadgeState(action.payload.params.badgeState) - break - case 'keybase.1.gregorUI.pushState': { - const {state} = action.payload.params - setGregorPushState(state) - break + `Keybase had a problem loading a team, please report this with \`keybase log send\`: ${action.payload.params.message}`, + ), + ); + break; + case "keybase.1.NotifyBadges.badgeState": + get().dispatch.setBadgeState(action.payload.params.badgeState); + break; + case "keybase.1.gregorUI.pushState": { + const { state } = action.payload.params; + setGregorPushState(state); + break; } - case 'keybase.1.NotifyRuntimeStats.runtimeStatsUpdate': { - updateRuntimeStats(action.payload.params.stats ?? undefined) - break + case "keybase.1.NotifyRuntimeStats.runtimeStatsUpdate": { + updateRuntimeStats(action.payload.params.stats ?? undefined); + break; } - case 'keybase.1.NotifyService.HTTPSrvInfoUpdate': { - get().dispatch.setHTTPSrvInfo(action.payload.params.info.address, action.payload.params.info.token) - break + case "keybase.1.NotifyService.HTTPSrvInfoUpdate": { + get().dispatch.setHTTPSrvInfo( + action.payload.params.info.address, + action.payload.params.info.token, + ); + break; } default: } }, - powerMonitorEvent: event => { + powerMonitorEvent: (event) => { const f = async () => { - await T.RPCGen.appStatePowerMonitorEventRpcPromise({event}) - } - ignorePromise(f()) + await T.RPCGen.appStatePowerMonitorEventRpcPromise({ event }); + }; + ignorePromise(f()); }, refreshAccounts: async () => { if (inflightRefreshAccounts) { - return inflightRefreshAccounts + return inflightRefreshAccounts; } const f = async () => { - const defaultUsername = get().defaultUsername - const configuredAccounts = (await T.RPCGen.loginGetConfiguredAccountsRpcPromise()) ?? [] - const {setAccounts, setDefaultUsername} = get().dispatch + const defaultUsername = get().defaultUsername; + const configuredAccounts = + (await T.RPCGen.loginGetConfiguredAccountsRpcPromise()) ?? []; + const { setAccounts, setDefaultUsername } = get().dispatch; - let existingDefaultFound = false as boolean - let currentName = '' - const nextConfiguredAccounts: Array = [] + let existingDefaultFound = false as boolean; + let currentName = ""; + const nextConfiguredAccounts: Array = []; - configuredAccounts.forEach(account => { - const {username, isCurrent, fullname, hasStoredSecret, uid} = account + configuredAccounts.forEach((account) => { + const { username, isCurrent, fullname, hasStoredSecret, uid } = + account; if (username === defaultUsername) { - existingDefaultFound = true + existingDefaultFound = true; } if (isCurrent) { - currentName = account.username + currentName = account.username; } - nextConfiguredAccounts.push({fullname, hasStoredSecret, uid, username}) - }) + nextConfiguredAccounts.push({ + fullname, + hasStoredSecret, + uid, + username, + }); + }); if (!existingDefaultFound) { - setDefaultUsername(currentName) + setDefaultUsername(currentName); } - setAccounts(nextConfiguredAccounts) - } - const p = f() - inflightRefreshAccounts = p + setAccounts(nextConfiguredAccounts); + }; + const p = f(); + inflightRefreshAccounts = p; try { - await p + await p; } finally { if (inflightRefreshAccounts === p) { - inflightRefreshAccounts = undefined + inflightRefreshAccounts = undefined; } } }, resetRevokedSelf: () => { - set(s => { - s.justRevokedSelf = '' - }) + set((s) => { + s.justRevokedSelf = ""; + }); }, - resetState: isDebug => { - if (isDebug) return - set(s => ({ + resetState: (isDebug) => { + if (isDebug) return; + set((s) => ({ ...initialStore, chatBuiltinCommands: s.chatBuiltinCommands, chatDeletableByDeleteHistory: s.chatDeletableByDeleteHistory, @@ -417,129 +472,139 @@ export const useConfigState = Z.createZustand('config', (set, get) => { dispatch: s.dispatch, // process-wide, not per account; nothing reloads it on logout httpSrv: s.httpSrv, - startup: {loaded: s.startup.loaded}, + startup: { loaded: s.startup.loaded }, userSwitching: s.userSwitching, - })) + })); }, revoke: (name, wasCurrentDevice) => { if (wasCurrentDevice) { - const {configuredAccounts, defaultUsername} = get() - const acc = configuredAccounts.find(n => n.username !== defaultUsername) - const du = acc?.username ?? '' - set(s => { - s.defaultUsername = du - s.justRevokedSelf = name - s.revokedTrigger++ - }) + const { configuredAccounts, defaultUsername } = get(); + const acc = configuredAccounts.find( + (n) => n.username !== defaultUsername, + ); + const du = acc?.username ?? ""; + set((s) => { + s.defaultUsername = du; + s.justRevokedSelf = name; + s.revokedTrigger++; + }); } }, - setAccounts: a => { + setAccounts: (a) => { // Compare against committed state, not the draft: immer 11.1.9 sanitizes // constructor/prototype access on drafts (prototype-pollution fix), which // makes lodash isEqual throw a proxy-invariant TypeError on a draft. - if (isEqual(a, get().configuredAccounts)) return - set(s => { - s.configuredAccounts = T.castDraft(a) - }) + if (isEqual(a, get().configuredAccounts)) return; + set((s) => { + s.configuredAccounts = T.castDraft(a); + }); }, - setAndroidShare: share => { - set(s => { - s.androidShare = T.castDraft(share) - }) + setAndroidShare: (share) => { + set((s) => { + s.androidShare = T.castDraft(share); + }); }, - setBadgeState: b => { - if (get().badgeState === b) return - set(s => { - s.badgeState = T.castDraft(b) - }) + setBadgeState: (b) => { + if (get().badgeState === b) return; + set((s) => { + s.badgeState = T.castDraft(b); + }); }, - setChatStaticConfig: staticConfig => { - set(s => { - s.chatBuiltinCommands = T.castDraft(staticConfig.builtinCommands) - s.chatDeletableByDeleteHistory = new Set(staticConfig.deletableByDeleteHistory) - }) + setChatStaticConfig: (staticConfig) => { + set((s) => { + s.chatBuiltinCommands = T.castDraft(staticConfig.builtinCommands); + s.chatDeletableByDeleteHistory = new Set( + staticConfig.deletableByDeleteHistory, + ); + }); }, - setDefaultUsername: u => { - set(s => { - s.defaultUsername = u - }) + setDefaultUsername: (u) => { + set((s) => { + s.defaultUsername = u; + }); }, - setGlobalError: _e => { + setGlobalError: (_e) => { if (_e) { - const e = convertToError(_e) - set(s => { - s.globalError = e - }) - logger.error('Error (global):', e.message, e) + const e = convertToError(_e); + set((s) => { + s.globalError = e; + }); + logger.error("Error (global):", e.message, e); if (isErrorTransient(e)) { - logger.info('globalError silencing:', e) - return + logger.info("globalError silencing:", e); + return; } } else { - set(s => { - s.globalError = undefined - }) + set((s) => { + s.globalError = undefined; + }); } }, setHTTPSrvInfo: (address, token) => { - set(s => { - s.httpSrv.address = address - s.httpSrv.token = token - }) + set((s) => { + s.httpSrv.address = address; + s.httpSrv.token = token; + }); }, - setJustDeletedSelf: self => { - set(s => { - s.justDeletedSelf = self - }) + setJustDeletedSelf: (self) => { + set((s) => { + s.justDeletedSelf = self; + }); }, - setLoggedIn: loggedIn => { - const changed = get().loggedIn !== loggedIn - set(s => { - s.loggedIn = loggedIn - }) + setLoggedIn: (loggedIn) => { + const changed = get().loggedIn !== loggedIn; + set((s) => { + s.loggedIn = loggedIn; + }); if (changed && !loggedIn) { - Z.resetAllStores() + Z.resetAllStores(); } }, - setLoginError: error => { - set(s => { - s.loginError = error - }) + setLoginError: (error) => { + set((s) => { + s.loginError = error; + }); if (error) { - get().dispatch.setUserSwitching(false) + get().dispatch.setUserSwitching(false); } }, - setOutOfDate: outOfDate => { - set(s => { - Object.assign(s.outOfDate, outOfDate) - }) + setOutOfDate: (outOfDate) => { + set((s) => { + Object.assign(s.outOfDate, outOfDate); + }); }, - setStartupDetails: st => { - set(s => { + setStartupDetails: (st) => { + set((s) => { if (s.startup.loaded) { - return + return; } s.startup = { ...st, loaded: true, - } - }) + }; + }); }, setUpdating: () => { - set(s => { - s.outOfDate.updating = true - }) + set((s) => { + s.outOfDate.updating = true; + }); }, - setUserSwitching: sw => { - set(s => { - s.userSwitching = sw - }) + setUserSwitching: (sw) => { + if (sw && !get().userSwitching) { + Z.resetAllStores(); + if (hasEngine()) { + getEngine().cancelOutstandingSessions(); + } + } + set((s) => { + s.userSwitching = sw; + }); }, toggleRuntimeStats: () => { const f = async () => { - await T.RPCGen.configToggleRuntimeStatsRpcPromise() - } - ignorePromise(f()) + await T.RPCGen.configToggleRuntimeStatsRpcPromise(); + }; + ignorePromise(f()); }, updateGregorCategory: (category, body, dtime) => { const f = async () => { @@ -547,15 +612,20 @@ export const useConfigState = Z.createZustand('config', (set, get) => { await T.RPCGen.gregorUpdateCategoryRpcPromise({ body, category, - dtime: dtime || {offset: 0, time: 0}, - }) + dtime: dtime || { offset: 0, time: 0 }, + }); } catch {} - } - ignorePromise(f()) + }; + ignorePromise(f()); }, - } + }; return { ...initialStore, dispatch, - } -}) + }; +}); + +export const isChatSessionReady = () => { + const { loggedIn, userSwitching } = useConfigState.getState(); + return loggedIn && !userSwitching; +};