diff --git a/AGENTS.md b/AGENTS.md index 8a29cb615d48..703ec1244559 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,5 +2,7 @@ - This repo uses React Compiler. Assume React Compiler patterns are enabled when editing React code, and avoid adding `useMemo`/`useCallback` by default unless they are clearly needed for correctness or compatibility with existing code. - Treat React mount/unmount effects as Strict-Mode-safe. Do not assume a component only mounts once; route-driven async startup and cleanup logic must be idempotent and must not leave refs or guards stuck false after a dev remount. +- When using mount guards like `mountedRef`/`isMountedRef`, always set the ref to `true` inside the effect body and set it to `false` in cleanup. Never rely on `useRef(true)` alone across the component lifetime, because Strict Mode remounts can leave the guard stuck `false` and silently drop async results. - When a component reads multiple adjacent values from the same store hook, prefer a consolidated selector with `C.useShallow(...)` instead of multiple separate subscriptions. - 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. +- 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. diff --git a/shared/constants/init/shared.tsx b/shared/constants/init/shared.tsx index b58948c28048..d6d812b37e6d 100644 --- a/shared/constants/init/shared.tsx +++ b/shared/constants/init/shared.tsx @@ -55,8 +55,6 @@ import {useProvisionState} from '@/stores/provision' import {usePushState} from '@/stores/push' import {useSettingsContactsState} from '@/stores/settings-contacts' import {useSettingsEmailState} from '@/stores/settings-email' -import {useSettingsPhoneState} from '@/stores/settings-phone' -import {useSettingsState} from '@/stores/settings' import {useSignupState} from '@/stores/signup' import {useState as useRecoverPasswordState} from '@/stores/recover-password' import {useTeamsState} from '@/stores/teams' @@ -363,27 +361,6 @@ export const initTracker2Callbacks = () => { }) } -export const initSettingsCallbacks = () => { - const currentState = useSettingsState.getState() - useSettingsState.setState({ - dispatch: { - ...currentState.dispatch, - defer: { - ...currentState.dispatch.defer, - getSettingsPhonePhones: () => { - return useSettingsPhoneState.getState().phones - }, - onSettingsEmailNotifyEmailsChanged: (emails: ReadonlyArray) => { - useSettingsEmailState.getState().dispatch.notifyEmailAddressEmailsChanged(emails) - }, - onSettingsPhoneSetNumbers: (phoneNumbers?: ReadonlyArray) => { - useSettingsPhoneState.getState().dispatch.setNumbers(phoneNumbers) - }, - }, - }, - }) -} - export const initSharedSubscriptions = () => { // HMR cleanup: unsubscribe old store subscriptions before re-subscribing for (const unsub of _sharedUnsubs) unsub() @@ -680,7 +657,6 @@ export const initSharedSubscriptions = () => { initNotificationsCallbacks() initPushCallbacks() initRecoverPasswordCallbacks() - initSettingsCallbacks() initSignupCallbacks() initTracker2Callbacks() } diff --git a/shared/desktop/renderer/remote-event-handler.desktop.tsx b/shared/desktop/renderer/remote-event-handler.desktop.tsx index 28db12978056..f4b439666548 100644 --- a/shared/desktop/renderer/remote-event-handler.desktop.tsx +++ b/shared/desktop/renderer/remote-event-handler.desktop.tsx @@ -137,7 +137,7 @@ export const eventFromRemoteWindows = (action: RemoteGen.Actions) => { break } case RemoteGen.stop: { - storeRegistry.getState('settings').dispatch.stop(action.payload.exitCode) + ignorePromise(T.RPCGen.ctlStopRpcPromise({exitCode: action.payload.exitCode})) break } case RemoteGen.trackerChangeFollow: { diff --git a/shared/devices/device-revoke.tsx b/shared/devices/device-revoke.tsx index 04e57a9fd8f9..014c9441625e 100644 --- a/shared/devices/device-revoke.tsx +++ b/shared/devices/device-revoke.tsx @@ -3,7 +3,7 @@ import {useConfigState} from '@/stores/config' import * as Kb from '@/common-adapters' import * as React from 'react' import * as T from '@/constants/types' -import {settingsDevicesTab} from '@/stores/settings' +import {settingsDevicesTab} from '@/constants/settings' import {useCurrentUserState} from '@/stores/current-user' type OwnProps = {device?: T.Devices.Device; deviceID?: T.Devices.DeviceID} diff --git a/shared/incoming-share/index.tsx b/shared/incoming-share/index.tsx index e20e91d9061b..a5b0aad182c6 100644 --- a/shared/incoming-share/index.tsx +++ b/shared/incoming-share/index.tsx @@ -4,7 +4,7 @@ import * as React from 'react' import * as Kb from '@/common-adapters' import * as T from '@/constants/types' import {MobileSendToChat} from '../chat/send-to-chat' -import {settingsFeedbackTab} from '@/stores/settings' +import {settingsFeedbackTab} from '@/constants/settings' import * as FS from '@/stores/fs' import {useConfigState} from '@/stores/config' import {useFSState} from '@/stores/fs' diff --git a/shared/settings/account/index.tsx b/shared/settings/account/index.tsx index 5f7b33bd130a..aa224799ff47 100644 --- a/shared/settings/account/index.tsx +++ b/shared/settings/account/index.tsx @@ -1,11 +1,14 @@ import * as C from '@/constants' import * as Kb from '@/common-adapters' +import * as T from '@/constants/types' import type * as React from 'react' import EmailPhoneRow from './email-phone-row' +import {openURL} from '@/util/misc' +import {loadSettings} from '../load-settings' import {usePWState} from '@/stores/settings-password' import {useSettingsPhoneState} from '@/stores/settings-phone' import {useSettingsEmailState} from '@/stores/settings-email' -import {useSettingsState, settingsPasswordTab} from '@/stores/settings' +import {settingsPasswordTab} from '@/constants/settings' export const SettingsSection = ({children}: {children: React.ReactNode}) => ( @@ -112,7 +115,16 @@ const Password = () => { } const WebAuthTokenLogin = () => { - const loginBrowserViaWebAuthToken = useSettingsState(s => s.dispatch.loginBrowserViaWebAuthToken) + const generateWebAuthToken = C.useRPC(T.RPCGen.configGenerateWebAuthTokenRpcPromise) + const loginBrowserViaWebAuthToken = () => { + generateWebAuthToken( + [undefined], + link => { + openURL(link) + }, + () => {} + ) + } return ( @@ -177,11 +189,6 @@ const AccountSettings = () => { editPhone: s.dispatch.editPhone, })) ) - const {loadSettings} = useSettingsState( - C.useShallow(s => ({ - loadSettings: s.dispatch.loadSettings, - })) - ) const {loadHasRandomPw} = usePWState( C.useShallow(s => ({ loadHasRandomPw: s.dispatch.loadHasRandomPw, diff --git a/shared/settings/advanced.tsx b/shared/settings/advanced.tsx index ae8dc817f568..5ea048de27bd 100644 --- a/shared/settings/advanced.tsx +++ b/shared/settings/advanced.tsx @@ -3,15 +3,75 @@ import * as Kb from '@/common-adapters' import * as T from '@/constants/types' import * as React from 'react' import {ProxySettings} from './proxy' -import {useSettingsState, traceInProgressKey, processorProfileInProgressKey} from '@/stores/settings' +import {processorProfileInProgressKey, traceInProgressKey} from '@/constants/settings' import {usePWState} from '@/stores/settings-password' import {useFSState} from '@/stores/fs' import {useConfigState} from '@/stores/config' +import {ignorePromise, timeoutPromise} from '@/constants/utils' +import {pprofDir} from '@/constants/platform' +import {clearLocalLogs} from '@/util/misc' +import {useWaitingState} from '@/stores/waiting' let initialUseNativeFrame: boolean | undefined const showMakeIcons = __DEV__ && (false as boolean) +const runPprofAction = ( + rpc: () => Promise, + waitingKey: string, + durationSeconds: number +) => { + const f = async () => { + await rpc() + const {decrement, increment} = useWaitingState.getState().dispatch + increment(waitingKey) + await timeoutPromise(durationSeconds * 1_000) + decrement(waitingKey) + } + ignorePromise(f()) +} + +const useLockdownMode = () => { + const [lockdownModeEnabled, setLockdownModeEnabled] = React.useState(undefined) + const loadLockdownModeRPC = C.useRPC(T.RPCGen.accountGetLockdownModeRpcPromise) + const setLockdownModeRPC = C.useRPC(T.RPCGen.accountSetLockdownModeRpcPromise) + + const loadLockdownMode = React.useCallback(() => { + if (!useConfigState.getState().loggedIn) { + return + } + loadLockdownModeRPC( + [undefined], + result => { + setLockdownModeEnabled(result.status) + }, + () => { + setLockdownModeEnabled(undefined) + } + ) + }, [loadLockdownModeRPC]) + + const setLockdownMode = React.useCallback( + (enabled: boolean) => { + if (!useConfigState.getState().loggedIn) { + return + } + setLockdownModeRPC( + [{enabled}, C.waitingKeySettingsSetLockdownMode], + () => { + setLockdownModeEnabled(enabled) + }, + () => { + setLockdownModeEnabled(undefined) + } + ) + }, + [setLockdownModeRPC] + ) + + return {loadLockdownMode, lockdownModeEnabled, setLockdownMode} +} + const UseNativeFrame = () => { const {onChangeUseNativeFrame, useNativeFrame} = useConfigState( C.useShallow(s => ({ @@ -40,14 +100,13 @@ const UseNativeFrame = () => { ) } -const LockdownCheckbox = (p: {hasRandomPW: boolean; settingLockdownMode: boolean}) => { - const {hasRandomPW, settingLockdownMode} = p - const {lockdownModeEnabled, setLockdownMode} = useSettingsState( - C.useShallow(s => ({ - lockdownModeEnabled: !!s.lockdownModeEnabled, - setLockdownMode: s.dispatch.setLockdownMode, - })) - ) +const LockdownCheckbox = (p: { + hasRandomPW: boolean + lockdownModeEnabled?: boolean + setLockdownMode: (enabled: boolean) => void + settingLockdownMode: boolean +}) => { + const {hasRandomPW, lockdownModeEnabled, setLockdownMode, settingLockdownMode} = p const onChangeLockdownMode = setLockdownMode const readMoreUrlProps = Kb.useClickURL('https://keybase.io/docs/lockdown/index') const label = 'Enable account lockdown mode' + (hasRandomPW ? ' (you need to set a password first)' : '') @@ -96,11 +155,7 @@ const Advanced = () => { openAtLogin: s.openAtLogin, })) ) - const {loadLockdownMode} = useSettingsState( - C.useShallow(s => ({ - loadLockdownMode: s.dispatch.loadLockdownMode, - })) - ) + const {loadLockdownMode, lockdownModeEnabled, setLockdownMode} = useLockdownMode() const setLockdownModeError = C.Waiting.useAnyErrors(C.waitingKeySettingsSetLockdownMode)?.message || '' const [rememberPassword, setRememberPassword] = React.useState(undefined) @@ -187,7 +242,12 @@ const Advanced = () => { {settingLockdownMode && } - + {!!setLockdownModeError && ( {setLockdownModeError} @@ -265,17 +325,38 @@ const Developer = () => { const showPprofControls = clickCount >= clickThreshold const traceInProgress = C.Waiting.useAnyWaiting(traceInProgressKey) - const {onProcessorProfile, onTrace} = useSettingsState( - C.useShallow(s => ({ - onProcessorProfile: s.dispatch.processorProfile, - onTrace: s.dispatch.trace, - })) - ) + const onProcessorProfile = () => { + runPprofAction( + async () => + T.RPCGen.pprofLogProcessorProfileRpcPromise({ + logDirForMobile: pprofDir, + profileDurationSeconds: processorProfileDurationSeconds, + }), + processorProfileInProgressKey, + processorProfileDurationSeconds + ) + } + const onTrace = () => { + runPprofAction( + async () => + T.RPCGen.pprofLogTraceRpcPromise({ + logDirForMobile: pprofDir, + traceDurationSeconds, + }), + traceInProgressKey, + traceDurationSeconds + ) + } const processorProfileInProgress = C.Waiting.useAnyWaiting(processorProfileInProgressKey) const navigateAppend = C.useRouterState(s => s.dispatch.navigateAppend) const onDBNuke = () => navigateAppend('dbNukeConfirm') const onMakeIcons = () => navigateAppend('makeIcons') - const onClearLogs = useSettingsState(s => s.dispatch.clearLogs) + const onClearLogs = () => { + const f = async () => { + await clearLocalLogs() + } + ignorePromise(f()) + } return ( @@ -320,14 +401,14 @@ const Developer = () => { style={{marginTop: Kb.Styles.globalMargins.small}} type="Danger" label={`Trace (${traceDurationSeconds}s)`} - onClick={() => onTrace(traceDurationSeconds)} + onClick={onTrace} /> onProcessorProfile(processorProfileDurationSeconds)} + label={`CPU Profile (${processorProfileDurationSeconds}s)`} + onClick={onProcessorProfile} /> Trace and profile files are included in logs sent with feedback. diff --git a/shared/settings/archive/modal.tsx b/shared/settings/archive/modal.tsx index 26ec411622ed..e28019eecefe 100644 --- a/shared/settings/archive/modal.tsx +++ b/shared/settings/archive/modal.tsx @@ -7,7 +7,7 @@ import {fsCacheDir, isAndroid} from '@/constants/platform' import {pickSave} from '@/util/misc' import * as FsCommon from '@/fs/common' import {useArchiveState} from '@/stores/archive' -import {settingsArchiveTab} from '@/stores/settings' +import {settingsArchiveTab} from '@/constants/settings' import {useCurrentUserState} from '@/stores/current-user' import {getConvoState} from '@/stores/convostate' import {makeUUID} from '@/util/uuid' diff --git a/shared/settings/chat.tsx b/shared/settings/chat.tsx index c5338d0e5bf9..cb5324ca5d29 100644 --- a/shared/settings/chat.tsx +++ b/shared/settings/chat.tsx @@ -4,9 +4,9 @@ import * as Teams from '@/stores/teams' import * as T from '@/constants/types' import * as React from 'react' import Group from './group' +import {loadSettings} from './load-settings' import {useSettingsChatState as useSettingsChatState} from '@/stores/settings-chat' import {useSettingsNotifState} from '@/stores/settings-notifications' -import {useSettingsState} from '@/stores/settings' import {useConfigState} from '@/stores/config' const emptyList = new Array() @@ -105,13 +105,11 @@ const Security = () => { } }, [_contactSettingsSelectedTeams, contactSettingsSelectedTeams]) - const loadSettings = useSettingsState(s => s.dispatch.loadSettings) - React.useEffect(() => { loadSettings() notifRefresh() contactSettingsRefresh() - }, [contactSettingsRefresh, loadSettings, notifRefresh]) + }, [contactSettingsRefresh, notifRefresh]) return ( <> diff --git a/shared/settings/db-nuke.confirm.tsx b/shared/settings/db-nuke.confirm.tsx index 6dd0660aa1d5..b60ac1dd06b1 100644 --- a/shared/settings/db-nuke.confirm.tsx +++ b/shared/settings/db-nuke.confirm.tsx @@ -1,16 +1,16 @@ import * as C from '@/constants' import * as Kb from '@/common-adapters' -import {useSettingsState} from '@/stores/settings' +import * as T from '@/constants/types' const DbNukeConfirm = () => { const navigateUp = C.useRouterState(s => s.dispatch.navigateUp) + const dbNuke = C.useRPC(T.RPCGen.ctlDbNukeRpcPromise) const onCancel = () => { navigateUp() } - const dbNuke = useSettingsState(s => s.dispatch.dbNuke) const onDBNuke = () => { navigateUp() - dbNuke() + dbNuke([undefined, C.waitingKeySettingsGeneric], () => {}, () => {}) } return ( diff --git a/shared/settings/delete-confirm/check-passphrase.native.tsx b/shared/settings/delete-confirm/check-passphrase.native.tsx index c5a7dc07c423..daf0337a1686 100644 --- a/shared/settings/delete-confirm/check-passphrase.native.tsx +++ b/shared/settings/delete-confirm/check-passphrase.native.tsx @@ -1,15 +1,15 @@ import * as C from '@/constants' import * as React from 'react' import * as Kb from '@/common-adapters' -import {useSettingsState} from '@/stores/settings' +import {useDeleteAccount} from '../use-delete-account' +import {usePasswordCheck} from '../use-password-check' const CheckPassphraseMobile = () => { const [password, setPassword] = React.useState('') const [showTyping, setShowTyping] = React.useState(false) - const checkPasswordIsCorrect = useSettingsState(s => s.checkPasswordIsCorrect) - const checkPassword = useSettingsState(s => s.dispatch.checkPassword) - const deleteAccountForever = useSettingsState(s => s.dispatch.deleteAccountForever) + const {checkPassword, checkPasswordIsCorrect} = usePasswordCheck() + const deleteAccountForever = useDeleteAccount() const onCheckPassword = checkPassword const deleteForever = () => { diff --git a/shared/settings/delete-confirm/index.tsx b/shared/settings/delete-confirm/index.tsx index 44e955d35a4c..818614fad0b8 100644 --- a/shared/settings/delete-confirm/index.tsx +++ b/shared/settings/delete-confirm/index.tsx @@ -2,8 +2,8 @@ import * as C from '@/constants' import * as React from 'react' import * as Kb from '@/common-adapters' import {useSafeNavigation} from '@/util/safe-navigation' +import {useDeleteAccount} from '../use-delete-account' import {usePWState} from '@/stores/settings-password' -import {useSettingsState} from '@/stores/settings' import {useCurrentUserState} from '@/stores/current-user' type CheckboxesProps = { @@ -37,7 +37,7 @@ const Checkboxes = (props: CheckboxesProps) => ( const DeleteConfirm = () => { const hasPassword = usePWState(s => !s.randomPW) - const deleteAccountForever = useSettingsState(s => s.dispatch.deleteAccountForever) + const deleteAccountForever = useDeleteAccount() const username = useCurrentUserState(s => s.username) const [checkData, setCheckData] = React.useState(false) const [checkTeams, setCheckTeams] = React.useState(false) diff --git a/shared/settings/disable-cert-pinning-modal.tsx b/shared/settings/disable-cert-pinning-modal.tsx deleted file mode 100644 index 8fe63caf18c2..000000000000 --- a/shared/settings/disable-cert-pinning-modal.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import * as C from '@/constants' -import * as Kb from '@/common-adapters' -import {useSettingsState} from '@/stores/settings' - -const DisableCertPinningModal = () => { - const navigateUp = C.useRouterState(s => s.dispatch.navigateUp) - const onCancel = () => { - navigateUp() - } - const setDidToggleCertificatePinning = useSettingsState(s => s.dispatch.setDidToggleCertificatePinning) - const onConfirm = () => { - setDidToggleCertificatePinning(true) - navigateUp() - } - - return ( - } - onCancel={onCancel} - onConfirm={onConfirm} - prompt="Are you sure you want to allow TLS MITM?" - /> - ) -} - -export default DisableCertPinningModal diff --git a/shared/settings/load-settings.tsx b/shared/settings/load-settings.tsx new file mode 100644 index 000000000000..d5689bbace31 --- /dev/null +++ b/shared/settings/load-settings.tsx @@ -0,0 +1,46 @@ +import * as Tabs from '@/constants/tabs' +import * as S from '@/constants/strings' +import * as T from '@/constants/types' +import {ignorePromise} from '@/constants/utils' +import logger from '@/logger' +import {navigateAppend, switchTab} from '@/constants/router' +import {RPCError} from '@/util/errors' +import {useConfigState} from '@/stores/config' +import {useSettingsEmailState} from '@/stores/settings-email' +import {useSettingsPhoneState} from '@/stores/settings-phone' + +let maybeLoadAppLinkOnce = false + +export const loadSettings = () => { + const maybeLoadAppLink = () => { + const phones = useSettingsPhoneState.getState().phones + if (!phones || phones.size > 0) { + return + } + + if (maybeLoadAppLinkOnce || !useConfigState.getState().startup.link.endsWith('/phone-app')) { + return + } + maybeLoadAppLinkOnce = true + switchTab(Tabs.settingsTab) + navigateAppend('settingsAddPhone') + } + + const f = async () => { + if (!useConfigState.getState().loggedIn) { + return + } + try { + const settings = await T.RPCGen.userLoadMySettingsRpcPromise(undefined, S.waitingKeySettingsLoadSettings) + useSettingsEmailState.getState().dispatch.notifyEmailAddressEmailsChanged(settings.emails ?? []) + useSettingsPhoneState.getState().dispatch.setNumbers(settings.phoneNumbers ?? undefined) + maybeLoadAppLink() + } catch (error) { + if (!(error instanceof RPCError)) { + return + } + logger.warn(`Error loading settings: ${error.message}`) + } + } + ignorePromise(f()) +} diff --git a/shared/settings/logout.tsx b/shared/settings/logout.tsx index 6675e93226cc..f6d18dfab0bc 100644 --- a/shared/settings/logout.tsx +++ b/shared/settings/logout.tsx @@ -5,17 +5,11 @@ import * as T from '@/constants/types' import * as Kb from '@/common-adapters' import {UpdatePassword, useSubmitNewPassword} from './password' import {useRequestLogout} from './use-request-logout' +import {usePasswordCheck} from './use-password-check' import {usePWState} from '@/stores/settings-password' -import {useSettingsState} from '@/stores/settings' const LogoutContainer = () => { - const {checkPassword, checkPasswordIsCorrect, resetCheckPassword} = useSettingsState( - C.useShallow(s => ({ - checkPassword: s.dispatch.checkPassword, - checkPasswordIsCorrect: s.checkPasswordIsCorrect, - resetCheckPassword: s.dispatch.resetCheckPassword, - })) - ) + const {checkPassword, checkPasswordIsCorrect, reset} = usePasswordCheck() const {hasRandomPW, loadHasRandomPw} = usePWState( C.useShallow(s => ({ hasRandomPW: s.randomPW, @@ -32,7 +26,7 @@ const LogoutContainer = () => { const _onLogout = () => { requestLogout() - resetCheckPassword() + reset() } const onLogout = useSafeSubmit(_onLogout, false) @@ -47,9 +41,9 @@ const LogoutContainer = () => { React.useEffect( () => () => { - resetCheckPassword() + reset() }, - [resetCheckPassword] + [reset] ) React.useEffect(() => { diff --git a/shared/settings/manage-contacts.tsx b/shared/settings/manage-contacts.tsx index 3443129f23bc..3afef07fa149 100644 --- a/shared/settings/manage-contacts.tsx +++ b/shared/settings/manage-contacts.tsx @@ -2,7 +2,7 @@ import * as C from '@/constants' import * as Kb from '@/common-adapters' import {SettingsSection} from './account' import {useSettingsContactsState} from '@/stores/settings-contacts' -import {settingsFeedbackTab} from '@/stores/settings' +import {settingsFeedbackTab} from '@/constants/settings' import {useConfigState} from '@/stores/config' const enabledDescription = 'Your phone contacts are being synced on this device.' diff --git a/shared/settings/notifications/hooks.tsx b/shared/settings/notifications/hooks.tsx index a3c0aecc59fd..1a56254f143e 100644 --- a/shared/settings/notifications/hooks.tsx +++ b/shared/settings/notifications/hooks.tsx @@ -1,7 +1,8 @@ import * as C from '@/constants' +import {settingsAccountTab} from '@/constants/settings' +import {loadSettings} from '../load-settings' import {useSettingsEmailState} from '@/stores/settings-email' import {useSettingsNotifState} from '@/stores/settings-notifications' -import {useSettingsState, settingsAccountTab} from '@/stores/settings' const useNotifications = () => { const _groups = useSettingsNotifState(s => s.groups) @@ -19,7 +20,6 @@ const useNotifications = () => { } const onToggle = toggle const onToggleUnsubscribeAll = toggle - const loadSettings = useSettingsState(s => s.dispatch.loadSettings) const refresh = useSettingsNotifState(s => s.dispatch.refresh) const onRefresh = () => { diff --git a/shared/settings/notifications/index.desktop.tsx b/shared/settings/notifications/index.desktop.tsx index cb3bf4b94e1b..d14c87e75c05 100644 --- a/shared/settings/notifications/index.desktop.tsx +++ b/shared/settings/notifications/index.desktop.tsx @@ -1,11 +1,10 @@ import {Reloadable} from '@/common-adapters' import * as C from '@/constants' import Render from './render' +import {loadSettings} from '../load-settings' import {useSettingsNotifState} from '@/stores/settings-notifications' -import {useSettingsState} from '@/stores/settings' const Notifications = () => { - const loadSettings = useSettingsState(s => s.dispatch.loadSettings) const refresh = useSettingsNotifState(s => s.dispatch.refresh) const onReload = () => { loadSettings() diff --git a/shared/settings/notifications/index.native.tsx b/shared/settings/notifications/index.native.tsx index acafb773a5c5..c60e45740ad5 100644 --- a/shared/settings/notifications/index.native.tsx +++ b/shared/settings/notifications/index.native.tsx @@ -2,12 +2,11 @@ import * as C from '@/constants' import * as Kb from '@/common-adapters' import Notifications from './render' import {Reloadable} from '@/common-adapters' +import {loadSettings} from '../load-settings' import {useSettingsNotifState} from '@/stores/settings-notifications' -import {useSettingsState} from '@/stores/settings' import {usePushState} from '@/stores/push' const MobileNotifications = () => { - const loadSettings = useSettingsState(s => s.dispatch.loadSettings) const refresh = useSettingsNotifState(s => s.dispatch.refresh) const navigateUp = C.useRouterState(s => s.dispatch.navigateUp) const onReload = () => { diff --git a/shared/settings/notifications/render.tsx b/shared/settings/notifications/render.tsx index 76a05bee8555..ba53526cc1cb 100644 --- a/shared/settings/notifications/render.tsx +++ b/shared/settings/notifications/render.tsx @@ -34,15 +34,17 @@ const PhoneSection = (props: Props) => ( const Notifications = () => { const props = useNotifications() const mobileHasPermissions = usePushState(s => s.hasPermissions) - return !props.groups.get('email')?.settings ? ( + const hasLoadedGroups = props.groups.size > 0 + const emailGroup = props.groups.get('email') + return !hasLoadedGroups ? ( ) : ( - {props.showEmailSection ? ( + {emailGroup ? ( - ) : ( + ) : !props.showEmailSection ? ( Email notifications @@ -53,7 +55,7 @@ const Notifications = () => { and add an email address. - )} + ) : null} {(!Kb.Styles.isMobile || mobileHasPermissions) && !!props.groups.get('app_push')?.settings ? ( <> diff --git a/shared/settings/proxy.tsx b/shared/settings/proxy.tsx index 994fa5926df7..97d1d82ca262 100644 --- a/shared/settings/proxy.tsx +++ b/shared/settings/proxy.tsx @@ -2,23 +2,28 @@ import * as React from 'react' import * as C from '@/constants' import * as Kb from '@/common-adapters' import * as T from '@/constants/types' -import {useSettingsState} from '@/stores/settings' +import logger from '@/logger' +import type {RPCError} from '@/util/errors' const useConnect = () => { - const allowTlsMitmToggle = useSettingsState(s => s.didToggleCertificatePinning) - const setDidToggleCertificatePinning = useSettingsState(s => s.dispatch.setDidToggleCertificatePinning) - const proxyData = useSettingsState(s => s.proxyData) - const saveProxyData = useSettingsState(s => s.dispatch.setProxyData) - const loadProxyData = useSettingsState(s => s.dispatch.loadProxyData) - const resetCertPinningToggle = () => { - setDidToggleCertificatePinning() - } - const navigateAppend = C.useRouterState(s => s.dispatch.navigateAppend) + const [allowTlsMitmToggle, setDidToggleCertificatePinning] = React.useState(undefined) + const [proxyData, setProxyData] = React.useState(undefined) + const [showDisableCertPinningWarning, setShowDisableCertPinningWarning] = React.useState(false) + const loadProxyData = C.useRPC(T.RPCGen.configGetProxyDataRpcPromise) + const saveProxyData = C.useRPC(T.RPCGen.configSetProxyDataRpcPromise) + const navigateUp = C.useRouterState(s => s.dispatch.navigateUp) const onBack = () => { - navigateAppend('login') + navigateUp() } const onDisableCertPinning = () => { - navigateAppend('disableCertPinningModal') + setShowDisableCertPinningWarning(true) + } + const onCancelDisableCertPinning = () => { + setShowDisableCertPinningWarning(false) + } + const onConfirmDisableCertPinning = () => { + setDidToggleCertificatePinning(true) + setShowDisableCertPinningWarning(false) } const onEnableCertPinning = () => { setDidToggleCertificatePinning(false) @@ -27,11 +32,14 @@ const useConnect = () => { allowTlsMitmToggle, loadProxyData, onBack, + onCancelDisableCertPinning, + onConfirmDisableCertPinning, onDisableCertPinning, onEnableCertPinning, proxyData, - resetCertPinningToggle, saveProxyData, + setProxyData, + showDisableCertPinningWarning, } return props @@ -58,48 +66,66 @@ const proxyTypeToDisplayName = { } type Props = { - loadProxyData: () => void - resetCertPinningToggle: () => void allowTlsMitmToggle?: boolean + loadProxyData: ( + args: [undefined], + setResult: (result: T.RPCGen.ProxyData) => void, + setError: (error: RPCError) => void + ) => void onBack: () => void + onCancelDisableCertPinning: () => void + onConfirmDisableCertPinning: () => void onDisableCertPinning: () => void onEnableCertPinning: () => void proxyData?: T.RPCGen.ProxyData - saveProxyData: (proxyData: T.RPCGen.ProxyData) => void + saveProxyData: ( + args: [{proxyData: T.RPCGen.ProxyData}], + setResult: () => void, + setError: (error: RPCError) => void + ) => void + setProxyData: React.Dispatch> + showDisableCertPinningWarning: boolean } const ProxySettingsComponent = (props: Props) => { - const {loadProxyData, resetCertPinningToggle, proxyData} = props + const {loadProxyData, proxyData, setProxyData} = props const [address, setAddress] = React.useState('') const [port, setPort] = React.useState('') const [proxyType, setProxyType] = React.useState<'noProxy' | 'httpConnect' | 'socks'>('noProxy') - React.useEffect(() => { - loadProxyData() - }, [loadProxyData]) + const applyProxyData = React.useCallback((proxyData_: T.RPCGen.ProxyData) => { + const addressPort = proxyData_.addressWithPort.split(':') + const newAddress = addressPort.slice(0, addressPort.length - 1).join(':') + const newPort = addressPort.length >= 2 ? (addressPort.at(-1) ?? '') : '8080' + const newProxyType = T.RPCGen.ProxyType[proxyData_.proxyType] as typeof proxyType + + setAddress(newAddress) + setPort(newPort) + setProxyType(newProxyType) + }, []) React.useEffect(() => { - return () => { - resetCertPinningToggle() - } - }, [resetCertPinningToggle]) + loadProxyData( + [undefined], + result => { + setProxyData(result) + applyProxyData(result) + }, + error => { + logger.warn('Error loading proxy data', error) + } + ) + }, [applyProxyData, loadProxyData, setProxyData]) const lastProxyDataRef = React.useRef(proxyData) React.useEffect(() => { if (lastProxyDataRef.current !== proxyData) { if (proxyData) { - const addressPort = proxyData.addressWithPort.split(':') - const newAddress = addressPort.slice(0, addressPort.length - 1).join(':') - const newPort = addressPort.length >= 2 ? (addressPort.at(-1) ?? '') : '8080' - const newProxyType = T.RPCGen.ProxyType[proxyData.proxyType] as typeof proxyType - - setAddress(newAddress) - setPort(newPort) - setProxyType(newProxyType) + applyProxyData(proxyData) } } lastProxyDataRef.current = proxyData - }, [proxyData]) + }, [applyProxyData, proxyData]) const certPinning = (): boolean => { if (props.allowTlsMitmToggle === undefined) { @@ -117,22 +143,56 @@ const ProxySettingsComponent = (props: Props) => { } } - const saveProxySettings = () => { - const proxyData = { + const saveProxySettings = (nextProxyType = proxyType) => { + const nextProxyData = { addressWithPort: address + ':' + port, certPinning: certPinning(), - proxyType: T.RPCGen.ProxyType[proxyType], + proxyType: T.RPCGen.ProxyType[nextProxyType], } - props.saveProxyData(proxyData) + props.saveProxyData( + [{proxyData: nextProxyData}], + () => { + setProxyData(nextProxyData) + }, + error => { + logger.warn('Error in saving proxy data', error) + } + ) } const proxyTypeSelected = (newProxyType: typeof proxyType) => { setProxyType(newProxyType) if (newProxyType === 'noProxy') { - saveProxySettings() + saveProxySettings(newProxyType) } } + if (props.showDisableCertPinningWarning) { + return ( + + + + Are you sure you want to allow TLS interception? + + + This means your proxy or your ISP will be able to view all traffic between you and Keybase servers. + It is not recommended to use this option unless absolutely required. + + + + + + + ) + } + return ( <> @@ -161,7 +221,7 @@ const ProxySettingsComponent = (props: Props) => { label="Allow TLS Interception" style={styles.proxySetting} /> - + saveProxySettings()} label="Save Proxy Settings" /> ) } @@ -192,6 +252,9 @@ const styles = Kb.Styles.styleSheetCreate(() => ({ cursor: 'default', }, }), + warningBody: {maxWidth: 420}, + warningContainer: {padding: Kb.Styles.globalMargins.medium}, + warningHeader: {maxWidth: 420}, })) export {ProxySettings} diff --git a/shared/settings/root-desktop-tablet.tsx b/shared/settings/root-desktop-tablet.tsx index 2bbc068a59e0..f8859f4e7ea3 100644 --- a/shared/settings/root-desktop-tablet.tsx +++ b/shared/settings/root-desktop-tablet.tsx @@ -9,7 +9,7 @@ import {useNavigationBuilder, TabRouter, createNavigatorFactory} from '@react-na import type {TypedNavigator, NavigatorTypeBagBase, StaticConfig} from '@react-navigation/native' import type {RootParamList} from '@/router-v2/route-params' import {settingsDesktopTabRoutes} from './routes' -import {settingsAccountTab} from '@/stores/settings' +import {settingsAccountTab} from '@/constants/settings' function LeftTabNavigator({ initialRouteName, diff --git a/shared/settings/root-phone.tsx b/shared/settings/root-phone.tsx index 04740ee00a17..e3611ec30aea 100644 --- a/shared/settings/root-phone.tsx +++ b/shared/settings/root-phone.tsx @@ -6,7 +6,7 @@ import * as T from '@/constants/types' import SettingsItem from './sub-nav/settings-item' import noop from 'lodash/noop' import {useSettingsContactsState} from '@/stores/settings-contacts' -import * as Settings from '@/stores/settings' +import * as Settings from '@/constants/settings' import {usePushState} from '@/stores/push' import {useNotifState} from '@/stores/notifications' diff --git a/shared/settings/routes.tsx b/shared/settings/routes.tsx index 40c5d363b7ff..c46bba937996 100644 --- a/shared/settings/routes.tsx +++ b/shared/settings/routes.tsx @@ -7,7 +7,6 @@ import {newRoutes as walletsRoutes} from '../wallets/routes' import * as Settings from '@/constants/settings' import {usePushState} from '@/stores/push' import {usePWState} from '@/stores/settings-password' -import {useSettingsState} from '@/stores/settings' import {e164ToDisplay} from '@/util/phone-numbers' import {useRoute} from '@react-navigation/native' import type {RootRouteProps} from '@/router-v2/route-params' @@ -35,13 +34,11 @@ const PasswordHeaderTitle = () => { } const CheckPassphraseCancelButton = () => { - const resetCheckPassword = useSettingsState(s => s.dispatch.resetCheckPassword) const navigateUp = C.useRouterState(s => s.dispatch.navigateUp) return ( { - resetCheckPassword() navigateUp() }} > @@ -160,7 +157,6 @@ const sharedNewModalRoutes = { getOptions: {title: 'Backup'}, }), deleteConfirm: {screen: React.lazy(async () => import('./delete-confirm'))}, - disableCertPinningModal: {screen: React.lazy(async () => import('./disable-cert-pinning-modal'))}, settingsAddEmail: C.makeScreen( React.lazy(async () => { const {Email} = await import('./account/add-modals') diff --git a/shared/settings/sub-nav/left-nav.tsx b/shared/settings/sub-nav/left-nav.tsx index 576081c49304..41f6c99cb6a1 100644 --- a/shared/settings/sub-nav/left-nav.tsx +++ b/shared/settings/sub-nav/left-nav.tsx @@ -1,7 +1,7 @@ import * as C from '@/constants' import * as Kb from '@/common-adapters' import SettingsItem from './settings-item' -import * as Settings from '@/stores/settings' +import * as Settings from '@/constants/settings' import {usePushState} from '@/stores/push' import {useNotifState} from '@/stores/notifications' diff --git a/shared/settings/use-delete-account.tsx b/shared/settings/use-delete-account.tsx new file mode 100644 index 000000000000..c85b0a96572f --- /dev/null +++ b/shared/settings/use-delete-account.tsx @@ -0,0 +1,41 @@ +import * as C from '@/constants' +import * as T from '@/constants/types' +import logger from '@/logger' +import {useConfigState} from '@/stores/config' +import {useCurrentUserState} from '@/stores/current-user' + +export const useDeleteAccount = () => { + const username = useCurrentUserState(s => s.username) + const setJustDeletedSelf = useConfigState(s => s.dispatch.setJustDeletedSelf) + const {clearModals, navigateAppend} = C.useRouterState( + C.useShallow(s => ({ + clearModals: s.dispatch.clearModals, + navigateAppend: s.dispatch.navigateAppend, + })) + ) + const deleteAccountRPC = C.useRPC(T.RPCGen.loginAccountDeleteRpcPromise) + + const deleteAccountForever = (passphrase?: string) => { + if (!username) { + throw new Error('Unable to delete account: no username set') + } + + if (C.androidIsTestDevice) { + return + } + + deleteAccountRPC( + [{passphrase}, C.waitingKeySettingsGeneric], + () => { + setJustDeletedSelf(username) + clearModals() + navigateAppend(C.Tabs.loginTab) + }, + error => { + logger.warn('Error deleting account', error) + } + ) + } + + return deleteAccountForever +} diff --git a/shared/settings/use-password-check.tsx b/shared/settings/use-password-check.tsx new file mode 100644 index 000000000000..929c675187b6 --- /dev/null +++ b/shared/settings/use-password-check.tsx @@ -0,0 +1,44 @@ +import * as React from 'react' +import * as C from '@/constants' +import * as T from '@/constants/types' +import logger from '@/logger' + +export const usePasswordCheck = () => { + const checkPasswordRPC = C.useRPC(T.RPCGen.accountPassphraseCheckRpcPromise) + const [checkPasswordIsCorrect, setCheckPasswordIsCorrect] = React.useState(undefined) + const mountedRef = React.useRef(true) + + React.useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) + + const checkPassword = React.useCallback( + (passphrase: string) => { + setCheckPasswordIsCorrect(undefined) + checkPasswordRPC( + [{passphrase}, C.waitingKeySettingsCheckPassword], + result => { + if (mountedRef.current) { + setCheckPasswordIsCorrect(result) + } + }, + error => { + logger.warn('Error checking password', error) + if (mountedRef.current) { + setCheckPasswordIsCorrect(undefined) + } + } + ) + }, + [checkPasswordRPC] + ) + + const reset = React.useCallback(() => { + setCheckPasswordIsCorrect(undefined) + }, []) + + return {checkPassword, checkPasswordIsCorrect, reset} +} diff --git a/shared/stores/settings-notifications.tsx b/shared/stores/settings-notifications.tsx index 5e974e83d4cd..d4b6a16d2a4d 100644 --- a/shared/stores/settings-notifications.tsx +++ b/shared/stores/settings-notifications.tsx @@ -171,7 +171,7 @@ export const useSettingsNotifState = Z.createZustand('settings-notificati resetState: Z.defaultReset, toggle: (group, name) => { const {groups} = get() - if (!groups.get('email')) { + if (groups.size === 0) { logger.warn('Trying to toggle while not loaded') return } @@ -211,7 +211,7 @@ export const useSettingsNotifState = Z.createZustand('settings-notificati const f = async () => { const {groups} = get() - if (!groups.get('email')) { + if (groups.size === 0) { throw new Error('No notifications loaded yet') } diff --git a/shared/stores/settings.tsx b/shared/stores/settings.tsx deleted file mode 100644 index 3ea76ae63d6b..000000000000 --- a/shared/stores/settings.tsx +++ /dev/null @@ -1,286 +0,0 @@ -import * as T from '@/constants/types' -import {ignorePromise, timeoutPromise} from '@/constants/utils' -import * as S from '@/constants/strings' -import {androidIsTestDevice, pprofDir} from '@/constants/platform' -import {openURL} from '@/util/misc' -import * as Z from '@/util/zustand' -import {RPCError} from '@/util/errors' -import * as Tabs from '@/constants/tabs' -import logger from '@/logger' -import {clearModals, navigateAppend, switchTab} from '@/constants/router' -import {useConfigState} from '@/stores/config' -import {useCurrentUserState} from '@/stores/current-user' -import {useWaitingState} from '@/stores/waiting' -import {processorProfileInProgressKey, traceInProgressKey} from '@/constants/settings' -import type {PhoneRow} from '@/stores/settings-phone' - -export * from '@/constants/settings' - -type Store = T.Immutable<{ - checkPasswordIsCorrect?: boolean - didToggleCertificatePinning?: boolean - lockdownModeEnabled?: boolean - proxyData?: T.RPCGen.ProxyData -}> - -const initialStore: Store = { - checkPasswordIsCorrect: undefined, - didToggleCertificatePinning: undefined, - lockdownModeEnabled: undefined, - proxyData: undefined, -} - -export type State = Store & { - dispatch: { - checkPassword: (password: string) => void - clearLogs: () => void - dbNuke: () => void - defer: { - getSettingsPhonePhones: () => undefined | ReadonlyMap - onSettingsEmailNotifyEmailsChanged: (list: ReadonlyArray) => void - onSettingsPhoneSetNumbers: (phoneNumbers?: ReadonlyArray) => void - } - deleteAccountForever: (passphrase?: string) => void - loadLockdownMode: () => void - loadProxyData: () => void - loadSettings: () => void - loginBrowserViaWebAuthToken: () => void - processorProfile: (durationSeconds: number) => void - resetCheckPassword: () => void - resetState: () => void - setDidToggleCertificatePinning: (t?: boolean) => void - setLockdownMode: (l: boolean) => void - setProxyData: (proxyData: T.RPCGen.ProxyData) => void - stop: (exitCode: T.RPCGen.ExitCode) => void - trace: (durationSeconds: number) => void - } -} - -const runPprofAction = ( - rpc: () => Promise, - waitingKey: string, - durationSeconds: number -) => { - const f = async () => { - await rpc() - const {decrement, increment} = useWaitingState.getState().dispatch - increment(waitingKey) - await timeoutPromise(durationSeconds * 1_000) - decrement(waitingKey) - } - ignorePromise(f()) -} - -let maybeLoadAppLinkOnce = false -export const useSettingsState = Z.createZustand('settings', (set, get) => { - const maybeLoadAppLink = () => { - const phones = get().dispatch.defer.getSettingsPhonePhones() - if (!phones || phones.size > 0) { - return - } - - if (maybeLoadAppLinkOnce || !useConfigState.getState().startup.link.endsWith('/phone-app')) { - return - } - maybeLoadAppLinkOnce = true - switchTab(Tabs.settingsTab) - navigateAppend('settingsAddPhone') - } - - const dispatch: State['dispatch'] = { - checkPassword: passphrase => { - set(s => { - s.checkPasswordIsCorrect = undefined - }) - const f = async () => { - const res = await T.RPCGen.accountPassphraseCheckRpcPromise( - {passphrase}, - S.waitingKeySettingsCheckPassword - ) - set(s => { - s.checkPasswordIsCorrect = res - }) - } - ignorePromise(f()) - }, - clearLogs: () => { - const f = async () => { - const {clearLocalLogs} = await import('@/util/misc') - await clearLocalLogs() - } - ignorePromise(f()) - }, - dbNuke: () => { - const f = async () => { - await T.RPCGen.ctlDbNukeRpcPromise(undefined, S.waitingKeySettingsGeneric) - } - ignorePromise(f()) - }, - defer: { - getSettingsPhonePhones: () => { - throw new Error('getSettingsPhonePhones not implemented') - }, - onSettingsEmailNotifyEmailsChanged: () => { - throw new Error('onSettingsEmailNotifyEmailsChanged not implemented') - }, - onSettingsPhoneSetNumbers: () => { - throw new Error('onSettingsPhoneSetNumbers not implemented') - }, - }, - deleteAccountForever: passphrase => { - const f = async () => { - const username = useCurrentUserState.getState().username - - if (!username) { - throw new Error('Unable to delete account: no username set') - } - - if (androidIsTestDevice) { - return - } - - await T.RPCGen.loginAccountDeleteRpcPromise({passphrase}, S.waitingKeySettingsGeneric) - useConfigState.getState().dispatch.setJustDeletedSelf(username) - clearModals() - navigateAppend(Tabs.loginTab) - } - ignorePromise(f()) - }, - loadLockdownMode: () => { - const f = async () => { - if (!useConfigState.getState().loggedIn) { - return - } - try { - const result = await T.RPCGen.accountGetLockdownModeRpcPromise() - set(s => { - s.lockdownModeEnabled = result.status - }) - } catch { - set(s => { - s.lockdownModeEnabled = undefined - }) - } - } - ignorePromise(f()) - }, - loadProxyData: () => { - const f = async () => { - try { - const result = await T.RPCGen.configGetProxyDataRpcPromise() - set(s => { - s.proxyData = result - }) - } catch (err) { - logger.warn('Error in loading proxy data', err) - return - } - } - ignorePromise(f()) - }, - loadSettings: () => { - const f = async () => { - if (!useConfigState.getState().loggedIn) { - return - } - try { - const settings = await T.RPCGen.userLoadMySettingsRpcPromise( - undefined, - S.waitingKeySettingsLoadSettings - ) - get().dispatch.defer.onSettingsEmailNotifyEmailsChanged(settings.emails ?? []) - get().dispatch.defer.onSettingsPhoneSetNumbers(settings.phoneNumbers ?? undefined) - maybeLoadAppLink() - } catch (error) { - if (!(error instanceof RPCError)) { - return - } - logger.warn(`Error loading settings: ${error.message}`) - return - } - } - ignorePromise(f()) - }, - loginBrowserViaWebAuthToken: () => { - const f = async () => { - const link = await T.RPCGen.configGenerateWebAuthTokenRpcPromise() - openURL(link) - } - ignorePromise(f()) - }, - processorProfile: durationSeconds => { - runPprofAction( - async () => - T.RPCGen.pprofLogProcessorProfileRpcPromise({ - logDirForMobile: pprofDir, - profileDurationSeconds: durationSeconds, - }), - processorProfileInProgressKey, - durationSeconds - ) - }, - resetCheckPassword: () => { - set(s => { - s.checkPasswordIsCorrect = undefined - }) - }, - resetState: Z.defaultReset, - setDidToggleCertificatePinning: t => { - set(s => { - s.didToggleCertificatePinning = t - }) - }, - setLockdownMode: enabled => { - const f = async () => { - if (!useConfigState.getState().loggedIn) { - return - } - try { - await T.RPCGen.accountSetLockdownModeRpcPromise({enabled}, S.waitingKeySettingsSetLockdownMode) - set(s => { - s.lockdownModeEnabled = enabled - }) - } catch { - set(s => { - s.lockdownModeEnabled = undefined - }) - } - } - ignorePromise(f()) - }, - setProxyData: proxyData => { - const f = async () => { - try { - await T.RPCGen.configSetProxyDataRpcPromise({proxyData}) - set(s => { - s.proxyData = proxyData - }) - } catch (err) { - logger.warn('Error in saving proxy data', err) - } - } - ignorePromise(f()) - }, - stop: exitCode => { - const f = async () => { - await T.RPCGen.ctlStopRpcPromise({exitCode}) - } - ignorePromise(f()) - }, - trace: durationSeconds => { - runPprofAction( - async () => - T.RPCGen.pprofLogTraceRpcPromise({ - logDirForMobile: pprofDir, - traceDurationSeconds: durationSeconds, - }), - traceInProgressKey, - durationSeconds - ) - }, - } - return { - ...initialStore, - dispatch, - } -}) diff --git a/shared/stores/store-registry.tsx b/shared/stores/store-registry.tsx index 943dc78aa122..fef27d3ecefc 100644 --- a/shared/stores/store-registry.tsx +++ b/shared/stores/store-registry.tsx @@ -13,7 +13,6 @@ import type { State as RecoverPasswordState, useState as useRecoverPasswordState, } from '@/stores/recover-password' -import type {State as SettingsState, useSettingsState} from '@/stores/settings' import type {State as SettingsEmailState, useSettingsEmailState} from '@/stores/settings-email' import type {State as SettingsPhoneState, useSettingsPhoneState} from '@/stores/settings-phone' import type {State as SignupState, useSignupState} from '@/stores/signup' @@ -29,7 +28,6 @@ type StoreName = | 'provision' | 'push' | 'recover-password' - | 'settings' | 'settings-email' | 'settings-phone' | 'signup' @@ -45,7 +43,6 @@ type StoreStates = { provision: ProvisionState push: PushState 'recover-password': RecoverPasswordState - settings: SettingsState 'settings-email': SettingsEmailState 'settings-phone': SettingsPhoneState signup: SignupState @@ -62,7 +59,6 @@ type StoreHooks = { provision: typeof useProvisionState push: typeof usePushState 'recover-password': typeof useRecoverPasswordState - settings: typeof useSettingsState 'settings-email': typeof useSettingsEmailState 'settings-phone': typeof useSettingsPhoneState signup: typeof useSignupState @@ -103,10 +99,6 @@ class StoreRegistry { const {useState} = require('@/stores/recover-password') return useState } - case 'settings': { - const {useSettingsState} = require('@/stores/settings') - return useSettingsState - } case 'settings-email': { const {useSettingsEmailState} = require('@/stores/settings-email') return useSettingsEmailState diff --git a/shared/stores/tests/settings-notifications.test.ts b/shared/stores/tests/settings-notifications.test.ts index da1df05b9dac..950d2f59a9c1 100644 --- a/shared/stores/tests/settings-notifications.test.ts +++ b/shared/stores/tests/settings-notifications.test.ts @@ -61,3 +61,45 @@ test('toggle updates the in-memory group state and persists the change', async ( expect(mockLocalSetGlobalAppNotificationSettingsLocalRpcPromise).toHaveBeenCalled() expect(useSettingsNotifState.getState().allowEdit).toBe(true) }) + +test('toggle works when notification groups are loaded without an email group', async () => { + mockApiserverPostJSONRpcPromise.mockResolvedValue({body: JSON.stringify({status: {code: 0}})}) + mockLocalSetGlobalAppNotificationSettingsLocalRpcPromise.mockResolvedValue({}) + jest.spyOn(T.RPCGen, 'apiserverPostJSONRpcPromise').mockImplementation(mockApiserverPostJSONRpcPromise) + jest.spyOn(T.RPCChat, 'localSetGlobalAppNotificationSettingsLocalRpcPromise').mockImplementation( + mockLocalSetGlobalAppNotificationSettingsLocalRpcPromise + ) + + useSettingsNotifState.setState(state => ({ + ...state, + allowEdit: true, + groups: new Map([ + [ + 'app_push', + { + settings: [{description: 'push', name: 'newmessages', subscribed: true}], + unsub: false, + }, + ], + [ + 'security', + { + settings: [{description: 'phone', name: 'plaintextmobile', subscribed: true}], + unsub: false, + }, + ], + ]), + })) + + useSettingsNotifState.getState().dispatch.toggle('security', 'plaintextmobile') + expect(useSettingsNotifState.getState().allowEdit).toBe(false) + expect(useSettingsNotifState.getState().groups.get('security')?.settings[0]?.subscribed).toBe(false) + + await flush() + await flush() + await flush() + + expect(mockApiserverPostJSONRpcPromise).toHaveBeenCalled() + expect(mockLocalSetGlobalAppNotificationSettingsLocalRpcPromise).toHaveBeenCalled() + expect(useSettingsNotifState.getState().allowEdit).toBe(true) +}) diff --git a/shared/stores/tests/settings.test.ts b/shared/stores/tests/settings.test.ts index 7a3cdd1ee071..ae699f7d314f 100644 --- a/shared/stores/tests/settings.test.ts +++ b/shared/stores/tests/settings.test.ts @@ -6,68 +6,49 @@ jest.mock('../../constants/router', () => ({ })) import * as T from '../../constants/types' +import {loadSettings} from '../../settings/load-settings' import {resetAllStores} from '../../util/zustand' import {useConfigState} from '../config' -import {useSettingsState} from '../settings' +import {useSettingsEmailState} from '../settings-email' +import {useSettingsPhoneState} from '../settings-phone' -describe('settings store', () => { +describe('settings loading', () => { afterEach(() => { jest.restoreAllMocks() resetAllStores() }) - test('loadSettings forwards email and phone settings through deferred handlers', async () => { + test('loadSettings forwards email and phone settings through email and phone stores', async () => { const emails = [{email: 'alice@example.com', isPrimary: true, isVerified: true, visibility: 0}] const phoneNumbers = [{phoneNumber: '+15555555555', superseded: false, verified: true, visibility: 0}] const emailHandler = jest.fn() const phoneHandler = jest.fn() useConfigState.setState({loggedIn: true}) - useSettingsState.setState(s => ({ + useSettingsEmailState.setState(s => ({ ...s, dispatch: { ...s.dispatch, - defer: { - ...s.dispatch.defer, - getSettingsPhonePhones: () => new Map([['existing', {} as never]]), - onSettingsEmailNotifyEmailsChanged: emailHandler, - onSettingsPhoneSetNumbers: phoneHandler, - }, + notifyEmailAddressEmailsChanged: emailHandler, }, })) + useSettingsPhoneState.setState(s => ({ + ...s, + dispatch: { + ...s.dispatch, + setNumbers: phoneHandler, + }, + phones: new Map([['existing', {} as never]]), + })) jest.spyOn(T.RPCGen, 'userLoadMySettingsRpcPromise').mockResolvedValue({ emails, phoneNumbers, } as never) - useSettingsState.getState().dispatch.loadSettings() + loadSettings() await Promise.resolve() expect(emailHandler).toHaveBeenCalledWith(emails) expect(phoneHandler).toHaveBeenCalledWith(phoneNumbers) }) - - test('setProxyData persists and updates local state on success', async () => { - const proxyData: T.RPCGen.ProxyData = { - addressWithPort: '127.0.0.1:8080', - certPinning: false, - proxyType: T.RPCGen.ProxyType.httpConnect, - } - jest.spyOn(T.RPCGen, 'configSetProxyDataRpcPromise').mockResolvedValue(undefined) - - useSettingsState.getState().dispatch.setProxyData(proxyData) - await Promise.resolve() - - expect(useSettingsState.getState().proxyData).toBe(proxyData) - }) - - test('setDidToggleCertificatePinning and resetState restore initial state', () => { - useSettingsState.getState().dispatch.setDidToggleCertificatePinning(true) - expect(useSettingsState.getState().didToggleCertificatePinning).toBe(true) - - resetAllStores() - - expect(useSettingsState.getState().didToggleCertificatePinning).toBeUndefined() - expect(useSettingsState.getState().checkPasswordIsCorrect).toBeUndefined() - }) }) diff --git a/skill/zustand-store-pruning/references/store-checklist.md b/skill/zustand-store-pruning/references/store-checklist.md index d9792a54dd7a..5fd240ce8707 100644 --- a/skill/zustand-store-pruning/references/store-checklist.md +++ b/skill/zustand-store-pruning/references/store-checklist.md @@ -28,7 +28,7 @@ Status: - [x] `pinentry` kept daemon passphrase callback coordination, remote-window prompt state, and submit/cancel closures in store - [x] `profile` kept proof/PGP listener callbacks plus shared navigation hooks in store; moved visible proof/PGP/revoke state and one-screen RPCs into route params or owning components - [x] `recover-password` kept only session callbacks plus `resetEmailSent`; moved recover-flow display state and navigation context into route params -- [ ] `settings` +- [x] `settings` removed the store entirely; shared settings reload is now a feature helper and lockdown/developer/account actions live in their owning settings screens - [ ] `settings-chat` - [x] `settings-email` moved add-email submit/error state into components; kept notification-backed `emails`, `addedEmail`, and row actions in store - [ ] `settings-notifications`