diff --git a/AGENTS.md b/AGENTS.md index c4885b6a5ce7..5fef66811aa1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,7 @@ - Do not add new exported functions, types, or constants unless they are required outside the file. Prefer file-local helpers for one-off implementation details and tests. - Do not edit lockfiles by hand. They are generated artifacts. If you cannot regenerate one locally, leave it unchanged. - Components must not mutate Zustand stores directly with `useXState.setState`, `getState()`-based writes, or similar ad hoc store mutation. If a component needs to affect store state, route it through a store dispatch action or move the state out of the store. +- When a Zustand store already uses `resetState: Z.defaultReset`, prefer calling `dispatch.resetState()` for full resets instead of manually reassigning each initial field in another dispatch action. - During refactors, do not delete existing guards, conditionals, or platform/test-specific behavior unless you have proven they are dead and the user asked for that behavior change. Port checks like `androidIsTestDevice` forward into the new code path instead of silently dropping them. - When addressing PR or review feedback, including bot or lint-style suggestions, do not apply it mechanically. Verify that the reported issue is real in this codebase and that the proposed fix is consistent with repo rules and improves correctness, behavior, or maintainability before making changes. - When working from a repo plan or checklist such as `PLAN.md`, update the checklist in the same change and mark implemented items done before you finish. diff --git a/shared/app/index.native.tsx b/shared/app/index.native.tsx index 3d63b6bf2f38..8a2ed25a4ed9 100644 --- a/shared/app/index.native.tsx +++ b/shared/app/index.native.tsx @@ -1,6 +1,7 @@ /// import * as C from '@/constants' import {useConfigState} from '@/stores/config' +import {useShellState} from '@/stores/shell' import * as Kb from '@/common-adapters' import * as React from 'react' import Main from './main.native' @@ -35,7 +36,7 @@ const useDarkHookup = () => { const initedRef = React.useRef(false) const appStateRef = React.useRef('active') const setSystemDarkMode = DarkMode.useDarkModeState(s => s.dispatch.setSystemDarkMode) - const setMobileAppState = useConfigState(s => s.dispatch.setMobileAppState) + const setMobileAppState = useShellState(s => s.dispatch.setMobileAppState) const setSystemSupported = DarkMode.useDarkModeState(s => s.dispatch.setSystemSupported) const setDarkModePreference = DarkMode.useDarkModeState(s => s.dispatch.setDarkModePreference) diff --git a/shared/chat/conversation/normal/container.tsx b/shared/chat/conversation/normal/container.tsx index d066b772b7d7..f79480a258a8 100644 --- a/shared/chat/conversation/normal/container.tsx +++ b/shared/chat/conversation/normal/container.tsx @@ -1,6 +1,6 @@ import * as C from '@/constants' import * as ConvoState from '@/stores/convostate' -import {useConfigState} from '@/stores/config' +import {useShellState} from '@/stores/shell' import * as React from 'react' import Normal from '.' import * as T from '@/constants/types' @@ -61,7 +61,7 @@ const useOrangeLine = () => { // just use the rpc for orange line if we're not active // if we are active we want to keep whatever state we had so it is maintained - const active = useConfigState(s => s.active) + const active = useShellState(s => s.active) React.useEffect(() => { if (!active) { loadOrangeLine() @@ -69,7 +69,7 @@ const useOrangeLine = () => { }, [maxVisibleMsgID, active]) // mobile backgrounded us - const mobileAppState = useConfigState(s => s.mobileAppState) + const mobileAppState = useShellState(s => s.mobileAppState) const lastMobileAppStateRef = React.useRef(mobileAppState) React.useEffect(() => { if (mobileAppState !== lastMobileAppStateRef.current) { diff --git a/shared/chat/inbox/use-inbox-search.tsx b/shared/chat/inbox/use-inbox-search.tsx index 7001e66f5412..0c5f7cecdb30 100644 --- a/shared/chat/inbox/use-inbox-search.tsx +++ b/shared/chat/inbox/use-inbox-search.tsx @@ -2,7 +2,7 @@ import * as C from '@/constants' import {ignorePromise} from '@/constants/utils' import * as T from '@/constants/types' import logger from '@/logger' -import {useConfigState} from '@/stores/config' +import {useShellState} from '@/stores/shell' import {RPCError} from '@/util/errors' import {isMobile} from '@/constants/platform' import * as ConvoState from '@/stores/convostate' @@ -151,7 +151,7 @@ export type InboxSearchController = { } export function useInboxSearch(): InboxSearchController { - const mobileAppState = useConfigState(s => s.mobileAppState) + const mobileAppState = useShellState(s => s.mobileAppState) const [isSearching, setIsSearching] = React.useState(false) const [searchInfo, setSearchInfo] = React.useState(makeInboxSearchInfo) const activeSearchIDRef = React.useRef(0) diff --git a/shared/constants/chat/common.tsx b/shared/constants/chat/common.tsx index cb3f54b501a3..4e51cdec4016 100644 --- a/shared/constants/chat/common.tsx +++ b/shared/constants/chat/common.tsx @@ -1,6 +1,6 @@ import * as T from '../types' import {getVisibleScreen} from '@/constants/router' -import {useConfigState} from '@/stores/config' +import {useShellState} from '@/stores/shell' import {isSplit, threadRouteName} from './layout' export const explodingModeGregorKeyPrefix = 'exploding:' @@ -28,7 +28,7 @@ export const isUserActivelyLookingAtThisThread = (conversationIDKey: T.Chat.Conv (maybeVisibleScreen === undefined ? undefined : maybeVisibleScreen.name) === threadRouteName } - const {appFocused, active: userActive} = useConfigState.getState() + const {appFocused, active: userActive} = useShellState.getState() return ( appFocused && // app focused? diff --git a/shared/constants/init/index.desktop.tsx b/shared/constants/init/index.desktop.tsx index 6211d65440cb..bc512150fbac 100644 --- a/shared/constants/init/index.desktop.tsx +++ b/shared/constants/init/index.desktop.tsx @@ -2,9 +2,9 @@ import * as Chat from '@/stores/chat' import {ignorePromise} from '@/constants/utils' import {useConfigState} from '@/stores/config' -import * as ConfigConstants from '@/stores/config' import {useDaemonState} from '@/stores/daemon' import {useFSState} from '@/stores/fs' +import {openAtLoginKey, useShellState} from '@/stores/shell' import type * as EngineGen from '@/constants/rpc' import * as T from '@/constants/types' import InputMonitor from '@/util/platform-specific/input-monitor.desktop' @@ -24,7 +24,7 @@ const {activeChanged, requestWindowsStartService} = KB2.functions const {quitApp, exitApp, setOpenAtLogin} = KB2.functions const maybePauseVideos = () => { - const {appFocused} = useConfigState.getState() + const {appFocused} = useShellState.getState() const videos = document.querySelectorAll('video') const allVideos = Array.from(videos) @@ -142,16 +142,18 @@ export const initPlatformListener = () => { for (const unsub of _platformUnsubs) unsub() _platformUnsubs.length = 0 - _platformUnsubs.push(useConfigState.subscribe((s, old) => { + _platformUnsubs.push(useShellState.subscribe((s, old) => { if (s.appFocused === old.appFocused) return useFSState.getState().dispatch.onChangedFocus(s.appFocused) })) _platformUnsubs.push(useConfigState.subscribe((s, old) => { if (s.loggedIn !== old.loggedIn) { - s.dispatch.osNetworkStatusChanged(navigator.onLine, 'notavailable', true) + useShellState.getState().dispatch.osNetworkStatusChanged(navigator.onLine, 'notavailable', true) } + })) + _platformUnsubs.push(useShellState.subscribe((s, old) => { if (s.appFocused !== old.appFocused) { maybePauseVideos() } @@ -164,7 +166,7 @@ export const initPlatformListener = () => { return } else { await T.RPCGen.configGuiSetValueRpcPromise({ - path: ConfigConstants.openAtLoginKey, + path: openAtLoginKey, value: {b: openAtLogin, isNull: false}, }) } @@ -198,7 +200,7 @@ export const initPlatformListener = () => { if (skipAppFocusActions) { console.log('Skipping app focus actions!') } else { - useConfigState.getState().dispatch.changedFocus(appFocused) + useShellState.getState().dispatch.changedFocus(appFocused) } } window.addEventListener('focus', () => handle(true)) @@ -208,16 +210,16 @@ export const initPlatformListener = () => { const setupReachabilityWatcher = () => { window.addEventListener('online', () => - useConfigState.getState().dispatch.osNetworkStatusChanged(true, 'notavailable') + useShellState.getState().dispatch.osNetworkStatusChanged(true, 'notavailable') ) window.addEventListener('offline', () => - useConfigState.getState().dispatch.osNetworkStatusChanged(false, 'notavailable') + useShellState.getState().dispatch.osNetworkStatusChanged(false, 'notavailable') ) } setupReachabilityWatcher() if (isLinux) { - useConfigState.getState().dispatch.initUseNativeFrame() + useShellState.getState().dispatch.initUseNativeFrame() } const initializeInputMonitor = () => { @@ -226,7 +228,7 @@ export const initPlatformListener = () => { if (skipAppFocusActions) { console.log('Skipping app focus actions!') } else { - useConfigState.getState().dispatch.setActive(userActive) + useShellState.getState().dispatch.setActive(userActive) // let node thread save file activeChanged?.(Date.now(), userActive) } diff --git a/shared/constants/init/index.native.tsx b/shared/constants/init/index.native.tsx index fa978f543c84..77c3c9b002ef 100644 --- a/shared/constants/init/index.native.tsx +++ b/shared/constants/init/index.native.tsx @@ -5,6 +5,7 @@ import {useDaemonState} from '@/stores/daemon' import {useDarkModeState} from '@/stores/darkmode' import {useFSState} from '@/stores/fs' import {useRouterState} from '@/stores/router' +import {useShellState} from '@/stores/shell' import {useSettingsContactsState} from '@/stores/settings-contacts' import * as T from '@/constants/types' import type * as EngineGen from '@/constants/rpc' @@ -243,7 +244,7 @@ export const onEngineIncoming = (action: EngineGen.Actions) => { } export const initPlatformListener = () => { - useConfigState.subscribe((s, old) => { + useShellState.subscribe((s, old) => { if (s.mobileAppState === old.mobileAppState) return let appFocused: boolean let logState: T.RPCGen.MobileAppState @@ -311,12 +312,16 @@ export const initPlatformListener = () => { if (s.loggedIn === old.loggedIn) return const f = async () => { const {type} = await NetInfo.fetch() - s.dispatch.osNetworkStatusChanged(type !== NetInfo.NetInfoStateType.none, type, true) + useShellState.getState().dispatch.osNetworkStatusChanged( + type !== NetInfo.NetInfoStateType.none, + type, + true + ) } ignorePromise(f()) }) - useConfigState.subscribe((s, old) => { + useShellState.subscribe((s, old) => { if (s.networkStatus === old.networkStatus) return const type = s.networkStatus?.type if (!type) return @@ -330,7 +335,7 @@ export const initPlatformListener = () => { ignorePromise(f()) }) - useConfigState.subscribe((s, old) => { + useShellState.subscribe((s, old) => { if (s.mobileAppState === old.mobileAppState) return if (s.mobileAppState === 'active') { // only reload on foreground @@ -382,7 +387,7 @@ export const initPlatformListener = () => { initPushListener() NetInfo.addEventListener(({type}) => { - useConfigState.getState().dispatch.osNetworkStatusChanged(type !== NetInfo.NetInfoStateType.none, type) + useShellState.getState().dispatch.osNetworkStatusChanged(type !== NetInfo.NetInfoStateType.none, type) }) const initAudioModes = () => { diff --git a/shared/constants/init/push-listener.native.tsx b/shared/constants/init/push-listener.native.tsx index 945d84be9daf..2aae9fbdd69b 100644 --- a/shared/constants/init/push-listener.native.tsx +++ b/shared/constants/init/push-listener.native.tsx @@ -15,6 +15,7 @@ import {useConfigState} from '@/stores/config' import {useCurrentUserState} from '@/stores/current-user' import {useLogoutState} from '@/stores/logout' import {usePushState} from '@/stores/push' +import {useShellState} from '@/stores/shell' type DataCommon = { userInteraction: boolean @@ -172,7 +173,7 @@ const getStartupDetailsFromInitialPush = async () => { export const initPushListener = () => { // Permissions - useConfigState.subscribe((s, old) => { + useShellState.subscribe((s, old) => { if (s.mobileAppState === old.mobileAppState) return // Only recheck on foreground, not background if (s.mobileAppState !== 'active') { diff --git a/shared/constants/init/shared.tsx b/shared/constants/init/shared.tsx index cc8c7c77a94f..c0c2eb4f99fc 100644 --- a/shared/constants/init/shared.tsx +++ b/shared/constants/init/shared.tsx @@ -44,6 +44,7 @@ import {useFSState} from '@/stores/fs' import {useModalHeaderState} from '@/stores/modal-header' import {usePeopleState} from '@/stores/people' import {useProvisionState} from '@/stores/provision' +import {useShellState} from '@/stores/shell' import {useSettingsEmailState} from '@/stores/settings-email' import {useSettingsPhoneState} from '@/stores/settings-phone' import {useSettingsContactsState} from '@/stores/settings-contacts' @@ -70,6 +71,39 @@ import {clearSignupDeviceNameDraft} from '@/signup/device-name-draft' let _emitStartupOnLoadDaemonConnectedOnce: boolean = __DEV__ ? (globalThis.__hmr_startupOnce ?? false) : false const _sharedUnsubs: Array<() => void> = __DEV__ ? (globalThis.__hmr_sharedUnsubs ??= []) : [] +const getAccountsWaitKey = 'config.getAccounts' + +const loadConfiguredAccountsForBootstrap = () => { + const configState = useConfigState.getState() + if (configState.configuredAccounts.length) { + return + } + + const version = useDaemonState.getState().handshakeVersion + const handshakeWait = !configState.loggedIn + const refreshAccounts = configState.dispatch.refreshAccounts + const {wait} = useDaemonState.getState().dispatch + + const f = async () => { + try { + if (handshakeWait) { + wait(getAccountsWaitKey, version, true) + } + + await refreshAccounts() + + if (handshakeWait && useDaemonState.getState().handshakeWaiters.get(getAccountsWaitKey)) { + wait(getAccountsWaitKey, version, false) + } + } catch { + if (handshakeWait && useDaemonState.getState().handshakeWaiters.get(getAccountsWaitKey)) { + wait(getAccountsWaitKey, version, false, "Can't get accounts") + } + } + } + + ignorePromise(f()) +} export const onEngineConnected = () => { { @@ -204,22 +238,14 @@ export const initSharedSubscriptions = () => { clearSignupEmail() clearSignupDeviceNameDraft() } - useDaemonState.getState().dispatch.loadDaemonAccounts( - s.configuredAccounts.length, - s.loggedIn, - useConfigState.getState().dispatch.refreshAccounts - ) + loadConfiguredAccountsForBootstrap() if (!s.loggedInCausedbyStartup) { ignorePromise(useConfigState.getState().dispatch.refreshAccounts()) } } if (s.revokedTrigger !== old.revokedTrigger) { - useDaemonState.getState().dispatch.loadDaemonAccounts( - s.configuredAccounts.length, - s.loggedIn, - useConfigState.getState().dispatch.refreshAccounts - ) + loadConfiguredAccountsForBootstrap() } if (s.configuredAccounts !== old.configuredAccounts) { @@ -232,6 +258,11 @@ export const initSharedSubscriptions = () => { } } + }) + ) + + _sharedUnsubs.push( + useShellState.subscribe((s, old) => { if (s.active !== old.active) { const cs = getConvoState(getSelectedConversation()) cs.dispatch.markThreadAsRead() @@ -244,12 +275,7 @@ export const initSharedSubscriptions = () => { if (s.handshakeVersion !== old.handshakeVersion) { useDarkModeState.getState().dispatch.loadDarkPrefs() useChatState.getState().dispatch.loadStaticConfig() - const configState = useConfigState.getState() - s.dispatch.loadDaemonAccounts( - configState.configuredAccounts.length, - configState.loggedIn, - useConfigState.getState().dispatch.refreshAccounts - ) + loadConfiguredAccountsForBootstrap() } if (s.bootstrapStatus !== old.bootstrapStatus) { diff --git a/shared/desktop/renderer/main2.desktop.tsx b/shared/desktop/renderer/main2.desktop.tsx index 23d59b640fb8..c32fab788d33 100644 --- a/shared/desktop/renderer/main2.desktop.tsx +++ b/shared/desktop/renderer/main2.desktop.tsx @@ -13,6 +13,7 @@ import {initDesktopStyles} from '@/styles/index.desktop' import {isWindows} from '@/constants/platform' import KB2 from '@/util/electron.desktop' import {useConfigState} from '@/stores/config' +import {useShellState} from '@/stores/shell' import {setServiceDecoration} from '@/common-adapters/markdown/react' import ServiceDecoration from '@/common-adapters/markdown/service-decoration' import {useDarkModeState} from '@/stores/darkmode' @@ -72,9 +73,9 @@ const setupApp = async () => { // issuing RPCs on a renderer reload. await appStartedUp?.() - useConfigState.getState().dispatch.initNotifySound() - useConfigState.getState().dispatch.initForceSmallNav() - useConfigState.getState().dispatch.initOpenAtLogin() + useShellState.getState().dispatch.initNotifySound() + useShellState.getState().dispatch.initForceSmallNav() + useShellState.getState().dispatch.initOpenAtLogin() useConfigState.getState().dispatch.initAppUpdateLoop() eng.listenersAreReady() diff --git a/shared/desktop/renderer/remote-event-handler.desktop.tsx b/shared/desktop/renderer/remote-event-handler.desktop.tsx index 49f0dbec18b8..9c05a334b794 100644 --- a/shared/desktop/renderer/remote-event-handler.desktop.tsx +++ b/shared/desktop/renderer/remote-event-handler.desktop.tsx @@ -13,7 +13,9 @@ import {useChatState} from '@/stores/chat' import {useConfigState} from '@/stores/config' import {useFSState} from '@/stores/fs' import {usePinentryState} from '@/stores/pinentry' +import {useShellState} from '@/stores/shell' import {useTrackerState} from '@/stores/tracker' +import {useUnlockFoldersState} from '@/unlock-folders/store' import logger from '@/logger' import {makeUUID} from '@/util/uuid' import {dumpLogs, showMain} from '@/util/storeless-actions' @@ -130,13 +132,11 @@ export const eventFromRemoteWindows = (action: RemoteGen.Actions) => { case RemoteGen.unlockFoldersSubmitPaperKey: { T.RPCGen.loginPaperKeySubmitRpcPromise({paperPhrase: action.payload.paperKey}, 'unlock-folders:waiting') .then(() => { - useConfigState.getState().dispatch.openUnlockFolders([]) + useUnlockFoldersState.getState().dispatch.close() }) .catch((e: unknown) => { if (!(e instanceof RPCError)) return - useConfigState.setState(s => { - s.unlockFoldersError = e.desc - }) + useUnlockFoldersState.getState().dispatch.setPaperKeyError(e.desc) }) break } @@ -144,7 +144,7 @@ export const eventFromRemoteWindows = (action: RemoteGen.Actions) => { T.RPCGen.rekeyRekeyStatusFinishRpcPromise() .then(() => {}) .catch(() => {}) - useConfigState.getState().dispatch.openUnlockFolders([]) + useUnlockFoldersState.getState().dispatch.close() break } case RemoteGen.stop: { @@ -191,12 +191,10 @@ export const eventFromRemoteWindows = (action: RemoteGen.Actions) => { .dispatch.remoteWindowNeedsProps(action.payload.component, action.payload.param) break case RemoteGen.updateWindowMaxState: - useConfigState.setState(s => { - s.windowState.isMaximized = action.payload.max - }) + useShellState.getState().dispatch.setWindowMaximized(action.payload.max) break case RemoteGen.updateWindowState: - useConfigState.getState().dispatch.updateWindowState(action.payload.windowState) + useShellState.getState().dispatch.updateWindowState(action.payload.windowState) break case RemoteGen.updateWindowShown: { const win = action.payload.component diff --git a/shared/router-v2/header/index.desktop.tsx b/shared/router-v2/header/index.desktop.tsx index 6049728e8984..154468aa4580 100644 --- a/shared/router-v2/header/index.desktop.tsx +++ b/shared/router-v2/header/index.desktop.tsx @@ -4,6 +4,7 @@ import * as Platform from '@/constants/platform' import SyncingFolders from './syncing-folders' import KB2 from '@/util/electron.desktop' import {useConfigState} from '@/stores/config' +import {useShellState} from '@/stores/shell' import type {HeaderBackButtonProps} from '@react-navigation/elements' import type {NativeStackHeaderProps} from '@react-navigation/native-stack' @@ -324,9 +325,9 @@ type HeaderProps = Omit s.useNativeFrame) + const useNativeFrame = useShellState(s => s.useNativeFrame) const loggedIn = useConfigState(s => s.loggedIn) - const isMaximized = useConfigState(s => s.windowState.isMaximized) + const isMaximized = useShellState(s => s.windowState.isMaximized) const {headerMode, title, headerTitle, headerRightActions, subHeader} = _options const {headerRight, headerTransparent, headerShadowVisible, headerBottomStyle, headerStyle, headerLeft} = _options diff --git a/shared/router-v2/tab-bar.desktop.tsx b/shared/router-v2/tab-bar.desktop.tsx index 7e73ba323fd9..e17ce124643e 100644 --- a/shared/router-v2/tab-bar.desktop.tsx +++ b/shared/router-v2/tab-bar.desktop.tsx @@ -18,6 +18,7 @@ import {useTrackerState} from '@/stores/tracker' import {useFSState} from '@/stores/fs' import {useNotifState} from '@/stores/notifications' import {useCurrentUserState} from '@/stores/current-user' +import {useShellState} from '@/stores/shell' import {navToProfile} from '@/constants/router' import {dumpLogs} from '@/util/storeless-actions' @@ -175,7 +176,7 @@ function TabBar(props: Props) { Kb.useHotKey(hotKeys, onHotKey) const onSelectTab = Common.useSubnavTabAction(navigation, state) - const forceSmallNav = useConfigState(s => s.forceSmallNav) + const forceSmallNav = useShellState(s => s.forceSmallNav) return username ? ( { } const UseNativeFrame = () => { - const {onChangeUseNativeFrame, useNativeFrame} = useConfigState( + const {onChangeUseNativeFrame, useNativeFrame} = useShellState( C.useShallow(s => ({ onChangeUseNativeFrame: s.dispatch.setUseNativeFrame, useNativeFrame: s.useNativeFrame, @@ -149,7 +150,7 @@ const Advanced = () => { loadHasRandomPw: s.dispatch.loadHasRandomPw, })) ) - const {onSetOpenAtLogin, openAtLogin} = useConfigState( + const {onSetOpenAtLogin, openAtLogin} = useShellState( C.useShallow(s => ({ onSetOpenAtLogin: s.dispatch.setOpenAtLogin, openAtLogin: s.openAtLogin, diff --git a/shared/settings/chat.tsx b/shared/settings/chat.tsx index f04536ebac34..5f06efc8f2c4 100644 --- a/shared/settings/chat.tsx +++ b/shared/settings/chat.tsx @@ -7,6 +7,7 @@ import Group from './group' import {loadSettings} from './load-settings' import useNotificationSettings from './notifications/use-notification-settings' import {useConfigState} from '@/stores/config' +import {useShellState} from '@/stores/shell' const emptyList = new Array() @@ -434,7 +435,7 @@ const Links = () => { } const Sound = ({allowEdit, groups, toggle}: NotificationSettingsState) => { - const {onToggleSound, sound} = useConfigState( + const {onToggleSound, sound} = useShellState( C.useShallow(s => ({ onToggleSound: s.dispatch.setNotifySound, sound: s.notifySound, diff --git a/shared/settings/display.tsx b/shared/settings/display.tsx index fb7a35055e41..a4b1ac60ae6a 100644 --- a/shared/settings/display.tsx +++ b/shared/settings/display.tsx @@ -3,12 +3,13 @@ import * as Kb from '@/common-adapters' import * as T from '@/constants/types' import logger from '@/logger' import {useConfigState} from '@/stores/config' +import {useShellState} from '@/stores/shell' import * as DarkMode from '@/stores/darkmode' const Display = () => { const allowAnimatedEmojis = useConfigState(s => s.allowAnimatedEmojis) - const forceSmallNav = useConfigState(s => s.forceSmallNav) - const setForceSmallNav = useConfigState(s => s.dispatch.setForceSmallNav) + const forceSmallNav = useShellState(s => s.forceSmallNav) + const setForceSmallNav = useShellState(s => s.dispatch.setForceSmallNav) const toggleForceSmallNav = () => { setForceSmallNav(!forceSmallNav) } diff --git a/shared/stores/config.tsx b/shared/stores/config.tsx index 125fb681b58a..d33b5c0b179a 100644 --- a/shared/stores/config.tsx +++ b/shared/stores/config.tsx @@ -1,4 +1,3 @@ -import type * as NetInfo from '@react-native-community/netinfo' import * as T from '@/constants/types' import {ignorePromise, timeoutPromise} from '@/constants/utils' import {waitingKeyConfigLogin, waitingKeyConfigLoginAsOther} from '@/constants/strings' @@ -9,7 +8,7 @@ import isEqual from 'lodash/isEqual' import logger from '@/logger' import type {Tab} from '@/constants/tabs' import {RPCError, convertToError, isErrorTransient, niceError} from '@/util/errors' -import {defaultUseNativeFrame, isMobile} from '@/constants/platform' +import {isMobile} from '@/constants/platform' import {type CommonResponseHandler} from '@/engine/types' import {invalidPasswordErrorString} from '@/constants/config' import {navigateAppend} from '@/constants/router' @@ -17,19 +16,14 @@ import { onEngineConnected as onEngineConnectedInPlatform, } from '@/util/storeless-actions' -export type ConnectionType = NetInfo.NetInfoStateType | 'notavailable' - type Store = T.Immutable<{ - active: boolean allowAnimatedEmojis: boolean androidShare?: | {type: T.RPCGen.IncomingShareType.file; urls: Array} | {type: T.RPCGen.IncomingShareType.text; text: string} - appFocused: boolean badgeState?: T.RPCGen.BadgeState configuredAccounts: Array defaultUsername: string - forceSmallNav: boolean globalError?: Error | RPCError gregorReachable?: T.RPCGen.Reachable gregorPushState: Array<{md: T.RPCGregor.Metadata; item: T.RPCGregor.Item}> @@ -51,10 +45,6 @@ type Store = T.Immutable<{ | 'connectedToDaemonForFirstTime' | 'reloggedIn' | 'startupOrReloginButNotInARush' - mobileAppState: 'active' | 'background' | 'inactive' | 'unknown' - networkStatus?: {online: boolean; type: ConnectionType; isInit?: boolean} - notifySound: boolean - openAtLogin: boolean outOfDate: T.Config.OutOfDate remoteWindowNeedsProps: Map> revokedTrigger: number @@ -66,36 +56,16 @@ type Store = T.Immutable<{ link: string tab?: Tab } - unlockFoldersDevices: Array<{ - type: T.Devices.DeviceType - name: string - deviceID: T.Devices.DeviceID - }> - unlockFoldersError: string - useNativeFrame: boolean userSwitching: boolean windowShownCount: Map - windowState: { - dockHidden: boolean - height: number - isFullScreen: boolean - isMaximized: boolean - width: number - windowHidden: boolean - x: number - y: number - } }> const initialStore: Store = { - active: true, allowAnimatedEmojis: true, androidShare: undefined, - appFocused: true, badgeState: undefined, configuredAccounts: [], defaultUsername: '', - forceSmallNav: false, globalError: undefined, gregorPushState: [], gregorReachable: undefined, @@ -112,10 +82,6 @@ const initialStore: Store = { loggedIn: false, loggedInCausedbyStartup: false, loginError: undefined, - mobileAppState: 'unknown', - networkStatus: undefined, - notifySound: false, - openAtLogin: true, outOfDate: { critical: false, message: '', @@ -130,32 +96,14 @@ const initialStore: Store = { link: '', loaded: false, }, - unlockFoldersDevices: [], - unlockFoldersError: '', - useNativeFrame: defaultUseNativeFrame, userSwitching: false, windowShownCount: new Map(), - windowState: { - dockHidden: false, - height: 800, - isFullScreen: false, - isMaximized: false, - width: 600, - windowHidden: false, - x: 0, - y: 0, - }, } export type State = Store & { dispatch: { - changedFocus: (f: boolean) => void checkForUpdate: () => void initAppUpdateLoop: () => void - initNotifySound: () => void - initForceSmallNav: () => void - initOpenAtLogin: () => void - initUseNativeFrame: () => void installerRan: () => void loadIsOnline: () => void loadOnStart: (phase: State['loadOnStartPhase']) => void @@ -165,8 +113,6 @@ export type State = Store & { logoutAndTryToLogInAs: (username: string) => void onEngineConnected: () => void onEngineIncoming: (action: EngineGen.Actions) => void - osNetworkStatusChanged: (online: boolean, type: ConnectionType, isInit?: boolean) => void - openUnlockFolders: (devices: ReadonlyArray) => void powerMonitorEvent: (event: string) => void resetState: (isDebug?: boolean) => void remoteWindowNeedsProps: (component: string, params: string) => void @@ -174,37 +120,25 @@ export type State = Store & { revoke: (deviceName: string, wasCurrentDevice: boolean) => void refreshAccounts: () => Promise setAccounts: (a: Store['configuredAccounts']) => void - setActive: (a: boolean) => void setAndroidShare: (s: Store['androidShare']) => void setBadgeState: (b: State['badgeState']) => void setDefaultUsername: (u: string) => void - setForceSmallNav: (f: boolean) => void setGlobalError: (e?: unknown) => void + setGregorReachable: (r: Store['gregorReachable']) => void setHTTPSrvInfo: (address: string, token: string) => void setIncomingShareUseOriginal: (use: boolean) => void setJustDeletedSelf: (s: string) => void setLoggedIn: (l: boolean, causedByStartup: boolean, fromMenubar?: boolean) => void - setMobileAppState: (nextAppState: 'active' | 'background' | 'inactive') => void - setNotifySound: (n: boolean) => void setStartupDetails: (st: Omit) => void - setOpenAtLogin: (open: boolean) => void setOutOfDate: (outOfDate: T.Config.OutOfDate) => void setUpdating: () => void - setUseNativeFrame: (use: boolean) => void setUserSwitching: (sw: boolean) => void toggleRuntimeStats: () => void updateGregorCategory: (category: string, body: string, dtime?: {offset: number; time: number}) => void - updateWindowState: (ws: Omit) => void } } -export const openAtLoginKey = 'openAtLogin' export const useConfigState = Z.createZustand('config', (set, get) => { - const nativeFrameKey = 'useNativeFrame' - const notifySoundKey = 'notifySound' - const forceSmallNavKey = 'ui.forceSmallNav' - const windowStateKey = 'windowState' - const _checkForUpdate = async () => { try { const {status, message} = await T.RPCGen.configGetUpdateInfoRpcPromise() @@ -261,12 +195,6 @@ export const useConfigState = Z.createZustand('config', (set, get) => { } const dispatch: State['dispatch'] = { - changedFocus: f => { - if (get().appFocused === f) return - set(s => { - s.appFocused = f - }) - }, checkForUpdate: () => { const f = async () => { await _checkForUpdate() @@ -284,58 +212,6 @@ export const useConfigState = Z.createZustand('config', (set, get) => { } ignorePromise(f()) }, - initForceSmallNav: () => { - const f = async () => { - try { - const val = await T.RPCGen.configGuiGetValueRpcPromise({path: forceSmallNavKey}) - const forceSmallNav = val.b - if (typeof forceSmallNav === 'boolean') { - set(s => { - s.forceSmallNav = forceSmallNav - }) - } - } catch {} - } - ignorePromise(f()) - }, - initNotifySound: () => { - const f = async () => { - try { - const val = await T.RPCGen.configGuiGetValueRpcPromise({path: notifySoundKey}) - const notifySound = val.b - if (typeof notifySound === 'boolean') { - set(s => { - s.notifySound = notifySound - }) - } - } catch {} - } - ignorePromise(f()) - }, - initOpenAtLogin: () => { - const f = async () => { - try { - const val = await T.RPCGen.configGuiGetValueRpcPromise({path: openAtLoginKey}) - const openAtLogin = val.b - if (typeof openAtLogin === 'boolean') { - get().dispatch.setOpenAtLogin(openAtLogin) - } - } catch {} - } - ignorePromise(f()) - }, - initUseNativeFrame: () => { - const f = async () => { - try { - const val = await T.RPCGen.configGuiGetValueRpcPromise({path: nativeFrameKey}) - const useNativeFrame = val.b === undefined || val.b === null ? defaultUseNativeFrame : val.b - set(s => { - s.useNativeFrame = useNativeFrame - }) - } catch {} - } - ignorePromise(f()) - }, installerRan: () => { set(s => { s.installerRanCount++ @@ -460,7 +336,7 @@ export const useConfigState = Z.createZustand('config', (set, get) => { const startReachability = async () => { try { const reachability = await T.RPCGen.reachabilityStartReachabilityRpcPromise() - setGregorReachable(reachability.reachable) + get().dispatch.setGregorReachable(reachability.reachable) } catch (err) { logger.warn('error bootstrapping reachability: ', err) } @@ -532,52 +408,12 @@ export const useConfigState = Z.createZustand('config', (set, get) => { } case 'keybase.1.reachability.reachabilityChanged': if (get().loggedIn) { - setGregorReachable(action.payload.params.reachability.reachable) + get().dispatch.setGregorReachable(action.payload.params.reachability.reachable) } break default: } }, - openUnlockFolders: devices => { - set(s => { - s.unlockFoldersDevices = devices.map(({name, type, deviceID}) => ({ - deviceID, - name, - type: T.Devices.stringToDeviceType(type), - })) - }) - }, - osNetworkStatusChanged: (online: boolean, type: ConnectionType, isInit?: boolean) => { - const old = get().networkStatus - if (old?.online === online && old.type === type && old.isInit === isInit) return - set(s => { - if (!s.networkStatus) { - s.networkStatus = {isInit, online, type} - } else { - s.networkStatus.isInit = isInit - s.networkStatus.online = online - s.networkStatus.type = type - } - }) - const updateGregor = async () => { - const reachability = await T.RPCGen.reachabilityCheckReachabilityRpcPromise() - setGregorReachable(reachability.reachable) - } - ignorePromise(updateGregor()) - - const updateFS = async () => { - if (isInit) return - try { - await T.RPCGen.SimpleFSSimpleFSCheckReachabilityRpcPromise() - } catch (error) { - if (!(error instanceof RPCError)) { - return - } - logger.warn(`failed to check KBFS reachability: ${error.message}`) - } - } - ignorePromise(updateFS()) - }, powerMonitorEvent: event => { const f = async () => { await T.RPCGen.appStatePowerMonitorEventRpcPromise({event}) @@ -624,14 +460,10 @@ export const useConfigState = Z.createZustand('config', (set, get) => { if (isDebug) return set(s => ({ ...initialStore, - appFocused: s.appFocused, configuredAccounts: s.configuredAccounts, defaultUsername: s.defaultUsername, dispatch: s.dispatch, - forceSmallNav: s.forceSmallNav, - mobileAppState: s.mobileAppState, startup: {loaded: s.startup.loaded}, - useNativeFrame: s.useNativeFrame, userSwitching: s.userSwitching, })) }, @@ -654,11 +486,6 @@ export const useConfigState = Z.createZustand('config', (set, get) => { } }) }, - setActive: a => { - set(s => { - s.active = a - }) - }, setAndroidShare: share => { set(s => { s.androidShare = T.castDraft(share) @@ -675,21 +502,6 @@ export const useConfigState = Z.createZustand('config', (set, get) => { s.defaultUsername = u }) }, - setForceSmallNav: force => { - const f = async () => { - await T.RPCGen.configGuiSetValueRpcPromise({ - path: forceSmallNavKey, - value: { - b: force, - isNull: false, - }, - }) - set(s => { - s.forceSmallNav = force - }) - } - ignorePromise(f()) - }, setGlobalError: _e => { if (_e) { const e = convertToError(_e) @@ -707,6 +519,9 @@ export const useConfigState = Z.createZustand('config', (set, get) => { }) } }, + setGregorReachable: r => { + setGregorReachable(r) + }, setHTTPSrvInfo: (address, token) => { set(s => { s.httpSrv.address = address @@ -758,31 +573,6 @@ export const useConfigState = Z.createZustand('config', (set, get) => { // hidden and the user can see and respond to the error. get().dispatch.setUserSwitching(false) }, - setMobileAppState: nextAppState => { - if (get().mobileAppState === nextAppState) return - set(s => { - s.mobileAppState = nextAppState - }) - }, - setNotifySound: n => { - set(s => { - s.notifySound = n - }) - ignorePromise( - T.RPCGen.configGuiSetValueRpcPromise({ - path: notifySoundKey, - value: { - b: n, - isNull: false, - }, - }) - ) - }, - setOpenAtLogin: open => { - set(s => { - s.openAtLogin = open - }) - }, setOutOfDate: outOfDate => { set(s => { Object.assign(s.outOfDate, outOfDate) @@ -804,20 +594,6 @@ export const useConfigState = Z.createZustand('config', (set, get) => { s.outOfDate.updating = true }) }, - setUseNativeFrame: use => { - set(s => { - s.useNativeFrame = use - }) - ignorePromise( - T.RPCGen.configGuiSetValueRpcPromise({ - path: nativeFrameKey, - value: { - b: use, - isNull: false, - }, - }) - ) - }, setUserSwitching: sw => { set(s => { s.userSwitching = sw @@ -841,24 +617,6 @@ export const useConfigState = Z.createZustand('config', (set, get) => { } ignorePromise(f()) }, - updateWindowState: ws => { - const old = get().windowState - const next = {...old, ...ws} - if (isEqual(old, next)) return - set(s => { - s.windowState = next - }) - - ignorePromise( - T.RPCGen.configGuiSetValueRpcPromise({ - path: windowStateKey, - value: { - isNull: false, - s: JSON.stringify(next), - }, - }) - ) - }, } return { ...initialStore, diff --git a/shared/stores/convostate.tsx b/shared/stores/convostate.tsx index 26f94c26f77c..aa5a351ca0dc 100644 --- a/shared/stores/convostate.tsx +++ b/shared/stores/convostate.tsx @@ -51,6 +51,7 @@ import * as Strings from '@/constants/strings' import {chatStores, convoUIStores} from './convo-registry' import {useConfigState} from '@/stores/config' +import {useShellState} from '@/stores/shell' import {useCurrentUserState} from '@/stores/current-user' import {useUsersState} from '@/stores/users' import {getUsernameToShow} from '@/chat/conversation/messages/separator-utils' @@ -1500,7 +1501,7 @@ const createSlice = } const onClose = () => {} logger.info('invoking NotifyPopup for chat notification') - const sound = useConfigState.getState().notifySound + const sound = useShellState.getState().notifySound const cleanBody = body.replaceAll(/!>(.*?) Promise - ) => void loadDaemonBootstrapStatus: () => Promise resetState: () => void setError: (e?: Error) => void @@ -115,48 +106,6 @@ export const useDaemonState = Z.createZustand('daemon', (set, get) => { daemonHandshakeDone: () => { get().dispatch.setState('done') }, - loadDaemonAccounts: ( - configuredAccountsLength: number, - loggedIn: boolean, - refreshAccounts: () => Promise - ) => { - const f = async () => { - const version = get().handshakeVersion - if (configuredAccountsLength) { - // bail on already loaded - return - } - - // did we beat getBootstrapStatus? - const handshakeWait = !loggedIn - - const {wait} = get().dispatch - try { - if (handshakeWait) { - wait(getAccountsWaitKey, version, true) - } - - await refreshAccounts() - - if (handshakeWait) { - // someone dismissed this already? - const {handshakeWaiters} = get() - if (handshakeWaiters.get(getAccountsWaitKey)) { - wait(getAccountsWaitKey, version, false) - } - } - } catch { - if (handshakeWait) { - // someone dismissed this already? - const {handshakeWaiters} = get() - if (handshakeWaiters.get(getAccountsWaitKey)) { - wait(getAccountsWaitKey, version, false, "Can't get accounts") - } - } - } - } - ignorePromise(f()) - }, // set to true so we reget status when we're reachable again loadDaemonBootstrapStatus: async () => { const version = get().handshakeVersion @@ -179,8 +128,8 @@ export const useDaemonState = Z.createZustand('daemon', (set, get) => { // if we're logged in act like getAccounts is done already if (s.loggedIn) { const {handshakeWaiters} = get() - if (handshakeWaiters.get(getAccountsWaitKey)) { - wait(getAccountsWaitKey, version, false) + if (handshakeWaiters.get('config.getAccounts')) { + wait('config.getAccounts', version, false) } } }, diff --git a/shared/stores/shell.tsx b/shared/stores/shell.tsx new file mode 100644 index 000000000000..9e421887c46a --- /dev/null +++ b/shared/stores/shell.tsx @@ -0,0 +1,266 @@ +import type * as NetInfo from '@react-native-community/netinfo' +import * as T from '@/constants/types' +import {ignorePromise} from '@/constants/utils' +import * as Z from '@/util/zustand' +import isEqual from 'lodash/isEqual' +import logger from '@/logger' +import {RPCError} from '@/util/errors' +import {defaultUseNativeFrame} from '@/constants/platform' +import {useConfigState} from '@/stores/config' + +export type ConnectionType = NetInfo.NetInfoStateType | 'notavailable' + +type WindowState = T.Immutable<{ + dockHidden: boolean + height: number + isFullScreen: boolean + isMaximized: boolean + width: number + windowHidden: boolean + x: number + y: number +}> + +type Store = T.Immutable<{ + active: boolean + appFocused: boolean + forceSmallNav: boolean + mobileAppState: 'active' | 'background' | 'inactive' | 'unknown' + networkStatus?: {online: boolean; type: ConnectionType; isInit?: boolean} + notifySound: boolean + openAtLogin: boolean + useNativeFrame: boolean + windowState: WindowState +}> + +const initialStore: Store = { + active: true, + appFocused: true, + forceSmallNav: false, + mobileAppState: 'unknown', + networkStatus: undefined, + notifySound: false, + openAtLogin: true, + useNativeFrame: defaultUseNativeFrame, + windowState: { + dockHidden: false, + height: 800, + isFullScreen: false, + isMaximized: false, + width: 600, + windowHidden: false, + x: 0, + y: 0, + }, +} + +export type State = Store & { + dispatch: { + changedFocus: (f: boolean) => void + initNotifySound: () => void + initForceSmallNav: () => void + initOpenAtLogin: () => void + initUseNativeFrame: () => void + osNetworkStatusChanged: (online: boolean, type: ConnectionType, isInit?: boolean) => void + resetState: (isDebug?: boolean) => void + setActive: (a: boolean) => void + setForceSmallNav: (f: boolean) => void + setMobileAppState: (nextAppState: 'active' | 'background' | 'inactive') => void + setNotifySound: (n: boolean) => void + setOpenAtLogin: (open: boolean) => void + setUseNativeFrame: (use: boolean) => void + setWindowMaximized: (isMaximized: boolean) => void + updateWindowState: (ws: Omit) => void + } +} + +export const openAtLoginKey = 'openAtLogin' + +export const useShellState = Z.createZustand('shell', (set, get) => { + const nativeFrameKey = 'useNativeFrame' + const notifySoundKey = 'notifySound' + const forceSmallNavKey = 'ui.forceSmallNav' + const windowStateKey = 'windowState' + + const dispatch: State['dispatch'] = { + changedFocus: f => { + if (get().appFocused === f) return + set(s => { + s.appFocused = f + }) + }, + initForceSmallNav: () => { + const f = async () => { + try { + const val = await T.RPCGen.configGuiGetValueRpcPromise({path: forceSmallNavKey}) + const forceSmallNav = val.b + if (typeof forceSmallNav === 'boolean') { + set(s => { + s.forceSmallNav = forceSmallNav + }) + } + } catch {} + } + ignorePromise(f()) + }, + initNotifySound: () => { + const f = async () => { + try { + const val = await T.RPCGen.configGuiGetValueRpcPromise({path: notifySoundKey}) + const notifySound = val.b + if (typeof notifySound === 'boolean') { + set(s => { + s.notifySound = notifySound + }) + } + } catch {} + } + ignorePromise(f()) + }, + initOpenAtLogin: () => { + const f = async () => { + try { + const val = await T.RPCGen.configGuiGetValueRpcPromise({path: openAtLoginKey}) + const openAtLogin = val.b + if (typeof openAtLogin === 'boolean') { + get().dispatch.setOpenAtLogin(openAtLogin) + } + } catch {} + } + ignorePromise(f()) + }, + initUseNativeFrame: () => { + const f = async () => { + try { + const val = await T.RPCGen.configGuiGetValueRpcPromise({path: nativeFrameKey}) + const useNativeFrame = val.b === undefined || val.b === null ? defaultUseNativeFrame : val.b + set(s => { + s.useNativeFrame = useNativeFrame + }) + } catch {} + } + ignorePromise(f()) + }, + osNetworkStatusChanged: (online, type, isInit) => { + const old = get().networkStatus + if (old?.online === online && old.type === type && old.isInit === isInit) return + set(s => { + if (!s.networkStatus) { + s.networkStatus = {isInit, online, type} + } else { + s.networkStatus.isInit = isInit + s.networkStatus.online = online + s.networkStatus.type = type + } + }) + const updateGregor = async () => { + const reachability = await T.RPCGen.reachabilityCheckReachabilityRpcPromise() + useConfigState.getState().dispatch.setGregorReachable(reachability.reachable) + } + ignorePromise(updateGregor()) + + const updateFS = async () => { + if (isInit) return + try { + await T.RPCGen.SimpleFSSimpleFSCheckReachabilityRpcPromise() + } catch (error) { + if (!(error instanceof RPCError)) { + return + } + logger.warn(`failed to check KBFS reachability: ${error.message}`) + } + } + ignorePromise(updateFS()) + }, + // Shell-owned prefs and focus/window state should survive account-level resets. + resetState: () => {}, + setActive: a => { + set(s => { + s.active = a + }) + }, + setForceSmallNav: force => { + const f = async () => { + await T.RPCGen.configGuiSetValueRpcPromise({ + path: forceSmallNavKey, + value: { + b: force, + isNull: false, + }, + }) + set(s => { + s.forceSmallNav = force + }) + } + ignorePromise(f()) + }, + setMobileAppState: nextAppState => { + if (get().mobileAppState === nextAppState) return + set(s => { + s.mobileAppState = nextAppState + }) + }, + setNotifySound: n => { + set(s => { + s.notifySound = n + }) + ignorePromise( + T.RPCGen.configGuiSetValueRpcPromise({ + path: notifySoundKey, + value: { + b: n, + isNull: false, + }, + }) + ) + }, + setOpenAtLogin: open => { + set(s => { + s.openAtLogin = open + }) + }, + setUseNativeFrame: use => { + set(s => { + s.useNativeFrame = use + }) + ignorePromise( + T.RPCGen.configGuiSetValueRpcPromise({ + path: nativeFrameKey, + value: { + b: use, + isNull: false, + }, + }) + ) + }, + setWindowMaximized: isMaximized => { + if (get().windowState.isMaximized === isMaximized) return + set(s => { + s.windowState.isMaximized = isMaximized + }) + }, + updateWindowState: ws => { + const old = get().windowState + const next = {...old, ...ws} + if (isEqual(old, next)) return + set(s => { + s.windowState = next + }) + + ignorePromise( + T.RPCGen.configGuiSetValueRpcPromise({ + path: windowStateKey, + value: { + isNull: false, + s: JSON.stringify(next), + }, + }) + ) + }, + } + + return { + ...initialStore, + dispatch, + } +}) diff --git a/shared/stores/tests/config.test.ts b/shared/stores/tests/config.test.ts index bd41037413f8..2bafad76faae 100644 --- a/shared/stores/tests/config.test.ts +++ b/shared/stores/tests/config.test.ts @@ -1,17 +1,13 @@ /// -import {defaultUseNativeFrame} from '../../constants/platform' import {noConversationIDKey} from '../../constants/types/chat/common' import {useConfigState} from '../config' const resetConfigState = () => { const {dispatch} = useConfigState.getState() useConfigState.setState({ - appFocused: true, configuredAccounts: [], defaultUsername: '', - forceSmallNav: false, globalError: undefined, - mobileAppState: 'unknown', outOfDate: { critical: false, message: '', @@ -25,7 +21,6 @@ const resetConfigState = () => { link: '', loaded: false, }, - useNativeFrame: defaultUseNativeFrame, userSwitching: false, } as any) dispatch.resetState() @@ -126,9 +121,7 @@ test('custom resetState preserves the fields config intentionally carries across dispatch.setAccounts([{hasStoredSecret: true, username: 'alice'}]) dispatch.setDefaultUsername('alice') useConfigState.setState({ - forceSmallNav: true, globalError: new Error('transient'), - mobileAppState: 'active', userSwitching: true, } as any) @@ -137,8 +130,6 @@ test('custom resetState preserves the fields config intentionally carries across const state = useConfigState.getState() expect(state.configuredAccounts).toEqual([{hasStoredSecret: true, username: 'alice'}]) expect(state.defaultUsername).toBe('alice') - expect(state.forceSmallNav).toBe(true) - expect(state.mobileAppState).toBe('active') expect(state.userSwitching).toBe(true) expect(state.globalError).toBeUndefined() }) diff --git a/shared/stores/tests/shell.test.ts b/shared/stores/tests/shell.test.ts new file mode 100644 index 000000000000..8195f62243d6 --- /dev/null +++ b/shared/stores/tests/shell.test.ts @@ -0,0 +1,104 @@ +/// +import {defaultUseNativeFrame} from '../../constants/platform' +import {useShellState} from '../shell' + +const defaultWindowState = { + dockHidden: false, + height: 800, + isFullScreen: false, + isMaximized: false, + width: 600, + windowHidden: false, + x: 0, + y: 0, +} + +const resetShellState = () => { + const {dispatch} = useShellState.getState() + useShellState.setState({ + active: true, + appFocused: true, + forceSmallNav: false, + mobileAppState: 'unknown', + networkStatus: undefined, + notifySound: false, + openAtLogin: true, + useNativeFrame: defaultUseNativeFrame, + windowState: {...defaultWindowState}, + } as any) + dispatch.resetState() +} + +beforeEach(() => { + resetShellState() +}) + +afterEach(() => { + resetShellState() +}) + +test('local shell actions update focus and activity state', () => { + const {dispatch} = useShellState.getState() + + dispatch.changedFocus(false) + dispatch.setActive(false) + dispatch.setMobileAppState('background') + dispatch.setWindowMaximized(true) + + expect(useShellState.getState()).toEqual( + expect.objectContaining({ + active: false, + appFocused: false, + mobileAppState: 'background', + windowState: expect.objectContaining({isMaximized: true}), + }) + ) +}) + +test('resetState preserves shell-owned fields across store resets', () => { + const {dispatch} = useShellState.getState() + + useShellState.setState({ + active: false, + appFocused: false, + forceSmallNav: true, + mobileAppState: 'background', + networkStatus: {isInit: true, online: false, type: 'notavailable'}, + notifySound: true, + openAtLogin: false, + useNativeFrame: !defaultUseNativeFrame, + windowState: { + ...defaultWindowState, + dockHidden: true, + height: 720, + isMaximized: true, + width: 1024, + x: 10, + y: 20, + }, + } as any) + + dispatch.resetState() + + expect(useShellState.getState()).toEqual( + expect.objectContaining({ + active: false, + appFocused: false, + forceSmallNav: true, + mobileAppState: 'background', + networkStatus: {isInit: true, online: false, type: 'notavailable'}, + notifySound: true, + openAtLogin: false, + useNativeFrame: !defaultUseNativeFrame, + windowState: { + ...defaultWindowState, + dockHidden: true, + height: 720, + isMaximized: true, + width: 1024, + x: 10, + y: 20, + }, + }) + ) +}) diff --git a/shared/stores/tests/unlock-folders.test.ts b/shared/stores/tests/unlock-folders.test.ts index 531435cd6bd7..53d300922bb3 100644 --- a/shared/stores/tests/unlock-folders.test.ts +++ b/shared/stores/tests/unlock-folders.test.ts @@ -1,14 +1,14 @@ /// import {onUnlockFoldersEngineIncoming} from '../unlock-folders' -const mockOpenUnlockFolders = jest.fn() +const mockOpen = jest.fn() const mockCreateSession = jest.fn() -jest.mock('@/stores/config', () => ({ - useConfigState: { +jest.mock('@/unlock-folders/store', () => ({ + useUnlockFoldersState: { getState: () => ({ dispatch: { - openUnlockFolders: mockOpenUnlockFolders, + open: mockOpen, }, }), }, @@ -23,10 +23,10 @@ jest.mock('@/engine/require', () => ({ afterEach(() => { jest.restoreAllMocks() mockCreateSession.mockReset() - mockOpenUnlockFolders.mockReset() + mockOpen.mockReset() }) -test('rekey refresh actions forward the device list to config', () => { +test('rekey refresh actions forward the device list to the unlock folders store', () => { onUnlockFoldersEngineIncoming({ payload: { params: { @@ -38,7 +38,7 @@ test('rekey refresh actions forward the device list to config', () => { type: 'keybase.1.rekeyUI.refresh', } as any) - expect(mockOpenUnlockFolders).toHaveBeenCalledWith([{deviceID: 'device-1', name: 'device-1', type: 'desktop'}]) + expect(mockOpen).toHaveBeenCalledWith([{deviceID: 'device-1', name: 'device-1', type: 'desktop'}]) }) test('delegateRekeyUI creates a dangling session and returns its id', () => { diff --git a/shared/stores/unlock-folders.tsx b/shared/stores/unlock-folders.tsx index d556aa52cf17..65fdec2a92f9 100644 --- a/shared/stores/unlock-folders.tsx +++ b/shared/stores/unlock-folders.tsx @@ -1,14 +1,14 @@ import type * as EngineGen from '@/constants/rpc' import logger from '@/logger' import {getEngine} from '@/engine/require' -import {useConfigState} from '@/stores/config' +import {useUnlockFoldersState} from '@/unlock-folders/store' export const onUnlockFoldersEngineIncoming = (action: EngineGen.Actions) => { switch (action.type) { case 'keybase.1.rekeyUI.refresh': { const {problemSetDevices} = action.payload.params logger.info('Asked for rekey') - useConfigState.getState().dispatch.openUnlockFolders(problemSetDevices.devices ?? []) + useUnlockFoldersState.getState().dispatch.open(problemSetDevices.devices ?? []) break } case 'keybase.1.rekeyUI.delegateRekeyUI': { @@ -17,7 +17,7 @@ export const onUnlockFoldersEngineIncoming = (action: EngineGen.Actions) => { dangling: true, incomingCallMap: { 'keybase.1.rekeyUI.refresh': ({problemSetDevices}) => { - useConfigState.getState().dispatch.openUnlockFolders(problemSetDevices.devices ?? []) + useUnlockFoldersState.getState().dispatch.open(problemSetDevices.devices ?? []) }, 'keybase.1.rekeyUI.rekeySendEvent': () => {}, // ignored debug call from daemon }, diff --git a/shared/unlock-folders/device-list.desktop.tsx b/shared/unlock-folders/device-list.desktop.tsx index 7c9774581987..933db39afd1a 100644 --- a/shared/unlock-folders/device-list.desktop.tsx +++ b/shared/unlock-folders/device-list.desktop.tsx @@ -1,13 +1,12 @@ import * as Kb from '@/common-adapters' -import type {State as ConfigStore} from '@/stores/config' +import type {UnlockFolderDevice} from './store' export type Props = { - devices: ConfigStore['unlockFoldersDevices'] + devices: ReadonlyArray toPaperKeyInput: () => void } -type Device = ConfigStore['unlockFoldersDevices'][0] -const DeviceRow = ({device}: {device: Device}) => { +const DeviceRow = ({device}: {device: UnlockFolderDevice}) => { const icon = ( { backup: 'icon-paper-key-32', diff --git a/shared/unlock-folders/index.desktop.tsx b/shared/unlock-folders/index.desktop.tsx index fd11a095be76..25982cd2766c 100644 --- a/shared/unlock-folders/index.desktop.tsx +++ b/shared/unlock-folders/index.desktop.tsx @@ -4,13 +4,13 @@ import DeviceList from './device-list.desktop' import DragHeader from '../desktop/remote/drag-header.desktop' import PaperKeyInput from './paper-key-input.desktop' import Success from './success.desktop' -import type {State as ConfigStore} from '@/stores/config' +import type {UnlockFolderDevice} from './store' type Phase = 'dead' | 'promptOtherDevice' | 'paperKeyInput' | 'success' export type Props = { phase: Phase - devices: ConfigStore['unlockFoldersDevices'] + devices: ReadonlyArray onClose: () => void toPaperKeyInput: () => void onBackFromPaperKey: () => void diff --git a/shared/unlock-folders/main2.desktop.tsx b/shared/unlock-folders/main2.desktop.tsx index 254a1980c5d8..08f4fcaf1012 100644 --- a/shared/unlock-folders/main2.desktop.tsx +++ b/shared/unlock-folders/main2.desktop.tsx @@ -4,11 +4,11 @@ import * as RemoteGen from '../constants/remote-actions' import UnlockFolders from './index.desktop' import loadRemoteComponent from '../desktop/remote/component-loader.desktop' import {RemoteDarkModeSync} from '../desktop/remote/remote-component.desktop' -import type {State as ConfigStore} from '@/stores/config' +import type {UnlockFolderDevice} from './store' export type ProxyProps = { darkMode: boolean - devices: ConfigStore['unlockFoldersDevices'] + devices: ReadonlyArray paperKeyError: string waiting: boolean } diff --git a/shared/unlock-folders/remote-proxy.desktop.tsx b/shared/unlock-folders/remote-proxy.desktop.tsx index 216f37ada0bb..b1e108a7dbbf 100644 --- a/shared/unlock-folders/remote-proxy.desktop.tsx +++ b/shared/unlock-folders/remote-proxy.desktop.tsx @@ -2,8 +2,8 @@ import * as C from '@/constants' import useBrowserWindow from '../desktop/remote/use-browser-window.desktop' import useSerializeProps from '../desktop/remote/use-serialize-props.desktop' import {useColorScheme} from 'react-native' -import {useConfigState} from '@/stores/config' import type {ProxyProps} from './main2.desktop' +import {useUnlockFoldersState} from './store' const windowOpts = {height: 300, width: 500} @@ -23,8 +23,8 @@ function UnlockFolders(p: ProxyProps) { } const UnlockRemoteProxy = () => { - const devices = useConfigState(s => s.unlockFoldersDevices) - const paperKeyError = useConfigState(s => s.unlockFoldersError) + const devices = useUnlockFoldersState(s => s.devices) + const paperKeyError = useUnlockFoldersState(s => s.paperKeyError) const waiting = C.Waiting.useAnyWaiting('unlock-folders:waiting') const isDarkMode = useColorScheme() === 'dark' if (devices.length) { diff --git a/shared/unlock-folders/store.tsx b/shared/unlock-folders/store.tsx new file mode 100644 index 000000000000..aca11f5a9c9f --- /dev/null +++ b/shared/unlock-folders/store.tsx @@ -0,0 +1,54 @@ +import * as T from '@/constants/types' +import * as Z from '@/util/zustand' + +export type UnlockFolderDevice = T.Immutable<{ + type: T.Devices.DeviceType + name: string + deviceID: T.Devices.DeviceID +}> + +type Store = T.Immutable<{ + devices: Array + paperKeyError: string +}> + +const initialStore: Store = { + devices: [], + paperKeyError: '', +} + +export type State = Store & { + dispatch: { + close: () => void + open: (devices: ReadonlyArray) => void + resetState: () => void + setPaperKeyError: (paperKeyError: string) => void + } +} + +export const useUnlockFoldersState = Z.createZustand('unlock-folders', (set, get) => { + const dispatch: State['dispatch'] = { + close: () => get().dispatch.resetState(), + open: devices => { + set(s => { + s.devices = devices.map(({name, type, deviceID}) => ({ + deviceID, + name, + type: T.Devices.stringToDeviceType(type), + })) + s.paperKeyError = '' + }) + }, + resetState: Z.defaultReset, + setPaperKeyError: paperKeyError => { + set(s => { + s.paperKeyError = paperKeyError + }) + }, + } + + return { + ...initialStore, + dispatch, + } +})