From 3be3e4acf97c32bd84417b4b81e71c6a2e6b0e08 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Wed, 25 Mar 2026 14:49:48 -0400 Subject: [PATCH 01/19] WIP --- shared/login/join-or-login.tsx | 17 ++- shared/login/relogin/container.tsx | 17 ++- shared/login/signup/error.tsx | 18 +-- shared/provision/username-or-email.tsx | 19 ++- shared/signup/device-name.tsx | 97 +++++++++++-- shared/signup/username.tsx | 65 ++++++++- shared/stores/signup.tsx | 188 ++++--------------------- shared/stores/tests/signup.test.ts | 65 +++++---- 8 files changed, 266 insertions(+), 220 deletions(-) diff --git a/shared/login/join-or-login.tsx b/shared/login/join-or-login.tsx index 6112c21883b5..9bb25bd5c624 100644 --- a/shared/login/join-or-login.tsx +++ b/shared/login/join-or-login.tsx @@ -19,12 +19,19 @@ const Intro = () => { const loadIsOnline = useConfigState(s => s.dispatch.loadIsOnline) const navigateAppend = C.useRouterState(s => s.dispatch.navigateAppend) + const navigateUp = C.useRouterState(s => s.dispatch.navigateUp) const checkIsOnline = loadIsOnline const startProvision = useProvisionState(s => s.dispatch.startProvision) const onLogin = () => { startProvision() } - const requestAutoInvite = useSignupState(s => s.dispatch.requestAutoInvite) + const {autoInviteState, requestAutoInvite, resetAutoInviteState} = useSignupState( + C.useShallow(s => ({ + autoInviteState: s.autoInviteState, + requestAutoInvite: s.dispatch.requestAutoInvite, + resetAutoInviteState: s.dispatch.resetAutoInviteState, + })) + ) const onSignup = () => { requestAutoInvite() } @@ -39,6 +46,14 @@ const Intro = () => { return () => setShowing(false) }) + React.useEffect(() => { + if (autoInviteState === 'ready') { + resetAutoInviteState() + navigateUp() + navigateAppend('signupEnterUsername') + } + }, [autoInviteState, navigateAppend, navigateUp, resetAutoInviteState]) + return ( { navigateAppend('signupSendFeedbackLoggedOut') } const onLogin = useConfigState(s => s.dispatch.login) - const requestAutoInvite = useSignupState(s => s.dispatch.requestAutoInvite) + const {autoInviteState, requestAutoInvite, resetAutoInviteState} = useSignupState( + C.useShallow(s => ({ + autoInviteState: s.autoInviteState, + requestAutoInvite: s.dispatch.requestAutoInvite, + resetAutoInviteState: s.dispatch.resetAutoInviteState, + })) + ) + const navigateUp = C.useRouterState(s => s.dispatch.navigateUp) const onSignup = () => requestAutoInvite() const onSomeoneElse = useProvisionState(s => s.dispatch.startProvision) const error = perror?.desc || '' @@ -75,6 +82,14 @@ const ReloginContainer = () => { } }, [error, setGotNeedPasswordError]) + React.useEffect(() => { + if (autoInviteState === 'ready') { + resetAutoInviteState() + navigateUp() + navigateAppend('signupEnterUsername') + } + }, [autoInviteState, navigateAppend, navigateUp, resetAutoInviteState]) + return ( { - const error = useSignupState(s => s.signupError) - const goBackAndClearErrors = useSignupState(s => s.dispatch.goBackAndClearErrors) - const onBack = goBackAndClearErrors +type Props = StaticScreenProps<{errorCode?: number; errorMessage?: string}> + +const ConnectedSignupError = (p: Props) => { + const navigateUp = C.useRouterState(s => s.dispatch.navigateUp) + const errorCode = p.route.params?.errorCode + const errorMessage = p.route.params?.errorMessage ?? '' let header = 'Ah Shoot! Something went wrong, try again?' - let body = error ? error.desc : '' - if (!!error && C.isNetworkErr(error.code)) { + let body = errorMessage + if (errorCode !== undefined && C.isNetworkErr(errorCode)) { header = 'Hit an unexpected error; try again?' body = 'This might be due to a bad connection.' } @@ -21,7 +23,7 @@ const ConnectedSignupError = () => { {body} - + ) } diff --git a/shared/provision/username-or-email.tsx b/shared/provision/username-or-email.tsx index d09f5525349f..af616dea7634 100644 --- a/shared/provision/username-or-email.tsx +++ b/shared/provision/username-or-email.tsx @@ -50,8 +50,13 @@ const UsernameOrEmailContainer = (op: OwnProps) => { const onBack = useSafeSubmit(navigateUp, hasError) const navigateAppend = C.useRouterState(s => s.dispatch.navigateAppend) const onForgotUsername = () => navigateAppend('forgotUsername') - const requestAutoInvite = useSignupState(s => s.dispatch.requestAutoInvite) - const _onGoToSignup = requestAutoInvite + const {autoInviteState, requestAutoInvite, resetAutoInviteState} = useSignupState( + C.useShallow(s => ({ + autoInviteState: s.autoInviteState, + requestAutoInvite: s.dispatch.requestAutoInvite, + resetAutoInviteState: s.dispatch.resetAutoInviteState, + })) + ) const _setUsername = useProvisionState(s => s.dispatch.dynamic.setUsername) const _onSubmit = (username: string) => { !waiting && _setUsername?.(username) @@ -66,9 +71,17 @@ const UsernameOrEmailContainer = (op: OwnProps) => { _onSubmit(username) } const onGoToSignup = () => { - _onGoToSignup(username) + requestAutoInvite(username) } + React.useEffect(() => { + if (autoInviteState === 'ready') { + resetAutoInviteState() + navigateUp() + navigateAppend('signupEnterUsername') + } + }, [autoInviteState, navigateAppend, navigateUp, resetAutoInviteState]) + return ( { - const error = useSignupState(s => s.devicenameError) - const initialDevicename = useSignupState(s => s.devicename) - const waiting = C.Waiting.useAnyWaiting(C.waitingKeyProvision) - const goBackAndClearErrors = useSignupState(s => s.dispatch.goBackAndClearErrors) - const checkDeviceName = useSignupState(s => s.dispatch.checkDeviceName) - const onBack = goBackAndClearErrors - const onContinue = checkDeviceName + const {initialDevicename, inviteCode, username} = useSignupState( + C.useShallow(s => ({ + initialDevicename: s.devicename, + inviteCode: s.inviteCode, + username: s.username, + })) + ) + const waiting = C.Waiting.useAnyWaiting(C.waitingKeySignup) + const {resetState, setDevicename, showPermissionsPrompt} = useSignupState( + C.useShallow(s => ({ + resetState: s.dispatch.resetState, + setDevicename: s.dispatch.setDevicename, + showPermissionsPrompt: s.dispatch.defer.onShowPermissionsPrompt, + })) + ) + const {navigateAppend, navigateUp} = C.useRouterState( + C.useShallow(s => ({ + navigateAppend: s.dispatch.navigateAppend, + navigateUp: s.dispatch.navigateUp, + })) + ) + const [error, setError] = React.useState('') + const onContinue = (devicename: string) => { + setError('') + setDevicename(devicename) + const f = async () => { + try { + await T.RPCGen.deviceCheckDeviceNameFormatRpcPromise({name: devicename}, C.waitingKeySignup) + } catch (error_) { + if (error_ instanceof RPCError) { + setError(error_.desc) + } + return + } + + if (!username || !devicename) { + logger.warn('Missing data during signup phase', username, devicename) + return + } + + try { + showPermissionsPrompt?.({justSignedUp: true}) + await T.RPCGen.signupSignupRpcListener({ + customResponseIncomingCallMap: { + 'keybase.1.gpgUi.wantToAddGPGKey': (_, response) => { + response.result(false) + }, + }, + incomingCallMap: { + 'keybase.1.loginUi.displayPrimaryPaperKey': () => {}, + }, + params: { + botToken: '', + deviceName: devicename, + deviceType: Platforms.isMobile ? T.RPCGen.DeviceType.mobile : T.RPCGen.DeviceType.desktop, + email: '', + genPGPBatch: false, + genPaper: false, + inviteCode, + passphrase: '', + randomPw: true, + skipGPG: true, + skipMail: true, + storeSecret: true, + username, + verifyEmail: true, + }, + waitingKey: C.waitingKeySignup, + }) + resetState() + } catch (error_) { + if (error_ instanceof RPCError) { + showPermissionsPrompt?.({justSignedUp: false}) + navigateAppend({ + name: 'signupError', + params: {errorCode: error_.code, errorMessage: error_.desc}, + }) + } + } + } + ignorePromise(f()) + } + const props = { error, initialDevicename, - onBack, + onBack: navigateUp, onContinue, waiting, } diff --git a/shared/signup/username.tsx b/shared/signup/username.tsx index fca50d729add..23c01c21deec 100644 --- a/shared/signup/username.tsx +++ b/shared/signup/username.tsx @@ -4,20 +4,64 @@ import * as React from 'react' import {SignupScreen, errorBanner} from './common' import {useSignupState} from '@/stores/signup' import {useProvisionState} from '@/stores/provision' +import * as T from '@/constants/types' +import {RPCError} from '@/util/errors' +import {ignorePromise} from '@/constants/utils' +import logger from '@/logger' +import {isValidUsername} from '@/util/simple-validators' const ConnectedEnterUsername = () => { - const error = useSignupState(s => s.usernameError) const initialUsername = useSignupState(s => s.username) - const usernameTaken = useSignupState(s => s.usernameTaken) - const checkUsername = useSignupState(s => s.dispatch.checkUsername) + const {resetState, setUsername} = useSignupState( + C.useShallow(s => ({ + resetState: s.dispatch.resetState, + setUsername: s.dispatch.setUsername, + })) + ) const waiting = C.Waiting.useAnyWaiting(C.waitingKeySignup) - const navigateUp = C.useRouterState(s => s.dispatch.navigateUp) - const resetState = useSignupState(s => s.dispatch.resetState) + const {navigateAppend, navigateUp} = C.useRouterState( + C.useShallow(s => ({ + navigateAppend: s.dispatch.navigateAppend, + navigateUp: s.dispatch.navigateUp, + })) + ) const onBack = () => { resetState() navigateUp() } - const onContinue = checkUsername + const [error, setError] = React.useState('') + const [usernameTaken, setUsernameTaken] = React.useState('') + const onUsernameChange = () => { + setError('') + setUsernameTaken('') + } + const onContinue = (username: string) => { + onUsernameChange() + const localError = isValidUsername(username) + if (localError) { + setError(localError) + return + } + const f = async () => { + logger.info(`checking ${username}`) + try { + await T.RPCGen.signupCheckUsernameAvailableRpcPromise({username}, C.waitingKeySignup) + logger.info(`${username} success`) + setUsername(username) + navigateAppend('signupEnterDevicename') + } catch (error_) { + if (error_ instanceof RPCError) { + logger.warn(`${username} error: ${error_.message}`) + if (error_.code === T.RPCGen.StatusCode.scbadsignupusernametaken) { + setUsernameTaken(username) + return + } + setError(error_.code === T.RPCGen.StatusCode.scinputerror ? C.usernameHint : error_.desc) + } + } + } + ignorePromise(f()) + } const startProvision = useProvisionState(s => s.dispatch.startProvision) const onLogin = (initUsername: string) => { @@ -29,6 +73,7 @@ const ConnectedEnterUsername = () => { onBack, onContinue, onLogin, + onUsernameChange, usernameTaken, waiting, } @@ -41,6 +86,7 @@ type Props = { onBack: () => void onContinue: (username: string) => void onLogin: (username: string) => void + onUsernameChange: () => void usernameTaken?: string waiting: boolean } @@ -51,6 +97,10 @@ const EnterUsername = (props: Props) => { const eulaUrlProps = Kb.useClickURL('https://keybase.io/docs/acceptable-use-policy') const usernameTrimmed = username.trim() const disabled = !usernameTrimmed || usernameTrimmed === props.usernameTaken || !acceptedEULA + const _onChangeUsername = (username: string) => { + onChangeUsername(username) + props.onUsernameChange() + } const onContinue = () => { if (disabled) { return @@ -122,8 +172,9 @@ const EnterUsername = (props: Props) => { containerStyle={styles.input} placeholder="Pick a username" maxLength={C.maxUsernameLength} - onChangeText={onChangeUsername} + onChangeText={_onChangeUsername} onEnterKeyDown={onContinue} + value={username} /> Your username is unique and can not be changed in the future. diff --git a/shared/stores/signup.tsx b/shared/stores/signup.tsx index f2823881bfb4..70b4009d9ddf 100644 --- a/shared/stores/signup.tsx +++ b/shared/stores/signup.tsx @@ -1,37 +1,28 @@ -import * as Platforms from '@/constants/platform' import {ignorePromise} from '@/constants/utils' import * as S from '@/constants/strings' import type * as EngineGen from '@/constants/rpc' import * as T from '@/constants/types' import * as Z from '@/util/zustand' -import logger from '@/logger' -import {RPCError} from '@/util/errors' -import {isValidUsername} from '@/util/simple-validators' -import {navigateAppend, navigateUp} from '@/constants/router' import {useConfigState} from '@/stores/config' +type AutoInviteState = 'idle' | 'requesting' | 'ready' + type Store = T.Immutable<{ + autoInviteState: AutoInviteState devicename: string - devicenameError: string email: string inviteCode: string justSignedUpEmail: string - signupError?: RPCError username: string - usernameError: string - usernameTaken: string }> const initialStore: Store = { + autoInviteState: 'idle', devicename: S.defaultDevicename, - devicenameError: '', email: '', inviteCode: '', justSignedUpEmail: '', - signupError: undefined, username: '', - usernameError: '', - usernameTaken: '', } export type State = Store & { @@ -40,154 +31,19 @@ export type State = Store & { onEditEmail?: (p: {email: string; makeSearchable: boolean}) => void onShowPermissionsPrompt?: (p: {justSignedUp?: boolean}) => void } - checkDeviceName: (devicename: string) => void - checkUsername: (username: string) => void clearJustSignedUpEmail: () => void - goBackAndClearErrors: () => void onEngineIncomingImpl: (action: EngineGen.Actions) => void requestAutoInvite: (username?: string) => void + resetAutoInviteState: () => void resetState: () => void + setDevicename: (devicename: string) => void setJustSignedUpEmail: (email: string) => void + setUsername: (username: string) => void } } export const useSignupState = Z.createZustand('signup', (set, get) => { - const noErrors = () => { - const {devicenameError, usernameError, signupError, usernameTaken} = get() - return !(devicenameError || usernameError || signupError || usernameTaken) - } - - const reallySignupOnNoErrors = () => { - const f = async () => { - if (!noErrors()) { - logger.warn('Still has errors, bailing on really signing up') - return - } - - const {username, inviteCode, devicename} = get() - if (!username || !devicename) { - logger.warn('Missing data during signup phase', username, devicename) - throw new Error('Missing data for signup') - } - - try { - get().dispatch.defer.onShowPermissionsPrompt?.({justSignedUp: true}) - - await T.RPCGen.signupSignupRpcListener({ - customResponseIncomingCallMap: { - // Do not add a gpg key for now - 'keybase.1.gpgUi.wantToAddGPGKey': (_, response) => { - response.result(false) - }, - }, - incomingCallMap: { - // We dont show the paperkey anymore - 'keybase.1.loginUi.displayPrimaryPaperKey': () => {}, - }, - params: { - botToken: '', - deviceName: devicename, - deviceType: Platforms.isMobile ? T.RPCGen.DeviceType.mobile : T.RPCGen.DeviceType.desktop, - email: '', - genPGPBatch: false, - genPaper: false, - inviteCode, - passphrase: '', - randomPw: true, - skipGPG: true, - skipMail: true, - storeSecret: true, - username, - verifyEmail: true, - }, - waitingKey: S.waitingKeySignup, - }) - set(s => { - s.signupError = undefined - }) - const ok = noErrors() - if (ok) { - get().dispatch.resetState() - } else { - navigateAppend('signupError') - } - } catch (_error) { - if (_error instanceof RPCError) { - const error = _error - set(s => { - s.signupError = error - }) - navigateAppend('signupError') - get().dispatch.defer.onShowPermissionsPrompt?.({justSignedUp: false}) - } - } - } - ignorePromise(f()) - } - const dispatch: State['dispatch'] = { - checkDeviceName: _devicename => { - const devicename = _devicename.trim() - set(s => { - s.devicename = devicename - s.devicenameError = devicename.length === 0 ? 'Device name must not be empty.' : '' - }) - const f = async () => { - if (!noErrors()) { - return - } - try { - await T.RPCGen.deviceCheckDeviceNameFormatRpcPromise({name: devicename}, S.waitingKeySignup) - reallySignupOnNoErrors() - } catch (error) { - if (error instanceof RPCError) { - const msg = error.desc - set(s => { - s.devicenameError = msg - }) - } - } - } - ignorePromise(f()) - }, - checkUsername: username => { - set(s => { - s.username = username - s.usernameError = isValidUsername(username) - s.usernameTaken = '' - }) - const f = async () => { - logger.info(`checking ${username}`) - if (!noErrors()) { - return - } - try { - await T.RPCGen.signupCheckUsernameAvailableRpcPromise({username}, S.waitingKeySignup) - logger.info(`${username} success`) - - set(s => { - s.usernameError = '' - s.usernameTaken = '' - }) - if (noErrors()) { - navigateAppend('signupEnterDevicename') - } - } catch (error) { - if (error instanceof RPCError) { - logger.warn(`${username} error: ${error.message}`) - const msg = error.code === T.RPCGen.StatusCode.scinputerror ? S.usernameHint : error.desc - // Don't set error if it's 'username taken', we show a banner in that case - const usernameError = error.code === T.RPCGen.StatusCode.scbadsignupusernametaken ? '' : msg - const usernameTaken = error.code === T.RPCGen.StatusCode.scbadsignupusernametaken ? username : '' - set(s => { - s.usernameError = usernameError - s.usernameTaken = usernameTaken - }) - } - } - } - ignorePromise(f()) - }, clearJustSignedUpEmail: () => { set(s => { s.justSignedUpEmail = '' @@ -201,15 +57,6 @@ export const useSignupState = Z.createZustand('signup', (set, get) => { throw new Error('onShowPermissionsPrompt not implemented') }, }, - goBackAndClearErrors: () => { - set(s => { - s.devicenameError = '' - s.signupError = undefined - s.usernameError = '' - s.usernameTaken = '' - }) - navigateUp() - }, onEngineIncomingImpl: action => { switch (action.type) { case 'keybase.1.NotifyEmailAddress.emailAddressVerified': @@ -220,30 +67,35 @@ export const useSignupState = Z.createZustand('signup', (set, get) => { }, requestAutoInvite: username => { set(s => { + s.autoInviteState = 'requesting' if (username) { s.username = username } }) const f = async () => { - // If we're logged in, we're coming from the user switcher; log out first to prevent the service from getting out of sync with the GUI about our logged-in-ness if (useConfigState.getState().loggedIn) { await T.RPCGen.loginLogoutRpcPromise({force: false, keepSecrets: true}) } try { const inviteCode = await T.RPCGen.signupGetInvitationCodeRpcPromise(undefined, S.waitingKeySignup) set(s => { + s.autoInviteState = 'ready' s.inviteCode = inviteCode }) } catch { set(s => { + s.autoInviteState = 'ready' s.inviteCode = '' }) } - navigateUp() - navigateAppend('signupEnterUsername') } ignorePromise(f()) }, + resetAutoInviteState: () => { + set(s => { + s.autoInviteState = 'idle' + }) + }, resetState: () => { set(s => ({ ...s, @@ -251,11 +103,21 @@ export const useSignupState = Z.createZustand('signup', (set, get) => { justSignedUpEmail: '', })) }, + setDevicename: (devicename: string) => { + set(s => { + s.devicename = devicename + }) + }, setJustSignedUpEmail: (email: string) => { set(s => { s.justSignedUpEmail = email }) }, + setUsername: (username: string) => { + set(s => { + s.username = username + }) + }, } return { ...initialStore, diff --git a/shared/stores/tests/signup.test.ts b/shared/stores/tests/signup.test.ts index 67bfa7912f64..0bb05304b412 100644 --- a/shared/stores/tests/signup.test.ts +++ b/shared/stores/tests/signup.test.ts @@ -1,52 +1,39 @@ /// +import * as S from '@/constants/strings' import * as T from '@/constants/types' import {resetAllStores} from '@/util/zustand' -jest.mock('@/constants/router', () => { - const actual = jest.requireActual('@/constants/router') - return { - ...actual, - navigateAppend: jest.fn(), - navigateUp: jest.fn(), - } -}) - import {useSignupState} from '../signup' -const {navigateAppend: mockNavigateAppend, navigateUp: mockNavigateUp} = require('@/constants/router') as { - navigateAppend: jest.Mock - navigateUp: jest.Mock -} - afterEach(() => { jest.restoreAllMocks() - mockNavigateAppend.mockReset() - mockNavigateUp.mockReset() resetAllStores() }) const flush = async () => new Promise(resolve => setImmediate(resolve)) -test('invalid usernames are rejected locally without calling the RPC', () => { - const checkUsername = jest.spyOn(T.RPCGen, 'signupCheckUsernameAvailableRpcPromise') +test('setUsername stages the validated signup username', () => { + useSignupState.getState().dispatch.setUsername('alice123') + + expect(useSignupState.getState().username).toBe('alice123') +}) - useSignupState.getState().dispatch.checkUsername('bad username') +test('setDevicename stages the selected signup device name', () => { + useSignupState.getState().dispatch.setDevicename('Phone 2') - expect(useSignupState.getState().username).toBe('bad username') - expect(useSignupState.getState().usernameError).not.toBe('') - expect(checkUsername).not.toHaveBeenCalled() + expect(useSignupState.getState().devicename).toBe('Phone 2') }) -test('checkUsername accepts a valid username and navigates to device setup', async () => { - jest.spyOn(T.RPCGen, 'signupCheckUsernameAvailableRpcPromise').mockResolvedValue(undefined as any) +test('requestAutoInvite marks signup as ready once the invite code request completes', async () => { + jest.spyOn(T.RPCGen, 'signupGetInvitationCodeRpcPromise').mockResolvedValue('invite-code') - useSignupState.getState().dispatch.checkUsername('alice123') + useSignupState.getState().dispatch.requestAutoInvite('alice') await flush() - expect(useSignupState.getState().username).toBe('alice123') - expect(useSignupState.getState().usernameError).toBe('') - expect(useSignupState.getState().usernameTaken).toBe('') - expect(mockNavigateAppend).toHaveBeenCalledWith('signupEnterDevicename') + const state = useSignupState.getState() + expect(state.autoInviteState).toBe('ready') + expect(state.inviteCode).toBe('invite-code') + expect(state.username).toBe('alice') }) test('email verification notifications clear the staged signup email', () => { @@ -59,3 +46,23 @@ test('email verification notifications clear the staged signup email', () => { expect(useSignupState.getState().justSignedUpEmail).toBe('') }) + +test('resetState clears staged signup values back to defaults', () => { + useSignupState.setState(s => ({ + ...s, + autoInviteState: 'ready', + devicename: 'Phone 2', + inviteCode: 'invite-code', + justSignedUpEmail: 'alice@example.com', + username: 'alice', + })) + + useSignupState.getState().dispatch.resetState() + + const state = useSignupState.getState() + expect(state.autoInviteState).toBe('idle') + expect(state.devicename).toBe(S.defaultDevicename) + expect(state.inviteCode).toBe('') + expect(state.justSignedUpEmail).toBe('') + expect(state.username).toBe('') +}) From 34dd4e41d8b0f94e7f0cef1622be6b4186f8cea7 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Wed, 25 Mar 2026 14:59:07 -0400 Subject: [PATCH 02/19] signup cleanup --- shared/login/join-or-login.tsx | 19 +-------- shared/login/relogin/container.tsx | 19 +-------- shared/provision/username-or-email.tsx | 18 +------- shared/signup/device-name.tsx | 19 ++++----- shared/signup/email.tsx | 3 +- shared/signup/use-request-auto-invite.ts | 34 ++++++++++++++++ shared/signup/username.tsx | 22 +++++----- shared/stores/signup.tsx | 52 ------------------------ shared/stores/tests/signup.test.ts | 27 ------------ 9 files changed, 60 insertions(+), 153 deletions(-) create mode 100644 shared/signup/use-request-auto-invite.ts diff --git a/shared/login/join-or-login.tsx b/shared/login/join-or-login.tsx index 9bb25bd5c624..2702a1d9fea8 100644 --- a/shared/login/join-or-login.tsx +++ b/shared/login/join-or-login.tsx @@ -3,7 +3,7 @@ import * as React from 'react' import {useConfigState} from '@/stores/config' import * as Kb from '@/common-adapters' import {InfoIcon} from '@/signup/common' -import {useSignupState} from '@/stores/signup' +import useRequestAutoInvite from '@/signup/use-request-auto-invite' import {useProvisionState} from '@/stores/provision' const Intro = () => { @@ -19,19 +19,12 @@ const Intro = () => { const loadIsOnline = useConfigState(s => s.dispatch.loadIsOnline) const navigateAppend = C.useRouterState(s => s.dispatch.navigateAppend) - const navigateUp = C.useRouterState(s => s.dispatch.navigateUp) const checkIsOnline = loadIsOnline const startProvision = useProvisionState(s => s.dispatch.startProvision) const onLogin = () => { startProvision() } - const {autoInviteState, requestAutoInvite, resetAutoInviteState} = useSignupState( - C.useShallow(s => ({ - autoInviteState: s.autoInviteState, - requestAutoInvite: s.dispatch.requestAutoInvite, - resetAutoInviteState: s.dispatch.resetAutoInviteState, - })) - ) + const requestAutoInvite = useRequestAutoInvite() const onSignup = () => { requestAutoInvite() } @@ -46,14 +39,6 @@ const Intro = () => { return () => setShowing(false) }) - React.useEffect(() => { - if (autoInviteState === 'ready') { - resetAutoInviteState() - navigateUp() - navigateAppend('signupEnterUsername') - } - }, [autoInviteState, navigateAppend, navigateUp, resetAutoInviteState]) - return ( { navigateAppend('signupSendFeedbackLoggedOut') } const onLogin = useConfigState(s => s.dispatch.login) - const {autoInviteState, requestAutoInvite, resetAutoInviteState} = useSignupState( - C.useShallow(s => ({ - autoInviteState: s.autoInviteState, - requestAutoInvite: s.dispatch.requestAutoInvite, - resetAutoInviteState: s.dispatch.resetAutoInviteState, - })) - ) - const navigateUp = C.useRouterState(s => s.dispatch.navigateUp) + const requestAutoInvite = useRequestAutoInvite() const onSignup = () => requestAutoInvite() const onSomeoneElse = useProvisionState(s => s.dispatch.startProvision) const error = perror?.desc || '' @@ -82,14 +75,6 @@ const ReloginContainer = () => { } }, [error, setGotNeedPasswordError]) - React.useEffect(() => { - if (autoInviteState === 'ready') { - resetAutoInviteState() - navigateUp() - navigateAppend('signupEnterUsername') - } - }, [autoInviteState, navigateAppend, navigateUp, resetAutoInviteState]) - return ( { const onBack = useSafeSubmit(navigateUp, hasError) const navigateAppend = C.useRouterState(s => s.dispatch.navigateAppend) const onForgotUsername = () => navigateAppend('forgotUsername') - const {autoInviteState, requestAutoInvite, resetAutoInviteState} = useSignupState( - C.useShallow(s => ({ - autoInviteState: s.autoInviteState, - requestAutoInvite: s.dispatch.requestAutoInvite, - resetAutoInviteState: s.dispatch.resetAutoInviteState, - })) - ) + const requestAutoInvite = useRequestAutoInvite() const _setUsername = useProvisionState(s => s.dispatch.dynamic.setUsername) const _onSubmit = (username: string) => { !waiting && _setUsername?.(username) @@ -74,14 +68,6 @@ const UsernameOrEmailContainer = (op: OwnProps) => { requestAutoInvite(username) } - React.useEffect(() => { - if (autoInviteState === 'ready') { - resetAutoInviteState() - navigateUp() - navigateAppend('signupEnterUsername') - } - }, [autoInviteState, navigateAppend, navigateUp, resetAutoInviteState]) - return ( { - const {initialDevicename, inviteCode, username} = useSignupState( - C.useShallow(s => ({ - initialDevicename: s.devicename, - inviteCode: s.inviteCode, - username: s.username, - })) - ) +type Props = StaticScreenProps<{inviteCode?: string; username?: string}> + +const ConnectedEnterDevicename = (p: Props) => { + const initialDevicename = useSignupState(s => s.devicename) + const inviteCode = p.route.params?.inviteCode ?? '' + const username = p.route.params?.username ?? '' const waiting = C.Waiting.useAnyWaiting(C.waitingKeySignup) const {resetState, setDevicename, showPermissionsPrompt} = useSignupState( C.useShallow(s => ({ @@ -106,7 +105,7 @@ const ConnectedEnterDevicename = () => { export default ConnectedEnterDevicename -type Props = { +type EnterDevicenameProps = { error: string initialDevicename?: string onBack: () => void @@ -120,7 +119,7 @@ const makeCleanDeviceName = (d: string) => { return good } -const EnterDevicename = (props: Props) => { +const EnterDevicename = (props: EnterDevicenameProps) => { const [deviceName, setDeviceName] = React.useState(props.initialDevicename || '') const [readyToShowError, setReadyToShowError] = React.useState(false) const _setReadyToShowError = C.useDebouncedCallback((ready: boolean) => { diff --git a/shared/signup/email.tsx b/shared/signup/email.tsx index 80f741f1f361..e6848ba266b7 100644 --- a/shared/signup/email.tsx +++ b/shared/signup/email.tsx @@ -10,7 +10,6 @@ const ConnectedEnterEmail = () => { const _showPushPrompt = usePushState(s => C.isMobile && !s.hasPermissions && s.showPushPrompt) const addedEmail = useSettingsEmailState(s => s.addedEmail) const error = useSettingsEmailState(s => s.error) - const initialEmail = useSignupState(s => s.email) const waiting = C.Waiting.useAnyWaiting(C.addEmailWaitingKey) const clearModals = C.useRouterState(s => s.dispatch.clearModals) const navigateAppend = C.useRouterState(s => s.dispatch.navigateAppend) @@ -38,7 +37,7 @@ const ConnectedEnterEmail = () => { setAddEmailInProgress(email) } - const [email, onChangeEmail] = React.useState(initialEmail || '') + const [email, onChangeEmail] = React.useState('') const [searchable, onChangeSearchable] = React.useState(true) const disabled = !email.trim() const onContinue = () => (disabled ? {} : onCreate(email.trim(), searchable)) diff --git a/shared/signup/use-request-auto-invite.ts b/shared/signup/use-request-auto-invite.ts new file mode 100644 index 000000000000..d4ae23c46b85 --- /dev/null +++ b/shared/signup/use-request-auto-invite.ts @@ -0,0 +1,34 @@ +import * as C from '@/constants' +import {ignorePromise} from '@/constants/utils' +import {useConfigState} from '@/stores/config' +import * as T from '@/constants/types' + +const useRequestAutoInvite = () => { + const waiting = C.Waiting.useAnyWaiting(C.waitingKeySignup) + const {navigateAppend, navigateUp} = C.useRouterState( + C.useShallow(s => ({ + navigateAppend: s.dispatch.navigateAppend, + navigateUp: s.dispatch.navigateUp, + })) + ) + + return (username?: string) => { + if (waiting) { + return + } + const f = async () => { + if (useConfigState.getState().loggedIn) { + await T.RPCGen.loginLogoutRpcPromise({force: false, keepSecrets: true}) + } + let inviteCode = '' + try { + inviteCode = await T.RPCGen.signupGetInvitationCodeRpcPromise(undefined, C.waitingKeySignup) + } catch {} + navigateUp() + navigateAppend({name: 'signupEnterUsername', params: {inviteCode, username}}) + } + ignorePromise(f()) + } +} + +export default useRequestAutoInvite diff --git a/shared/signup/username.tsx b/shared/signup/username.tsx index 23c01c21deec..8035eb1a4395 100644 --- a/shared/signup/username.tsx +++ b/shared/signup/username.tsx @@ -9,15 +9,14 @@ import {RPCError} from '@/util/errors' import {ignorePromise} from '@/constants/utils' import logger from '@/logger' import {isValidUsername} from '@/util/simple-validators' +import type {StaticScreenProps} from '@react-navigation/core' -const ConnectedEnterUsername = () => { - const initialUsername = useSignupState(s => s.username) - const {resetState, setUsername} = useSignupState( - C.useShallow(s => ({ - resetState: s.dispatch.resetState, - setUsername: s.dispatch.setUsername, - })) - ) +type Props = StaticScreenProps<{inviteCode?: string; username?: string}> + +const ConnectedEnterUsername = (p: Props) => { + const initialUsername = p.route.params?.username ?? '' + const inviteCode = p.route.params?.inviteCode ?? '' + const resetState = useSignupState(s => s.dispatch.resetState) const waiting = C.Waiting.useAnyWaiting(C.waitingKeySignup) const {navigateAppend, navigateUp} = C.useRouterState( C.useShallow(s => ({ @@ -47,8 +46,7 @@ const ConnectedEnterUsername = () => { try { await T.RPCGen.signupCheckUsernameAvailableRpcPromise({username}, C.waitingKeySignup) logger.info(`${username} success`) - setUsername(username) - navigateAppend('signupEnterDevicename') + navigateAppend({name: 'signupEnterDevicename', params: {inviteCode, username}}) } catch (error_) { if (error_ instanceof RPCError) { logger.warn(`${username} error: ${error_.message}`) @@ -80,7 +78,7 @@ const ConnectedEnterUsername = () => { return } -type Props = { +type EnterUsernameProps = { error: string initialUsername?: string onBack: () => void @@ -91,7 +89,7 @@ type Props = { waiting: boolean } -const EnterUsername = (props: Props) => { +const EnterUsername = (props: EnterUsernameProps) => { const [username, onChangeUsername] = React.useState(props.initialUsername || '') const [acceptedEULA, setAcceptedEULA] = React.useState(false) const eulaUrlProps = Kb.useClickURL('https://keybase.io/docs/acceptable-use-policy') diff --git a/shared/stores/signup.tsx b/shared/stores/signup.tsx index 70b4009d9ddf..500dbd85e4f0 100644 --- a/shared/stores/signup.tsx +++ b/shared/stores/signup.tsx @@ -1,28 +1,16 @@ -import {ignorePromise} from '@/constants/utils' import * as S from '@/constants/strings' import type * as EngineGen from '@/constants/rpc' import * as T from '@/constants/types' import * as Z from '@/util/zustand' -import {useConfigState} from '@/stores/config' - -type AutoInviteState = 'idle' | 'requesting' | 'ready' type Store = T.Immutable<{ - autoInviteState: AutoInviteState devicename: string - email: string - inviteCode: string justSignedUpEmail: string - username: string }> const initialStore: Store = { - autoInviteState: 'idle', devicename: S.defaultDevicename, - email: '', - inviteCode: '', justSignedUpEmail: '', - username: '', } export type State = Store & { @@ -33,12 +21,9 @@ export type State = Store & { } clearJustSignedUpEmail: () => void onEngineIncomingImpl: (action: EngineGen.Actions) => void - requestAutoInvite: (username?: string) => void - resetAutoInviteState: () => void resetState: () => void setDevicename: (devicename: string) => void setJustSignedUpEmail: (email: string) => void - setUsername: (username: string) => void } } @@ -65,42 +50,10 @@ export const useSignupState = Z.createZustand('signup', (set, get) => { default: } }, - requestAutoInvite: username => { - set(s => { - s.autoInviteState = 'requesting' - if (username) { - s.username = username - } - }) - const f = async () => { - if (useConfigState.getState().loggedIn) { - await T.RPCGen.loginLogoutRpcPromise({force: false, keepSecrets: true}) - } - try { - const inviteCode = await T.RPCGen.signupGetInvitationCodeRpcPromise(undefined, S.waitingKeySignup) - set(s => { - s.autoInviteState = 'ready' - s.inviteCode = inviteCode - }) - } catch { - set(s => { - s.autoInviteState = 'ready' - s.inviteCode = '' - }) - } - } - ignorePromise(f()) - }, - resetAutoInviteState: () => { - set(s => { - s.autoInviteState = 'idle' - }) - }, resetState: () => { set(s => ({ ...s, ...initialStore, - justSignedUpEmail: '', })) }, setDevicename: (devicename: string) => { @@ -113,11 +66,6 @@ export const useSignupState = Z.createZustand('signup', (set, get) => { s.justSignedUpEmail = email }) }, - setUsername: (username: string) => { - set(s => { - s.username = username - }) - }, } return { ...initialStore, diff --git a/shared/stores/tests/signup.test.ts b/shared/stores/tests/signup.test.ts index 0bb05304b412..1c5b73f575ca 100644 --- a/shared/stores/tests/signup.test.ts +++ b/shared/stores/tests/signup.test.ts @@ -1,6 +1,5 @@ /// import * as S from '@/constants/strings' -import * as T from '@/constants/types' import {resetAllStores} from '@/util/zustand' import {useSignupState} from '../signup' @@ -10,32 +9,12 @@ afterEach(() => { resetAllStores() }) -const flush = async () => new Promise(resolve => setImmediate(resolve)) - -test('setUsername stages the validated signup username', () => { - useSignupState.getState().dispatch.setUsername('alice123') - - expect(useSignupState.getState().username).toBe('alice123') -}) - test('setDevicename stages the selected signup device name', () => { useSignupState.getState().dispatch.setDevicename('Phone 2') expect(useSignupState.getState().devicename).toBe('Phone 2') }) -test('requestAutoInvite marks signup as ready once the invite code request completes', async () => { - jest.spyOn(T.RPCGen, 'signupGetInvitationCodeRpcPromise').mockResolvedValue('invite-code') - - useSignupState.getState().dispatch.requestAutoInvite('alice') - await flush() - - const state = useSignupState.getState() - expect(state.autoInviteState).toBe('ready') - expect(state.inviteCode).toBe('invite-code') - expect(state.username).toBe('alice') -}) - test('email verification notifications clear the staged signup email', () => { useSignupState.getState().dispatch.setJustSignedUpEmail('alice@example.com') expect(useSignupState.getState().justSignedUpEmail).toBe('alice@example.com') @@ -50,19 +29,13 @@ test('email verification notifications clear the staged signup email', () => { test('resetState clears staged signup values back to defaults', () => { useSignupState.setState(s => ({ ...s, - autoInviteState: 'ready', devicename: 'Phone 2', - inviteCode: 'invite-code', justSignedUpEmail: 'alice@example.com', - username: 'alice', })) useSignupState.getState().dispatch.resetState() const state = useSignupState.getState() - expect(state.autoInviteState).toBe('idle') expect(state.devicename).toBe(S.defaultDevicename) - expect(state.inviteCode).toBe('') expect(state.justSignedUpEmail).toBe('') - expect(state.username).toBe('') }) From 39f71de569ca0a9591cf049aa2226c86e2c8a485 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Wed, 25 Mar 2026 15:14:07 -0400 Subject: [PATCH 03/19] WIP --- shared/login/recover-password/password.tsx | 2 +- shared/settings/account/index.tsx | 4 +- shared/settings/advanced.tsx | 41 ++++- shared/settings/logout.tsx | 52 +++--- shared/settings/password.tsx | 100 +++++----- shared/stores/settings-password.tsx | 104 ----------- shared/stores/tests/settings-password.test.ts | 75 ++------ skill/zustand-store-pruning/SKILL.md | 172 ++++++++++++++++++ .../zustand-store-pruning/agents/openai.yaml | 4 + .../references/keybase-examples.md | 112 ++++++++++++ .../references/store-checklist.md | 67 +++++++ 11 files changed, 475 insertions(+), 258 deletions(-) create mode 100644 skill/zustand-store-pruning/SKILL.md create mode 100644 skill/zustand-store-pruning/agents/openai.yaml create mode 100644 skill/zustand-store-pruning/references/keybase-examples.md create mode 100644 skill/zustand-store-pruning/references/store-checklist.md diff --git a/shared/login/recover-password/password.tsx b/shared/login/recover-password/password.tsx index 2e61426b7edc..890b5801dbcc 100644 --- a/shared/login/recover-password/password.tsx +++ b/shared/login/recover-password/password.tsx @@ -9,7 +9,7 @@ const Password = () => { const onSave = (p: string) => { submitPassword?.(p) } - return + return } export default Password diff --git a/shared/settings/account/index.tsx b/shared/settings/account/index.tsx index a106ee6b67b8..5f7b33bd130a 100644 --- a/shared/settings/account/index.tsx +++ b/shared/settings/account/index.tsx @@ -182,10 +182,9 @@ const AccountSettings = () => { loadSettings: s.dispatch.loadSettings, })) ) - const {loadHasRandomPw, loadRememberPassword} = usePWState( + const {loadHasRandomPw} = usePWState( C.useShallow(s => ({ loadHasRandomPw: s.dispatch.loadHasRandomPw, - loadRememberPassword: s.dispatch.loadRememberPassword, })) ) const {navigateAppend, switchTab} = C.useRouterState( @@ -201,7 +200,6 @@ const AccountSettings = () => { const onClearAddedPhone = clearAddedPhone const onReload = () => { loadSettings() - loadRememberPassword() loadHasRandomPw() } const onStartPhoneConversation = () => { diff --git a/shared/settings/advanced.tsx b/shared/settings/advanced.tsx index c0cd1b3b58f0..ae8dc817f568 100644 --- a/shared/settings/advanced.tsx +++ b/shared/settings/advanced.tsx @@ -84,16 +84,12 @@ let disableSpellCheckInitialValue: boolean | undefined const Advanced = () => { const settingLockdownMode = C.Waiting.useAnyWaiting(C.waitingKeySettingsSetLockdownMode) - const pwState = usePWState( + const {hasRandomPW, loadHasRandomPw} = usePWState( C.useShallow(s => ({ hasRandomPW: !!s.randomPW, loadHasRandomPw: s.dispatch.loadHasRandomPw, - loadRememberPassword: s.dispatch.loadRememberPassword, - rememberPassword: s.rememberPassword, - setRememberPassword: s.dispatch.setRememberPassword, })) ) - const {hasRandomPW, loadHasRandomPw, loadRememberPassword, rememberPassword, setRememberPassword} = pwState const {onSetOpenAtLogin, openAtLogin} = useConfigState( C.useShallow(s => ({ onSetOpenAtLogin: s.dispatch.setOpenAtLogin, @@ -106,10 +102,12 @@ const Advanced = () => { })) ) const setLockdownModeError = C.Waiting.useAnyErrors(C.waitingKeySettingsSetLockdownMode)?.message || '' - const onChangeRememberPassword = setRememberPassword + const [rememberPassword, setRememberPassword] = React.useState(undefined) const [disableSpellCheck, setDisableSpellcheck] = React.useState(undefined) const loadDisableSpellcheck = C.useRPC(T.RPCGen.configGuiGetValueRpcPromise) + const loadRememberPassword = C.useRPC(T.RPCGen.configGetRememberPassphraseRpcPromise) + const submitRememberPassword = C.useRPC(T.RPCGen.configSetRememberPassphraseRpcPromise) // load it React.useEffect(() => { @@ -133,7 +131,32 @@ const Advanced = () => { } }, [disableSpellCheck, loadDisableSpellcheck]) + React.useEffect(() => { + if (rememberPassword === undefined) { + loadRememberPassword( + [undefined], + remember => { + setRememberPassword(remember) + }, + () => { + setRememberPassword(true) + } + ) + } + }, [loadRememberPassword, rememberPassword]) + const submitDisableSpellcheck = C.useRPC(T.RPCGen.configGuiSetValueRpcPromise) + const onChangeRememberPassword = (remember: boolean) => { + const previous = rememberPassword + setRememberPassword(remember) + submitRememberPassword( + [{remember}], + () => {}, + () => { + setRememberPassword(previous) + } + ) + } const onToggleDisableSpellcheck = () => { const next = !disableSpellCheck @@ -156,8 +179,7 @@ const Advanced = () => { React.useEffect(() => { loadHasRandomPw() loadLockdownMode() - loadRememberPassword() - }, [loadRememberPassword, loadHasRandomPw, loadLockdownMode]) + }, [loadHasRandomPw, loadLockdownMode]) return ( @@ -173,7 +195,8 @@ const Advanced = () => { )} {!hasRandomPW && ( Always stay logged in diff --git a/shared/settings/logout.tsx b/shared/settings/logout.tsx index 93c4df907ee2..fb0fef006974 100644 --- a/shared/settings/logout.tsx +++ b/shared/settings/logout.tsx @@ -1,8 +1,9 @@ import * as React from 'react' import {useSafeSubmit} from '@/util/safe-submit' import * as C from '@/constants' +import * as T from '@/constants/types' import * as Kb from '@/common-adapters' -import {UpdatePassword} from './password' +import {UpdatePassword, useSubmitNewPassword} from './password' import {usePWState} from '@/stores/settings-password' import {useSettingsState} from '@/stores/settings' import {useLogoutState} from '@/stores/logout' @@ -15,42 +16,25 @@ const LogoutContainer = () => { resetCheckPassword: s.dispatch.resetCheckPassword, })) ) - const pwState = usePWState( + const {hasRandomPW, loadHasRandomPw} = usePWState( C.useShallow(s => ({ - _setPassword: s.dispatch.setPassword, - hasPGPKeyOnServer: !!s.hasPGPKeyOnServer, hasRandomPW: s.randomPW, loadHasRandomPw: s.dispatch.loadHasRandomPw, - onUpdatePGPSettings: s.dispatch.loadPgpSettings, - setPasswordConfirm: s.dispatch.setPasswordConfirm, - submitNewPassword: s.dispatch.submitNewPassword, })) ) - const {hasPGPKeyOnServer, hasRandomPW, loadHasRandomPw, onUpdatePGPSettings} = pwState - const {setPasswordConfirm, submitNewPassword, _setPassword} = pwState - const waitingForResponse = C.Waiting.useAnyWaiting(C.waitingKeySettingsGeneric) + const {error, onSave, waitingForResponse} = useSubmitNewPassword(true) + const [hasPGPKeyOnServer, setHasPGPKeyOnServer] = React.useState(false) + const loadPgpSettings = C.useRPC(T.RPCGen.accountHasServerKeysRpcPromise) + const requestLogout = useLogoutState(s => s.dispatch.requestLogout) const onBootstrap = loadHasRandomPw - const navigateUp = C.useRouterState(s => s.dispatch.navigateUp) - const onCancel = () => { - resetCheckPassword() - navigateUp() - } const onCheckPassword = checkPassword - const requestLogout = useLogoutState(s => s.dispatch.requestLogout) - const _onLogout = () => { requestLogout() resetCheckPassword() } - const onSavePassword = (password: string) => { - _setPassword(password) - setPasswordConfirm(password) - submitNewPassword(true) - } - const onLogout = useSafeSubmit(_onLogout, false) const [loggingOut, setLoggingOut] = React.useState(false) @@ -61,6 +45,21 @@ const LogoutContainer = () => { onBootstrap() }, [onBootstrap]) + React.useEffect(() => { + if (!hasRandomPW) { + return + } + loadPgpSettings( + [undefined], + ({hasServerKeys}) => { + setHasPGPKeyOnServer(hasServerKeys) + }, + () => { + setHasPGPKeyOnServer(false) + } + ) + }, [hasRandomPW, loadPgpSettings]) + const logOut = () => { if (loggingOut) return onLogout() @@ -73,12 +72,9 @@ const LogoutContainer = () => { ) : hasRandomPW ? ( diff --git a/shared/settings/password.tsx b/shared/settings/password.tsx index 7e1f3faff992..7794f3f7a3cb 100644 --- a/shared/settings/password.tsx +++ b/shared/settings/password.tsx @@ -1,33 +1,25 @@ import * as React from 'react' import * as Kb from '@/common-adapters' import * as C from '@/constants' +import * as T from '@/constants/types' import {usePWState} from '@/stores/settings-password' +import {useLogoutState} from '@/stores/logout' type Props = { error: string hasPGPKeyOnServer?: boolean - hasRandomPW: boolean - newPasswordError?: string - newPasswordConfirmError?: string - onCancel?: () => void onSave: (password: string) => void // will only be called if password.length > 8 & passwords match saveLabel?: string showTyping?: boolean waitingForResponse?: boolean - onUpdatePGPSettings?: () => void } export const UpdatePassword = (props: Props) => { - const {onUpdatePGPSettings} = props const [password, setPassword] = React.useState('') const [passwordConfirm, setPasswordConfirm] = React.useState('') const [showTyping, setShowTyping] = React.useState(!!props.showTyping) const [errorSaving, setErrorSaving] = React.useState('') - React.useEffect(() => { - onUpdatePGPSettings?.() - }, [onUpdatePGPSettings]) - const handlePasswordChange = (password: string) => { setPassword(password) setErrorSaving(errorSavingFunc(password, passwordConfirm)) @@ -77,24 +69,6 @@ export const UpdatePassword = (props: Props) => { ) : null} - {props.newPasswordError ? ( - - - - ) : null} - {props.hasPGPKeyOnServer === undefined ? ( - - - - ) : null} - {props.newPasswordConfirmError ? ( - - - - ) : null} { - const error = usePWState(s => s.error) - const hasPGPKeyOnServer = usePWState(s => !!s.hasPGPKeyOnServer) - const hasRandomPW = usePWState(s => !!s.randomPW) - const newPasswordConfirmError = usePWState(s => s.newPasswordConfirmError) - const newPasswordError = usePWState(s => s.newPasswordError) - const saveLabel = usePWState(s => (s.randomPW ? 'Create password' : 'Save')) +export const useSubmitNewPassword = (thenLogout: boolean) => { + const [error, setError] = React.useState('') const waitingForResponse = C.Waiting.useAnyWaiting(C.waitingKeySettingsGeneric) - const navigateUp = C.useRouterState(s => s.dispatch.navigateUp) - const onCancel = () => { - navigateUp() - } - - const setPassword = usePWState(s => s.dispatch.setPassword) - const setPasswordConfirm = usePWState(s => s.dispatch.setPasswordConfirm) - const submitNewPassword = usePWState(s => s.dispatch.submitNewPassword) + const requestLogout = useLogoutState(s => s.dispatch.requestLogout) + const submitNewPassword = C.useRPC(T.RPCGen.accountPassphraseChangeRpcPromise) const onSave = (password: string) => { - setPassword(password) - setPasswordConfirm(password) - submitNewPassword(false) + setError('') + submitNewPassword( + [ + { + force: true, + oldPassphrase: '', + passphrase: password, + }, + C.waitingKeySettingsGeneric, + ], + () => { + if (thenLogout) { + requestLogout() + } + navigateUp() + }, + err => { + setError(err.desc) + } + ) } - const onUpdatePGPSettings = usePWState(s => s.dispatch.loadPgpSettings) + return {error, onSave, waitingForResponse} +} + +const Container = () => { + const randomPW = usePWState(s => s.randomPW) + const saveLabel = randomPW ? 'Create password' : 'Save' + const {error, onSave, waitingForResponse} = useSubmitNewPassword(false) + + const [hasPGPKeyOnServer, setHasPGPKeyOnServer] = React.useState(false) + const loadPgpSettings = C.useRPC(T.RPCGen.accountHasServerKeysRpcPromise) + React.useEffect(() => { + loadPgpSettings( + [undefined], + ({hasServerKeys}) => { + setHasPGPKeyOnServer(hasServerKeys) + }, + () => { + setHasPGPKeyOnServer(false) + } + ) + }, [loadPgpSettings]) + const props = { error, hasPGPKeyOnServer, - hasRandomPW, - newPasswordConfirmError, - newPasswordError, - onCancel, onSave, - onUpdatePGPSettings, saveLabel, waitingForResponse, } diff --git a/shared/stores/settings-password.tsx b/shared/stores/settings-password.tsx index cd0fcea0c52c..54578d1b9669 100644 --- a/shared/stores/settings-password.tsx +++ b/shared/stores/settings-password.tsx @@ -1,45 +1,22 @@ import * as Z from '@/util/zustand' import {ignorePromise} from '@/constants/utils' -import {waitingKeySettingsGeneric} from '@/constants/strings' import logger from '@/logger' import {RPCError} from '@/util/errors' import * as T from '@/constants/types' -import {navigateUp} from '@/constants/router' -import {useLogoutState} from '@/stores/logout' type Store = T.Immutable<{ - error: string - hasPGPKeyOnServer?: boolean - newPassword: string - newPasswordConfirm: string - newPasswordConfirmError: string - newPasswordError: string randomPW?: boolean - rememberPassword: boolean }> const initialStore: Store = { - error: '', - hasPGPKeyOnServer: undefined, - newPassword: '', - newPasswordConfirm: '', - newPasswordConfirmError: '', - newPasswordError: '', randomPW: undefined, - rememberPassword: true, } export type State = Store & { dispatch: { loadHasRandomPw: () => void - loadPgpSettings: () => void - loadRememberPassword: () => void notifyUsersPasswordChanged: (randomPW: boolean) => void resetState: () => void - setPassword: (password: string) => void - setPasswordConfirm: (confirm: string) => void - setRememberPassword: (remember: boolean) => void - submitNewPassword: (thenLogOut?: boolean) => void } } @@ -69,93 +46,12 @@ export const usePWState = Z.createZustand('settings-password', (set, get) } ignorePromise(f()) }, - loadPgpSettings: () => { - const f = async () => { - try { - const {hasServerKeys} = await T.RPCGen.accountHasServerKeysRpcPromise() - set(s => { - s.hasPGPKeyOnServer = hasServerKeys - }) - } catch (error) { - if (!(error instanceof RPCError)) { - return - } - const msg = error.desc - set(s => { - s.error = msg - }) - } - } - ignorePromise(f()) - }, - loadRememberPassword: () => { - const f = async () => { - const remember = await T.RPCGen.configGetRememberPassphraseRpcPromise() - set(s => { - s.rememberPassword = remember - }) - } - ignorePromise(f()) - }, notifyUsersPasswordChanged: randomPW => { set(s => { s.randomPW = randomPW }) }, resetState: Z.defaultReset, - setPassword: password => { - set(s => { - s.error = '' - s.newPassword = password - }) - }, - setPasswordConfirm: confirm => { - set(s => { - s.error = '' - s.newPasswordConfirm = confirm - }) - }, - setRememberPassword: remember => { - const f = async () => { - await T.RPCGen.configSetRememberPassphraseRpcPromise({remember}) - } - ignorePromise(f()) - }, - submitNewPassword: (thenLogout = false) => { - const f = async () => { - try { - const {newPassword, newPasswordConfirm} = get() - if (newPassword !== newPasswordConfirm) { - set(s => { - s.error = "Passwords don't match" - }) - return - } - await T.RPCGen.accountPassphraseChangeRpcPromise( - { - force: true, - oldPassphrase: '', - passphrase: newPassword, - }, - waitingKeySettingsGeneric - ) - - if (thenLogout) { - useLogoutState.getState().dispatch.requestLogout() - } - navigateUp() - } catch (error) { - if (!(error instanceof RPCError)) { - return - } - const msg = error.desc - set(s => { - s.error = msg - }) - } - } - ignorePromise(f()) - }, } return { ...initialStore, diff --git a/shared/stores/tests/settings-password.test.ts b/shared/stores/tests/settings-password.test.ts index 995788d77849..8b2f084d6eb9 100644 --- a/shared/stores/tests/settings-password.test.ts +++ b/shared/stores/tests/settings-password.test.ts @@ -2,80 +2,33 @@ import * as T from '@/constants/types' import {resetAllStores} from '@/util/zustand' -jest.mock('@/constants/router', () => { - const actual = jest.requireActual('@/constants/router') - return { - ...actual, - navigateUp: jest.fn(), - } -}) - -jest.mock('@/stores/logout', () => { - const mockRequestLogout = jest.fn() - return { - __mockRequestLogout: mockRequestLogout, - useLogoutState: { - getState: () => ({ - dispatch: { - requestLogout: mockRequestLogout, - }, - }), - }, - } -}) - import {usePWState} from '../settings-password' -const {navigateUp: mockNavigateUp} = require('@/constants/router') as { - navigateUp: jest.Mock -} -const mockRequestLogout = require('@/stores/logout').__mockRequestLogout as jest.Mock - afterEach(() => { jest.restoreAllMocks() - mockNavigateUp.mockReset() - mockRequestLogout.mockReset() resetAllStores() }) const flush = async () => new Promise(resolve => setImmediate(resolve)) -test('setPassword and setPasswordConfirm update the staged values and clear errors', () => { - usePWState.setState({ - ...usePWState.getState(), - error: 'boom', - }) - - usePWState.getState().dispatch.setPassword('hunter2') - usePWState.getState().dispatch.setPasswordConfirm('hunter2') +test('loadHasRandomPw caches the loaded state', async () => { + const loadPassphraseState = jest + .spyOn(T.RPCGen, 'userLoadPassphraseStateRpcPromise') + .mockResolvedValue(T.RPCGen.PassphraseState.random) - expect(usePWState.getState().newPassword).toBe('hunter2') - expect(usePWState.getState().newPasswordConfirm).toBe('hunter2') - expect(usePWState.getState().error).toBe('') -}) - -test('submitNewPassword rejects mismatched passwords locally', async () => { - const changePassword = jest.spyOn(T.RPCGen, 'accountPassphraseChangeRpcPromise') - - usePWState.getState().dispatch.setPassword('one') - usePWState.getState().dispatch.setPasswordConfirm('two') - usePWState.getState().dispatch.submitNewPassword() + usePWState.getState().dispatch.loadHasRandomPw() + await flush() + usePWState.getState().dispatch.loadHasRandomPw() await flush() - expect(usePWState.getState().error).toBe("Passwords don't match") - expect(changePassword).not.toHaveBeenCalled() - expect(mockNavigateUp).not.toHaveBeenCalled() + expect(usePWState.getState().randomPW).toBe(true) + expect(loadPassphraseState).toHaveBeenCalledTimes(1) }) -test('submitNewPassword logs out and navigates away on success', async () => { - jest.spyOn(T.RPCGen, 'accountPassphraseChangeRpcPromise').mockResolvedValue(undefined as any) - - usePWState.getState().dispatch.setPassword('hunter2') - usePWState.getState().dispatch.setPasswordConfirm('hunter2') - usePWState.getState().dispatch.submitNewPassword(true) - await flush() +test('notifyUsersPasswordChanged overwrites the cached state', () => { + usePWState.getState().dispatch.notifyUsersPasswordChanged(true) + expect(usePWState.getState().randomPW).toBe(true) - expect(mockRequestLogout).toHaveBeenCalled() - expect(mockNavigateUp).toHaveBeenCalled() - expect(usePWState.getState().error).toBe('') + usePWState.getState().dispatch.notifyUsersPasswordChanged(false) + expect(usePWState.getState().randomPW).toBe(false) }) diff --git a/skill/zustand-store-pruning/SKILL.md b/skill/zustand-store-pruning/SKILL.md new file mode 100644 index 000000000000..eff772765b10 --- /dev/null +++ b/skill/zustand-store-pruning/SKILL.md @@ -0,0 +1,172 @@ +--- +name: zustand-store-pruning +description: Use when refactoring Keybase Zustand stores in `shared/stores` to remove screen-local or route-owned state, keep only truly global or cross-screen data in the store, move one-off RPC calls into components with `C.useRPC`, and split the work into safe stacked commits. +--- + +# Pruning Zustand Stores + +Use this skill for store-by-store cleanup in the Keybase client. The goal is to shrink `shared/stores/*` down to state that is genuinely global, notification-driven, or shared across unrelated screens. + +Do not silently drop behavior. If a field or action is ambiguous, state the tradeoff and keep the behavior intact. + +## Best Targets First + +Start with small settings or wizard stores that mix form state and RPC orchestration: + +- `shared/stores/settings-email.tsx` +- `shared/stores/settings-phone.tsx` +- `shared/stores/settings-password.tsx` +- `shared/stores/recover-password.tsx` +- `shared/stores/logout.tsx` + +Defer large or clearly global stores unless the user explicitly wants them: + +- `shared/stores/config.tsx` +- `shared/stores/current-user.tsx` +- `shared/stores/router.tsx` +- `shared/stores/waiting.tsx` +- `shared/stores/convostate.tsx` +- `shared/stores/fs.tsx` +- `shared/stores/teams.tsx` + +See [keybase-examples.md](references/keybase-examples.md) for store-specific guidance. +Use [store-checklist.md](references/store-checklist.md) to track which stores are untouched, in progress, or done. + +## Triage Rules + +Classify every store field and action before editing. + +Keep it in the store if it is: + +- Shared across multiple unrelated screens or tabs +- Updated by engine notifications, daemon pushes, or other background events +- Needed outside React components or used for cross-store coordination +- A long-lived cache keyed by usernames, team IDs, conversation IDs, paths, or other global entities +- Awkward or lossy to pass through navigation because it must survive independent screen entry points + +Move it to component state if it is: + +- Form input text, local validation errors, banners, or submit progress +- Temporary selection, highlight, filter, or sort state +- Wizard-step state that only matters while one screen or modal is mounted +- A one-shot RPC result only used by the current screen +- Reset on every screen entry and not meaningful elsewhere + +Move it to route params if it is: + +- Data screen A already knows and screen B only needs for that navigation +- A modal confirmation payload such as IDs, usernames, booleans, or prefilled values +- Entry context that should be explicit in navigation rather than hidden in a global store + +Move RPC calls out of the store if: + +- The RPC is initiated by a screen and its result only updates that screen +- The RPC was only stored so the screen could call `dispatch.someAction()` +- Failure and waiting state are screen-local + +Keep RPC logic in the store if: + +- It services notification handlers or global refresh flows +- It fans results out to multiple screens +- It maintains a shared cache that survives navigation + +## Refactor Workflow + +### 1. Pick one store and map consumers + +From `shared/`, find the store hook, its selectors, and its dispatch callers with `rg`. + +Look for: + +- Components reading store fields +- Components calling `dispatch.*` +- Notification handlers keeping the store in sync +- Navigation calls that could carry explicit params instead + +### 2. Build a keep-or-move table + +For each field and action, label it: + +- `keep-global` +- `move-component` +- `move-route` +- `delete-derived` +- `unsure` + +Do this before writing code. If several fields move together, migrate that whole screen flow in one pass. + +### 3. Move screen-owned RPCs into components + +Prefer `C.useRPC` when the RPC belongs to the current screen: + +```tsx +const loadThing = C.useRPC(T.RPCGen.someRpcPromise) + +loadThing( + [rpcArgs, waitingKey], + result => { + setLocalState(result) + }, + err => { + setError(err.message) + } +) +``` + +Keep waiting keys when they drive UI. If the store only existed to wrap that RPC, remove the wrapper action after consumers are updated. + +### 4. Move per-screen flow state into components + +Use `React.useState`, `React.useEffect`, and existing screen hooks. In plain `.tsx` files, use `Kb.*` components rather than raw DOM elements. + +If a component reads multiple adjacent values from the same remaining store, prefer one selector with `C.useShallow(...)` over several subscriptions. + +### 5. Move navigation-owned data into params + +Use existing typed navigation patterns: + +```tsx +navigateAppend({name: 'someScreen', params: {foo, bar}}) +``` + +Read params in the destination screen with the existing route helpers, for example: + +```tsx +const {params} = useRoute>() +``` + +Keep params limited to explicit entry context. Do not recreate a hidden global store inside the route object. + +### 6. Collapse the store + +After consumers move off the store: + +- Delete dead fields, actions, helpers, imports, and tests +- Remove unused notification plumbing only if behavior is preserved +- Keep reset behavior coherent for whatever remains +- Preserve public store names unless there is a strong reason to rename them + +## Commit Shape + +Prefer one stacked commit per store. Each commit should be reviewable on its own: + +1. Pick one store and one user-visible flow. +2. Move local state and route-owned data to the component layer. +3. Move screen-owned RPC calls to `C.useRPC`. +4. Remove dead store fields and actions. +5. Update or prune store tests that no longer apply. + +Do not mix multiple unrelated stores into one commit unless they are tightly coupled and impossible to review separately. + +## Validation + +On this machine, `node_modules` is not installed for this repo. Do pure code work only. + +Still validate by inspection: + +- Read all updated call sites +- Confirm no component still selects removed state or dispatches removed actions +- Confirm route param names line up between navigation and destination screens +- Confirm notification handlers still land somewhere intentional + +If the refactor removes a store test, explain why the behavior moved to the component layer. diff --git a/skill/zustand-store-pruning/agents/openai.yaml b/skill/zustand-store-pruning/agents/openai.yaml new file mode 100644 index 000000000000..b0a6b90fcd90 --- /dev/null +++ b/skill/zustand-store-pruning/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Zustand Store Pruning" + short_description: "Prune Keybase Zustand stores safely" + default_prompt: "Use $zustand-store-pruning to refactor one `shared/stores` store by moving local state and screen-owned RPCs out of it." diff --git a/skill/zustand-store-pruning/references/keybase-examples.md b/skill/zustand-store-pruning/references/keybase-examples.md new file mode 100644 index 000000000000..975e28942b58 --- /dev/null +++ b/skill/zustand-store-pruning/references/keybase-examples.md @@ -0,0 +1,112 @@ +# Keybase Store Pruning Notes + +Use these examples to decide what to migrate first and what usually stays global. + +## Strong Early Candidates + +### `shared/stores/settings-email.tsx` + +Likely local or route-owned: + +- `addingEmail` +- `newEmail` +- `error` +- most submit-time RPC orchestration in `addEmail` and `editEmail` + +Potentially local unless another screen truly depends on it: + +- `addedEmail` +- `emails` + +Good target shape: + +- Move submit and resend flows into the owning settings components with `C.useRPC` +- Keep only notification-backed email data if it is still shared outside the screen +- Pass one-shot success context through route params instead of storing it globally when feasible + +### `shared/stores/settings-phone.tsx` + +Likely local: + +- `error` +- `pendingVerification` +- `verificationState` +- `defaultCountry` +- RPC orchestration in `addPhoneNumber`, `resendVerificationForPhone`, `verifyPhoneNumber` + +Potentially local if only the account settings flow reads it: + +- `phones` + +Good target shape: + +- Put add/resend/verify state next to the phone settings UI +- Use route params if a follow-up screen only needs the number or entry context +- Preserve notification-driven updates if phone rows can change while the screen is open + +### `shared/stores/settings-password.tsx` + +Likely local: + +- `newPassword` +- `newPasswordConfirm` +- `newPasswordError` +- `newPasswordConfirmError` +- `error` +- `hasPGPKeyOnServer` +- `rememberPassword` if only the password screen uses it + +Maybe keep if used elsewhere or notification-backed: + +- `randomPW` + +Good target shape: + +- Run load and submit RPCs from the screen with `C.useRPC` +- Keep only truly shared password metadata if another part of the app consumes it + +## Usually Keep Global + +These stores are not automatic no-touch zones, but they need a stronger reason before pruning: + +- `config` +- `current-user` +- `router` +- `waiting` +- `convostate` +- `fs` +- `teams` + +They contain global caches, notification-driven state, navigation coordination, or app/session state that does not belong to a single screen. + +## Route Param Patterns In This Repo + +Common navigation shape: + +```tsx +navigateAppend({name: 'devicePage', params: {deviceID}}) +``` + +Common read shape: + +```tsx +const {params} = useRoute>() +``` + +Use params for explicit handoff data such as IDs, usernames, booleans, prefilled values, and one-shot screen entry context. + +## Stacked Commit Heuristics + +Good stacked series: + +1. One commit per store. +2. Keep each commit focused on a single screen flow. +3. Delete dead store code in the same commit that removes the last consumer. +4. Mention any intentionally retained global field in the summary so the next pass has context. + +Bad stacked series: + +- One commit that touches half the stores in `shared/stores` +- Moving state into components but leaving dead actions behind for later +- Replacing explicit route params with a new ad hoc store field +- Quietly dropping notification handlers because the current screen no longer needs them diff --git a/skill/zustand-store-pruning/references/store-checklist.md b/skill/zustand-store-pruning/references/store-checklist.md new file mode 100644 index 000000000000..560991171c4b --- /dev/null +++ b/skill/zustand-store-pruning/references/store-checklist.md @@ -0,0 +1,67 @@ +# Store Checklist + +Use this file as the running checklist for the stacked cleanup series. + +Status: + +- `[ ]` not started +- `[-]` in progress +- `[x]` done +- `[~]` intentionally skipped for now + +## Smaller / Likely Early Passes + +- [ ] `archive` +- [ ] `autoreset` +- [ ] `bots` +- [ ] `crypto` +- [ ] `daemon` +- [ ] `darkmode` +- [ ] `devices` +- [ ] `followers` +- [ ] `git` +- [ ] `inbox-rows` +- [ ] `logout` +- [ ] `modal-header` +- [ ] `notifications` +- [ ] `people` +- [ ] `pinentry` +- [ ] `profile` +- [ ] `provision` +- [ ] `recover-password` +- [ ] `settings` +- [ ] `settings-chat` +- [ ] `settings-email` +- [ ] `settings-notifications` +- [x] `settings-password` kept only `randomPW` in store; moved submit/load flows into settings screens +- [ ] `settings-phone` +- [ ] `signup` +- [ ] `team-building` +- [ ] `tracker` +- [ ] `unlock-folders` +- [ ] `users` +- [ ] `wallets` + +## Larger / More Global Stores + +- [ ] `chat` +- [ ] `config` +- [ ] `convostate` +- [ ] `current-user` +- [ ] `fs` +- [ ] `router` +- [ ] `teams` +- [ ] `waiting` + +## Platform-Specific Logical Stores + +- [ ] `push` + Files: `shared/stores/push.desktop.tsx`, `shared/stores/push.native.tsx`, `shared/stores/push.d.ts` +- [ ] `settings-contacts` + Files: `shared/stores/settings-contacts.desktop.tsx`, `shared/stores/settings-contacts.native.tsx`, `shared/stores/settings-contacts.d.ts` + +## Notes + +- Track logical stores here, not `shared/stores/tests/*`. +- `store-registry.tsx` is infrastructure, not a target store. +- When a store is done, optionally append a short note with the commit hash or summary. From faba214f3af0bf23fe9e96688dd52b8c5fd04327 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Wed, 25 Mar 2026 18:07:46 -0400 Subject: [PATCH 04/19] WIP --- shared/people/todo.tsx | 8 +- shared/settings/account/add-modals.tsx | 72 +++++++---------- shared/settings/account/use-add-email.tsx | 74 +++++++++++++++++ shared/signup/email.tsx | 36 +++------ shared/stores/settings-email.tsx | 81 +------------------ shared/stores/tests/settings-email.test.ts | 30 ++----- .../references/store-checklist.md | 2 +- 7 files changed, 124 insertions(+), 179 deletions(-) create mode 100644 shared/settings/account/use-add-email.tsx diff --git a/shared/people/todo.tsx b/shared/people/todo.tsx index eb5b05f36bf9..e39f57323ef9 100644 --- a/shared/people/todo.tsx +++ b/shared/people/todo.tsx @@ -216,12 +216,7 @@ const TeamShowcaseConnector = (props: TodoOwnProps) => { } const VerifyAllEmailConnector = (props: TodoOwnProps) => { - const {addingEmail, editEmail} = useSettingsEmailState( - C.useShallow(s => ({ - addingEmail: s.addingEmail, - editEmail: s.dispatch.editEmail, - })) - ) + const editEmail = useSettingsEmailState(s => s.dispatch.editEmail) const setResentEmail = usePeopleState(s => s.dispatch.setResentEmail) const onConfirm = (email: string) => { editEmail({email, verify: true}) @@ -251,7 +246,6 @@ const VerifyAllEmailConnector = (props: TodoOwnProps) => { label: hasRecentVerifyEmail ? `Verify again` : 'Verify', onClick: () => onConfirm(meta.email), type: 'Success' as const, - waiting: addingEmail ? addingEmail === meta.email : false, }, ] : []), diff --git a/shared/settings/account/add-modals.tsx b/shared/settings/account/add-modals.tsx index db284fefe26e..c296f6562a4e 100644 --- a/shared/settings/account/add-modals.tsx +++ b/shared/settings/account/add-modals.tsx @@ -5,59 +5,38 @@ import {useSafeNavigation} from '@/util/safe-navigation' import {EnterEmailBody} from '@/signup/email' import {EnterPhoneNumberBody} from '@/signup/phone-number' import VerifyBody from '@/signup/phone-number/verify-body' +import {useAddEmail} from './use-add-email' import {useSettingsPhoneState} from '@/stores/settings-phone' -import {useSettingsEmailState} from '@/stores/settings-email' export const Email = () => { const nav = useSafeNavigation() const [email, onChangeEmail] = React.useState('') const [searchable, onChangeSearchable] = React.useState(true) - const [addEmailInProgress, onAddEmailInProgress] = React.useState('') + const [submittedEmail, setSubmittedEmail] = React.useState('') const emailTrimmed = email.trim() const disabled = !emailTrimmed - const {addedEmail, addEmail, emailError, resetAddingEmail} = useSettingsEmailState( - C.useShallow(s => ({ - addEmail: s.dispatch.addEmail, - addedEmail: s.addedEmail, - emailError: s.error, - resetAddingEmail: s.dispatch.resetAddingEmail, - })) - ) - const waiting = C.Waiting.useAnyWaiting(C.addEmailWaitingKey) - - // clean on unmount - React.useEffect( - () => () => { - resetAddingEmail() - }, - [resetAddingEmail] - ) + const {clearError, error: emailError, submitEmail, waiting} = useAddEmail() const clearModals = C.useRouterState(s => s.dispatch.clearModals) - // watch for + nav away on success - React.useEffect(() => { - if (addedEmail && addedEmail === addEmailInProgress) { - // success - clearModals() - } - }, [addEmailInProgress, addedEmail, clearModals]) // clean on edit React.useEffect(() => { - if (emailTrimmed !== addEmailInProgress && emailError) { - resetAddingEmail() + if (emailTrimmed !== submittedEmail && emailError) { + clearError() } - }, [addEmailInProgress, resetAddingEmail, emailError, emailTrimmed]) + }, [clearError, emailError, emailTrimmed, submittedEmail]) const onClose = () => nav.safeNavigateUp() const onContinue = () => { if (disabled || waiting) { return } - onAddEmailInProgress(emailTrimmed) - addEmail(emailTrimmed, searchable) + setSubmittedEmail(emailTrimmed) + submitEmail(emailTrimmed, searchable, () => { + clearModals() + }) } return ( <> @@ -91,19 +70,24 @@ export const Email = () => { } /> - - - {!Kb.Styles.isMobile && ( - - )} - - + + + {!Kb.Styles.isMobile && ( + + )} + + ) diff --git a/shared/settings/account/use-add-email.tsx b/shared/settings/account/use-add-email.tsx new file mode 100644 index 000000000000..35ccfb27d77a --- /dev/null +++ b/shared/settings/account/use-add-email.tsx @@ -0,0 +1,74 @@ +import * as C from '@/constants' +import * as React from 'react' +import * as T from '@/constants/types' +import {useSettingsEmailState} from '@/stores/settings-email' +import {RPCError} from '@/util/errors' +import {isValidEmail} from '@/util/simple-validators' + +const makeAddEmailError = (err: RPCError): string => { + switch (err.code) { + case T.RPCGen.StatusCode.scratelimit: + return "Sorry, you've added too many email addresses lately. Please try again later." + case T.RPCGen.StatusCode.scemailtaken: + return 'This email is already claimed by another user.' + case T.RPCGen.StatusCode.scemaillimitexceeded: + return 'You have too many emails, delete one and try again.' + case T.RPCGen.StatusCode.scinputerror: + return 'Invalid email.' + default: + return err.message + } +} + +export const useAddEmail = () => { + const addEmail = C.useRPC(T.RPCGen.emailsAddEmailRpcPromise) + const setAddedEmail = useSettingsEmailState(s => s.dispatch.setAddedEmail) + const waiting = C.Waiting.useAnyWaiting(C.addEmailWaitingKey) + const [error, setError] = React.useState('') + const mountedRef = React.useRef(true) + + React.useEffect( + () => () => { + mountedRef.current = false + }, + [] + ) + + const clearError = React.useCallback(() => { + setError('') + }, []) + + const submitEmail = (email: string, searchable: boolean, onSuccess: (email: string) => void) => { + const emailError = isValidEmail(email) + if (emailError) { + setError(emailError) + return + } + + setError('') + addEmail( + [ + { + email, + visibility: searchable + ? T.RPCGen.IdentityVisibility.public + : T.RPCGen.IdentityVisibility.private, + }, + C.addEmailWaitingKey, + ], + () => { + setAddedEmail(email) + if (mountedRef.current) { + onSuccess(email) + } + }, + error_ => { + if (mountedRef.current) { + setError(makeAddEmailError(error_)) + } + } + ) + } + + return {clearError, error, submitEmail, waiting} +} diff --git a/shared/signup/email.tsx b/shared/signup/email.tsx index e6848ba266b7..1f8ac176b00c 100644 --- a/shared/signup/email.tsx +++ b/shared/signup/email.tsx @@ -2,45 +2,41 @@ import * as C from '@/constants' import * as React from 'react' import * as Kb from '@/common-adapters' import {SignupScreen, errorBanner} from './common' -import {useSettingsEmailState} from '@/stores/settings-email' +import {useAddEmail} from '@/settings/account/use-add-email' import {useSignupState} from '@/stores/signup' import {usePushState} from '@/stores/push' const ConnectedEnterEmail = () => { const _showPushPrompt = usePushState(s => C.isMobile && !s.hasPermissions && s.showPushPrompt) - const addedEmail = useSettingsEmailState(s => s.addedEmail) - const error = useSettingsEmailState(s => s.error) - const waiting = C.Waiting.useAnyWaiting(C.addEmailWaitingKey) + const {error, submitEmail, waiting} = useAddEmail() const clearModals = C.useRouterState(s => s.dispatch.clearModals) const navigateAppend = C.useRouterState(s => s.dispatch.navigateAppend) const setJustSignedUpEmail = useSignupState(s => s.dispatch.setJustSignedUpEmail) const _onSkip = () => { setJustSignedUpEmail(C.noEmail) } - const _onSuccess = setJustSignedUpEmail - const addEmail = useSettingsEmailState(s => s.dispatch.addEmail) const onSkip = () => { _onSkip() _showPushPrompt ? navigateAppend('settingsPushPrompt', true) : clearModals() } - const [addEmailInProgress, setAddEmailInProgress] = React.useState('') - React.useEffect(() => { - if (addedEmail === addEmailInProgress) { - _onSuccess(addEmailInProgress) - _showPushPrompt ? navigateAppend('settingsPushPrompt', true) : clearModals() - } - }, [addedEmail, addEmailInProgress, _onSuccess, _showPushPrompt, navigateAppend, clearModals]) const onCreate = (email: string, searchable: boolean) => { - addEmail(email, searchable) - setAddEmailInProgress(email) + submitEmail(email, searchable, addedEmail => { + setJustSignedUpEmail(addedEmail) + _showPushPrompt ? navigateAppend('settingsPushPrompt', true) : clearModals() + }) } const [email, onChangeEmail] = React.useState('') const [searchable, onChangeSearchable] = React.useState(true) const disabled = !email.trim() - const onContinue = () => (disabled ? {} : onCreate(email.trim(), searchable)) + const onContinue = () => { + if (disabled || waiting) { + return + } + onCreate(email.trim(), searchable) + } return ( { ) } -export type Props = { - error: string - initialEmail: string - onCreate: (email: string, searchable: boolean) => void - onSkip?: () => void - waiting: boolean -} - type BodyProps = { onChangeEmail: (email: string) => void onContinue: () => void diff --git a/shared/stores/settings-email.tsx b/shared/stores/settings-email.tsx index 6c7b35b2771e..9e9c6e0f89be 100644 --- a/shared/stores/settings-email.tsx +++ b/shared/stores/settings-email.tsx @@ -1,26 +1,8 @@ import * as Z from '@/util/zustand' -import {addEmailWaitingKey} from '@/constants/strings' import {ignorePromise} from '@/constants/utils' import * as T from '@/constants/types' -import {isValidEmail} from '@/util/simple-validators' -import {RPCError} from '@/util/errors' import logger from '@/logger' -const makeAddEmailError = (err: RPCError): string => { - switch (err.code) { - case T.RPCGen.StatusCode.scratelimit: - return "Sorry, you've added too many email addresses lately. Please try again later." - case T.RPCGen.StatusCode.scemailtaken: - return 'This email is already claimed by another user.' - case T.RPCGen.StatusCode.scemaillimitexceeded: - return 'You have too many emails, delete one and try again.' - case T.RPCGen.StatusCode.scinputerror: - return 'Invalid email.' - default: - return err.message - } -} - const makeEmailRow = (): EmailRow => ({ email: '', isPrimary: false, @@ -34,23 +16,16 @@ type EmailRow = Writeable type Store = T.Immutable<{ addedEmail: string // show banner with dismiss on account settings - addingEmail: string emails: Map - error: string - newEmail: string }> const initialStore: Store = { addedEmail: '', - addingEmail: '', emails: new Map(), - error: '', - newEmail: '', } export type State = Store & { dispatch: { - addEmail: (email: string, searchable: boolean) => void editEmail: (p: { email: string delete?: boolean @@ -61,62 +36,13 @@ export type State = Store & { notifyEmailAddressEmailsChanged: (list: ReadonlyArray) => void notifyEmailVerified: (email: string) => void resetAddedEmail: () => void - resetAddingEmail: () => void resetState: () => void + setAddedEmail: (email: string) => void } } export const useSettingsEmailState = Z.createZustand('settings-email', (set, get) => { const dispatch: State['dispatch'] = { - addEmail: (email, searchable) => { - set(s => { - const emailError = isValidEmail(email) - s.addingEmail = email - s.error = emailError - }) - const f = async () => { - if (get().error) { - logger.info('email error; bailing') - return - } - try { - await T.RPCGen.emailsAddEmailRpcPromise( - { - email, - visibility: searchable - ? T.RPCGen.IdentityVisibility.public - : T.RPCGen.IdentityVisibility.private, - }, - addEmailWaitingKey - ) - logger.info('success') - if (email !== get().addingEmail) { - logger.warn("addedEmail: doesn't match") - return - } - set(s => { - s.addedEmail = email - s.addingEmail = '' - s.error = '' - }) - } catch (error) { - if (!(error instanceof RPCError)) { - return - } - logger.warn(`error: ${error.message}`) - const msg = makeAddEmailError(error) - if (email !== get().addingEmail) { - logger.warn("addedEmail: doesn't match") - return - } - set(s => { - s.addedEmail = '' - s.error = msg - }) - } - } - ignorePromise(f()) - }, editEmail: p => { const f = async () => { // TODO: consider allowing more than one action here @@ -180,10 +106,9 @@ export const useSettingsEmailState = Z.createZustand('settings-email', (s s.addedEmail = '' }) }, - resetAddingEmail: () => { + setAddedEmail: email => { set(s => { - s.addingEmail = '' - s.error = '' + s.addedEmail = email }) }, resetState: Z.defaultReset, diff --git a/shared/stores/tests/settings-email.test.ts b/shared/stores/tests/settings-email.test.ts index 995fe18daf9e..e55e067f989e 100644 --- a/shared/stores/tests/settings-email.test.ts +++ b/shared/stores/tests/settings-email.test.ts @@ -1,5 +1,4 @@ /// -import * as T from '@/constants/types' import {resetAllStores} from '@/util/zustand' import {useSettingsEmailState} from '../settings-email' @@ -8,8 +7,6 @@ afterEach(() => { resetAllStores() }) -const flush = async () => new Promise(resolve => setImmediate(resolve)) - test('email change notifications populate the email map and verification updates the row', () => { useSettingsEmailState.getState().dispatch.notifyEmailAddressEmailsChanged([ { @@ -29,29 +26,12 @@ test('email change notifications populate the email map and verification updates expect(useSettingsEmailState.getState().addedEmail).toBe('') }) -test('invalid emails fail locally without calling the RPC', () => { - const addEmail = jest.spyOn(T.RPCGen, 'emailsAddEmailRpcPromise') - - useSettingsEmailState.getState().dispatch.addEmail('not-an-email', true) +test('setAddedEmail stages the banner state until reset', () => { + useSettingsEmailState.getState().dispatch.setAddedEmail('alice@example.com') - expect(useSettingsEmailState.getState().error).not.toBe('') - expect(addEmail).not.toHaveBeenCalled() -}) - -test('successful addEmail updates the staged banner state', async () => { - const addEmail = jest.spyOn(T.RPCGen, 'emailsAddEmailRpcPromise').mockResolvedValue(undefined as any) + expect(useSettingsEmailState.getState().addedEmail).toBe('alice@example.com') - useSettingsEmailState.getState().dispatch.addEmail('alice@example.com', true) - await flush() + useSettingsEmailState.getState().dispatch.resetAddedEmail() - expect(addEmail).toHaveBeenCalledWith( - { - email: 'alice@example.com', - visibility: T.RPCGen.IdentityVisibility.public, - }, - expect.any(String) - ) - expect(useSettingsEmailState.getState().addedEmail).toBe('alice@example.com') - expect(useSettingsEmailState.getState().addingEmail).toBe('') - expect(useSettingsEmailState.getState().error).toBe('') + expect(useSettingsEmailState.getState().addedEmail).toBe('') }) diff --git a/skill/zustand-store-pruning/references/store-checklist.md b/skill/zustand-store-pruning/references/store-checklist.md index 560991171c4b..4581b16c6369 100644 --- a/skill/zustand-store-pruning/references/store-checklist.md +++ b/skill/zustand-store-pruning/references/store-checklist.md @@ -31,7 +31,7 @@ Status: - [ ] `recover-password` - [ ] `settings` - [ ] `settings-chat` -- [ ] `settings-email` +- [x] `settings-email` moved add-email submit/error state into components; kept notification-backed `emails`, `addedEmail`, and row actions in store - [ ] `settings-notifications` - [x] `settings-password` kept only `randomPW` in store; moved submit/load flows into settings screens - [ ] `settings-phone` From 8d14692534a5c5fc90246160a1a3a99a938c544f Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 26 Mar 2026 10:27:21 -0400 Subject: [PATCH 05/19] WIP --- shared/settings/logout.tsx | 6 ++---- shared/settings/password.tsx | 6 ++---- shared/signup/device-name.tsx | 2 +- shared/signup/username.tsx | 2 +- 4 files changed, 6 insertions(+), 10 deletions(-) diff --git a/shared/settings/logout.tsx b/shared/settings/logout.tsx index fb0fef006974..3594720dc1dd 100644 --- a/shared/settings/logout.tsx +++ b/shared/settings/logout.tsx @@ -23,7 +23,7 @@ const LogoutContainer = () => { })) ) const {error, onSave, waitingForResponse} = useSubmitNewPassword(true) - const [hasPGPKeyOnServer, setHasPGPKeyOnServer] = React.useState(false) + const [hasPGPKeyOnServer, setHasPGPKeyOnServer] = React.useState(undefined) const loadPgpSettings = C.useRPC(T.RPCGen.accountHasServerKeysRpcPromise) const requestLogout = useLogoutState(s => s.dispatch.requestLogout) @@ -54,9 +54,7 @@ const LogoutContainer = () => { ({hasServerKeys}) => { setHasPGPKeyOnServer(hasServerKeys) }, - () => { - setHasPGPKeyOnServer(false) - } + () => {} ) }, [hasRandomPW, loadPgpSettings]) diff --git a/shared/settings/password.tsx b/shared/settings/password.tsx index 7794f3f7a3cb..833989a46a1f 100644 --- a/shared/settings/password.tsx +++ b/shared/settings/password.tsx @@ -213,7 +213,7 @@ const Container = () => { const saveLabel = randomPW ? 'Create password' : 'Save' const {error, onSave, waitingForResponse} = useSubmitNewPassword(false) - const [hasPGPKeyOnServer, setHasPGPKeyOnServer] = React.useState(false) + const [hasPGPKeyOnServer, setHasPGPKeyOnServer] = React.useState(undefined) const loadPgpSettings = C.useRPC(T.RPCGen.accountHasServerKeysRpcPromise) React.useEffect(() => { loadPgpSettings( @@ -221,9 +221,7 @@ const Container = () => { ({hasServerKeys}) => { setHasPGPKeyOnServer(hasServerKeys) }, - () => { - setHasPGPKeyOnServer(false) - } + () => {} ) }, [loadPgpSettings]) diff --git a/shared/signup/device-name.tsx b/shared/signup/device-name.tsx index a64a0bf9f2f1..e34213edb4cb 100644 --- a/shared/signup/device-name.tsx +++ b/shared/signup/device-name.tsx @@ -138,7 +138,7 @@ const EnterDevicename = (props: EnterDevicenameProps) => { setReadyToShowError(false) _setReadyToShowError(true) } - const onContinue = () => (disabled ? {} : props.onContinue(cleanDeviceName)) + const onContinue = () => (disabled || props.waiting ? {} : props.onContinue(cleanDeviceName)) React.useEffect(() => { if (cleanDeviceName !== deviceName) { diff --git a/shared/signup/username.tsx b/shared/signup/username.tsx index 8035eb1a4395..422b720cafef 100644 --- a/shared/signup/username.tsx +++ b/shared/signup/username.tsx @@ -100,7 +100,7 @@ const EnterUsername = (props: EnterUsernameProps) => { props.onUsernameChange() } const onContinue = () => { - if (disabled) { + if (disabled || props.waiting) { return } onChangeUsername(usernameTrimmed) // maybe trim the input From c2d0ebcdb72da8bf54e27b593c1a6b76751df139 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 26 Mar 2026 10:38:29 -0400 Subject: [PATCH 06/19] WIP --- shared/login/signup/error.tsx | 4 ++-- shared/settings/account/use-add-email.tsx | 6 ++---- shared/settings/logout.tsx | 7 +++++++ shared/signup/device-name.tsx | 4 ++-- shared/signup/username.tsx | 4 ++-- shared/stores/settings-email.tsx | 2 +- shared/stores/signup.tsx | 2 +- 7 files changed, 17 insertions(+), 12 deletions(-) diff --git a/shared/login/signup/error.tsx b/shared/login/signup/error.tsx index fe4cb691d0f7..21837fb3ed46 100644 --- a/shared/login/signup/error.tsx +++ b/shared/login/signup/error.tsx @@ -7,8 +7,8 @@ type Props = StaticScreenProps<{errorCode?: number; errorMessage?: string}> const ConnectedSignupError = (p: Props) => { const navigateUp = C.useRouterState(s => s.dispatch.navigateUp) - const errorCode = p.route.params?.errorCode - const errorMessage = p.route.params?.errorMessage ?? '' + const errorCode = p.route.params.errorCode + const errorMessage = p.route.params.errorMessage ?? '' let header = 'Ah Shoot! Something went wrong, try again?' let body = errorMessage if (errorCode !== undefined && C.isNetworkErr(errorCode)) { diff --git a/shared/settings/account/use-add-email.tsx b/shared/settings/account/use-add-email.tsx index 35ccfb27d77a..8ecc9630c467 100644 --- a/shared/settings/account/use-add-email.tsx +++ b/shared/settings/account/use-add-email.tsx @@ -2,7 +2,7 @@ import * as C from '@/constants' import * as React from 'react' import * as T from '@/constants/types' import {useSettingsEmailState} from '@/stores/settings-email' -import {RPCError} from '@/util/errors' +import type {RPCError} from '@/util/errors' import {isValidEmail} from '@/util/simple-validators' const makeAddEmailError = (err: RPCError): string => { @@ -58,9 +58,7 @@ export const useAddEmail = () => { ], () => { setAddedEmail(email) - if (mountedRef.current) { - onSuccess(email) - } + onSuccess(email) }, error_ => { if (mountedRef.current) { diff --git a/shared/settings/logout.tsx b/shared/settings/logout.tsx index 3594720dc1dd..94a5deaa5e9d 100644 --- a/shared/settings/logout.tsx +++ b/shared/settings/logout.tsx @@ -45,6 +45,13 @@ const LogoutContainer = () => { onBootstrap() }, [onBootstrap]) + React.useEffect( + () => () => { + resetCheckPassword() + }, + [resetCheckPassword] + ) + React.useEffect(() => { if (!hasRandomPW) { return diff --git a/shared/signup/device-name.tsx b/shared/signup/device-name.tsx index e34213edb4cb..c08002cc58c5 100644 --- a/shared/signup/device-name.tsx +++ b/shared/signup/device-name.tsx @@ -15,8 +15,8 @@ type Props = StaticScreenProps<{inviteCode?: string; username?: string}> const ConnectedEnterDevicename = (p: Props) => { const initialDevicename = useSignupState(s => s.devicename) - const inviteCode = p.route.params?.inviteCode ?? '' - const username = p.route.params?.username ?? '' + const inviteCode = p.route.params.inviteCode ?? '' + const username = p.route.params.username ?? '' const waiting = C.Waiting.useAnyWaiting(C.waitingKeySignup) const {resetState, setDevicename, showPermissionsPrompt} = useSignupState( C.useShallow(s => ({ diff --git a/shared/signup/username.tsx b/shared/signup/username.tsx index 422b720cafef..ab007f881616 100644 --- a/shared/signup/username.tsx +++ b/shared/signup/username.tsx @@ -14,8 +14,8 @@ import type {StaticScreenProps} from '@react-navigation/core' type Props = StaticScreenProps<{inviteCode?: string; username?: string}> const ConnectedEnterUsername = (p: Props) => { - const initialUsername = p.route.params?.username ?? '' - const inviteCode = p.route.params?.inviteCode ?? '' + const initialUsername = p.route.params.username ?? '' + const inviteCode = p.route.params.inviteCode ?? '' const resetState = useSignupState(s => s.dispatch.resetState) const waiting = C.Waiting.useAnyWaiting(C.waitingKeySignup) const {navigateAppend, navigateUp} = C.useRouterState( diff --git a/shared/stores/settings-email.tsx b/shared/stores/settings-email.tsx index 9e9c6e0f89be..e813c7350358 100644 --- a/shared/stores/settings-email.tsx +++ b/shared/stores/settings-email.tsx @@ -106,12 +106,12 @@ export const useSettingsEmailState = Z.createZustand('settings-email', (s s.addedEmail = '' }) }, + resetState: Z.defaultReset, setAddedEmail: email => { set(s => { s.addedEmail = email }) }, - resetState: Z.defaultReset, } return { ...initialStore, diff --git a/shared/stores/signup.tsx b/shared/stores/signup.tsx index 500dbd85e4f0..8672e578e352 100644 --- a/shared/stores/signup.tsx +++ b/shared/stores/signup.tsx @@ -1,6 +1,6 @@ import * as S from '@/constants/strings' import type * as EngineGen from '@/constants/rpc' -import * as T from '@/constants/types' +import type * as T from '@/constants/types' import * as Z from '@/util/zustand' type Store = T.Immutable<{ From ba6e31d4ea2f95ce1a9c1e7bffaad3306758ee04 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Thu, 26 Mar 2026 11:07:46 -0400 Subject: [PATCH 07/19] WIP --- shared/people/todo.tsx | 5 +- shared/provision/forgot-username.tsx | 9 +- shared/settings/account/add-modals.tsx | 101 ++++-------- shared/settings/account/email-phone-row.tsx | 4 +- shared/settings/routes.tsx | 9 +- shared/signup/phone-number/index.tsx | 46 ++---- .../signup/phone-number/use-verification.tsx | 116 ++++++++++++++ shared/signup/phone-number/verify.tsx | 38 ++--- shared/signup/routes.tsx | 3 - shared/stores/settings-phone.tsx | 151 +----------------- shared/stores/tests/settings-phone.test.ts | 28 +--- shared/team-building/phone-search.tsx | 9 +- shared/teams/add-members-wizard/add-phone.tsx | 11 +- shared/util/phone-numbers/index.tsx | 46 ++++++ .../references/store-checklist.md | 2 +- 15 files changed, 245 insertions(+), 333 deletions(-) create mode 100644 shared/signup/phone-number/use-verification.tsx diff --git a/shared/people/todo.tsx b/shared/people/todo.tsx index d3f24948f378..8702b1a431ed 100644 --- a/shared/people/todo.tsx +++ b/shared/people/todo.tsx @@ -6,7 +6,6 @@ import type * as T from '@/constants/types' import type {IconType} from '@/common-adapters/icon.constants-gen' import PeopleItem, {type TaskButton} from './item' import * as Kb from '@/common-adapters' -import {useSettingsPhoneState} from '@/stores/settings-phone' import {useSettingsEmailState} from '@/stores/settings-email' import {settingsAccountTab, settingsGitTab} from '@/constants/settings' import {useTrackerState} from '@/stores/tracker' @@ -259,7 +258,6 @@ const VerifyAllEmailConnector = (props: TodoOwnProps) => { } const VerifyAllPhoneNumberConnector = (props: TodoOwnProps) => { - const resendVerificationForPhone = useSettingsPhoneState(s => s.dispatch.resendVerificationForPhone) const {navigateAppend, switchTab} = C.useRouterState( C.useShallow(s => ({ navigateAppend: s.dispatch.navigateAppend, @@ -267,8 +265,7 @@ const VerifyAllPhoneNumberConnector = (props: TodoOwnProps) => { })) ) const onConfirm = (phoneNumber: string) => { - resendVerificationForPhone(phoneNumber) - navigateAppend('settingsVerifyPhone') + navigateAppend({name: 'settingsVerifyPhone', params: {initialResend: true, phoneNumber}}) } const onManage = () => { switchTab(C.Tabs.settingsTab) diff --git a/shared/provision/forgot-username.tsx b/shared/provision/forgot-username.tsx index 2dd8a1e3e796..70449146b3f4 100644 --- a/shared/provision/forgot-username.tsx +++ b/shared/provision/forgot-username.tsx @@ -2,16 +2,11 @@ import * as C from '@/constants' import * as React from 'react' import * as Kb from '@/common-adapters' import {SignupScreen, errorBanner} from '../signup/common' -import {useSettingsPhoneState} from '@/stores/settings-phone' import {useProvisionState} from '@/stores/provision' +import {useDefaultPhoneCountry} from '@/util/phone-numbers' const ForgotUsername = () => { - const defaultCountry = useSettingsPhoneState(s => s.defaultCountry) - const loadDefaultPhoneCountry = useSettingsPhoneState(s => s.dispatch.loadDefaultPhoneCountry) - // trigger a default phone number country rpc if it's not already loaded - React.useEffect(() => { - !defaultCountry && loadDefaultPhoneCountry() - }, [defaultCountry, loadDefaultPhoneCountry]) + const defaultCountry = useDefaultPhoneCountry() const forgotUsernameResult = useProvisionState(s => s.forgotUsernameResult) const navigateUp = C.useRouterState(s => s.dispatch.navigateUp) diff --git a/shared/settings/account/add-modals.tsx b/shared/settings/account/add-modals.tsx index c296f6562a4e..2928a25f3f6d 100644 --- a/shared/settings/account/add-modals.tsx +++ b/shared/settings/account/add-modals.tsx @@ -5,8 +5,10 @@ import {useSafeNavigation} from '@/util/safe-navigation' import {EnterEmailBody} from '@/signup/email' import {EnterPhoneNumberBody} from '@/signup/phone-number' import VerifyBody from '@/signup/phone-number/verify-body' +import {useAddPhoneNumber, usePhoneVerification} from '@/signup/phone-number/use-verification' import {useAddEmail} from './use-add-email' import {useSettingsPhoneState} from '@/stores/settings-phone' +import {useDefaultPhoneCountry} from '@/util/phone-numbers' export const Email = () => { const nav = useSafeNavigation() @@ -100,49 +102,29 @@ export const Phone = () => { const [searchable, onChangeSearchable] = React.useState(true) const disabled = !valid - const phoneState = useSettingsPhoneState( - C.useShallow(s => ({ - addPhoneNumber: s.dispatch.addPhoneNumber, - clearPhoneNumberAdd: s.dispatch.clearPhoneNumberAdd, - clearPhoneNumberErrors: s.dispatch.clearPhoneNumberErrors, - defaultCountry: s.defaultCountry, - error: s.error, - loadDefaultPhoneCountry: s.dispatch.loadDefaultPhoneCountry, - pendingVerification: s.pendingVerification, - })) - ) - const {addPhoneNumber, clearPhoneNumberAdd, clearPhoneNumberErrors, defaultCountry} = phoneState - const {error, loadDefaultPhoneCountry, pendingVerification} = phoneState - const waiting = C.Waiting.useAnyWaiting(C.waitingKeySettingsPhoneAddPhoneNumber) - - // clean only errors on unmount so verify screen still has info - React.useEffect( - () => () => { - clearPhoneNumberErrors() - }, - [clearPhoneNumberErrors] - ) - // watch for go to verify - React.useEffect(() => { - if (!error && !!pendingVerification) { - nav.safeNavigateAppend('settingsVerifyPhone') - } - }, [error, nav, pendingVerification]) - // trigger a default phone number country rpc if it's not already loaded - React.useEffect(() => { - !defaultCountry && loadDefaultPhoneCountry() - }, [defaultCountry, loadDefaultPhoneCountry]) + const defaultCountry = useDefaultPhoneCountry() + const {clearError, error, submitPhoneNumber, waiting} = useAddPhoneNumber() const onClose = () => { - clearPhoneNumberAdd() nav.safeNavigateUp() } const onContinue = () => { - disabled || waiting ? null : addPhoneNumber(phoneNumber, searchable) + if (disabled || waiting) { + return + } + submitPhoneNumber(phoneNumber, searchable, submittedPhoneNumber => { + nav.safeNavigateAppend({ + name: 'settingsVerifyPhone', + params: {initialResend: false, phoneNumber: submittedPhoneNumber}, + }) + }) } const onChangeNumberCb = (phoneNumber: string, validity: boolean) => { + if (error) { + clearError() + } onChangeNumber(phoneNumber) onChangeValidity(validity) } @@ -195,46 +177,31 @@ export const Phone = () => { ) } -export const VerifyPhone = () => { +type VerifyPhoneProps = { + initialResend?: boolean + phoneNumber: string +} + +export const VerifyPhone = ({initialResend, phoneNumber}: VerifyPhoneProps) => { const [code, onChangeCode] = React.useState('') - const phoneState = useSettingsPhoneState( - C.useShallow(s => ({ - clearPhoneNumberAdd: s.dispatch.clearPhoneNumberAdd, - error: s.error, - pendingVerification: s.pendingVerification, - resendVerificationForPhone: s.dispatch.resendVerificationForPhone, - verificationState: s.verificationState, - verifyPhoneNumber: s.dispatch.verifyPhoneNumber, - })) - ) - const {clearPhoneNumberAdd, error, pendingVerification} = phoneState - const {resendVerificationForPhone, verificationState, verifyPhoneNumber} = phoneState + const setAddedPhone = useSettingsPhoneState(s => s.dispatch.setAddedPhone) const clearModals = C.useRouterState(s => s.dispatch.clearModals) - const resendWaiting = C.Waiting.useAnyWaiting([ - C.waitingKeySettingsPhoneAddPhoneNumber, - C.waitingKeySettingsPhoneResendVerification, - ]) - const verifyWaiting = C.Waiting.useAnyWaiting(C.waitingKeySettingsPhoneVerifyPhoneNumber) - - // clean everything on unmount - React.useEffect( - () => () => { - clearPhoneNumberAdd() - }, - [clearPhoneNumberAdd] - ) - // Clear on success - React.useEffect(() => { - if (verificationState === 'success' && !error) { + const {error, resendVerificationForPhone, verifyPhoneNumber} = usePhoneVerification({ + initialResend, + onSuccess: () => { + setAddedPhone(true) clearModals() - } - }, [verificationState, error, clearModals]) + }, + phoneNumber, + }) + const resendWaiting = C.Waiting.useAnyWaiting(C.waitingKeySettingsPhoneResendVerification) + const verifyWaiting = C.Waiting.useAnyWaiting(C.waitingKeySettingsPhoneVerifyPhoneNumber) const onResend = () => { - resendVerificationForPhone(pendingVerification) + resendVerificationForPhone(phoneNumber) } - const onContinue = () => verifyPhoneNumber(pendingVerification, code) + const onContinue = () => verifyPhoneNumber(phoneNumber, code) const disabled = !code return ( diff --git a/shared/settings/account/email-phone-row.tsx b/shared/settings/account/email-phone-row.tsx index 616b7486171e..e51374969640 100644 --- a/shared/settings/account/email-phone-row.tsx +++ b/shared/settings/account/email-phone-row.tsx @@ -209,7 +209,6 @@ const useData = (contactKey: string) => { } const editPhone = useSettingsPhoneState(s => s.dispatch.editPhone) - const resendVerificationForPhoneNumber = useSettingsPhoneState(s => s.dispatch.resendVerificationForPhone) const navigateAppend = C.useRouterState(s => s.dispatch.navigateAppend) const dispatchProps = { @@ -233,8 +232,7 @@ const useData = (contactKey: string) => { editPhone(contactKey, undefined, setSearchable) }, _onVerify: (phoneNumber: string) => { - resendVerificationForPhoneNumber(phoneNumber) - navigateAppend('settingsVerifyPhone') + navigateAppend({name: 'settingsVerifyPhone', params: {initialResend: true, phoneNumber}}) }, onMakePrimary: () => {}, // this is not a supported phone action }, diff --git a/shared/settings/routes.tsx b/shared/settings/routes.tsx index b5045fef7d66..08b8e74a9bb5 100644 --- a/shared/settings/routes.tsx +++ b/shared/settings/routes.tsx @@ -8,8 +8,9 @@ import * as Settings from '@/constants/settings' import {usePushState} from '@/stores/push' import {usePWState} from '@/stores/settings-password' import {useSettingsState} from '@/stores/settings' -import {useSettingsPhoneState} from '@/stores/settings-phone' import {e164ToDisplay} from '@/util/phone-numbers' +import {useRoute} from '@react-navigation/native' +import type {RootRouteProps} from '@/router-v2/route-params' const PushPromptSkipButton = () => { const rejectPermissions = usePushState(s => s.dispatch.rejectPermissions) @@ -50,8 +51,8 @@ const CheckPassphraseCancelButton = () => { } const VerifyPhoneHeaderTitle = () => { - const pendingVerification = useSettingsPhoneState(s => s.pendingVerification) - const displayPhone = e164ToDisplay(pendingVerification) + const {params} = useRoute>() + const displayPhone = e164ToDisplay(params.phoneNumber) return ( {displayPhone || 'Unknown number'} @@ -60,12 +61,10 @@ const VerifyPhoneHeaderTitle = () => { } const VerifyPhoneHeaderLeft = () => { - const clearPhoneNumberAdd = useSettingsPhoneState(s => s.dispatch.clearPhoneNumberAdd) const clearModals = C.useRouterState(s => s.dispatch.clearModals) return ( { - clearPhoneNumberAdd() clearModals() }} iconColor={Kb.Styles.globalColors.white} diff --git a/shared/signup/phone-number/index.tsx b/shared/signup/phone-number/index.tsx index d0c948ea3601..7323e3866926 100644 --- a/shared/signup/phone-number/index.tsx +++ b/shared/signup/phone-number/index.tsx @@ -2,7 +2,8 @@ import * as C from '@/constants' import * as React from 'react' import * as Kb from '@/common-adapters' import {SignupScreen, errorBanner} from '../common' -import {useSettingsPhoneState} from '@/stores/settings-phone' +import {useAddPhoneNumber} from './use-verification' +import {useDefaultPhoneCountry} from '@/util/phone-numbers' type BodyProps = { autoFocus?: boolean @@ -71,45 +72,28 @@ const styles = Kb.Styles.styleSheetCreate(() => ({ })) const ConnectedEnterPhoneNumber = () => { - const defaultCountry = useSettingsPhoneState(s => s.defaultCountry) - const error = useSettingsPhoneState(s => s.error) - const pendingVerification = useSettingsPhoneState(s => s.pendingVerification) - const waiting = C.Waiting.useAnyWaiting(C.waitingKeySettingsPhoneAddPhoneNumber) - const clearPhoneNumberErrors = useSettingsPhoneState(s => s.dispatch.clearPhoneNumberErrors) - const clearPhoneNumberAdd = useSettingsPhoneState(s => s.dispatch.clearPhoneNumberAdd) - const onClear = clearPhoneNumberErrors - const addPhoneNumber = useSettingsPhoneState(s => s.dispatch.addPhoneNumber) + const defaultCountry = useDefaultPhoneCountry() const navigateAppend = C.useRouterState(s => s.dispatch.navigateAppend) + const {clearError, error, submitPhoneNumber, waiting} = useAddPhoneNumber() const onSkip = () => { - clearPhoneNumberAdd() navigateAppend('signupEnterEmail', true) } - React.useEffect(() => { - return () => { - onClear() - } - }, [onClear]) - - const lastPendingVerificationRef = React.useRef(pendingVerification) - React.useEffect(() => { - if (!error && pendingVerification && lastPendingVerificationRef.current !== pendingVerification) { - navigateAppend('signupVerifyPhoneNumber') - } - lastPendingVerificationRef.current = pendingVerification - }, [pendingVerification, error, navigateAppend]) - - // trigger a default phone number country rpc if it's not already loaded - const loadDefaultPhoneCountry = useSettingsPhoneState(s => s.dispatch.loadDefaultPhoneCountry) - React.useEffect(() => { - !defaultCountry && loadDefaultPhoneCountry() - }, [defaultCountry, loadDefaultPhoneCountry]) - const [phoneNumber, onChangePhoneNumber] = React.useState('') const [valid, onChangeValidity] = React.useState(false) const disabled = !valid - const onContinue = () => (disabled || waiting ? {} : addPhoneNumber(phoneNumber, true /* searchable */)) + const onContinue = () => { + if (disabled || waiting) { + return + } + submitPhoneNumber(phoneNumber, true, submittedPhoneNumber => { + navigateAppend({name: 'signupVerifyPhoneNumber', params: {phoneNumber: submittedPhoneNumber}}) + }) + } const onChangeNumberCb = (phoneNumber: string, validity: boolean) => { + if (error) { + clearError() + } onChangePhoneNumber(phoneNumber) onChangeValidity(validity) } diff --git a/shared/signup/phone-number/use-verification.tsx b/shared/signup/phone-number/use-verification.tsx new file mode 100644 index 000000000000..e8b2110e8ac1 --- /dev/null +++ b/shared/signup/phone-number/use-verification.tsx @@ -0,0 +1,116 @@ +import * as C from '@/constants' +import * as React from 'react' +import * as T from '@/constants/types' +import {makePhoneError} from '@/stores/settings-phone' + +const useMountedRef = () => { + const mountedRef = React.useRef(true) + + React.useEffect( + () => () => { + mountedRef.current = false + }, + [] + ) + + return mountedRef +} + +export const useAddPhoneNumber = () => { + const addPhoneNumber = C.useRPC(T.RPCGen.phoneNumbersAddPhoneNumberRpcPromise) + const waiting = C.Waiting.useAnyWaiting(C.waitingKeySettingsPhoneAddPhoneNumber) + const [error, setError] = React.useState('') + const mountedRef = useMountedRef() + + const clearError = () => { + setError('') + } + + const submitPhoneNumber = ( + phoneNumber: string, + searchable: boolean, + onSuccess: (phoneNumber: string) => void + ) => { + clearError() + addPhoneNumber( + [ + { + phoneNumber, + visibility: searchable + ? T.RPCGen.IdentityVisibility.public + : T.RPCGen.IdentityVisibility.private, + }, + C.waitingKeySettingsPhoneAddPhoneNumber, + ], + () => { + onSuccess(phoneNumber) + }, + error_ => { + if (mountedRef.current) { + setError(makePhoneError(error_)) + } + } + ) + } + + return {clearError, error, submitPhoneNumber, waiting} +} + +type UsePhoneVerificationParams = { + initialResend?: boolean + onSuccess?: () => void + phoneNumber: string +} + +export const usePhoneVerification = ({ + initialResend = false, + onSuccess, + phoneNumber, +}: UsePhoneVerificationParams) => { + const resendVerification = C.useRPC(T.RPCGen.phoneNumbersResendVerificationForPhoneNumberRpcPromise) + const verifyPhoneNumberRpc = C.useRPC(T.RPCGen.phoneNumbersVerifyPhoneNumberRpcPromise) + const [error, setError] = React.useState('') + const initialResendDone = React.useRef(false) + const mountedRef = useMountedRef() + + const resendVerificationForPhone = (phoneNumberToVerify: string) => { + setError('') + resendVerification( + [{phoneNumber: phoneNumberToVerify}, C.waitingKeySettingsPhoneResendVerification], + () => {}, + error_ => { + if (mountedRef.current) { + setError(makePhoneError(error_)) + } + } + ) + } + + const verifyPhoneNumber = (phoneNumberToVerify: string, code: string) => { + setError('') + verifyPhoneNumberRpc( + [{code, phoneNumber: phoneNumberToVerify}, C.waitingKeySettingsPhoneVerifyPhoneNumber], + () => { + if (mountedRef.current) { + setError('') + onSuccess?.() + } + }, + error_ => { + if (mountedRef.current) { + setError(makePhoneError(error_)) + } + } + ) + } + + React.useEffect(() => { + if (!initialResend || initialResendDone.current) { + return + } + initialResendDone.current = true + resendVerificationForPhone(phoneNumber) + }, [initialResend, phoneNumber, resendVerificationForPhone]) + + return {error, resendVerificationForPhone, verifyPhoneNumber} +} diff --git a/shared/signup/phone-number/verify.tsx b/shared/signup/phone-number/verify.tsx index 83fc1ef27533..6fd694318c74 100644 --- a/shared/signup/phone-number/verify.tsx +++ b/shared/signup/phone-number/verify.tsx @@ -1,25 +1,21 @@ import * as C from '@/constants' import * as React from 'react' import * as Kb from '@/common-adapters' +import type {ScreenProps} from '@/constants/types/router' import {SignupScreen} from '../common' import {e164ToDisplay} from '@/util/phone-numbers' import VerifyBody from './verify-body' -import {useSettingsPhoneState} from '@/stores/settings-phone' +import {usePhoneVerification} from './use-verification' -const Container = () => { - const error = useSettingsPhoneState(s => (s.verificationState === 'error' ? s.error : '')) - const phoneNumber = useSettingsPhoneState(s => s.pendingVerification) - const resendWaiting = C.Waiting.useAnyWaiting([ - C.waitingKeySettingsPhoneResendVerification, - C.waitingKeySettingsPhoneAddPhoneNumber, - ]) - const verificationStatus = useSettingsPhoneState(s => s.verificationState) +const Container = ({route}: ScreenProps<'signupVerifyPhoneNumber'>) => { + const {phoneNumber} = route.params + const resendWaiting = C.Waiting.useAnyWaiting(C.waitingKeySettingsPhoneResendVerification) const verifyWaiting = C.Waiting.useAnyWaiting(C.waitingKeySettingsPhoneVerifyPhoneNumber) - - const verifyPhoneNumber = useSettingsPhoneState(s => s.dispatch.verifyPhoneNumber) - const resendVerificationForPhone = useSettingsPhoneState(s => s.dispatch.resendVerificationForPhone) - - const clearPhoneNumberAdd = useSettingsPhoneState(s => s.dispatch.clearPhoneNumberAdd) + const onSuccess = C.useRouterState(s => s.dispatch.clearModals) + const {error, resendVerificationForPhone, verifyPhoneNumber} = usePhoneVerification({ + onSuccess, + phoneNumber, + }) const _onContinue = (phoneNumber: string, code: string) => { verifyPhoneNumber(phoneNumber, code) @@ -31,23 +27,9 @@ const Container = () => { const onBack = () => { navigateUp() } - const onCleanup = clearPhoneNumberAdd - const onSuccess = C.useRouterState(s => s.dispatch.clearModals) const ponContinue = (code: string) => _onContinue(phoneNumber, code) const onResend = () => _onResend(phoneNumber) - React.useEffect(() => { - if (verificationStatus === 'success') { - onSuccess() - } - }, [verificationStatus, onSuccess]) - - React.useEffect(() => { - return () => { - onCleanup() - } - }, [onCleanup]) - const [code, onChangeCode] = React.useState('') const disabled = !code const onContinue = disabled diff --git a/shared/signup/routes.tsx b/shared/signup/routes.tsx index 4cbcd42e2136..07c920a218ef 100644 --- a/shared/signup/routes.tsx +++ b/shared/signup/routes.tsx @@ -3,7 +3,6 @@ import * as C from '@/constants' import * as Kb from '@/common-adapters' import {InfoIcon} from './common' import {useSignupState} from '@/stores/signup' -import {useSettingsPhoneState} from '@/stores/settings-phone' import {usePushState} from '@/stores/push' const EmailSkipButton = () => { @@ -26,12 +25,10 @@ const EmailSkipButton = () => { const PhoneSkipButton = () => { const navigateAppend = C.useRouterState(s => s.dispatch.navigateAppend) - const clearPhoneNumberAdd = useSettingsPhoneState(s => s.dispatch.clearPhoneNumberAdd) return ( { - clearPhoneNumberAdd() navigateAppend('signupEnterEmail', true) }} > diff --git a/shared/stores/settings-phone.tsx b/shared/stores/settings-phone.tsx index 2d46f24caaef..2b4a2fde8bc7 100644 --- a/shared/stores/settings-phone.tsx +++ b/shared/stores/settings-phone.tsx @@ -1,8 +1,5 @@ import * as T from '@/constants/types' -import * as S from '@/constants/strings' -import {ignorePromise} from '@/constants/utils' import * as Z from '@/util/zustand' -import logger from '@/logger' import {RPCError} from '@/util/errors' import type {e164ToDisplay as e164ToDisplayType} from '@/util/phone-numbers' @@ -53,89 +50,32 @@ export type PhoneRow = { type Store = T.Immutable<{ addedPhone: boolean - defaultCountry?: string - error: string - pendingVerification: string phones?: Map - verificationState?: 'success' | 'error' }> const initialStore: Store = { addedPhone: false, - defaultCountry: undefined, - error: '', - pendingVerification: '', phones: undefined, - verificationState: undefined, } export type State = Store & { dispatch: { - addPhoneNumber: (phoneNumber: string, searchable: boolean) => void clearAddedPhone: () => void - clearPhoneNumberAdd: () => void - clearPhoneNumberErrors: () => void editPhone: (phone: string, del?: boolean, setSearchable?: boolean) => void - loadDefaultPhoneCountry: () => void notifyPhoneNumberPhoneNumbersChanged: (list?: ReadonlyArray) => void - resendVerificationForPhone: (phoneNumber: string) => void resetState: () => void + setAddedPhone: (added: boolean) => void setNumbers: (phoneNumbers?: ReadonlyArray) => void - verifyPhoneNumber: (phoneNumber: string, code: string) => void } } -export const useSettingsPhoneState = Z.createZustand('settings-phone', (set, get) => { +export const useSettingsPhoneState = Z.createZustand('settings-phone', set => { const dispatch: State['dispatch'] = { - addPhoneNumber: (phoneNumber, searchable) => { - const f = async () => { - logger.info('adding phone number') - const visibility = searchable - ? T.RPCGen.IdentityVisibility.public - : T.RPCGen.IdentityVisibility.private - try { - await T.RPCGen.phoneNumbersAddPhoneNumberRpcPromise( - {phoneNumber, visibility}, - S.waitingKeySettingsPhoneAddPhoneNumber - ) - logger.info('success') - set(s => { - s.error = '' - s.pendingVerification = phoneNumber - s.verificationState = undefined - }) - } catch (error) { - if (!(error instanceof RPCError)) { - return - } - logger.warn('error ', error.message) - const message = makePhoneError(error) - set(s => { - s.error = message - s.pendingVerification = phoneNumber - s.verificationState = undefined - }) - } - } - ignorePromise(f()) - }, clearAddedPhone: () => { set(s => { s.addedPhone = false }) }, - clearPhoneNumberAdd: () => { - set(s => { - s.error = '' - s.pendingVerification = '' - s.verificationState = undefined - }) - }, - clearPhoneNumberErrors: () => { - set(s => { - s.error = '' - }) - }, editPhone: (phoneNumber, del, setSearchable) => { const f = async () => { if (del) { @@ -150,61 +90,19 @@ export const useSettingsPhoneState = Z.createZustand('settings-phone', (s }) } } - ignorePromise(f()) - }, - loadDefaultPhoneCountry: () => { - const f = async () => { - // noop if we've already loaded it - if (get().defaultCountry) { - return - } - const country = await T.RPCGen.accountGuessCurrentLocationRpcPromise({ - defaultCountry: 'US', - }) - set(s => { - s.defaultCountry = country - }) - } - ignorePromise(f()) + void f() }, notifyPhoneNumberPhoneNumbersChanged: list => { set(s => { s.phones = new Map((list ?? []).map(row => [row.phoneNumber, toPhoneRow(row)])) }) }, - resendVerificationForPhone: phoneNumber => { + resetState: Z.defaultReset, + setAddedPhone: added => { set(s => { - s.error = '' - s.pendingVerification = phoneNumber - s.verificationState = undefined + s.addedPhone = added }) - const f = async () => { - logger.info(`resending verification code for ${phoneNumber}`) - try { - await T.RPCGen.phoneNumbersResendVerificationForPhoneNumberRpcPromise( - {phoneNumber}, - S.waitingKeySettingsPhoneResendVerification - ) - } catch (error) { - if (!(error instanceof RPCError)) { - return - } - const message = makePhoneError(error) - logger.warn('error ', message) - set(s => { - if (phoneNumber !== s.pendingVerification) { - logger.warn("Got verifiedPhoneNumber but number doesn't match") - return - } - s.addedPhone = false - s.error = message - s.verificationState = 'error' - }) - } - } - ignorePromise(f()) }, - resetState: Z.defaultReset, setNumbers: phoneNumbers => { set(s => { s.phones = phoneNumbers?.reduce((map, row) => { @@ -216,43 +114,6 @@ export const useSettingsPhoneState = Z.createZustand('settings-phone', (s }, new Map()) }) }, - verifyPhoneNumber: (phoneNumber, code) => { - const f = async () => { - logger.info('verifying phone number') - try { - await T.RPCGen.phoneNumbersVerifyPhoneNumberRpcPromise( - {code, phoneNumber}, - S.waitingKeySettingsPhoneVerifyPhoneNumber - ) - logger.info('success') - set(s => { - if (phoneNumber !== s.pendingVerification) { - logger.warn("Got verifiedPhoneNumber but number doesn't match") - return - } - s.addedPhone = true - s.error = '' - s.verificationState = 'success' - }) - } catch (error) { - if (!(error instanceof RPCError)) { - return - } - const message = makePhoneError(error) - logger.warn('error ', message) - set(s => { - if (phoneNumber !== s.pendingVerification) { - logger.warn("Got verifiedPhoneNumber but number doesn't match") - return - } - s.addedPhone = false - s.error = message - s.verificationState = 'error' - }) - } - } - ignorePromise(f()) - }, } return { ...initialStore, diff --git a/shared/stores/tests/settings-phone.test.ts b/shared/stores/tests/settings-phone.test.ts index 4e4eaddd3d22..99bba7469374 100644 --- a/shared/stores/tests/settings-phone.test.ts +++ b/shared/stores/tests/settings-phone.test.ts @@ -9,8 +9,6 @@ afterEach(() => { resetAllStores() }) -const flush = async () => new Promise(resolve => setImmediate(resolve)) - test('makePhoneError maps the expected RPC errors', () => { expect( makePhoneError(new RPCError('wrong code', T.RPCGen.StatusCode.scphonenumberwrongverificationcode)) @@ -43,26 +41,10 @@ test('setNumbers keeps the first non-superseded row for a phone number', () => { expect(row?.verified).toBe(false) }) -test('successful addPhoneNumber updates pending verification and can be cleared', async () => { - const addPhone = jest - .spyOn(T.RPCGen, 'phoneNumbersAddPhoneNumberRpcPromise') - .mockResolvedValue(undefined as any) - - useSettingsPhoneState.getState().dispatch.addPhoneNumber('+15555550124', true) - await flush() - - expect(addPhone).toHaveBeenCalledWith( - { - phoneNumber: '+15555550124', - visibility: T.RPCGen.IdentityVisibility.public, - }, - expect.any(String) - ) - expect(useSettingsPhoneState.getState().pendingVerification).toBe('+15555550124') - expect(useSettingsPhoneState.getState().error).toBe('') - - useSettingsPhoneState.getState().dispatch.clearPhoneNumberAdd() +test('setAddedPhone and clearAddedPhone only update the success banner state', () => { + useSettingsPhoneState.getState().dispatch.setAddedPhone(true) + expect(useSettingsPhoneState.getState().addedPhone).toBe(true) - expect(useSettingsPhoneState.getState().pendingVerification).toBe('') - expect(useSettingsPhoneState.getState().verificationState).toBeUndefined() + useSettingsPhoneState.getState().dispatch.clearAddedPhone() + expect(useSettingsPhoneState.getState().addedPhone).toBe(false) }) diff --git a/shared/team-building/phone-search.tsx b/shared/team-building/phone-search.tsx index 88153ca53273..691bdc9812f7 100644 --- a/shared/team-building/phone-search.tsx +++ b/shared/team-building/phone-search.tsx @@ -4,8 +4,8 @@ import * as React from 'react' import * as Kb from '@/common-adapters/index' import type * as T from '@/constants/types' import ContinueButton from './continue-button' -import {useSettingsPhoneState} from '@/stores/settings-phone' import {searchWaitingKey} from '@/constants/strings' +import {useDefaultPhoneCountry} from '@/util/phone-numbers' type PhoneSearchProps = { continueLabel: string @@ -20,12 +20,7 @@ const PhoneSearch = (props: PhoneSearchProps) => { const [phoneNumber, setPhoneNumber] = React.useState('') const [phoneInputKey, setPhoneInputKey] = React.useState(0) const waiting = C.Waiting.useAnyWaiting(searchWaitingKey) - const loadDefaultPhoneCountry = useSettingsPhoneState(s => s.dispatch.loadDefaultPhoneCountry) - // trigger a default phone number country rpc if it's not already loaded - const defaultCountry = useSettingsPhoneState(s => s.defaultCountry) - React.useEffect(() => { - !defaultCountry && loadDefaultPhoneCountry() - }, [defaultCountry, loadDefaultPhoneCountry]) + const defaultCountry = useDefaultPhoneCountry() const onChangeNumberCb = (phoneNumber: string, validity: boolean) => { setPhoneValidity(validity) diff --git a/shared/teams/add-members-wizard/add-phone.tsx b/shared/teams/add-members-wizard/add-phone.tsx index 140a10c0aec0..5ec001f96c05 100644 --- a/shared/teams/add-members-wizard/add-phone.tsx +++ b/shared/teams/add-members-wizard/add-phone.tsx @@ -4,7 +4,7 @@ import {useTeamsState} from '@/stores/teams' import * as Kb from '@/common-adapters' import * as T from '@/constants/types' import {usePhoneNumberList} from '../common' -import {useSettingsPhoneState} from '@/stores/settings-phone' +import {useDefaultPhoneCountry} from '@/util/phone-numbers' const waitingKey = 'phoneLookup' @@ -15,14 +15,7 @@ const AddPhone = () => { const disabled = !phoneNumbers.length || phoneNumbers.some(pn => !pn.valid) const waiting = C.Waiting.useAnyWaiting(waitingKey) - const defaultCountry = useSettingsPhoneState(s => s.defaultCountry) - const loadDefaultPhoneCountry = useSettingsPhoneState(s => s.dispatch.loadDefaultPhoneCountry) - - React.useEffect(() => { - if (!defaultCountry) { - loadDefaultPhoneCountry() - } - }, [defaultCountry, loadDefaultPhoneCountry]) + const defaultCountry = useDefaultPhoneCountry() const emailsToAssertionsRPC = C.useRPC(T.RPCGen.userSearchBulkEmailOrPhoneSearchRpcPromise) const addMembersWizardPushMembers = useTeamsState(s => s.dispatch.addMembersWizardPushMembers) diff --git a/shared/util/phone-numbers/index.tsx b/shared/util/phone-numbers/index.tsx index 587795dbaf09..f1a15007f5ff 100644 --- a/shared/util/phone-numbers/index.tsx +++ b/shared/util/phone-numbers/index.tsx @@ -1,4 +1,6 @@ import * as C from '@/constants' +import * as React from 'react' +import * as T from '@/constants/types' import libphonenumber from 'google-libphonenumber' const PNF = libphonenumber.PhoneNumberFormat @@ -7,6 +9,8 @@ const PhoneNumberFormat = PNF const phoneUtil = libphonenumber.PhoneNumberUtil.getInstance() const ValidationResult = libphonenumber.PhoneNumberUtil.ValidationResult const supported = phoneUtil.getSupportedRegions() +let _defaultPhoneCountry: string | undefined +let _defaultPhoneCountryPromise: Promise | undefined export type CountryData = { alpha2: string @@ -202,3 +206,45 @@ export const getE164 = (phoneNumber: string, countryCode?: string) => { return null } } + +const loadDefaultPhoneCountry = () => { + if (_defaultPhoneCountry) { + return Promise.resolve(_defaultPhoneCountry) + } + if (!_defaultPhoneCountryPromise) { + _defaultPhoneCountryPromise = T.RPCGen.accountGuessCurrentLocationRpcPromise({ + defaultCountry: 'US', + }) + .then(country => { + _defaultPhoneCountry = country + return country + }) + .catch(() => undefined) + .finally(() => { + _defaultPhoneCountryPromise = undefined + }) + } + return _defaultPhoneCountryPromise +} + +export const useDefaultPhoneCountry = () => { + const [defaultCountry, setDefaultCountry] = React.useState(_defaultPhoneCountry) + + React.useEffect(() => { + let canceled = false + if (_defaultPhoneCountry) { + setDefaultCountry(_defaultPhoneCountry) + return + } + void loadDefaultPhoneCountry().then(country => { + if (!canceled) { + setDefaultCountry(country) + } + }) + return () => { + canceled = true + } + }, []) + + return defaultCountry +} diff --git a/skill/zustand-store-pruning/references/store-checklist.md b/skill/zustand-store-pruning/references/store-checklist.md index 4581b16c6369..8438fc9cbd85 100644 --- a/skill/zustand-store-pruning/references/store-checklist.md +++ b/skill/zustand-store-pruning/references/store-checklist.md @@ -34,7 +34,7 @@ Status: - [x] `settings-email` moved add-email submit/error state into components; kept notification-backed `emails`, `addedEmail`, and row actions in store - [ ] `settings-notifications` - [x] `settings-password` kept only `randomPW` in store; moved submit/load flows into settings screens -- [ ] `settings-phone` +- [x] `settings-phone` kept notification-backed `phones` and `addedPhone`; moved add/verify/default-country flow into local hooks and route params - [ ] `signup` - [ ] `team-building` - [ ] `tracker` From 75e4ca5225e7a0ed248f46985c679e04f50c518d Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Thu, 26 Mar 2026 13:57:31 -0400 Subject: [PATCH 08/19] remove crypto store --- .../conversation/messages/attachment/file.tsx | 32 +- shared/constants/crypto.tsx | 7 - shared/constants/init/shared.tsx | 18 +- shared/constants/router.tsx | 2 + shared/crypto/input.tsx | 281 +++----- shared/crypto/operations/decrypt.tsx | 279 ++++++- shared/crypto/operations/encrypt.tsx | 561 ++++++++++++-- shared/crypto/operations/sign.tsx | 305 +++++++- shared/crypto/operations/verify.tsx | 277 ++++++- shared/crypto/output.tsx | 316 +++----- shared/crypto/recipients.tsx | 28 +- shared/crypto/routes.tsx | 125 +++- shared/crypto/state.tsx | 176 +++++ shared/crypto/sub-nav/index.desktop.tsx | 2 +- shared/crypto/sub-nav/index.native.tsx | 2 +- shared/crypto/sub-nav/left-nav.desktop.tsx | 2 +- .../renderer/remote-event-handler.desktop.tsx | 19 +- shared/stores/crypto.tsx | 682 ------------------ shared/stores/tests/crypto.test.ts | 656 ++--------------- 19 files changed, 1878 insertions(+), 1892 deletions(-) create mode 100644 shared/crypto/state.tsx delete mode 100644 shared/stores/crypto.tsx diff --git a/shared/chat/conversation/messages/attachment/file.tsx b/shared/chat/conversation/messages/attachment/file.tsx index 6ed0c50adb3c..fd63dcf19c2d 100644 --- a/shared/chat/conversation/messages/attachment/file.tsx +++ b/shared/chat/conversation/messages/attachment/file.tsx @@ -1,14 +1,14 @@ import * as C from '@/constants' +import * as CryptoRoutes from '@/constants/crypto' import * as Chat from '@/stores/chat' -import * as Crypto from '@/stores/crypto' import {isPathSaltpack, isPathSaltpackEncrypted, isPathSaltpackSigned} from '@/util/path' -import type * as T from '@/constants/types' import {useOrdinal} from '@/chat/conversation/messages/ids-context' import captialize from 'lodash/capitalize' import * as Kb from '@/common-adapters' import type {StyleOverride} from '@/common-adapters/markdown' import {getEditStyle, ShowToastAfterSaving} from './shared' import {useFSState} from '@/stores/fs' +import {makeUUID} from '@/util/uuid' type OwnProps = {showPopup: () => void} @@ -46,11 +46,18 @@ function FileContainer(p: OwnProps) { const {conversationIDKey, fileType, downloadPath, isEditing, progress, messageAttachmentNativeShare} = data const {attachmentDownload, title, transferState, transferErrMsg, fileName: _fileName} = data - const saltpackOpenFile = Crypto.useCryptoState(s => s.dispatch.onSaltpackOpenFile) const switchTab = C.useRouterState(s => s.dispatch.switchTab) - const onSaltpackFileOpen = (path: string, operation: T.Crypto.Operations) => { + const navigateAppend = C.useRouterState(s => s.dispatch.navigateAppend) + const onSaltpackFileOpen = (path: string, name: typeof CryptoRoutes.decryptTab | typeof CryptoRoutes.verifyTab) => { switchTab(C.Tabs.cryptoTab) - saltpackOpenFile(operation, path) + navigateAppend({ + name, + params: { + entryNonce: makeUUID(), + seedInputPath: path, + seedInputType: 'file', + }, + }, true) } const openLocalPathInSystemFileManagerDesktop = useFSState( s => s.dispatch.defer.openLocalPathInSystemFileManagerDesktop @@ -59,7 +66,6 @@ function FileContainer(p: OwnProps) { downloadPath && openLocalPathInSystemFileManagerDesktop?.(downloadPath) } - const navigateAppend = C.useRouterState(s => s.dispatch.navigateAppend) const onDownload = () => { if (C.isMobile) { messageAttachmentNativeShare(ordinal, true) @@ -100,12 +106,12 @@ function FileContainer(p: OwnProps) { const progressLabel = Chat.messageAttachmentTransferStateToProgressLabel(transferState) const iconType = isSaltpackFile ? 'icon-file-saltpack-32' : 'icon-file-32' - const operation = isPathSaltpackEncrypted(fileName) - ? Crypto.Operations.Decrypt + const cryptoRoute = isPathSaltpackEncrypted(fileName) + ? CryptoRoutes.decryptTab : isPathSaltpackSigned(fileName) - ? Crypto.Operations.Verify + ? CryptoRoutes.verifyTab : undefined - const operationTitle = captialize(operation) + const actionTitle = captialize(cryptoRoute?.replace('Tab', '')) const styleOverride = Kb.Styles.isMobile ? ({paragraph: getEditStyle(isEditing)} as StyleOverride) @@ -159,14 +165,14 @@ function FileContainer(p: OwnProps) { )} - {!Kb.Styles.isMobile && isSaltpackFile && operation && ( + {!Kb.Styles.isMobile && isSaltpackFile && cryptoRoute && ( onSaltpackFileOpen(fileName, operation)} + onClick={() => onSaltpackFileOpen(fileName, cryptoRoute)} /> )} diff --git a/shared/constants/crypto.tsx b/shared/constants/crypto.tsx index 459215e60c1f..5815510b1ad5 100644 --- a/shared/constants/crypto.tsx +++ b/shared/constants/crypto.tsx @@ -55,10 +55,3 @@ export const Tabs = [ title: 'Verify', }, ] as const - -export const Operations = { - Decrypt: 'decrypt', - Encrypt: 'encrypt', - Sign: 'sign', - Verify: 'verify', -} as const diff --git a/shared/constants/init/shared.tsx b/shared/constants/init/shared.tsx index f1d101b3380c..f70246104d71 100644 --- a/shared/constants/init/shared.tsx +++ b/shared/constants/init/shared.tsx @@ -42,6 +42,7 @@ import type * as UseUnlockFoldersStateType from '@/stores/unlock-folders' import type * as UseUsersStateType from '@/stores/users' import {createTBStore, getTBStore} from '@/stores/team-building' import {getSelectedConversation} from '@/constants/chat/common' +import * as CryptoRoutes from '@/constants/crypto' import {emitDeepLink} from '@/router-v2/linking' import {ignorePromise} from '../utils' import {isMobile, serverConfigFileName} from '../platform' @@ -50,7 +51,6 @@ import {useAutoResetState} from '@/stores/autoreset' import {useAvatarState} from '@/common-adapters/avatar/store' import {useChatState} from '@/stores/chat' import {useConfigState} from '@/stores/config' -import {useCryptoState} from '@/stores/crypto' import {useCurrentUserState} from '@/stores/current-user' import {useDaemonState} from '@/stores/daemon' import {useDarkModeState} from '@/stores/darkmode' @@ -181,7 +181,21 @@ export const initTeamBuildingCallbacks = () => { ...(namespace === 'crypto' ? { onFinishedTeamBuildingCrypto: users => { - useCryptoState.getState().dispatch.onTeamBuildingFinished(users) + const visible = Util.getVisibleScreen() + const visibleParams = + visible?.name === 'cryptoTeamBuilder' ? (visible.params as {teamBuilderNonce?: string} | undefined) : undefined + const teamBuilderUsers = [...users].map(({serviceId, username}) => ({serviceId, username})) + Util.clearModals() + Util.navigateAppend( + { + name: CryptoRoutes.encryptTab, + params: { + teamBuilderNonce: visibleParams?.teamBuilderNonce, + teamBuilderUsers, + }, + }, + true + ) }, } : {}), diff --git a/shared/constants/router.tsx b/shared/constants/router.tsx index c0b72d35ae8a..3dc176dc072b 100644 --- a/shared/constants/router.tsx +++ b/shared/constants/router.tsx @@ -16,6 +16,7 @@ import {isSplit} from './chat/layout' import {isMobile} from './platform' import {shallowEqual} from './utils' import {registerDebugClear} from '@/util/debug' +import {makeUUID} from '@/util/uuid' type InferComponentProps = T extends React.LazyExoticComponent | undefined>> ? P @@ -334,6 +335,7 @@ export const appendEncryptRecipientsBuilder = () => { filterServices: ['facebook', 'github', 'hackernews', 'keybase', 'reddit', 'twitter'], goButtonLabel: 'Add', namespace: 'crypto', + teamBuilderNonce: makeUUID(), recommendedHideYourself: true, title: 'Recipients', }, diff --git a/shared/crypto/input.tsx b/shared/crypto/input.tsx index d4901b87d186..439655dcfabe 100644 --- a/shared/crypto/input.tsx +++ b/shared/crypto/input.tsx @@ -1,86 +1,69 @@ import * as C from '@/constants' -import * as Crypto from '@/stores/crypto' import * as React from 'react' import type * as T from '@/constants/types' +import type {CommonState} from './state' import * as Kb from '@/common-adapters' import * as FS from '@/constants/fs' import type {IconType} from '@/common-adapters/icon.constants-gen' -import capitalize from 'lodash/capitalize' import {pickFiles} from '@/util/misc' type CommonProps = { - operation: T.Crypto.Operations + state: CommonState } type TextProps = CommonProps & { + allowDirectories: boolean + emptyInputWidth: number + inputPlaceholder: string onChangeText: (text: string) => void onSetFile: (path: string) => void - value: string setBlurCB?: (cb: () => void) => void + textInputType: 'cipher' | 'plain' } type FileProps = CommonProps & { - path: string - size?: number + fileIcon: IconType onClearFiles: () => void } -type DragAndDropProps = CommonProps & { - prompt: string +type DragAndDropProps = { + allowFolders: boolean children: React.ReactNode + inProgress: boolean + onAttach: (path: string) => void + prompt: string } -type RunOperationProps = CommonProps & { - children?: React.ReactNode +type RunActionBarProps = { blurCBRef?: React.RefObject<() => void> + children?: React.ReactNode + onRun: () => void + runLabel: string } -// Tese magic numbers set the width of the single line `textarea` such that the -// placeholder text is visible and pushes the "browse" button far enough to the -// right to be exactly one empty character with from the end of the placeholder text -const operationToEmptyInputWidth = { - [Crypto.Operations.Encrypt]: 207, - [Crypto.Operations.Decrypt]: 320, - [Crypto.Operations.Sign]: 207, - [Crypto.Operations.Verify]: 342, +type InputProps = CommonProps & { + allowDirectories: boolean + emptyInputWidth: number + fileIcon: IconType + inputPlaceholder: string + onClearInput: () => void + onSetInput: (type: T.Crypto.InputTypes, value: string) => void + setBlurCB?: (cb: () => void) => void + textInputType: 'cipher' | 'plain' } -const inputTextType = new Map([ - ['decrypt', 'cipher'], - ['encrypt', 'plain'], - ['sign', 'plain'], - ['verify', 'cipher'], -] as const) -const inputPlaceholder = new Map([ - [ - 'decrypt', - C.isMobile ? 'Enter text to decrypt' : 'Enter ciphertext, drop an encrypted file or folder, or', - ], - ['encrypt', C.isMobile ? 'Enter text to encrypt' : 'Enter text, drop a file or folder, or'], - ['sign', C.isMobile ? 'Enter text to sign' : 'Enter text, drop a file or folder, or'], - [ - 'verify', - C.isMobile ? 'Enter text to verify' : 'Enter a signed message, drop a signed file or folder, or', - ], -] as const) +type BannerContent = React.ComponentProps['content'] + +export type CryptoBannerProps = { + infoMessage: BannerContent + state: CommonState +} -/* - * Before user enters text: - * - Single line input - * - Browse file button - * - * Afte user enters text: - * - Multiline input - * - Clear button - */ const TextInput = (props: TextProps) => { - const {value, operation, onChangeText, onSetFile, setBlurCB} = props - const textType = inputTextType.get(operation) - const placeholder = inputPlaceholder.get(operation) - const emptyWidth = operationToEmptyInputWidth[operation] + const {allowDirectories, emptyInputWidth, inputPlaceholder, state, onChangeText, onSetFile, setBlurCB, textInputType} = + props + const value = state.inputType === 'text' ? state.input : '' - // When 'browse file' is show, focus input by clicking anywhere in the input box - // (despite the input being one line tall) const inputRef = React.useRef(null) const onFocusInput = () => { inputRef.current?.focus() @@ -97,26 +80,22 @@ const TextInput = (props: TextProps) => { const onOpenFile = () => { const f = async () => { - // On Windows and Linux only files will be able to be selected. Their native pickers don't allow for selecting both directories and files at once. - // To set a directory as input, a user will need to drag the directory into Keybase. const filePaths = await pickFiles({ - allowDirectories: C.isDarwin, + allowDirectories: allowDirectories && C.isDarwin, buttonLabel: 'Select', }) if (!filePaths.length) return - const path = filePaths[0]! - onSetFile(path) + onSetFile(filePaths[0] ?? '') } C.ignorePromise(f()) } - // Styling const rowsMax = Kb.Styles.isMobile ? undefined : value ? undefined : 1 const growAndScroll = !Kb.Styles.isMobile && !!value const inputStyle = Kb.Styles.collapseStyles([ styles.input, value ? styles.inputFull : styles.inputEmpty, - !value && !Kb.Styles.isMobile && {width: emptyWidth}, + !value && !Kb.Styles.isMobile && {width: emptyInputWidth}, ]) const inputContainerStyle = value ? styles.inputContainer : styles.inputContainerEmpty @@ -146,7 +125,7 @@ const TextInput = (props: TextProps) => { > { growAndScroll={growAndScroll} containerStyle={inputContainerStyle} inputStyle={inputStyle} - textType={textType === 'cipher' ? 'Terminal' : 'Body'} - autoCorrect={textType !== 'cipher'} - spellCheck={textType !== 'cipher'} - onChangeText={(text: string) => onChangeText(text)} + textType={textInputType === 'cipher' ? 'Terminal' : 'Body'} + autoCorrect={textInputType !== 'cipher'} + spellCheck={textInputType !== 'cipher'} + onChangeText={onChangeText} ref={inputRef} /> {!Kb.Styles.isMobile && browseButton} @@ -168,16 +147,7 @@ const TextInput = (props: TextProps) => { ) } -const inputFileIcon = new Map([ - ['decrypt', 'icon-file-saltpack-64'], - ['encrypt', 'icon-file-64'], - ['sign', 'icon-file-64'], - ['verify', 'icon-file-saltpack-64'], -] as const) - -const FileInput = (props: FileProps) => { - const {path, size, operation} = props - const fileIcon = inputFileIcon.get(operation) as IconType +const FileInput = ({fileIcon, onClearFiles, state}: FileProps) => { const waiting = C.Waiting.useAnyWaiting(C.waitingKeyCrypto) return ( @@ -192,17 +162,15 @@ const FileInput = (props: FileProps) => { - {path} - {size ? {FS.humanReadableFileSize(size)} : null} + {state.input} + {state.bytesTotal ? ( + {FS.humanReadableFileSize(state.bytesTotal)} + ) : null} - {path && !waiting && ( + {state.input && !waiting && ( - props.onClearFiles()} - style={styles.clearButtonInput} - > + Clear @@ -212,104 +180,49 @@ const FileInput = (props: FileProps) => { ) } -export const Input = (props: CommonProps & {setBlurCB?: (cb: () => void) => void}) => { - const {operation, setBlurCB} = props - - const {input: _input, inputType} = Crypto.useCryptoState( - C.useShallow(s => { - const o = s[operation] - const {input, inputType} = o - return {input, inputType} - }) - ) - const input = _input.stringValue() - - const [inputValue, setInputValue] = React.useState(input) - - const setInput = Crypto.useCryptoState(s => s.dispatch.setInput) - const clearInput = Crypto.useCryptoState(s => s.dispatch.clearInput) - - const onSetInput = (type: T.Crypto.InputTypes, newValue: string) => { - setInput(operation, type, newValue) - } - const onClearInput = () => { - clearInput(operation) - } - - return inputType === 'file' ? ( - { - setInputValue('') - onClearInput() - }} - /> +export const Input = ({ + allowDirectories, + emptyInputWidth, + fileIcon, + inputPlaceholder, + onClearInput, + onSetInput, + setBlurCB, + state, + textInputType, +}: InputProps) => + state.inputType === 'file' ? ( + ) : ( { - onSetInput('file', path) - }} - onChangeText={text => { - setInputValue(text) - onSetInput('text', text) - }} + state={state} + textInputType={textInputType} + onSetFile={path => onSetInput('file', path)} + onChangeText={text => onSetInput('text', text)} /> ) -} - -const allowInputFolders = new Map([ - ['decrypt', false], - ['encrypt', true], - ['sign', true], - ['verify', false], -] as const) - -export const DragAndDrop = (props: DragAndDropProps) => { - const {prompt, children, operation} = props - const inProgress = Crypto.useCryptoState(s => s[operation].inProgress) - const setInput = Crypto.useCryptoState(s => s.dispatch.setInput) - - const onAttach = (localPaths: Array) => { - const path = localPaths[0] - setInput(operation, 'file', path ?? '') - } - - const allowFolders = allowInputFolders.get(operation) as boolean - - return ( - - - {children} - - - ) -} - -export const OperationBanner = (props: CommonProps) => { - const {operation} = props - const infoMessage = Crypto.infoMessage[operation] - const {errorMessage: _errorMessage, warningMessage: _warningMessage} = Crypto.useCryptoState( - C.useShallow(s => { - const {errorMessage, warningMessage} = s[operation] - return {errorMessage, warningMessage} - }) - ) - const errorMessage = _errorMessage.stringValue() - const warningMessage = _warningMessage.stringValue() +export const DragAndDrop = ({allowFolders, children, inProgress, onAttach, prompt}: DragAndDropProps) => ( + + onAttach(localPaths[0] ?? '')} + prompt={prompt} + > + {children} + + +) - if (!errorMessage && !warningMessage) { +export const CryptoBanner = ({infoMessage, state}: CryptoBannerProps) => { + if (!state.errorMessage && !state.warningMessage) { return ( @@ -319,29 +232,25 @@ export const OperationBanner = (props: CommonProps) => { return ( <> - {errorMessage ? ( + {state.errorMessage ? ( - + ) : null} - {warningMessage ? ( + {state.warningMessage ? ( - + ) : null} ) } -// Mobile only -export const InputActionsBar = (props: RunOperationProps) => { - const {operation, children, blurCBRef} = props - const operationTitle = capitalize(operation) - const runTextOperation = Crypto.useCryptoState(s => s.dispatch.runTextOperation) - const onRunOperation = () => { +export const InputActionsBar = ({blurCBRef, children, onRun, runLabel}: RunActionBarProps) => { + const onClick = () => { blurCBRef?.current() setTimeout(() => { - runTextOperation(operation) + onRun() }, 100) } @@ -356,9 +265,9 @@ export const InputActionsBar = (props: RunOperationProps) => { ) : null @@ -387,7 +296,6 @@ const styles = Kb.Styles.styleSheetCreate( }, isMobile: { flexShrink: 1, - // Give space on mobile for Recipients divider marginTop: 1, }, }), @@ -420,7 +328,6 @@ const styles = Kb.Styles.styleSheetCreate( }), inputContainer: Kb.Styles.platformStyles({ isElectron: { - // We want the immediate container not to overflow, so we tell it be height: 100% to match the parent ...Kb.Styles.globalStyles.fullHeight, alignItems: 'stretch', padding: 0, diff --git a/shared/crypto/operations/decrypt.tsx b/shared/crypto/operations/decrypt.tsx index 61060317259c..4d7f2e57f738 100644 --- a/shared/crypto/operations/decrypt.tsx +++ b/shared/crypto/operations/decrypt.tsx @@ -1,31 +1,229 @@ import * as C from '@/constants' -import * as Crypto from '@/stores/crypto' +import * as Crypto from '@/constants/crypto' import * as Kb from '@/common-adapters' import * as React from 'react' -import {Input, DragAndDrop, InputActionsBar, OperationBanner} from '../input' -import {OperationOutput, OutputActionsBar, SignedSender} from '../output' +import type * as T from '@/constants/types' +import {CryptoBanner, DragAndDrop, Input, InputActionsBar} from '../input' +import {CryptoOutput, CryptoOutputActionsBar, CryptoSignedSender} from '../output' +import { + createCommonState, + getStatusCodeMessage, + outputParamsToCommonState, + type CommonOutputRouteParams, + type CryptoInputRouteParams, + type CommonState, +} from '../state' +import {RPCError} from '@/util/errors' +import logger from '@/logger' +import type {RootRouteProps} from '@/router-v2/route-params' +import {useRoute} from '@react-navigation/core' -const operation = Crypto.Operations.Decrypt +const bannerMessage = Crypto.infoMessage.decrypt +const filePrompt = 'Drop a file to decrypt' +const inputEmptyWidth = 320 +const inputFileIcon = 'icon-file-saltpack-64' as const +const inputPlaceholder = C.isMobile + ? 'Enter text to decrypt' + : 'Enter ciphertext, drop an encrypted file or folder, or' + +const resetWarnings = (state: CommonState): CommonState => ({ + ...state, + errorMessage: '', + warningMessage: '', +}) + +const resetOutput = (state: CommonState): CommonState => ({ + ...resetWarnings(state), + bytesComplete: 0, + bytesTotal: 0, + output: '', + outputSenderFullname: undefined, + outputSenderUsername: undefined, + outputSigned: false, + outputStatus: undefined, + outputType: undefined, + outputValid: false, +}) + +const beginRun = (state: CommonState): CommonState => ({ + ...resetWarnings(state), + bytesComplete: 0, + bytesTotal: 0, + inProgress: true, + outputStatus: 'pending', + outputValid: false, +}) + +const onError = (state: CommonState, errorMessage: string): CommonState => ({ + ...resetOutput(state), + errorMessage, + inProgress: false, +}) + +const onSuccess = ( + state: CommonState, + outputValid: boolean, + output: string, + inputType: 'file' | 'text', + signed: boolean, + senderUsername: string, + senderFullname: string +): CommonState => ({ + ...resetWarnings(state), + inProgress: false, + output, + outputSenderFullname: signed ? senderFullname : undefined, + outputSenderUsername: signed ? senderUsername : undefined, + outputSigned: signed, + outputStatus: 'success', + outputType: inputType, + outputValid, +}) + +const useDecryptState = (params?: CryptoInputRouteParams) => { + const [state, setState] = React.useState(() => createCommonState(params)) + const stateRef = React.useRef(state) + React.useEffect(() => { + stateRef.current = state + }, [state]) + + const clearInput = React.useCallback(() => { + setState(prev => ({ + ...resetOutput(prev), + input: '', + inputType: 'text', + outputValid: true, + })) + }, []) + + const decrypt = React.useCallback(async (destinationDir = '') => { + const snapshot = stateRef.current + setState(prev => beginRun(prev)) + try { + if (snapshot.inputType === 'text') { + const res = await T.RPCGen.saltpackSaltpackDecryptStringRpcPromise( + {ciphertext: snapshot.input}, + C.waitingKeyCrypto + ) + const next = onSuccess( + stateRef.current, + stateRef.current.input === snapshot.input, + res.plaintext, + 'text', + res.signed, + res.info.sender.username, + res.info.sender.fullname + ) + setState(next) + return next + } + + const res = await T.RPCGen.saltpackSaltpackDecryptFileRpcPromise( + {destinationDir, encryptedFilename: snapshot.input}, + C.waitingKeyCrypto + ) + const next = onSuccess( + stateRef.current, + stateRef.current.input === snapshot.input, + res.decryptedFilename, + 'file', + res.signed, + res.info.sender.username, + res.info.sender.fullname + ) + setState(next) + return next + } catch (_error) { + if (!(_error instanceof RPCError)) throw _error + logger.error(_error) + const next = onError(stateRef.current, getStatusCodeMessage(_error, 'decrypt', snapshot.inputType)) + setState(next) + return next + } + }, []) + + const setInput = React.useCallback( + (type: T.Crypto.InputTypes, value: string) => { + if (!value) { + clearInput() + return + } + setState(prev => { + const outputValid = prev.input === value + const next = { + ...resetWarnings(prev), + input: value, + inputType: type, + outputValid, + } + return type === 'file' ? resetOutput(next) : next + }) + if (type === 'text' && !C.isMobile) { + C.ignorePromise(decrypt()) + } + }, + [clearInput, decrypt] + ) + + const openFile = React.useCallback((path: string) => { + if (!path) return + setState(prev => { + if (prev.inProgress) return prev + return { + ...resetOutput(prev), + input: path, + inputType: 'file', + } + }) + }, []) -export const DecryptInput = () => { - const resetOperation = Crypto.useCryptoState(s => s.dispatch.resetOperation) React.useEffect(() => { - return () => { + if (!params?.seedInputPath) return + if ((params.seedInputType ?? 'file') === 'file') { + openFile(params.seedInputPath) + } else { + setInput('text', params.seedInputPath) + } + }, [openFile, params?.entryNonce, params?.seedInputPath, params?.seedInputType, setInput]) + + return {clearInput, decrypt, openFile, setInput, state} +} + +export const DecryptInput = (_props: unknown) => { + const {params} = useRoute>() + const controller = useDecryptState(params) + const navigateAppend = C.useRouterState(s => s.dispatch.navigateAppend) + + const onRun = () => { + const f = async () => { + const next = await controller.decrypt() if (C.isMobile) { - resetOperation(operation) + navigateAppend({name: Crypto.decryptOutput, params: next}) } } - }, [resetOperation]) + C.ignorePromise(f()) + } + const contents = ( <> - - + + ) + return C.isMobile ? ( {contents} - + ) : ( @@ -34,17 +232,25 @@ export const DecryptInput = () => { ) } -export const DecryptOutput = () => { - const errorMessage = Crypto.useCryptoState(s => s[operation].errorMessage.stringValue()) +export const DecryptOutput = ({route}: {route: {params: CommonOutputRouteParams}}) => { + const state = outputParamsToCommonState(route.params) const content = ( <> - {C.isMobile && errorMessage ? : null} - + {C.isMobile && state.errorMessage ? : null} + {C.isMobile ? : null} - - + undefined} + /> + ) + return C.isMobile ? ( content ) : ( @@ -55,12 +261,43 @@ export const DecryptOutput = () => { } export const DecryptIO = () => { + const {params} = useRoute>() + const controller = useDecryptState(params) return ( - + - + + + + - + + + { + C.ignorePromise(controller.decrypt(destinationDir)) + }} + /> + + ) diff --git a/shared/crypto/operations/encrypt.tsx b/shared/crypto/operations/encrypt.tsx index 7a2f051a276c..9a6cd68220ed 100644 --- a/shared/crypto/operations/encrypt.tsx +++ b/shared/crypto/operations/encrypt.tsx @@ -1,27 +1,342 @@ import * as C from '@/constants' -import * as Crypto from '@/stores/crypto' +import * as Crypto from '@/constants/crypto' import * as Kb from '@/common-adapters' import * as React from 'react' +import type * as T from '@/constants/types' import Recipients from '../recipients' import {openURL} from '@/util/misc' -import {DragAndDrop, Input, InputActionsBar, OperationBanner} from '../input' -import {OutputInfoBanner, OperationOutput, OutputActionsBar, SignedSender} from '../output' - -const operation = Crypto.Operations.Encrypt - -const EncryptOptions = () => { - const {hasSBS, hasRecipients, hideIncludeSelf, includeSelf, inProgress, sign} = Crypto.useCryptoState( - C.useShallow(s => { - const o = s[operation] - const {inProgress} = o - const {hasRecipients, hideIncludeSelf, hasSBS} = o.meta - const {includeSelf, sign} = o.options - return {hasRecipients, hasSBS, hideIncludeSelf, inProgress, includeSelf, sign} +import {CryptoBanner, DragAndDrop, Input, InputActionsBar} from '../input' +import {CryptoOutput, CryptoOutputActionsBar, CryptoSignedSender, OutputInfoBanner} from '../output' +import { + createEncryptState, + encryptToOutputParams, + getStatusCodeMessage, + outputParamsToCommonState, + teamBuilderResultToRecipients, + type EncryptOutputRouteParams, + type EncryptRouteParams, + type EncryptState, +} from '../state' +import {RPCError} from '@/util/errors' +import logger from '@/logger' +import {useCurrentUserState} from '@/stores/current-user' +import type {RootRouteProps} from '@/router-v2/route-params' +import {useRoute} from '@react-navigation/core' + +const bannerMessage = Crypto.infoMessage.encrypt +const filePrompt = 'Drop a file to encrypt' +const inputEmptyWidth = 207 +const inputFileIcon = 'icon-file-64' as const +const inputPlaceholder = C.isMobile ? 'Enter text to encrypt' : 'Enter text, drop a file or folder, or' + +const getWarningMessageForSBS = (sbsAssertion: string) => + `Note: Encrypted for "${sbsAssertion}" who is not yet a Keybase user. One of your devices will need to be online after they join Keybase in order for them to decrypt the message.` + +const resetWarnings = (state: EncryptState): EncryptState => ({ + ...state, + errorMessage: '', + warningMessage: '', +}) + +const resetOutput = (state: EncryptState): EncryptState => ({ + ...resetWarnings(state), + bytesComplete: 0, + bytesTotal: 0, + output: '', + outputSenderFullname: undefined, + outputSenderUsername: undefined, + outputSigned: false, + outputStatus: undefined, + outputType: undefined, + outputValid: false, +}) + +const beginRun = (state: EncryptState): EncryptState => ({ + ...resetWarnings(state), + bytesComplete: 0, + bytesTotal: 0, + inProgress: true, + outputStatus: 'pending', + outputValid: false, +}) + +const onError = (state: EncryptState, errorMessage: string): EncryptState => ({ + ...resetOutput(state), + errorMessage, + inProgress: false, +}) + +const onSuccess = ( + state: EncryptState, + outputValid: boolean, + warningMessage: string, + output: string, + inputType: 'file' | 'text', + signed: boolean, + senderUsername: string +): EncryptState => ({ + ...resetWarnings(state), + inProgress: false, + output, + outputSenderFullname: undefined, + outputSenderUsername: signed ? senderUsername : undefined, + outputSigned: signed, + outputStatus: 'success', + outputType: inputType, + outputValid, + warningMessage, +}) + +const nextRecipientState = ( + state: EncryptState, + recipients: ReadonlyArray, + hasSBS: boolean +): EncryptState => { + const currentUser = useCurrentUserState.getState().username + const hideIncludeSelf = recipients.includes(currentUser) && !hasSBS + const next = state.inputType === 'file' ? resetOutput(state) : resetWarnings(state) + return { + ...next, + meta: { + hasRecipients: recipients.length > 0, + hasSBS, + hideIncludeSelf, + }, + options: { + includeSelf: hasSBS ? true : hideIncludeSelf ? false : next.options.includeSelf, + sign: hasSBS ? true : next.options.sign, + }, + outputValid: false, + recipients: [...recipients], + } +} + +const nextOptionState = ( + state: EncryptState, + newOptions: {includeSelf?: boolean; sign?: boolean}, + hideIncludeSelf?: boolean +): EncryptState => { + const next = state.inputType === 'file' ? resetOutput(state) : resetWarnings(state) + return { + ...next, + meta: { + ...next.meta, + hideIncludeSelf: hideIncludeSelf ?? next.meta.hideIncludeSelf, + }, + options: { + ...next.options, + ...newOptions, + ...(hideIncludeSelf ? {includeSelf: false} : {}), + }, + outputValid: false, + } +} + +const useEncryptScreenState = (params?: EncryptRouteParams) => { + const [state, setState] = React.useState(() => createEncryptState(params)) + const stateRef = React.useRef(state) + const handledTeamBuilderNonceRef = React.useRef() + + React.useEffect(() => { + stateRef.current = state + }, [state]) + + const runEncrypt = React.useCallback(async (destinationDir = '') => { + const snapshot = stateRef.current + const username = useCurrentUserState.getState().username + const signed = snapshot.options.sign + const opts = { + includeSelf: snapshot.options.includeSelf, + recipients: snapshot.recipients.length ? snapshot.recipients : [username], + signed, + } + + setState(prev => beginRun(prev)) + try { + let output = '' + let unresolvedSBSAssertion = '' + let usedUnresolvedSBS = false + if (snapshot.inputType === 'text') { + const result = await T.RPCGen.saltpackSaltpackEncryptStringRpcPromise( + {opts, plaintext: snapshot.input}, + C.waitingKeyCrypto + ) + output = result.ciphertext + unresolvedSBSAssertion = result.unresolvedSBSAssertion + usedUnresolvedSBS = result.usedUnresolvedSBS + } else { + const result = await T.RPCGen.saltpackSaltpackEncryptFileRpcPromise( + {destinationDir, filename: snapshot.input, opts}, + C.waitingKeyCrypto + ) + output = result.filename + unresolvedSBSAssertion = result.unresolvedSBSAssertion + usedUnresolvedSBS = result.usedUnresolvedSBS + } + + const next = onSuccess( + stateRef.current, + stateRef.current.input === snapshot.input, + usedUnresolvedSBS ? getWarningMessageForSBS(unresolvedSBSAssertion) : '', + output, + snapshot.inputType, + signed, + username + ) + setState(next) + return next + } catch (_error) { + if (!(_error instanceof RPCError)) throw _error + logger.error(_error) + const next = onError(stateRef.current, getStatusCodeMessage(_error, 'encrypt', snapshot.inputType)) + setState(next) + return next + } + }, []) + + const clearInput = React.useCallback(() => { + setState(prev => ({ + ...resetOutput(prev), + input: '', + inputType: 'text', + outputValid: true, + })) + }, []) + + const setInput = React.useCallback( + (type: T.Crypto.InputTypes, value: string) => { + if (!value) { + clearInput() + return + } + setState(prev => { + const outputValid = prev.input === value + const next = { + ...resetWarnings(prev), + input: value, + inputType: type, + outputValid, + } + return type === 'file' ? resetOutput(next) : next + }) + if (type === 'text' && !C.isMobile) { + C.ignorePromise(runEncrypt()) + } + }, + [clearInput, runEncrypt] + ) + + const openFile = React.useCallback((path: string) => { + if (!path) return + setState(prev => { + if (prev.inProgress) return prev + return { + ...resetOutput(prev), + input: path, + inputType: 'file', + } }) + }, []) + + const setRecipients = React.useCallback( + (recipients: ReadonlyArray, hasSBS: boolean) => { + setState(prev => nextRecipientState(prev, recipients, hasSBS)) + if (stateRef.current.inputType === 'text' && !C.isMobile) { + C.ignorePromise(runEncrypt()) + } + }, + [runEncrypt] ) - const setEncryptOptions = Crypto.useCryptoState(s => s.dispatch.setEncryptOptions) + const clearRecipients = React.useCallback(() => { + setState(prev => { + const next = resetOutput(prev) + return { + ...next, + meta: { + hasRecipients: false, + hasSBS: false, + hideIncludeSelf: false, + }, + options: { + includeSelf: true, + sign: true, + }, + recipients: [], + } + }) + }, []) + + const setEncryptOptions = React.useCallback( + (options: {includeSelf?: boolean; sign?: boolean}, hideIncludeSelf?: boolean) => { + setState(prev => nextOptionState(prev, options, hideIncludeSelf)) + if (stateRef.current.inputType === 'text' && !C.isMobile) { + C.ignorePromise(runEncrypt()) + } + }, + [runEncrypt] + ) + const saveOutputAsText = React.useCallback(async () => { + const output = await T.RPCGen.saltpackSaltpackSaveCiphertextToFileRpcPromise({ + ciphertext: stateRef.current.output, + }) + const next = { + ...resetWarnings(stateRef.current), + output, + outputStatus: 'success' as const, + outputType: 'file' as const, + } + setState(next) + return next + }, []) + + React.useEffect(() => { + if (!params?.seedInputPath) return + if ((params.seedInputType ?? 'file') === 'file') { + openFile(params.seedInputPath) + } else { + setInput('text', params.seedInputPath) + } + }, [openFile, params?.entryNonce, params?.seedInputPath, params?.seedInputType, setInput]) + + React.useEffect(() => { + if (!params?.teamBuilderNonce || !params.teamBuilderUsers) return + if (handledTeamBuilderNonceRef.current === params.teamBuilderNonce) return + handledTeamBuilderNonceRef.current = params.teamBuilderNonce + const {hasSBS, recipients} = teamBuilderResultToRecipients(params.teamBuilderUsers) + setRecipients(recipients, hasSBS) + }, [params?.teamBuilderNonce, params?.teamBuilderUsers, setRecipients]) + + return { + clearInput, + clearRecipients, + openFile, + runEncrypt, + saveOutputAsText, + setEncryptOptions, + setInput, + state, + } +} + +const EncryptOptions = ({ + hasRecipients, + hasSBS, + hideIncludeSelf, + includeSelf, + inProgress, + sign, + setEncryptOptions, +}: { + hasRecipients: boolean + hasSBS: boolean + hideIncludeSelf: boolean + includeSelf: boolean + inProgress: boolean + setEncryptOptions: (options: {includeSelf?: boolean; sign?: boolean}, hideIncludeSelf?: boolean) => void + sign: boolean +}) => { const onSetOptions = (opts: {newIncludeSelf: boolean; newSign: boolean}) => { const {newIncludeSelf, newSign} = opts setEncryptOptions({includeSelf: newIncludeSelf, sign: newSign}) @@ -56,22 +371,22 @@ const EncryptOptions = () => { ) } -const EncryptOutputBanner = () => { - const {hasRecipients, includeSelf, recipients, outputType} = Crypto.useCryptoState( - C.useShallow(s => { - const o = s[operation] - const {recipients, outputType} = o - const {hasRecipients} = o.meta - const {includeSelf} = o.options - return {hasRecipients, includeSelf, outputType, recipients} - }) - ) - +const EncryptOutputBanner = ({ + hasRecipients, + includeSelf, + outputStatus, + outputType, + recipients, +}: { + hasRecipients: boolean + includeSelf: boolean + outputStatus?: EncryptOutputRouteParams['outputStatus'] + outputType?: EncryptOutputRouteParams['outputType'] + recipients: ReadonlyArray +}) => { const youAnd = (who: string) => (includeSelf ? `you and ${who}` : who) const whoCanRead = hasRecipients - ? ` Only ${ - recipients.length > 1 ? youAnd('your recipients') : youAnd(recipients[0] ?? '') - } can decipher it.` + ? ` Only ${recipients.length > 1 ? youAnd('your recipients') : youAnd(recipients[0] ?? '')} can decipher it.` : '' const paragraphs: Array> = [] @@ -80,7 +395,9 @@ const EncryptOutputBanner = () => { key="saltpackDisclaimer" bannerColor="grey" content={[ - `This is your encrypted ${outputType === 'file' ? 'file' : 'message'}, using `, + 'This is your encrypted ', + outputType === 'file' ? 'file' : 'message', + ', using ', { onClick: () => openURL(Crypto.saltpackDocumentation), text: 'Saltpack', @@ -100,7 +417,7 @@ const EncryptOutputBanner = () => { ) } - return {paragraphs} + return {paragraphs} } const styles = Kb.Styles.styleSheetCreate( @@ -125,36 +442,73 @@ const styles = Kb.Styles.styleSheetCreate( }) as const ) -export const EncryptInput = () => { +const EncryptInputBody = ({params}: {params?: EncryptRouteParams}) => { + const controller = useEncryptScreenState(params) const blurCBRef = React.useRef(() => {}) + const navigateAppend = C.useRouterState(s => s.dispatch.navigateAppend) + const appendEncryptRecipientsBuilder = C.useRouterState(s => s.appendEncryptRecipientsBuilder) const setBlurCB = (cb: () => void) => { blurCBRef.current = cb } + const onRun = () => { + const f = async () => { + const next = await controller.runEncrypt() + if (C.isMobile) { + navigateAppend({name: Crypto.encryptOutput, params: encryptToOutputParams(next)}) + } + } + C.ignorePromise(f()) + } + const options = C.isMobile ? ( - - + + ) : ( - + ) + const content = ( <> - - - + + + {options} ) - const resetOperation = Crypto.useCryptoState(s => s.dispatch.resetOperation) - React.useEffect(() => { - return () => { - if (C.isMobile) { - resetOperation(operation) - } - } - }, [resetOperation]) return C.isMobile ? ( {content} ) : ( @@ -164,25 +518,114 @@ export const EncryptInput = () => { ) } -export const EncryptOutput = () => ( +const EncryptOutputBody = ({params}: {params: EncryptOutputRouteParams}) => ( - - + + {C.isMobile ? : null} - - + undefined} + /> + ) -export const EncryptIO = () => ( - - - - - - -) +export const EncryptInput = (_props: unknown) => { + const {params} = useRoute>() + return +} + +export const EncryptOutput = ({route}: {route: {params: EncryptOutputRouteParams}}) => { + return +} + +export const EncryptIO = () => { + const {params} = useRoute>() + const controller = useEncryptScreenState(params) + const appendEncryptRecipientsBuilder = C.useRouterState(s => s.appendEncryptRecipientsBuilder) + + return ( + + + + + + + + + + + + {C.isMobile ? : null} + { + C.ignorePromise(controller.runEncrypt(destinationDir)) + }} + /> + { + C.ignorePromise(controller.saveOutputAsText()) + }} + /> + + + + ) +} diff --git a/shared/crypto/operations/sign.tsx b/shared/crypto/operations/sign.tsx index 90baa815c9c9..12f5673ef86a 100644 --- a/shared/crypto/operations/sign.tsx +++ b/shared/crypto/operations/sign.tsx @@ -1,52 +1,236 @@ import * as C from '@/constants' -import * as Crypto from '@/stores/crypto' +import * as Crypto from '@/constants/crypto' import * as React from 'react' import * as Kb from '@/common-adapters' +import type * as T from '@/constants/types' import {openURL} from '@/util/misc' -import {Input, DragAndDrop, OperationBanner, InputActionsBar} from '../input' -import {OutputInfoBanner, OperationOutput, OutputActionsBar, SignedSender} from '../output' +import {CryptoBanner, DragAndDrop, Input, InputActionsBar} from '../input' +import {CryptoOutput, CryptoOutputActionsBar, CryptoSignedSender, OutputInfoBanner} from '../output' +import { + createCommonState, + getStatusCodeMessage, + outputParamsToCommonState, + type CommonOutputRouteParams, + type CryptoInputRouteParams, + type CommonState, +} from '../state' +import {RPCError} from '@/util/errors' +import logger from '@/logger' +import {useCurrentUserState} from '@/stores/current-user' +import type {RootRouteProps} from '@/router-v2/route-params' +import {useRoute} from '@react-navigation/core' -const operation = Crypto.Operations.Sign +const bannerMessage = Crypto.infoMessage.sign +const filePrompt = 'Drop a file to sign' +const inputEmptyWidth = 207 +const inputFileIcon = 'icon-file-64' as const +const inputPlaceholder = C.isMobile ? 'Enter text to sign' : 'Enter text, drop a file or folder, or' -const SignOutputBanner = () => { - const outputType = Crypto.useCryptoState(s => s.sign.outputType) - return ( - - - This is your signed {outputType === 'file' ? 'file' : 'message'}, using{` `} - openURL(Crypto.saltpackDocumentation)} - > - Saltpack - - .{` `}Anyone who has it can verify you signed it. - - +const resetWarnings = (state: CommonState): CommonState => ({ + ...state, + errorMessage: '', + warningMessage: '', +}) + +const resetOutput = (state: CommonState): CommonState => ({ + ...resetWarnings(state), + bytesComplete: 0, + bytesTotal: 0, + output: '', + outputSenderFullname: undefined, + outputSenderUsername: undefined, + outputSigned: false, + outputStatus: undefined, + outputType: undefined, + outputValid: false, +}) + +const beginRun = (state: CommonState): CommonState => ({ + ...resetWarnings(state), + bytesComplete: 0, + bytesTotal: 0, + inProgress: true, + outputStatus: 'pending', + outputValid: false, +}) + +const onError = (state: CommonState, errorMessage: string): CommonState => ({ + ...resetOutput(state), + errorMessage, + inProgress: false, +}) + +const onSuccess = ( + state: CommonState, + outputValid: boolean, + output: string, + inputType: 'file' | 'text', + username: string +): CommonState => ({ + ...resetWarnings(state), + inProgress: false, + output, + outputSenderUsername: username, + outputSigned: true, + outputStatus: 'success', + outputType: inputType, + outputValid, +}) + +const useSignState = (params?: CryptoInputRouteParams) => { + const [state, setState] = React.useState(() => createCommonState(params)) + const stateRef = React.useRef(state) + React.useEffect(() => { + stateRef.current = state + }, [state]) + + const clearInput = React.useCallback(() => { + setState(prev => ({ + ...resetOutput(prev), + input: '', + inputType: 'text', + outputValid: true, + })) + }, []) + + const sign = React.useCallback(async (destinationDir = '') => { + const snapshot = stateRef.current + setState(prev => beginRun(prev)) + try { + const username = useCurrentUserState.getState().username + const output = + snapshot.inputType === 'text' + ? await T.RPCGen.saltpackSaltpackSignStringRpcPromise( + {plaintext: snapshot.input}, + C.waitingKeyCrypto + ) + : await T.RPCGen.saltpackSaltpackSignFileRpcPromise( + {destinationDir, filename: snapshot.input}, + C.waitingKeyCrypto + ) + const next = onSuccess( + stateRef.current, + stateRef.current.input === snapshot.input, + output, + snapshot.inputType, + username + ) + setState(next) + return next + } catch (_error) { + if (!(_error instanceof RPCError)) throw _error + logger.error(_error) + const next = onError(stateRef.current, getStatusCodeMessage(_error, 'sign', snapshot.inputType)) + setState(next) + return next + } + }, []) + + const setInput = React.useCallback( + (type: T.Crypto.InputTypes, value: string) => { + if (!value) { + clearInput() + return + } + setState(prev => { + const outputValid = prev.input === value + const next = { + ...resetWarnings(prev), + input: value, + inputType: type, + outputValid, + } + return type === 'file' ? resetOutput(next) : next + }) + if (type === 'text' && !C.isMobile) { + C.ignorePromise(sign()) + } + }, + [clearInput, sign] ) + + const openFile = React.useCallback((path: string) => { + if (!path) return + setState(prev => { + if (prev.inProgress) return prev + return { + ...resetOutput(prev), + input: path, + inputType: 'file', + } + }) + }, []) + + const saveOutputAsText = React.useCallback(async () => { + const output = await T.RPCGen.saltpackSaltpackSaveSignedMsgToFileRpcPromise({signedMsg: stateRef.current.output}) + const next = { + ...resetWarnings(stateRef.current), + output, + outputStatus: 'success' as const, + outputType: 'file' as const, + } + setState(next) + return next + }, []) + + React.useEffect(() => { + if (!params?.seedInputPath) return + if ((params.seedInputType ?? 'file') === 'file') { + openFile(params.seedInputPath) + } else { + setInput('text', params.seedInputPath) + } + }, [openFile, params?.entryNonce, params?.seedInputPath, params?.seedInputType, setInput]) + + return {clearInput, openFile, saveOutputAsText, setInput, sign, state} } -export const SignInput = () => { +const SignOutputBanner = ({state}: {state: CommonOutputRouteParams}) => ( + + + This is your signed {state.outputType === 'file' ? 'file' : 'message'}, using{' '} + openURL(Crypto.saltpackDocumentation)}> + Saltpack + + . Anyone who has it can verify you signed it. + + +) + +export const SignInput = (_props: unknown) => { + const {params} = useRoute>() + const controller = useSignState(params) const blurCBRef = React.useRef(() => {}) + const navigateAppend = C.useRouterState(s => s.dispatch.navigateAppend) const setBlurCB = (cb: () => void) => { blurCBRef.current = cb } - const resetOperation = Crypto.useCryptoState(s => s.dispatch.resetOperation) - React.useEffect(() => { - return () => { + const onRun = () => { + const f = async () => { + const next = await controller.sign() if (C.isMobile) { - resetOperation(operation) + navigateAppend({name: Crypto.signOutput, params: next}) } } - }, [resetOperation]) + C.ignorePromise(f()) + } const content = ( <> - - - {C.isMobile ? : null} + + + {C.isMobile ? : null} ) @@ -59,16 +243,24 @@ export const SignInput = () => { ) } -export const SignOutput = () => { +export const SignOutput = ({route}: {route: {params: CommonOutputRouteParams}}) => { + const state = outputParamsToCommonState(route.params) const content = ( <> - - + + {C.isMobile ? : null} - - + undefined} + /> + ) + return C.isMobile ? ( content ) : ( @@ -79,11 +271,50 @@ export const SignOutput = () => { } export const SignIO = () => { + const {params} = useRoute>() + const controller = useSignState(params) return ( - + - - + + + + + + + + { + C.ignorePromise(controller.sign(destinationDir)) + }} + /> + { + C.ignorePromise(controller.saveOutputAsText()) + }} + /> + ) diff --git a/shared/crypto/operations/verify.tsx b/shared/crypto/operations/verify.tsx index 56decc162d9d..161ff381acb6 100644 --- a/shared/crypto/operations/verify.tsx +++ b/shared/crypto/operations/verify.tsx @@ -1,27 +1,223 @@ import * as C from '@/constants' -import * as Crypto from '@/stores/crypto' +import * as Crypto from '@/constants/crypto' import * as Kb from '@/common-adapters' import * as React from 'react' -import {Input, InputActionsBar, DragAndDrop, OperationBanner} from '../input' -import {OperationOutput, SignedSender, OutputActionsBar} from '../output' +import type * as T from '@/constants/types' +import {CryptoBanner, DragAndDrop, Input, InputActionsBar} from '../input' +import {CryptoOutput, CryptoOutputActionsBar, CryptoSignedSender} from '../output' +import { + createCommonState, + getStatusCodeMessage, + outputParamsToCommonState, + type CommonOutputRouteParams, + type CryptoInputRouteParams, + type CommonState, +} from '../state' +import {RPCError} from '@/util/errors' +import logger from '@/logger' +import type {RootRouteProps} from '@/router-v2/route-params' +import {useRoute} from '@react-navigation/core' -const operation = Crypto.Operations.Verify +const bannerMessage = Crypto.infoMessage.verify +const filePrompt = 'Drop a file to verify' +const inputEmptyWidth = 342 +const inputFileIcon = 'icon-file-saltpack-64' as const +const inputPlaceholder = C.isMobile + ? 'Enter text to verify' + : 'Enter a signed message, drop a signed file or folder, or' -export const VerifyInput = () => { - const resetOperation = Crypto.useCryptoState(s => s.dispatch.resetOperation) +const resetWarnings = (state: CommonState): CommonState => ({ + ...state, + errorMessage: '', + warningMessage: '', +}) + +const resetOutput = (state: CommonState): CommonState => ({ + ...resetWarnings(state), + bytesComplete: 0, + bytesTotal: 0, + output: '', + outputSenderFullname: undefined, + outputSenderUsername: undefined, + outputSigned: false, + outputStatus: undefined, + outputType: undefined, + outputValid: false, +}) + +const beginRun = (state: CommonState): CommonState => ({ + ...resetWarnings(state), + bytesComplete: 0, + bytesTotal: 0, + inProgress: true, + outputStatus: 'pending', + outputValid: false, +}) + +const onError = (state: CommonState, errorMessage: string): CommonState => ({ + ...resetOutput(state), + errorMessage, + inProgress: false, +}) + +const onSuccess = ( + state: CommonState, + outputValid: boolean, + output: string, + inputType: 'file' | 'text', + signed: boolean, + senderUsername: string, + senderFullname: string +): CommonState => ({ + ...resetWarnings(state), + inProgress: false, + output, + outputSenderFullname: signed ? senderFullname : undefined, + outputSenderUsername: signed ? senderUsername : undefined, + outputSigned: signed, + outputStatus: 'success', + outputType: inputType, + outputValid, +}) + +const useVerifyState = (params?: CryptoInputRouteParams) => { + const [state, setState] = React.useState(() => createCommonState(params)) + const stateRef = React.useRef(state) React.useEffect(() => { - return () => { + stateRef.current = state + }, [state]) + + const clearInput = React.useCallback(() => { + setState(prev => ({ + ...resetOutput(prev), + input: '', + inputType: 'text', + outputValid: true, + })) + }, []) + + const verify = React.useCallback(async (destinationDir = '') => { + const snapshot = stateRef.current + setState(prev => beginRun(prev)) + try { + if (snapshot.inputType === 'text') { + const res = await T.RPCGen.saltpackSaltpackVerifyStringRpcPromise( + {signedMsg: snapshot.input}, + C.waitingKeyCrypto + ) + const next = onSuccess( + stateRef.current, + stateRef.current.input === snapshot.input, + res.plaintext, + 'text', + res.verified, + res.sender.username, + res.sender.fullname + ) + setState(next) + return next + } + + const res = await T.RPCGen.saltpackSaltpackVerifyFileRpcPromise( + {destinationDir, signedFilename: snapshot.input}, + C.waitingKeyCrypto + ) + const next = onSuccess( + stateRef.current, + stateRef.current.input === snapshot.input, + res.verifiedFilename, + 'file', + res.verified, + res.sender.username, + res.sender.fullname + ) + setState(next) + return next + } catch (_error) { + if (!(_error instanceof RPCError)) throw _error + logger.error(_error) + const next = onError(stateRef.current, getStatusCodeMessage(_error, 'verify', snapshot.inputType)) + setState(next) + return next + } + }, []) + + const setInput = React.useCallback( + (type: T.Crypto.InputTypes, value: string) => { + if (!value) { + clearInput() + return + } + setState(prev => { + const outputValid = prev.input === value + const next = { + ...resetWarnings(prev), + input: value, + inputType: type, + outputValid, + } + return type === 'file' ? resetOutput(next) : next + }) + if (type === 'text' && !C.isMobile) { + C.ignorePromise(verify()) + } + }, + [clearInput, verify] + ) + + const openFile = React.useCallback((path: string) => { + if (!path) return + setState(prev => { + if (prev.inProgress) return prev + return { + ...resetOutput(prev), + input: path, + inputType: 'file', + } + }) + }, []) + + React.useEffect(() => { + if (!params?.seedInputPath) return + if ((params.seedInputType ?? 'file') === 'file') { + openFile(params.seedInputPath) + } else { + setInput('text', params.seedInputPath) + } + }, [openFile, params?.entryNonce, params?.seedInputPath, params?.seedInputType, setInput]) + + return {clearInput, openFile, setInput, state, verify} +} + +export const VerifyInput = (_props: unknown) => { + const {params} = useRoute>() + const controller = useVerifyState(params) + const navigateAppend = C.useRouterState(s => s.dispatch.navigateAppend) + + const onRun = () => { + const f = async () => { + const next = await controller.verify() if (C.isMobile) { - resetOperation(operation) + navigateAppend({name: Crypto.verifyOutput, params: next}) } } - }, [resetOperation]) + C.ignorePromise(f()) + } const content = ( <> - - - {C.isMobile ? : null} + + + {C.isMobile ? : null} ) @@ -33,17 +229,25 @@ export const VerifyInput = () => { ) } -export const VerifyOutput = () => { - const errorMessage = Crypto.useCryptoState(s => s[operation].errorMessage.stringValue()) + +export const VerifyOutput = ({route}: {route: {params: CommonOutputRouteParams}}) => { + const state = outputParamsToCommonState(route.params) const content = ( <> - {C.isMobile && errorMessage ? : null} - + {C.isMobile && state.errorMessage ? : null} + {C.isMobile ? : null} - - + undefined} + /> + ) + return C.isMobile ? ( content ) : ( @@ -54,12 +258,43 @@ export const VerifyOutput = () => { } export const VerifyIO = () => { + const {params} = useRoute>() + const controller = useVerifyState(params) return ( - + - + + + + - + + + { + C.ignorePromise(controller.verify(destinationDir)) + }} + /> + + ) diff --git a/shared/crypto/output.tsx b/shared/crypto/output.tsx index 59f87d96776d..0b9d683fe84b 100644 --- a/shared/crypto/output.tsx +++ b/shared/crypto/output.tsx @@ -1,54 +1,56 @@ import * as C from '@/constants' import * as Chat from '@/stores/chat' -import * as Crypto from '@/stores/crypto' import * as Kb from '@/common-adapters' import * as Path from '@/util/path' import * as React from 'react' -import capitalize from 'lodash/capitalize' -import type * as T from '@/constants/types' +import type {IconType} from '@/common-adapters/icon.constants-gen' +import type {CommonState} from './state' import {pickFiles} from '@/util/misc' -import type HiddenString from '@/util/hidden-string' import {useFSState} from '@/stores/fs' import * as FS from '@/constants/fs' import {useConfigState} from '@/stores/config' -type OutputProps = {operation: T.Crypto.Operations} -type OutputActionsBarProps = {operation: T.Crypto.Operations} -type SignedSenderProps = {operation: T.Crypto.Operations} -type OutputProgressProps = {operation: T.Crypto.Operations} +type CryptoOutputProps = { + actionLabel: string + onChooseOutputFolder: (destinationDir: string) => void + outputFileIcon?: IconType + outputTextType: 'cipher' | 'plain' + state: CommonState +} + +type OutputActionsBarProps = { + canReplyInChat: boolean + canSaveAsText: boolean + onSaveAsText?: () => void + state: CommonState +} + +type SignedSenderProps = { + isSelfSigned: boolean + state: CommonState +} + type OutputInfoProps = { - operation: T.Crypto.Operations children: | string | React.ReactElement | Array> + outputStatus?: CommonState['outputStatus'] } -export const SignedSender = (props: SignedSenderProps) => { - const {operation} = props +export const CryptoSignedSender = ({isSelfSigned, state}: SignedSenderProps) => { const waiting = C.Waiting.useAnyWaiting(C.waitingKeyCrypto) + const signed = state.outputSigned + const signedByUsername = state.outputSenderUsername + const signedByFullname = state.outputSenderFullname - const { - outputSigned: signed, - outputSenderUsername: signedByUsername, - outputSenderFullname: signedByFullname, - outputStatus, - } = Crypto.useCryptoState( - C.useShallow(s => { - const o = s[operation] - const {outputSigned, outputSenderUsername, outputSenderFullname, outputStatus} = o - return {outputSenderFullname, outputSenderUsername, outputSigned, outputStatus} - }) - ) - - const isSelfSigned = operation === Crypto.Operations.Encrypt || operation === Crypto.Operations.Sign const avatarSize = isSelfSigned ? 16 : Kb.Styles.isMobile ? 32 : 48 const usernameType = isSelfSigned ? 'BodySmallBold' : 'BodyBold' const space = Kb.Styles.isMobile ? '' : ' ' const signedByText = `Signed by ${isSelfSigned ? `${space}you` : ''}` - if (!outputStatus || outputStatus === 'error') { + if (!state.outputStatus) { return null } @@ -71,8 +73,7 @@ export const SignedSender = (props: SignedSenderProps) => { alignItems="center" style={styles.signedSender} > - - + {isSelfSigned ? ( @@ -81,7 +82,7 @@ export const SignedSender = (props: SignedSenderProps) => { @@ -90,13 +91,11 @@ export const SignedSender = (props: SignedSenderProps) => { - {signedByFullname?.stringValue() ? ( - {signedByFullname.stringValue()} - ) : null} + {signedByFullname ? {signedByFullname} : null} )} @@ -109,7 +108,7 @@ export const SignedSender = (props: SignedSenderProps) => { )} - {isSelfSigned ? `Not signed (Sending anonymously)` : `(Not signed)`} + {isSelfSigned ? 'Not signed (Sending anonymously)' : '(Not signed)'} )} @@ -119,115 +118,66 @@ export const SignedSender = (props: SignedSenderProps) => { ) } -const OutputProgress = (props: OutputProgressProps) => { - const {operation} = props - - const {bytesComplete, bytesTotal, inProgress} = Crypto.useCryptoState( - C.useShallow(s => { - const o = s[operation] - const {bytesComplete, bytesTotal, inProgress} = o - return {bytesComplete, bytesTotal, inProgress} - }) - ) - - const ratio = bytesComplete === 0 ? 0 : bytesComplete / bytesTotal +const OutputProgress = ({state}: {state: CommonState}) => { + if (!state.inProgress) { + return null + } + if (!state.bytesTotal) { + return + } - return inProgress ? ( + const ratio = state.bytesComplete === 0 ? 0 : state.bytesComplete / state.bytesTotal + return ( - {`${FS.humanizeBytes(bytesComplete, 1)} / ${FS.humanizeBytes(bytesTotal, 1)}`} + {`${FS.humanizeBytes(state.bytesComplete, 1)} / ${FS.humanizeBytes( + state.bytesTotal, + 1 + )}`} - ) : null + ) } -export const OutputInfoBanner = (props: OutputInfoProps) => { - const {operation} = props - - const outputStatus = Crypto.useCryptoState(s => s[operation].outputStatus) - return outputStatus === 'success' ? ( +export const OutputInfoBanner = ({outputStatus, children}: OutputInfoProps) => + outputStatus === 'success' ? ( - {props.children} + {children} ) : null -} - -export const OutputActionsBar = (props: OutputActionsBarProps) => { - const {operation} = props - const canSaveAsText = operation === Crypto.Operations.Encrypt || operation === Crypto.Operations.Sign - const canReplyInChat = operation === Crypto.Operations.Decrypt || operation === Crypto.Operations.Verify +export const CryptoOutputActionsBar = ({ + canReplyInChat, + canSaveAsText, + onSaveAsText, + state, +}: OutputActionsBarProps) => { const waiting = C.Waiting.useAnyWaiting(C.waitingKeyCrypto) - - const { - output, - outputValid, - outputStatus, - outputType, - outputSigned: signed, - outputSenderUsername: signedByUsername, - } = Crypto.useCryptoState( - C.useShallow(s => { - const o = s[operation] - const {output, outputValid, outputStatus, outputType, outputSigned, outputSenderUsername} = o - return {output, outputSenderUsername, outputSigned, outputStatus, outputType, outputValid} - }) - ) - - const actionsDisabled = waiting || !outputValid + const actionsDisabled = waiting || !state.outputValid const openLocalPathInSystemFileManagerDesktop = useFSState( s => s.dispatch.defer.openLocalPathInSystemFileManagerDesktop ) const onShowInFinder = () => { - openLocalPathInSystemFileManagerDesktop?.(output.stringValue()) + openLocalPathInSystemFileManagerDesktop?.(state.output) } const navigateUp = C.useRouterState(s => s.dispatch.navigateUp) const previewConversation = Chat.useChatState(s => s.dispatch.previewConversation) - const onReplyInChat = (username: HiddenString) => { + const onReplyInChat = (username: string) => { navigateUp() - previewConversation({participants: [username.stringValue()], reason: 'search'}) + previewConversation({participants: [username], reason: 'search'}) } const copyToClipboard = useConfigState(s => s.dispatch.defer.copyToClipboard) - const onCopyOutput = () => { - copyToClipboard(output.stringValue()) - } - - const downloadSignedText = Crypto.useCryptoState(s => s.dispatch.downloadSignedText) - const downloadEncryptedText = Crypto.useCryptoState(s => s.dispatch.downloadEncryptedText) - - const onSaveAsText = () => { - if (operation === Crypto.Operations.Sign) { - downloadSignedText() - return - } - - if (operation === Crypto.Operations.Encrypt) { - downloadEncryptedText() - return - } - } - const popupAnchor = React.useRef(null) const [showingToast, setShowingToast] = React.useState(false) - const setHideToastTimeout = Kb.useTimeout(() => setShowingToast(false), 1500) - - const copy = () => { - if (!output.stringValue()) return - setShowingToast(true) - onCopyOutput() - } - const [lastShowingToast, setLastShowingToast] = React.useState(showingToast) - - // Start timeout to clear toast if currently displayed if (lastShowingToast !== showingToast) { setLastShowingToast(showingToast) if (showingToast) { @@ -235,14 +185,20 @@ export const OutputActionsBar = (props: OutputActionsBarProps) => { } } - return outputStatus && outputStatus === 'success' ? ( + const copy = () => { + if (!state.output) return + setShowingToast(true) + copyToClipboard(state.output) + } + + return state.outputStatus === 'success' ? ( - {outputType === 'file' && !Kb.Styles.isMobile ? ( + {state.outputType === 'file' && !Kb.Styles.isMobile ? ( onShowInFinder()} + onClick={onShowInFinder} /> ) : ( @@ -251,15 +207,15 @@ export const OutputActionsBar = (props: OutputActionsBarProps) => { align={Kb.Styles.isTablet ? 'center' : 'flex-start'} style={styles.buttonBar} > - {canReplyInChat && signed && signedByUsername && ( + {canReplyInChat && state.outputSigned && state.outputSenderUsername ? ( onReplyInChat(signedByUsername)} + onClick={() => onReplyInChat(state.outputSenderUsername ?? '')} /> - )} + ) : null} @@ -272,18 +228,18 @@ export const OutputActionsBar = (props: OutputActionsBarProps) => { label="Copy to clipboard" disabled={actionsDisabled} fullWidth={Kb.Styles.isMobile} - onClick={() => copy()} + onClick={copy} /> )} - {canSaveAsText && !Kb.Styles.isMobile && ( + {canSaveAsText && !Kb.Styles.isMobile && onSaveAsText ? ( - )} + ) : null} )} @@ -300,13 +256,15 @@ export const OutputActionsBar = (props: OutputActionsBarProps) => { ) } -const OutputFileDestination = (props: {operation: T.Crypto.Operations}) => { - const {operation} = props - const operationTitle = capitalize(operation) - - const input = Crypto.useCryptoState(s => s[operation].input.stringValue()) - const runFileOperation = Crypto.useCryptoState(s => s.dispatch.runFileOperation) - +const OutputFileDestination = ({ + actionLabel, + input, + onChooseOutputFolder, +}: { + actionLabel: string + input: string + onChooseOutputFolder: (destinationDir: string) => void +}) => { const onOpenFile = () => { const f = async () => { const defaultPath = Path.dirname(input) @@ -317,8 +275,7 @@ const OutputFileDestination = (props: {operation: T.Crypto.Operations}) => { ...(C.isDarwin ? {defaultPath} : {}), }) if (!filePaths.length) return - const path = filePaths[0]! - runFileOperation(operation, path) + onChooseOutputFolder(filePaths[0] ?? '') } C.ignorePromise(f()) } @@ -326,7 +283,7 @@ const OutputFileDestination = (props: {operation: T.Crypto.Operations}) => { return ( - + ) @@ -334,57 +291,23 @@ const OutputFileDestination = (props: {operation: T.Crypto.Operations}) => { const MobileScroll = Kb.Styles.isMobile ? Kb.ScrollView : React.Fragment -const outputTextType = new Map([ - ['decrypt', 'plain'], - ['encrypt', 'cipher'], - ['sign', 'cipher'], - ['verify', 'plain'], -] as const) - -const outputFileIcon = new Map([ - ['decrypt', 'icon-file-64'], - ['encrypt', 'icon-file-saltpack-64'], - ['sign', 'icon-file-saltpack-64'], - ['verify', 'icon-file-64'], -] as const) - -export const OperationOutput = (props: OutputProps) => { - const {operation} = props - const textType = outputTextType.get(operation) - - const { - inputType, - inProgress, - output: _output, - outputValid, - outputStatus, - outputType, - } = Crypto.useCryptoState( - C.useShallow(s => { - const o = s[operation] - const {inProgress, inputType, output, outputValid, outputStatus, outputType} = o - return {inProgress, inputType, output, outputStatus, outputType, outputValid} - }) - ) - const output = _output.stringValue() - +export const CryptoOutput = ({ + actionLabel, + onChooseOutputFolder, + outputFileIcon, + outputTextType, + state, +}: CryptoOutputProps) => { const openLocalPathInSystemFileManagerDesktop = useFSState( s => s.dispatch.defer.openLocalPathInSystemFileManagerDesktop ) - const onShowInFinder = () => { - if (!output) return - openLocalPathInSystemFileManagerDesktop?.(output) - } - const waiting = C.Waiting.useAnyWaiting(C.waitingKeyCrypto) + const actionsDisabled = waiting || !state.outputValid const fileOutputTextColor = - textType === 'cipher' ? Kb.Styles.globalColors.greenDark : Kb.Styles.globalColors.black - const fileIcon = outputFileIcon.get(operation) - const actionsDisabled = waiting || !outputValid + outputTextType === 'cipher' ? Kb.Styles.globalColors.greenDark : Kb.Styles.globalColors.black - // Placeholder, progress, or encrypt file button - if (!outputStatus || outputStatus !== 'success') { + if (state.outputStatus !== 'success') { return ( { fullWidth={true} style={Kb.Styles.collapseStyles([styles.coverOutput, styles.outputPlaceholder])} > - {inProgress ? ( - + {state.inProgress ? ( + ) : ( - inputType === 'file' && - outputStatus !== 'pending' && + state.inputType === 'file' && + state.outputStatus !== 'pending' && ( + + ) )} ) } - // File output - if (outputType === 'file') { + if (state.outputType === 'file') { return ( { alignItems="center" style={styles.fileOutputContainer} > - {fileIcon ? : null} + {outputFileIcon ? : null} onShowInFinder()} + onClick={() => state.output && openLocalPathInSystemFileManagerDesktop?.(state.output)} > - {output} + {state.output} ) } - // Text output - return ( - {output} + {state.output} @@ -518,25 +444,19 @@ const styles = Kb.Styles.styleSheetCreate( paddingTop: Kb.Styles.globalMargins.tiny, }, isMobile: { - ...Kb.Styles.padding(Kb.Styles.globalMargins.small), + paddingLeft: Kb.Styles.globalMargins.small, + paddingRight: Kb.Styles.globalMargins.small, }, }), signedContainerSelf: Kb.Styles.platformStyles({ isElectron: { - paddingLeft: Kb.Styles.globalMargins.tiny, - paddingRight: Kb.Styles.globalMargins.tiny, - paddingTop: Kb.Styles.globalMargins.tiny, + ...Kb.Styles.padding(Kb.Styles.globalMargins.xtiny, Kb.Styles.globalMargins.tiny), }, isMobile: { - ...Kb.Styles.padding(Kb.Styles.globalMargins.tiny), + ...Kb.Styles.padding(Kb.Styles.globalMargins.xsmall, Kb.Styles.globalMargins.small), }, }), - signedSender: { - ...Kb.Styles.globalStyles.flexGrow, - }, - toastText: { - color: Kb.Styles.globalColors.white, - textAlign: 'center', - }, + signedSender: {alignItems: 'center'}, + toastText: {color: Kb.Styles.globalColors.white}, }) as const ) diff --git a/shared/crypto/recipients.tsx b/shared/crypto/recipients.tsx index fce2497bc070..0cbd8e4db729 100644 --- a/shared/crypto/recipients.tsx +++ b/shared/crypto/recipients.tsx @@ -1,25 +1,15 @@ -import * as C from '@/constants' -import * as Crypto from '@/stores/crypto' import * as Kb from '@/common-adapters' -const placeholder = 'Search people' - -const Recipients = () => { - const recipients = Crypto.useCryptoState(s => s.encrypt.recipients) - const inProgress = Crypto.useCryptoState(s => s.encrypt.inProgress) - const clearRecipients = Crypto.useCryptoState(s => s.dispatch.clearRecipients) - const appendEncryptRecipientsBuilder = C.useRouterState(s => s.appendEncryptRecipientsBuilder) - - const onAddRecipients = () => { - if (inProgress) return - appendEncryptRecipientsBuilder() - } +type Props = { + inProgress: boolean + onAddRecipients: () => void + onClearRecipients: () => void + recipients: ReadonlyArray +} - const onClearRecipients = () => { - if (inProgress) return - clearRecipients() - } +const placeholder = 'Search people' +const Recipients = ({inProgress, onAddRecipients, onClearRecipients, recipients}: Props) => { return ( @@ -43,7 +33,7 @@ const Recipients = () => { type="iconfont-remove" color={Kb.Styles.globalColors.black_20} hoverColor={inProgress ? Kb.Styles.globalColors.black_20 : undefined} - onClick={onClearRecipients} + onClick={inProgress ? undefined : onClearRecipients} /> ) : null} diff --git a/shared/crypto/routes.tsx b/shared/crypto/routes.tsx index 3101c4fcdcca..576e83e298f1 100644 --- a/shared/crypto/routes.tsx +++ b/shared/crypto/routes.tsx @@ -3,35 +3,103 @@ import * as C from '@/constants' import * as Crypto from '@/constants/crypto' import {HeaderLeftButton, type HeaderBackButtonProps} from '@/common-adapters/header-buttons' import cryptoTeamBuilder from '../team-building/page' +import type {StaticScreenProps} from '@react-navigation/core' +import type { + CommonOutputRouteParams, + CryptoInputRouteParams, + CryptoTeamBuilderResult, + EncryptOutputRouteParams, + EncryptRouteParams, +} from './state' + +type CryptoTeamBuilderRouteParams = Parameters[0]['route']['params'] & { + teamBuilderNonce?: string + teamBuilderUsers?: CryptoTeamBuilderResult +} + +const DecryptInputScreen = React.lazy(async () => { + const {DecryptInput} = await import('./operations/decrypt') + return { + default: (p: StaticScreenProps) => , + } +}) + +const EncryptInputScreen = React.lazy(async () => { + const {EncryptInput} = await import('./operations/encrypt') + return { + default: (p: StaticScreenProps) => , + } +}) + +const SignInputScreen = React.lazy(async () => { + const {SignInput} = await import('./operations/sign') + return { + default: (p: StaticScreenProps) => , + } +}) + +const VerifyInputScreen = React.lazy(async () => { + const {VerifyInput} = await import('./operations/verify') + return { + default: (p: StaticScreenProps) => , + } +}) + +const DecryptOutputScreen = React.lazy(async () => { + const {DecryptOutput} = await import('./operations/decrypt') + return { + default: (p: StaticScreenProps) => , + } +}) + +const EncryptOutputScreen = React.lazy(async () => { + const {EncryptOutput} = await import('./operations/encrypt') + return { + default: (p: StaticScreenProps) => , + } +}) + +const SignOutputScreen = React.lazy(async () => { + const {SignOutput} = await import('./operations/sign') + return { + default: (p: StaticScreenProps) => , + } +}) + +const VerifyOutputScreen = React.lazy(async () => { + const {VerifyOutput} = await import('./operations/verify') + return { + default: (p: StaticScreenProps) => , + } +}) + +const CryptoTeamBuilderScreen = React.lazy(async () => { + const {default: teamBuilder} = await import('../team-building/page') + const TeamBuilderScreen = teamBuilder.screen + return { + default: (p: StaticScreenProps) => { + const {teamBuilderNonce: _teamBuilderNonce, teamBuilderUsers: _teamBuilderUsers, ...params} = p.route.params + return + }, + } +}) export const newRoutes = { [Crypto.decryptTab]: { getOptions: {headerShown: true, title: 'Decrypt'}, - screen: React.lazy(async () => { - const {DecryptInput} = await import('./operations/decrypt') - return {default: DecryptInput} - }), + screen: DecryptInputScreen, }, [Crypto.encryptTab]: { getOptions: {headerShown: true, title: 'Encrypt'}, - screen: React.lazy(async () => { - const {EncryptInput} = await import('./operations/encrypt') - return {default: EncryptInput} - }), + screen: EncryptInputScreen, }, [Crypto.signTab]: { getOptions: {headerShown: true, title: 'Sign'}, - screen: React.lazy(async () => { - const {SignInput} = await import('./operations/sign') - return {default: SignInput} - }), + screen: SignInputScreen, }, [Crypto.verifyTab]: { getOptions: {headerShown: true, title: 'Verify'}, - screen: React.lazy(async () => { - const {VerifyInput} = await import('./operations/verify') - return {default: VerifyInput} - }), + screen: VerifyInputScreen, }, cryptoRoot: { getOptions: C.isMobile ? {title: 'Crypto'} : {title: 'Crypto tools'}, @@ -46,17 +114,11 @@ export const newModalRoutes = { headerShown: true, title: 'Decrypted', }, - screen: React.lazy(async () => { - const {DecryptOutput} = await import('./operations/decrypt') - return {default: DecryptOutput} - }), + screen: DecryptOutputScreen, }, [Crypto.encryptOutput]: { getOptions: {headerShown: true, title: 'Encrypted'}, - screen: React.lazy(async () => { - const {EncryptOutput} = await import('./operations/encrypt') - return {default: EncryptOutput} - }), + screen: EncryptOutputScreen, }, [Crypto.signOutput]: { getOptions: { @@ -64,10 +126,7 @@ export const newModalRoutes = { headerShown: true, title: 'Signed', }, - screen: React.lazy(async () => { - const {SignOutput} = await import('./operations/sign') - return {default: SignOutput} - }), + screen: SignOutputScreen, }, [Crypto.verifyOutput]: { getOptions: { @@ -75,10 +134,10 @@ export const newModalRoutes = { headerShown: true, title: 'Verified', }, - screen: React.lazy(async () => { - const {VerifyOutput} = await import('./operations/verify') - return {default: VerifyOutput} - }), + screen: VerifyOutputScreen, + }, + cryptoTeamBuilder: { + ...cryptoTeamBuilder, + screen: CryptoTeamBuilderScreen, }, - cryptoTeamBuilder, } diff --git a/shared/crypto/state.tsx b/shared/crypto/state.tsx new file mode 100644 index 000000000000..6969895d8351 --- /dev/null +++ b/shared/crypto/state.tsx @@ -0,0 +1,176 @@ +import * as T from '@/constants/types' +import {RPCError} from '@/util/errors' + +export type OutputStatus = 'success' | 'pending' + +export type CommonState = { + bytesComplete: number + bytesTotal: number + errorMessage: string + inProgress: boolean + input: string + inputType: 'text' | 'file' + output: string + outputSenderFullname?: string + outputSenderUsername?: string + outputSigned: boolean + outputStatus?: OutputStatus + outputType?: 'text' | 'file' + outputValid: boolean + warningMessage: string +} + +export type EncryptOptions = { + includeSelf: boolean + sign: boolean +} + +export type EncryptMeta = { + hasRecipients: boolean + hasSBS: boolean + hideIncludeSelf: boolean +} + +export type EncryptState = CommonState & { + meta: EncryptMeta + options: EncryptOptions + recipients: Array +} + +export type CryptoInputRouteParams = { + entryNonce?: string + seedInputPath?: string + seedInputType?: 'text' | 'file' +} + +export type CryptoTeamBuilderResult = Array<{ + serviceId: T.TB.ServiceIdWithContact + username: string +}> + +export type EncryptRouteParams = CryptoInputRouteParams & { + teamBuilderNonce?: string + teamBuilderUsers?: CryptoTeamBuilderResult +} + +export type CommonOutputRouteParams = CommonState + +export type EncryptOutputRouteParams = CommonOutputRouteParams & { + hasRecipients: boolean + includeSelf: boolean + recipients: Array +} + +type CryptoKind = 'decrypt' | 'encrypt' | 'sign' | 'verify' + +export const getStatusCodeMessage = ( + error: RPCError, + kind: CryptoKind, + type: T.Crypto.InputTypes +): string => { + const inputType = + type === 'text' ? (kind === 'verify' ? 'signed message' : 'ciphertext') : 'file' + const action = type === 'text' ? (kind === 'verify' ? 'enter a' : 'enter') : 'drop a' + const addInput = + type === 'text' ? (kind === 'verify' ? 'signed message' : 'ciphertext') : 'encrypted file' + + const offlineMessage = 'You are offline.' + const genericMessage = `Failed to ${kind} ${type}.` + + let wrongTypeHelpText = '' + if (kind === 'verify') { + wrongTypeHelpText = ' Did you mean to decrypt it?' + } else if (kind === 'decrypt') { + wrongTypeHelpText = ' Did you mean to verify it?' + } + + const fields = error.fields as Array<{key: string; value: T.RPCGen.StatusCode}> | undefined + const field = fields?.[1] + const causeStatusCode = field?.key === 'Code' ? field.value : T.RPCGen.StatusCode.scgeneric + const causeStatusCodeToMessage = new Map([ + [T.RPCGen.StatusCode.scapinetworkerror, offlineMessage], + [ + T.RPCGen.StatusCode.scdecryptionkeynotfound, + "This message was encrypted for someone else or for a key you don't have.", + ], + [ + T.RPCGen.StatusCode.scverificationkeynotfound, + "This message couldn't be verified, because the signing key wasn't recognized.", + ], + [T.RPCGen.StatusCode.scwrongcryptomsgtype, `This Saltpack format is unexpected.${wrongTypeHelpText}`], + ]) + + const statusCodeToMessage = new Map([ + [T.RPCGen.StatusCode.scapinetworkerror, offlineMessage], + [ + T.RPCGen.StatusCode.scgeneric, + error.message.includes('API network error') ? offlineMessage : genericMessage, + ], + [ + T.RPCGen.StatusCode.scstreamunknown, + `This ${inputType} is not in a valid Saltpack format. Please ${action} Saltpack ${addInput}.`, + ], + [T.RPCGen.StatusCode.scsigcannotverify, causeStatusCodeToMessage.get(causeStatusCode) || genericMessage], + [T.RPCGen.StatusCode.scdecryptionerror, causeStatusCodeToMessage.get(causeStatusCode) || genericMessage], + ]) + + return statusCodeToMessage.get(error.code) ?? genericMessage +} + +export const createCommonState = (params?: CryptoInputRouteParams): CommonState => ({ + bytesComplete: 0, + bytesTotal: 0, + errorMessage: '', + inProgress: false, + input: params?.seedInputPath ?? '', + inputType: params?.seedInputType ?? 'text', + output: '', + outputSenderFullname: undefined, + outputSenderUsername: undefined, + outputSigned: false, + outputStatus: undefined, + outputType: undefined, + outputValid: false, + warningMessage: '', +}) + +export const createEncryptState = (params?: EncryptRouteParams): EncryptState => ({ + ...createCommonState(params), + meta: { + hasRecipients: false, + hasSBS: false, + hideIncludeSelf: false, + }, + options: { + includeSelf: true, + sign: true, + }, + recipients: [], +}) + +export const encryptToOutputParams = (state: EncryptState): EncryptOutputRouteParams => ({ + ...state, + hasRecipients: state.meta.hasRecipients, + includeSelf: state.options.includeSelf, + recipients: state.recipients, +}) + +export const outputParamsToCommonState = (params: CommonOutputRouteParams): CommonState => ({...params}) + +export const teamBuilderResultToRecipients = ( + users: ReadonlyArray<{serviceId: T.TB.ServiceIdWithContact; username: string}> +) => { + let hasSBS = false + const recipients = users.map(user => { + if (user.serviceId === 'email') { + hasSBS = true + return `[${user.username}]@email` + } + if (user.serviceId !== 'keybase') { + hasSBS = true + return `${user.username}@${user.serviceId}` + } + return user.username + }) + return {hasSBS, recipients} +} diff --git a/shared/crypto/sub-nav/index.desktop.tsx b/shared/crypto/sub-nav/index.desktop.tsx index 9c6579fd04fd..199aa3a3130f 100644 --- a/shared/crypto/sub-nav/index.desktop.tsx +++ b/shared/crypto/sub-nav/index.desktop.tsx @@ -1,6 +1,6 @@ import * as React from 'react' import * as Kb from '@/common-adapters' -import * as Crypto from '@/stores/crypto' +import * as Crypto from '@/constants/crypto' import * as Common from '@/router-v2/common.desktop' import LeftNav from './left-nav.desktop' import { diff --git a/shared/crypto/sub-nav/index.native.tsx b/shared/crypto/sub-nav/index.native.tsx index f2ee824bec3b..c41261148096 100644 --- a/shared/crypto/sub-nav/index.native.tsx +++ b/shared/crypto/sub-nav/index.native.tsx @@ -1,5 +1,5 @@ import * as C from '@/constants' -import * as Crypto from '@/stores/crypto' +import * as Crypto from '@/constants/crypto' import * as Kb from '@/common-adapters' import NavRow from './nav-row' diff --git a/shared/crypto/sub-nav/left-nav.desktop.tsx b/shared/crypto/sub-nav/left-nav.desktop.tsx index c8922f493e86..df60e5c0b9f6 100644 --- a/shared/crypto/sub-nav/left-nav.desktop.tsx +++ b/shared/crypto/sub-nav/left-nav.desktop.tsx @@ -1,6 +1,6 @@ import type * as React from 'react' import * as Kb from '@/common-adapters' -import * as Crypto from '@/stores/crypto' +import * as Crypto from '@/constants/crypto' import NavRow from './nav-row' type Row = (typeof Crypto.Tabs)[number] & { diff --git a/shared/desktop/renderer/remote-event-handler.desktop.tsx b/shared/desktop/renderer/remote-event-handler.desktop.tsx index fdd4e2157faf..28db12978056 100644 --- a/shared/desktop/renderer/remote-event-handler.desktop.tsx +++ b/shared/desktop/renderer/remote-event-handler.desktop.tsx @@ -4,7 +4,7 @@ import * as Crypto from '@/constants/crypto' import * as Tabs from '@/constants/tabs' import {RPCError} from '@/util/errors' import {ignorePromise} from '@/constants/utils' -import {switchTab} from '@/constants/router' +import {navigateAppend, switchTab} from '@/constants/router' import {storeRegistry} from '@/stores/store-registry' import {onEngineConnected, onEngineDisconnected} from '@/constants/init/index.desktop' import {emitDeepLink} from '@/router-v2/linking' @@ -12,8 +12,8 @@ import {isPathSaltpackEncrypted, isPathSaltpackSigned} from '@/util/path' import type HiddenString from '@/util/hidden-string' import {useConfigState} from '@/stores/config' import {usePinentryState} from '@/stores/pinentry' -import {useCryptoState} from '@/stores/crypto' import logger from '@/logger' +import {makeUUID} from '@/util/uuid' const handleSaltPackOpen = (_path: string | HiddenString) => { const path = typeof _path === 'string' ? _path : _path.stringValue() @@ -22,19 +22,26 @@ const handleSaltPackOpen = (_path: string | HiddenString) => { console.warn('Tried to open a saltpack file before being logged in') return } - let operation: T.Crypto.Operations | undefined + let name: typeof Crypto.decryptTab | typeof Crypto.verifyTab | undefined if (isPathSaltpackEncrypted(path)) { - operation = Crypto.Operations.Decrypt + name = Crypto.decryptTab } else if (isPathSaltpackSigned(path)) { - operation = Crypto.Operations.Verify + name = Crypto.verifyTab } else { logger.warn( 'Deeplink received saltpack file path not ending in ".encrypted.saltpack" or ".signed.saltpack"' ) return } - useCryptoState.getState().dispatch.onSaltpackOpenFile(operation, path) switchTab(Tabs.cryptoTab) + navigateAppend({ + name, + params: { + entryNonce: makeUUID(), + seedInputPath: path, + seedInputType: 'file', + }, + }, true) } const updateApp = () => { diff --git a/shared/stores/crypto.tsx b/shared/stores/crypto.tsx deleted file mode 100644 index 785d1143ebda..000000000000 --- a/shared/stores/crypto.tsx +++ /dev/null @@ -1,682 +0,0 @@ -import * as Z from '@/util/zustand' -import {ignorePromise} from '@/constants/utils' -import {isMobile} from '@/constants/platform' -import {waitingKeyCrypto} from '@/constants/strings' -import HiddenString from '@/util/hidden-string' -import logger from '@/logger' -import * as T from '@/constants/types' -import {RPCError} from '@/util/errors' -import {navigateAppend} from '@/constants/router' -import {useCurrentUserState} from '@/stores/current-user' -import {Operations} from '@/constants/crypto' -export * from '@/constants/crypto' - -type CommonStore = { - bytesComplete: number - bytesTotal: number - errorMessage: HiddenString - inProgress: boolean - input: HiddenString - inputType: 'text' | 'file' - output: HiddenString - outputFileDestination: HiddenString - outputSenderFullname?: HiddenString - outputSenderUsername?: HiddenString - outputSigned?: boolean - outputStatus?: 'success' | 'pending' | 'error' - outputType?: 'text' | 'file' - warningMessage: HiddenString - // to ensure what the user types matches the input - outputValid: boolean -} - -type EncryptOptions = { - includeSelf: boolean - sign: boolean -} - -type Store = T.Immutable<{ - decrypt: CommonStore - encrypt: CommonStore & { - meta: { - hasRecipients: boolean - hasSBS: boolean - hideIncludeSelf: boolean - } - options: EncryptOptions - recipients: Array // Only for encrypt operation - } - sign: CommonStore - verify: CommonStore -}> - -const getWarningMessageForSBS = (sbsAssertion: string) => - `Note: Encrypted for "${sbsAssertion}" who is not yet a Keybase user. One of your devices will need to be online after they join Keybase in order for them to decrypt the message.` - -const getStatusCodeMessage = ( - error: RPCError, - operation: T.Crypto.Operations, - type: T.Crypto.InputTypes -): string => { - const inputType = - type === 'text' ? (operation === Operations.Verify ? 'signed message' : 'ciphertext') : 'file' - const action = type === 'text' ? (operation === Operations.Verify ? 'enter a' : 'enter') : 'drop a' - const addInput = - type === 'text' ? (operation === Operations.Verify ? 'signed message' : 'ciphertext') : 'encrypted file' - - const offlineMessage = `You are offline.` - const genericMessage = `Failed to ${operation} ${type}.` - - let wrongTypeHelpText = `` - if (operation === Operations.Verify) { - wrongTypeHelpText = ` Did you mean to decrypt it?` // just a guess. could get specific expected type from Cause with more effort. - } else if (operation === Operations.Decrypt) { - wrongTypeHelpText = ` Did you mean to verify it?` // just a guess. - } - - const fields = error.fields as Array<{key: string; value: T.RPCGen.StatusCode}> | undefined - const field = fields?.[1] - const causeStatusCode = field?.key === 'Code' ? field.value : T.RPCGen.StatusCode.scgeneric - const causeStatusCodeToMessage = new Map([ - [T.RPCGen.StatusCode.scapinetworkerror, offlineMessage], - [ - T.RPCGen.StatusCode.scdecryptionkeynotfound, - `This message was encrypted for someone else or for a key you don't have.`, - ], - [ - T.RPCGen.StatusCode.scverificationkeynotfound, - `This message couldn't be verified, because the signing key wasn't recognized.`, - ], - [T.RPCGen.StatusCode.scwrongcryptomsgtype, `This Saltpack format is unexpected.` + wrongTypeHelpText], - ]) - - const statusCodeToMessage = new Map([ - [T.RPCGen.StatusCode.scapinetworkerror, offlineMessage], - [ - T.RPCGen.StatusCode.scgeneric, - `${error.message.includes('API network error') ? offlineMessage : genericMessage}`, - ], - [ - T.RPCGen.StatusCode.scstreamunknown, - `This ${inputType} is not in a valid Saltpack format. Please ${action} Saltpack ${addInput}.`, - ], - [T.RPCGen.StatusCode.scsigcannotverify, causeStatusCodeToMessage.get(causeStatusCode) || genericMessage], - [T.RPCGen.StatusCode.scdecryptionerror, causeStatusCodeToMessage.get(causeStatusCode) || genericMessage], - ]) - - return statusCodeToMessage.get(error.code) ?? genericMessage -} - -// State -const defaultCommonStore = { - bytesComplete: 0, - bytesTotal: 0, - errorMessage: new HiddenString(''), - inProgress: false, - input: new HiddenString(''), - inputType: 'text' as T.Crypto.InputTypes, - output: new HiddenString(''), - outputFileDestination: new HiddenString(''), - outputSenderFullname: undefined, - outputSenderUsername: undefined, - outputSigned: false, - outputStatus: undefined, - outputType: undefined, - outputValid: false, - warningMessage: new HiddenString(''), -} - -const initialStore: Store = { - decrypt: {...defaultCommonStore}, - encrypt: { - ...defaultCommonStore, - meta: { - hasRecipients: false, - hasSBS: false, - hideIncludeSelf: false, - }, - options: { - includeSelf: true, - sign: true, - }, - recipients: [], - }, - sign: {...defaultCommonStore}, - verify: {...defaultCommonStore}, -} - -export type State = Store & { - dispatch: { - clearInput: (op: T.Crypto.Operations) => void - clearRecipients: () => void - downloadEncryptedText: () => void - downloadSignedText: () => void - resetState: () => void - resetOperation: (op: T.Crypto.Operations) => void - runFileOperation: (op: T.Crypto.Operations, destinationDir: string) => void - runTextOperation: (op: T.Crypto.Operations) => void - onSaltpackDone: (op: T.Crypto.Operations) => void - onSaltpackStart: (op: T.Crypto.Operations) => void - onSaltpackProgress: (op: T.Crypto.Operations, bytesComplete: number, bytesTotal: number) => void - onSaltpackOpenFile: (op: T.Crypto.Operations, path: string) => void - onTeamBuildingFinished: (users: ReadonlySet) => void - setEncryptOptions: (options: EncryptOptions, hideIncludeSelf?: boolean) => void - setInput: (op: T.Crypto.Operations, type: T.Crypto.InputTypes, value: string) => void - setRecipients: (recipients: ReadonlyArray, hasSBS: boolean) => void - } -} - -export const useCryptoState = Z.createZustand('crypto', (set, get) => { - const resetWarnings = (o: CommonStore) => { - o.errorMessage = new HiddenString('') - o.warningMessage = new HiddenString('') - } - - const resetOutput = (o: CommonStore) => { - resetWarnings(o) - o.bytesComplete = 0 - o.bytesTotal = 0 - o.outputSigned = false - o.output = new HiddenString('') - o.outputStatus = undefined - o.outputType = undefined - o.outputSenderUsername = undefined - o.outputSenderFullname = undefined - o.outputValid = false - } - - const onError = (cs: CommonStore, errorMessage: string) => { - resetOutput(cs) - cs.errorMessage = new HiddenString(errorMessage) - } - - const onSuccess = ( - cs: CommonStore, - outputValid: boolean, - warningMessage: string, - output: string, - inputType: 'file' | 'text', - signed: boolean, - senderUsername: string, - senderFullname: string - ) => { - cs.outputValid = outputValid - - resetWarnings(cs) - cs.warningMessage = new HiddenString(warningMessage) - cs.output = new HiddenString(output) - cs.outputStatus = 'success' - cs.outputType = inputType - cs.outputSigned = signed - cs.outputSenderUsername = new HiddenString(signed ? senderUsername : '') - cs.outputSenderFullname = new HiddenString(signed ? senderFullname : '') - } - - const encrypt = (destinationDir: string = '') => { - const f = async () => { - const start = get().encrypt - const username = useCurrentUserState.getState().username - const signed = start.options.sign - const inputType = start.inputType - const input = start.input.stringValue() - const opts = { - includeSelf: start.options.includeSelf, - recipients: start.recipients.length ? start.recipients : [username], - signed, - } - try { - const callText = async () => { - const { - usedUnresolvedSBS, - unresolvedSBSAssertion, - ciphertext: output, - } = await T.RPCGen.saltpackSaltpackEncryptStringRpcPromise( - {opts, plaintext: input}, - waitingKeyCrypto - ) - return {output, unresolvedSBSAssertion, usedUnresolvedSBS} - } - const callFile = async () => { - const { - usedUnresolvedSBS, - unresolvedSBSAssertion, - filename: output, - } = await T.RPCGen.saltpackSaltpackEncryptFileRpcPromise( - {destinationDir, filename: input, opts}, - waitingKeyCrypto - ) - return {output, unresolvedSBSAssertion, usedUnresolvedSBS} - } - const {output, unresolvedSBSAssertion, usedUnresolvedSBS} = await (inputType === 'text' - ? callText() - : callFile()) - - set(s => { - onSuccess( - s.encrypt, - s.encrypt.input.stringValue() === input, - usedUnresolvedSBS ? getWarningMessageForSBS(unresolvedSBSAssertion) : '', - output, - inputType, - signed, - username, - '' - ) - }) - } catch (_error) { - if (!(_error instanceof RPCError)) { - return - } - const error = _error - logger.error(error) - set(s => { - onError(s.encrypt, getStatusCodeMessage(error, 'encrypt', inputType)) - }) - } - } - ignorePromise(f()) - } - - const decrypt = (destinationDir: string = '') => { - const f = async () => { - const start = get().decrypt - const inputType = start.inputType - const input = start.input.stringValue() - try { - const callText = async () => { - const res = await T.RPCGen.saltpackSaltpackDecryptStringRpcPromise( - {ciphertext: input}, - waitingKeyCrypto - ) - const {plaintext: output, info, signed} = res - const {sender} = info - const {username, fullname} = sender - return {fullname, output, signed, username} - } - - const callFile = async () => { - const result = await T.RPCGen.saltpackSaltpackDecryptFileRpcPromise( - {destinationDir, encryptedFilename: input}, - waitingKeyCrypto - ) - const {decryptedFilename: output, info, signed} = result - const {sender} = info - const {username, fullname} = sender - return {fullname, output, signed, username} - } - - const {fullname, output, signed, username} = await (inputType === 'text' ? callText() : callFile()) - set(s => { - onSuccess( - s.decrypt, - s.decrypt.input.stringValue() === input, - '', - output, - inputType, - signed, - username, - fullname - ) - }) - } catch (_error) { - if (!(_error instanceof RPCError)) { - return - } - const error = _error - logger.error(error) - set(s => { - onError(s.decrypt, getStatusCodeMessage(error, 'decrypt', inputType)) - }) - } - } - - ignorePromise(f()) - } - - const sign = (destinationDir: string = '') => { - const f = async () => { - const start = get().sign - const inputType = start.inputType - const input = start.input.stringValue() - try { - const callText = async () => - await T.RPCGen.saltpackSaltpackSignStringRpcPromise({plaintext: input}, waitingKeyCrypto) - - const callFile = async () => - await T.RPCGen.saltpackSaltpackSignFileRpcPromise( - {destinationDir, filename: input}, - waitingKeyCrypto - ) - - const output = await (inputType === 'text' ? callText() : callFile()) - - const username = useCurrentUserState.getState().username - set(s => { - onSuccess(s.sign, s.sign.input.stringValue() === input, '', output, inputType, true, username, '') - }) - } catch (_error) { - if (!(_error instanceof RPCError)) { - return - } - const error = _error - logger.error(error) - set(s => { - onError(s.sign, getStatusCodeMessage(error, 'sign', inputType)) - }) - } - } - - ignorePromise(f()) - } - - const verify = (destinationDir: string = '') => { - const f = async () => { - const start = get().verify - const inputType = start.inputType - const input = start.input.stringValue() - try { - const callText = async () => { - const res = await T.RPCGen.saltpackSaltpackVerifyStringRpcPromise( - {signedMsg: input}, - waitingKeyCrypto - ) - const {plaintext: output, sender, verified: signed} = res - const {username, fullname} = sender - return {fullname, output, signed, username} - } - const callFile = async () => { - const res = await T.RPCGen.saltpackSaltpackVerifyFileRpcPromise( - {destinationDir, signedFilename: input}, - waitingKeyCrypto - ) - const {verifiedFilename: output, sender, verified: signed} = res - const {username, fullname} = sender - return {fullname, output, signed, username} - } - const {fullname, output, signed, username} = await (inputType === 'text' ? callText() : callFile()) - set(s => { - onSuccess( - s.verify, - s.verify.input.stringValue() === input, - '', - output, - inputType, - signed, - username, - fullname - ) - }) - } catch (_error) { - if (!(_error instanceof RPCError)) { - return - } - const error = _error - logger.error(error) - set(s => { - onError(s.verify, getStatusCodeMessage(error, 'verify', inputType)) - }) - } - } - - ignorePromise(f()) - } - - const download = (op: T.Crypto.Operations) => { - const f = async () => { - const callEncrypt = async () => - await T.RPCGen.saltpackSaltpackSaveCiphertextToFileRpcPromise({ - ciphertext: get().encrypt.output.stringValue(), - }) - const callSign = async () => - await T.RPCGen.saltpackSaltpackSaveSignedMsgToFileRpcPromise({ - signedMsg: get().sign.output.stringValue(), - }) - const output = await (op === 'encrypt' ? callEncrypt() : callSign()) - set(s => { - const o = s[op] - resetWarnings(o) - o.output = new HiddenString(output) - o.outputStatus = 'success' - o.outputType = 'file' - }) - } - ignorePromise(f()) - } - - const dispatch: State['dispatch'] = { - clearInput: op => { - set(s => { - const o = s[op] - resetOutput(o) - o.inputType = 'text' - o.input = new HiddenString('') - o.outputValid = true - }) - }, - clearRecipients: () => { - set(s => { - const e = s.encrypt - resetOutput(e) - e.recipients = T.castDraft(initialStore.encrypt.recipients) - // Reset options since they depend on the recipients - e.options = initialStore.encrypt.options - e.meta = initialStore.encrypt.meta - }) - }, - downloadEncryptedText: () => { - download('encrypt') - }, - downloadSignedText: () => { - download('sign') - }, - onSaltpackDone: op => { - set(s => { - const o = s[op] - // For any file operation that completes, invalidate the output since multiple decrypt/verify operations will produce filenames with unique - // counters on the end (as to not overwrite any existing files in the user's FS). - // E.g. `${plaintextFilename} (n).ext` - o.outputValid = false - o.bytesComplete = 0 - o.bytesTotal = 0 - o.inProgress = false - o.outputStatus = 'pending' - }) - }, - onSaltpackOpenFile: (op, path) => { - set(s => { - const o = s[op] - // Bail on setting operation input if another file RPC is in progress - if (o.inProgress) return - if (!path) return - - resetOutput(o) - o.input = new HiddenString(path) - o.inputType = 'file' - }) - }, - onSaltpackProgress: (op, bytesComplete, bytesTotal) => { - set(s => { - const o = s[op] - const done = bytesComplete === bytesTotal - o.bytesComplete = done ? 0 : bytesComplete - o.bytesTotal = done ? 0 : bytesTotal - o.inProgress = !done - if (!done) { - o.outputStatus = 'pending' - } - }) - }, - onSaltpackStart: op => { - set(s => { - s[op].inProgress = true - }) - }, - onTeamBuildingFinished: _users => { - const users = [..._users] - let hasSBS = false as boolean - const usernames = users.map(user => { - // If we're encrypting to service account that is not proven on keybase set - // (SBS) then we *must* encrypt to ourselves - if (user.serviceId === 'email') { - hasSBS = true - return `[${user.username}]@email` - } - if (user.serviceId !== 'keybase') { - hasSBS = true - return `${user.username}@${user.serviceId}` - } - return user.username - }) - - // User set themselves as a recipient, so don't show 'includeSelf' option - // However we don't want to set hideIncludeSelf if we are also encrypting to an SBS user (since we must force includeSelf) - const currentUser = useCurrentUserState.getState().username - if (usernames.includes(currentUser) && !hasSBS) { - // Update options state directly rather than via setEncryptOptions to avoid - // triggering a redundant encrypt — setRecipients below will trigger it - set(s => { - const e = s.encrypt - if (e.inputType === 'file') resetOutput(e) - e.outputValid = false - e.meta.hideIncludeSelf = true - e.options.includeSelf = false - }) - } - get().dispatch.setRecipients(usernames, hasSBS) - }, - resetOperation: op => { - set(s => { - switch (op) { - case Operations.Encrypt: - s[op] = T.castDraft(initialStore[op]) - break - case Operations.Decrypt: - case Operations.Sign: - case Operations.Verify: - s[op] = T.castDraft(initialStore[op]) - break - } - }) - }, - resetState: Z.defaultReset, - runFileOperation: (op, destinationDir) => { - set(s => { - const o = s[op] - o.outputValid = false - resetWarnings(o) - }) - switch (op) { - case 'encrypt': - encrypt(destinationDir) - break - case 'decrypt': - decrypt(destinationDir) - break - case 'verify': - verify(destinationDir) - break - case 'sign': - sign(destinationDir) - break - } - }, - runTextOperation: op => { - let route: 'decryptOutput' | 'encryptOutput' | 'signOutput' | 'verifyOutput' - switch (op) { - case 'decrypt': - decrypt() - route = 'decryptOutput' - break - case 'encrypt': - route = 'encryptOutput' - encrypt() - break - case 'sign': - route = 'signOutput' - sign() - break - case 'verify': - route = 'verifyOutput' - verify() - break - } - if (isMobile) { - navigateAppend(route) - } - }, - setEncryptOptions: (newOptions, hideIncludeSelf) => { - set(s => { - const e = s.encrypt - e.options = { - ...e.options, - ...newOptions, - } - if (e.inputType === 'file') { - resetOutput(e) - } - // Output no longer valid since options have changed - e.outputValid = false - // User set themselves as a recipient so don't show the 'includeSelf' option for encrypt (since they're encrypting to themselves) - if (hideIncludeSelf) { - e.meta.hideIncludeSelf = hideIncludeSelf - e.options.includeSelf = false - } - }) - - if (get().encrypt.inputType === 'text') { - encrypt('') - } - }, - setInput: (op: T.Crypto.Operations, type: T.Crypto.InputTypes, value: string) => { - if (!value) { - get().dispatch.clearInput(op) - return - } - set(s => { - const o = s[op] - const oldInput = o.input - const outputValid = oldInput.stringValue() === value - - o.inputType = type - o.input = new HiddenString(value) - o.outputValid = outputValid - resetWarnings(o) - - if (type === 'file') { - resetOutput(o) - } - }) - // mobile doesn't run anything automatically - if (type === 'text' && !isMobile) { - get().dispatch.runTextOperation(op) - } - }, - setRecipients: (recipients, hasSBS) => { - set(s => { - const o = s.encrypt - if (o.inputType === 'file') { - resetOutput(o) - } - // Output no longer valid since recipients have changed - o.outputValid = false - if (!o.recipients.length && recipients.length) { - o.meta.hasRecipients = true - o.meta.hasSBS = hasSBS - } - // Force signing when user is SBS - if (hasSBS) { - o.options.sign = true - } - o.recipients = T.castDraft(recipients) - }) - // mobile doesn't run anything automatically - if (get().encrypt.inputType === 'text' && !isMobile) { - get().dispatch.runTextOperation('encrypt') - } - }, - } - return { - ...initialStore, - dispatch, - } -}) diff --git a/shared/stores/tests/crypto.test.ts b/shared/stores/tests/crypto.test.ts index 5a5689eac660..8d387cd80b6c 100644 --- a/shared/stores/tests/crypto.test.ts +++ b/shared/stores/tests/crypto.test.ts @@ -1,626 +1,74 @@ /// import * as T from '@/constants/types' -import logger from '@/logger' -import HiddenString from '@/util/hidden-string' import RPCError from '@/util/rpcerror' -import {resetAllStores} from '@/util/zustand' -import {useCurrentUserState} from '../current-user' -import {Operations, useCryptoState} from '../crypto' - -const bootstrapCurrentUser = () => { - useCurrentUserState.getState().dispatch.setBootstrap({ - deviceID: 'device-id', - deviceName: 'device-name', - uid: 'uid', - username: 'alice', - }) -} - -const flushPromises = async () => new Promise(resolve => setImmediate(resolve)) - -const makeCommonState = ( - overrides: Partial<{ - bytesComplete: number - bytesTotal: number - errorMessage: string - inProgress: boolean - input: string - inputType: 'text' | 'file' - output: string - outputFileDestination: string - outputSenderFullname?: string - outputSenderUsername?: string - outputSigned: boolean - outputStatus?: 'success' | 'pending' | 'error' - outputType?: 'text' | 'file' - outputValid: boolean - warningMessage: string - }> = {} -) => ({ - bytesComplete: 0, - bytesTotal: 0, - errorMessage: '', - inProgress: false, - input: '', - inputType: 'text' as const, - output: '', - outputFileDestination: '', - outputSenderFullname: undefined, - outputSenderUsername: undefined, - outputSigned: false, - outputStatus: undefined, - outputType: undefined, - outputValid: false, - warningMessage: '', - ...overrides, -}) - -const makeEncryptState = ( - overrides: Partial< - ReturnType & { - meta: {hasRecipients: boolean; hasSBS: boolean; hideIncludeSelf: boolean} - options: {includeSelf: boolean; sign: boolean} - recipients: Array - } - > = {} -) => ({ - ...makeCommonState(), - meta: {hasRecipients: false, hasSBS: false, hideIncludeSelf: false}, - options: {includeSelf: true, sign: true}, - recipients: [] as Array, - ...overrides, -}) - -const getOperationState = (operation: T.Crypto.Operations) => { - const o = useCryptoState.getState()[operation] - return { - bytesComplete: o.bytesComplete, - bytesTotal: o.bytesTotal, - errorMessage: o.errorMessage.stringValue(), - inProgress: o.inProgress, - input: o.input.stringValue(), - inputType: o.inputType, - output: o.output.stringValue(), - outputFileDestination: o.outputFileDestination.stringValue(), - outputSenderFullname: o.outputSenderFullname?.stringValue(), - outputSenderUsername: o.outputSenderUsername?.stringValue(), - outputSigned: o.outputSigned, - outputStatus: o.outputStatus, - outputType: o.outputType, - outputValid: o.outputValid, - warningMessage: o.warningMessage.stringValue(), - } -} - -const getPublicState = () => { - const encrypt = useCryptoState.getState().encrypt - return { - decrypt: getOperationState(Operations.Decrypt), - encrypt: { - ...getOperationState(Operations.Encrypt), - meta: {...encrypt.meta}, - options: {...encrypt.options}, - recipients: [...encrypt.recipients], - }, - sign: getOperationState(Operations.Sign), - verify: getOperationState(Operations.Verify), - } -} - -const getDefaultPublicState = () => ({ - decrypt: makeCommonState(), - encrypt: makeEncryptState(), - sign: makeCommonState(), - verify: makeCommonState(), -}) - -const setOperationInput = (operation: T.Crypto.Operations, inputType: 'text' | 'file', input: string) => { - useCryptoState.setState(s => { - const o = s[operation] - o.inputType = inputType - o.input = new HiddenString(input) - }) -} - -const dispatchKeys = [ - 'clearInput', - 'clearRecipients', - 'downloadEncryptedText', - 'downloadSignedText', - 'onSaltpackDone', - 'onSaltpackOpenFile', - 'onSaltpackProgress', - 'onSaltpackStart', - 'onTeamBuildingFinished', - 'resetOperation', - 'resetState', - 'runFileOperation', - 'runTextOperation', - 'setEncryptOptions', - 'setInput', - 'setRecipients', -].sort() - -beforeEach(() => { - jest.spyOn(logger, 'error').mockImplementation(() => undefined) - resetAllStores() - bootstrapCurrentUser() -}) - -afterEach(() => { - jest.restoreAllMocks() - resetAllStores() -}) - -test('resetState restores the full public store surface to defaults', () => { - const runTextOperation = jest.fn() - useCryptoState.setState(s => { - s.dispatch.runTextOperation = runTextOperation - }) - - const {dispatch} = useCryptoState.getState() - dispatch.onSaltpackOpenFile(Operations.Encrypt, '/tmp/plain.txt') - dispatch.onSaltpackOpenFile(Operations.Decrypt, '/tmp/ciphertext.saltpack') - dispatch.onSaltpackStart(Operations.Decrypt) - dispatch.onSaltpackProgress(Operations.Decrypt, 3, 10) - dispatch.setRecipients(['bob'], false) - dispatch.setEncryptOptions({includeSelf: false, sign: false}, true) - dispatch.setInput(Operations.Verify, 'text', 'signed message') - - expect(runTextOperation).toHaveBeenCalledWith(Operations.Verify) - expect(getPublicState()).not.toEqual(getDefaultPublicState()) - - dispatch.resetState() - - expect(Object.keys(useCryptoState.getState().dispatch).sort()).toEqual(dispatchKeys) - expect(getPublicState()).toEqual(getDefaultPublicState()) -}) - -test('encrypt supports file and text flows, downloads, and clearing state', async () => { - const encryptFile = jest.spyOn(T.RPCGen, 'saltpackSaltpackEncryptFileRpcPromise').mockResolvedValue({ - filename: '/tmp/plain.txt.encrypted.saltpack', - unresolvedSBSAssertion: '[carol]@email', - usedUnresolvedSBS: true, - } as any) - const encryptString = jest.spyOn(T.RPCGen, 'saltpackSaltpackEncryptStringRpcPromise').mockResolvedValue({ - ciphertext: 'ciphertext', - unresolvedSBSAssertion: '', - usedUnresolvedSBS: false, - } as any) - const saveCiphertext = jest - .spyOn(T.RPCGen, 'saltpackSaltpackSaveCiphertextToFileRpcPromise') - .mockResolvedValue('/tmp/ciphertext.saltpack' as any) - - const {dispatch} = useCryptoState.getState() - - dispatch.onSaltpackOpenFile(Operations.Encrypt, '/tmp/plain.txt') - dispatch.setRecipients(['[carol]@email'], true) - dispatch.runFileOperation(Operations.Encrypt, '/tmp/out') - await flushPromises() - - expect(encryptFile).toHaveBeenCalledWith( - { - destinationDir: '/tmp/out', - filename: '/tmp/plain.txt', - opts: { - includeSelf: true, - recipients: ['[carol]@email'], - signed: true, - }, - }, - expect.any(String) - ) - expect(getPublicState().encrypt).toEqual( - makeEncryptState({ - input: '/tmp/plain.txt', - inputType: 'file', - meta: {hasRecipients: true, hasSBS: true, hideIncludeSelf: false}, - options: {includeSelf: true, sign: true}, - output: '/tmp/plain.txt.encrypted.saltpack', - outputSenderFullname: '', - outputSenderUsername: 'alice', - outputSigned: true, - outputStatus: 'success', - outputType: 'file', - outputValid: true, - recipients: ['[carol]@email'], - warningMessage: - 'Note: Encrypted for "[carol]@email" who is not yet a Keybase user. One of your devices will need to be online after they join Keybase in order for them to decrypt the message.', - }) - ) - - dispatch.clearRecipients() - expect(getPublicState().encrypt).toEqual( - makeEncryptState({ - input: '/tmp/plain.txt', +import { + createCommonState, + createEncryptState, + encryptToOutputParams, + getStatusCodeMessage, + teamBuilderResultToRecipients, +} from '@/crypto/state' + +test('createCommonState seeds route-provided input', () => { + expect( + createCommonState({ + entryNonce: 'nonce', + seedInputPath: '/tmp/file.saltpack', + seedInputType: 'file', + }) + ).toEqual( + expect.objectContaining({ + input: '/tmp/file.saltpack', inputType: 'file', + output: '', + outputStatus: undefined, }) ) +}) - dispatch.onSaltpackOpenFile(Operations.Encrypt, '/tmp/plain.txt') - dispatch.setRecipients(['bob'], false) - dispatch.setEncryptOptions({includeSelf: false, sign: false}, true) - dispatch.setInput(Operations.Encrypt, 'text', 'secret message') - await flushPromises() +test('encryptToOutputParams preserves encrypt-specific metadata', () => { + const state = { + ...createEncryptState(), + input: 'secret', + meta: {hasRecipients: true, hasSBS: false, hideIncludeSelf: true}, + options: {includeSelf: false, sign: true}, + output: 'ciphertext', + outputStatus: 'success' as const, + outputType: 'text' as const, + outputValid: true, + recipients: ['bob'], + } - expect(encryptString).toHaveBeenCalledWith( - { - opts: { - includeSelf: false, - recipients: ['bob'], - signed: false, - }, - plaintext: 'secret message', - }, - expect.any(String) - ) - expect(getPublicState().encrypt).toEqual( - makeEncryptState({ - input: 'secret message', - meta: {hasRecipients: true, hasSBS: false, hideIncludeSelf: true}, - options: {includeSelf: false, sign: false}, + expect(encryptToOutputParams(state)).toEqual( + expect.objectContaining({ + hasRecipients: true, + includeSelf: false, + input: 'secret', output: 'ciphertext', - outputSenderFullname: '', - outputSenderUsername: '', - outputSigned: false, - outputStatus: 'success', - outputType: 'text', - outputValid: true, - recipients: ['bob'], - }) - ) - - dispatch.downloadEncryptedText() - await flushPromises() - - expect(saveCiphertext).toHaveBeenCalledWith({ciphertext: 'ciphertext'}) - expect(getPublicState().encrypt).toEqual( - makeEncryptState({ - input: 'secret message', - meta: {hasRecipients: true, hasSBS: false, hideIncludeSelf: true}, - options: {includeSelf: false, sign: false}, - output: '/tmp/ciphertext.saltpack', - outputSenderFullname: '', - outputSenderUsername: '', - outputSigned: false, - outputStatus: 'success', - outputType: 'file', - outputValid: true, - recipients: ['bob'], - }) - ) - - dispatch.clearInput(Operations.Encrypt) - expect(getPublicState().encrypt).toEqual( - makeEncryptState({ - meta: {hasRecipients: true, hasSBS: false, hideIncludeSelf: true}, - options: {includeSelf: false, sign: false}, - outputValid: true, recipients: ['bob'], }) ) }) -test('progress, open-file, team building, and resetOperation cover the remaining UI-driven state transitions', () => { - const {dispatch} = useCryptoState.getState() - - useCryptoState.setState(s => { - s.decrypt.output = new HiddenString('stale output') - s.decrypt.outputStatus = 'success' - s.decrypt.outputValid = true - }) - dispatch.onSaltpackOpenFile(Operations.Decrypt, '/tmp/ciphertext.saltpack') - expect(getPublicState().decrypt).toEqual( - makeCommonState({ - input: '/tmp/ciphertext.saltpack', - inputType: 'file', - }) - ) - - dispatch.onSaltpackStart(Operations.Decrypt) - dispatch.onSaltpackOpenFile(Operations.Decrypt, '/tmp/ignored.saltpack') - dispatch.onSaltpackProgress(Operations.Decrypt, 3, 10) - expect(getPublicState().decrypt).toEqual( - makeCommonState({ - bytesComplete: 3, - bytesTotal: 10, - inProgress: true, - input: '/tmp/ciphertext.saltpack', - inputType: 'file', - outputStatus: 'pending', - }) - ) - - dispatch.onSaltpackProgress(Operations.Decrypt, 10, 10) - dispatch.onSaltpackDone(Operations.Decrypt) - expect(getPublicState().decrypt).toEqual( - makeCommonState({ - input: '/tmp/ciphertext.saltpack', - inputType: 'file', - outputStatus: 'pending', - }) - ) - - dispatch.onSaltpackOpenFile(Operations.Encrypt, '/tmp/plain.txt') - useCryptoState.setState(s => { - s.encrypt.output = new HiddenString('stale output') - s.encrypt.outputStatus = 'success' - s.encrypt.outputValid = true - s.encrypt.warningMessage = new HiddenString('stale warning') - }) - dispatch.onTeamBuildingFinished( - new Set([ - {serviceId: 'keybase', username: 'alice'}, - {serviceId: 'keybase', username: 'bob'}, - ]) as any - ) - expect(getPublicState().encrypt).toEqual( - makeEncryptState({ - input: '/tmp/plain.txt', - inputType: 'file', - meta: {hasRecipients: true, hasSBS: false, hideIncludeSelf: true}, - options: {includeSelf: false, sign: true}, - recipients: ['alice', 'bob'], - }) - ) - - dispatch.resetOperation(Operations.Encrypt) - expect(getPublicState().encrypt).toEqual(makeEncryptState()) - - dispatch.onSaltpackOpenFile(Operations.Encrypt, '/tmp/plain.txt') - dispatch.onTeamBuildingFinished( - new Set([ +test('teamBuilderResultToRecipients converts SBS assertions', () => { + expect( + teamBuilderResultToRecipients([ {serviceId: 'keybase', username: 'alice'}, {serviceId: 'email', username: 'carol'}, - ]) as any - ) - expect(getPublicState().encrypt).toEqual( - makeEncryptState({ - input: '/tmp/plain.txt', - inputType: 'file', - meta: {hasRecipients: true, hasSBS: true, hideIncludeSelf: false}, - options: {includeSelf: true, sign: true}, - recipients: ['alice', '[carol]@email'], - }) - ) -}) - -test('decrypt supports text and file flows and maps crypto-specific errors', async () => { - const decryptString = jest.spyOn(T.RPCGen, 'saltpackSaltpackDecryptStringRpcPromise').mockResolvedValue({ - info: {sender: {fullname: 'Bob', username: 'bob'}}, - plaintext: 'hello', - signed: true, - } as any) - const decryptFile = jest.spyOn(T.RPCGen, 'saltpackSaltpackDecryptFileRpcPromise').mockResolvedValue({ - decryptedFilename: '/tmp/plain.txt', - info: {sender: {fullname: '', username: ''}}, - signed: false, - } as any) - - const {dispatch} = useCryptoState.getState() - - setOperationInput(Operations.Decrypt, 'text', 'ciphertext') - dispatch.runTextOperation(Operations.Decrypt) - await flushPromises() - - expect(decryptString).toHaveBeenCalledWith({ciphertext: 'ciphertext'}, expect.any(String)) - expect(getPublicState().decrypt).toEqual( - makeCommonState({ - input: 'ciphertext', - output: 'hello', - outputSenderFullname: 'Bob', - outputSenderUsername: 'bob', - outputSigned: true, - outputStatus: 'success', - outputType: 'text', - outputValid: true, - }) - ) - - dispatch.resetOperation(Operations.Decrypt) - dispatch.onSaltpackOpenFile(Operations.Decrypt, '/tmp/ciphertext.saltpack') - dispatch.runFileOperation(Operations.Decrypt, '/tmp/out') - await flushPromises() - - expect(decryptFile).toHaveBeenCalledWith( - { - destinationDir: '/tmp/out', - encryptedFilename: '/tmp/ciphertext.saltpack', - }, - expect.any(String) - ) - expect(getPublicState().decrypt).toEqual( - makeCommonState({ - input: '/tmp/ciphertext.saltpack', - inputType: 'file', - output: '/tmp/plain.txt', - outputSenderFullname: '', - outputSenderUsername: '', - outputSigned: false, - outputStatus: 'success', - outputType: 'file', - outputValid: true, - }) - ) - - decryptString.mockRejectedValueOnce( - new RPCError('decrypt failed', T.RPCGen.StatusCode.scdecryptionerror, [ - {key: 'ignored', value: T.RPCGen.StatusCode.scgeneric}, - {key: 'Code', value: T.RPCGen.StatusCode.scwrongcryptomsgtype}, + {serviceId: 'twitter', username: 'bob'}, ]) - ) - setOperationInput(Operations.Decrypt, 'text', 'bad ciphertext') - dispatch.runTextOperation(Operations.Decrypt) - await flushPromises() - - expect(getPublicState().decrypt).toEqual( - makeCommonState({ - errorMessage: 'This Saltpack format is unexpected. Did you mean to verify it?', - input: 'bad ciphertext', - }) - ) -}) - -test('sign supports text and file flows, downloads, and offline errors', async () => { - const signString = jest.spyOn(T.RPCGen, 'saltpackSaltpackSignStringRpcPromise').mockResolvedValue( - 'signed message' as any - ) - const signFile = jest.spyOn(T.RPCGen, 'saltpackSaltpackSignFileRpcPromise').mockResolvedValue( - '/tmp/plain.txt.signed.saltpack' as any - ) - const saveSigned = jest - .spyOn(T.RPCGen, 'saltpackSaltpackSaveSignedMsgToFileRpcPromise') - .mockResolvedValue('/tmp/signed-message.saltpack' as any) - - const {dispatch} = useCryptoState.getState() - - setOperationInput(Operations.Sign, 'text', 'hello') - dispatch.runTextOperation(Operations.Sign) - await flushPromises() - - expect(signString).toHaveBeenCalledWith({plaintext: 'hello'}, expect.any(String)) - expect(getPublicState().sign).toEqual( - makeCommonState({ - input: 'hello', - output: 'signed message', - outputSenderFullname: '', - outputSenderUsername: 'alice', - outputSigned: true, - outputStatus: 'success', - outputType: 'text', - outputValid: true, - }) - ) - - dispatch.downloadSignedText() - await flushPromises() - - expect(saveSigned).toHaveBeenCalledWith({signedMsg: 'signed message'}) - expect(getPublicState().sign).toEqual( - makeCommonState({ - input: 'hello', - output: '/tmp/signed-message.saltpack', - outputSenderFullname: '', - outputSenderUsername: 'alice', - outputSigned: true, - outputStatus: 'success', - outputType: 'file', - outputValid: true, - }) - ) - - dispatch.resetOperation(Operations.Sign) - dispatch.onSaltpackOpenFile(Operations.Sign, '/tmp/plain.txt') - dispatch.runFileOperation(Operations.Sign, '/tmp/out') - await flushPromises() - - expect(signFile).toHaveBeenCalledWith( - { - destinationDir: '/tmp/out', - filename: '/tmp/plain.txt', - }, - expect.any(String) - ) - expect(getPublicState().sign).toEqual( - makeCommonState({ - input: '/tmp/plain.txt', - inputType: 'file', - output: '/tmp/plain.txt.signed.saltpack', - outputSenderFullname: '', - outputSenderUsername: 'alice', - outputSigned: true, - outputStatus: 'success', - outputType: 'file', - outputValid: true, - }) - ) - - signFile.mockRejectedValueOnce(new RPCError('API network error', T.RPCGen.StatusCode.scgeneric)) - dispatch.onSaltpackOpenFile(Operations.Sign, '/tmp/offline.txt') - dispatch.runFileOperation(Operations.Sign, '/tmp/out') - await flushPromises() - - expect(getPublicState().sign).toEqual( - makeCommonState({ - errorMessage: 'You are offline.', - input: '/tmp/offline.txt', - inputType: 'file', - }) - ) + ).toEqual({ + hasSBS: true, + recipients: ['alice', '[carol]@email', 'bob@twitter'], + }) }) -test('verify supports text and file flows and exposes verification failures', async () => { - const verifyString = jest.spyOn(T.RPCGen, 'saltpackSaltpackVerifyStringRpcPromise').mockResolvedValue({ - plaintext: 'verified text', - sender: {fullname: 'Bob', username: 'bob'}, - verified: true, +test('getStatusCodeMessage maps wrong-format verify errors with the decrypt hint', () => { + const error = new RPCError({ + code: T.RPCGen.StatusCode.scwrongcryptomsgtype, + desc: 'wrong type', + fields: [{key: 'ignored', value: T.RPCGen.StatusCode.scgeneric}, {key: 'Code', value: T.RPCGen.StatusCode.scwrongcryptomsgtype}], } as any) - const verifyFile = jest.spyOn(T.RPCGen, 'saltpackSaltpackVerifyFileRpcPromise').mockResolvedValue({ - sender: {fullname: '', username: ''}, - verified: false, - verifiedFilename: '/tmp/verified.txt', - } as any) - - const {dispatch} = useCryptoState.getState() - - setOperationInput(Operations.Verify, 'text', 'signed message') - dispatch.runTextOperation(Operations.Verify) - await flushPromises() - expect(verifyString).toHaveBeenCalledWith({signedMsg: 'signed message'}, expect.any(String)) - expect(getPublicState().verify).toEqual( - makeCommonState({ - input: 'signed message', - output: 'verified text', - outputSenderFullname: 'Bob', - outputSenderUsername: 'bob', - outputSigned: true, - outputStatus: 'success', - outputType: 'text', - outputValid: true, - }) - ) - - dispatch.resetOperation(Operations.Verify) - dispatch.onSaltpackOpenFile(Operations.Verify, '/tmp/signed.saltpack') - dispatch.runFileOperation(Operations.Verify, '/tmp/out') - await flushPromises() - - expect(verifyFile).toHaveBeenCalledWith( - { - destinationDir: '/tmp/out', - signedFilename: '/tmp/signed.saltpack', - }, - expect.any(String) - ) - expect(getPublicState().verify).toEqual( - makeCommonState({ - input: '/tmp/signed.saltpack', - inputType: 'file', - output: '/tmp/verified.txt', - outputSenderFullname: '', - outputSenderUsername: '', - outputSigned: false, - outputStatus: 'success', - outputType: 'file', - outputValid: true, - }) - ) - - verifyString.mockRejectedValueOnce( - new RPCError('verify failed', T.RPCGen.StatusCode.scsigcannotverify, [ - {key: 'ignored', value: T.RPCGen.StatusCode.scgeneric}, - {key: 'Code', value: T.RPCGen.StatusCode.scverificationkeynotfound}, - ]) - ) - setOperationInput(Operations.Verify, 'text', 'bad signed message') - dispatch.runTextOperation(Operations.Verify) - await flushPromises() - - expect(getPublicState().verify).toEqual( - makeCommonState({ - errorMessage: "This message couldn't be verified, because the signing key wasn't recognized.", - input: 'bad signed message', - }) - ) + expect(getStatusCodeMessage(error, 'verify', 'text')).toContain('Did you mean to decrypt it?') }) From d541563d88ca5c651add7ebc35da9993b99677ab Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Thu, 26 Mar 2026 14:05:06 -0400 Subject: [PATCH 09/19] WIP --- shared/constants/router.tsx | 8 +++--- shared/crypto/operations/decrypt.tsx | 6 ++--- shared/crypto/operations/encrypt.tsx | 14 +++++------ shared/crypto/operations/sign.tsx | 8 +++--- shared/crypto/operations/verify.tsx | 6 ++--- shared/crypto/routes.tsx | 16 ++++++------ shared/crypto/state.tsx | 25 ++++++++++--------- .../signup/phone-number/use-verification.tsx | 8 +++--- shared/signup/routes.tsx | 5 +++- shared/stores/settings-phone.tsx | 23 +++++++++-------- shared/stores/tests/crypto.test.ts | 9 +++---- shared/util/phone-numbers/index.tsx | 4 +-- 12 files changed, 68 insertions(+), 64 deletions(-) diff --git a/shared/constants/router.tsx b/shared/constants/router.tsx index 3dc176dc072b..44a48d45d69e 100644 --- a/shared/constants/router.tsx +++ b/shared/constants/router.tsx @@ -210,7 +210,7 @@ export const navigateAppend = (path: PathParam, replace?: boolean) => { if (typeof path === 'string') { routeName = path } else { - routeName = path.name + routeName = typeof path.name === 'string' ? path.name : String(path.name) params = path.params as object } if (!routeName) { @@ -272,9 +272,9 @@ export const navToThread = (conversationIDKey: T.Chat.ConversationIDKey) => { // A single reset on the tab navigator atomically switches tabs and sets params. const tabNavState = rs.routes?.[0]?.state if (!tabNavState?.key) return - const chatTabIndex = tabNavState.routes.findIndex(r => r.name === Tabs.chatTab) + const chatTabIndex = tabNavState.routes.findIndex((r: Route) => r.name === Tabs.chatTab) if (chatTabIndex < 0) return - const updatedRoutes = tabNavState.routes.map((route, i) => { + const updatedRoutes = tabNavState.routes.map((route: Route, i: number) => { if (i !== chatTabIndex) return route return {...route, state: {...(route.state ?? {}), index: 0, routes: [{name: 'chatRoot', params: {conversationIDKey}}]}} }) @@ -335,8 +335,8 @@ export const appendEncryptRecipientsBuilder = () => { filterServices: ['facebook', 'github', 'hackernews', 'keybase', 'reddit', 'twitter'], goButtonLabel: 'Add', namespace: 'crypto', - teamBuilderNonce: makeUUID(), recommendedHideYourself: true, + teamBuilderNonce: makeUUID(), title: 'Recipients', }, }) diff --git a/shared/crypto/operations/decrypt.tsx b/shared/crypto/operations/decrypt.tsx index 4d7f2e57f738..d3cf41126c09 100644 --- a/shared/crypto/operations/decrypt.tsx +++ b/shared/crypto/operations/decrypt.tsx @@ -2,7 +2,7 @@ import * as C from '@/constants' import * as Crypto from '@/constants/crypto' import * as Kb from '@/common-adapters' import * as React from 'react' -import type * as T from '@/constants/types' +import * as T from '@/constants/types' import {CryptoBanner, DragAndDrop, Input, InputActionsBar} from '../input' import {CryptoOutput, CryptoOutputActionsBar, CryptoSignedSender} from '../output' import { @@ -159,7 +159,7 @@ const useDecryptState = (params?: CryptoInputRouteParams) => { return type === 'file' ? resetOutput(next) : next }) if (type === 'text' && !C.isMobile) { - C.ignorePromise(decrypt()) + C.ignorePromise(decrypt().then(() => undefined)) } }, [clearInput, decrypt] @@ -293,7 +293,7 @@ export const DecryptIO = () => { outputTextType="plain" state={controller.state} onChooseOutputFolder={destinationDir => { - C.ignorePromise(controller.decrypt(destinationDir)) + C.ignorePromise(controller.decrypt(destinationDir).then(() => undefined)) }} /> diff --git a/shared/crypto/operations/encrypt.tsx b/shared/crypto/operations/encrypt.tsx index 9a6cd68220ed..83b352de9dfd 100644 --- a/shared/crypto/operations/encrypt.tsx +++ b/shared/crypto/operations/encrypt.tsx @@ -2,7 +2,7 @@ import * as C from '@/constants' import * as Crypto from '@/constants/crypto' import * as Kb from '@/common-adapters' import * as React from 'react' -import type * as T from '@/constants/types' +import * as T from '@/constants/types' import Recipients from '../recipients' import {openURL} from '@/util/misc' import {CryptoBanner, DragAndDrop, Input, InputActionsBar} from '../input' @@ -135,7 +135,7 @@ const nextOptionState = ( const useEncryptScreenState = (params?: EncryptRouteParams) => { const [state, setState] = React.useState(() => createEncryptState(params)) const stateRef = React.useRef(state) - const handledTeamBuilderNonceRef = React.useRef() + const handledTeamBuilderNonceRef = React.useRef(undefined) React.useEffect(() => { stateRef.current = state @@ -220,7 +220,7 @@ const useEncryptScreenState = (params?: EncryptRouteParams) => { return type === 'file' ? resetOutput(next) : next }) if (type === 'text' && !C.isMobile) { - C.ignorePromise(runEncrypt()) + C.ignorePromise(runEncrypt().then(() => undefined)) } }, [clearInput, runEncrypt] @@ -242,7 +242,7 @@ const useEncryptScreenState = (params?: EncryptRouteParams) => { (recipients: ReadonlyArray, hasSBS: boolean) => { setState(prev => nextRecipientState(prev, recipients, hasSBS)) if (stateRef.current.inputType === 'text' && !C.isMobile) { - C.ignorePromise(runEncrypt()) + C.ignorePromise(runEncrypt().then(() => undefined)) } }, [runEncrypt] @@ -271,7 +271,7 @@ const useEncryptScreenState = (params?: EncryptRouteParams) => { (options: {includeSelf?: boolean; sign?: boolean}, hideIncludeSelf?: boolean) => { setState(prev => nextOptionState(prev, options, hideIncludeSelf)) if (stateRef.current.inputType === 'text' && !C.isMobile) { - C.ignorePromise(runEncrypt()) + C.ignorePromise(runEncrypt().then(() => undefined)) } }, [runEncrypt] @@ -613,7 +613,7 @@ export const EncryptIO = () => { outputTextType="cipher" state={controller.state} onChooseOutputFolder={destinationDir => { - C.ignorePromise(controller.runEncrypt(destinationDir)) + C.ignorePromise(controller.runEncrypt(destinationDir).then(() => undefined)) }} /> { canSaveAsText={true} state={controller.state} onSaveAsText={() => { - C.ignorePromise(controller.saveOutputAsText()) + C.ignorePromise(controller.saveOutputAsText().then(() => undefined)) }} /> diff --git a/shared/crypto/operations/sign.tsx b/shared/crypto/operations/sign.tsx index 12f5673ef86a..e2e76359d563 100644 --- a/shared/crypto/operations/sign.tsx +++ b/shared/crypto/operations/sign.tsx @@ -2,7 +2,7 @@ import * as C from '@/constants' import * as Crypto from '@/constants/crypto' import * as React from 'react' import * as Kb from '@/common-adapters' -import type * as T from '@/constants/types' +import * as T from '@/constants/types' import {openURL} from '@/util/misc' import {CryptoBanner, DragAndDrop, Input, InputActionsBar} from '../input' import {CryptoOutput, CryptoOutputActionsBar, CryptoSignedSender, OutputInfoBanner} from '../output' @@ -143,7 +143,7 @@ const useSignState = (params?: CryptoInputRouteParams) => { return type === 'file' ? resetOutput(next) : next }) if (type === 'text' && !C.isMobile) { - C.ignorePromise(sign()) + C.ignorePromise(sign().then(() => undefined)) } }, [clearInput, sign] @@ -303,7 +303,7 @@ export const SignIO = () => { outputTextType="cipher" state={controller.state} onChooseOutputFolder={destinationDir => { - C.ignorePromise(controller.sign(destinationDir)) + C.ignorePromise(controller.sign(destinationDir).then(() => undefined)) }} /> { canSaveAsText={true} state={controller.state} onSaveAsText={() => { - C.ignorePromise(controller.saveOutputAsText()) + C.ignorePromise(controller.saveOutputAsText().then(() => undefined)) }} /> diff --git a/shared/crypto/operations/verify.tsx b/shared/crypto/operations/verify.tsx index 161ff381acb6..e700d97b5683 100644 --- a/shared/crypto/operations/verify.tsx +++ b/shared/crypto/operations/verify.tsx @@ -2,7 +2,7 @@ import * as C from '@/constants' import * as Crypto from '@/constants/crypto' import * as Kb from '@/common-adapters' import * as React from 'react' -import type * as T from '@/constants/types' +import * as T from '@/constants/types' import {CryptoBanner, DragAndDrop, Input, InputActionsBar} from '../input' import {CryptoOutput, CryptoOutputActionsBar, CryptoSignedSender} from '../output' import { @@ -159,7 +159,7 @@ const useVerifyState = (params?: CryptoInputRouteParams) => { return type === 'file' ? resetOutput(next) : next }) if (type === 'text' && !C.isMobile) { - C.ignorePromise(verify()) + C.ignorePromise(verify().then(() => undefined)) } }, [clearInput, verify] @@ -290,7 +290,7 @@ export const VerifyIO = () => { outputTextType="plain" state={controller.state} onChooseOutputFolder={destinationDir => { - C.ignorePromise(controller.verify(destinationDir)) + C.ignorePromise(controller.verify(destinationDir).then(() => undefined)) }} /> diff --git a/shared/crypto/routes.tsx b/shared/crypto/routes.tsx index 576e83e298f1..f940df370292 100644 --- a/shared/crypto/routes.tsx +++ b/shared/crypto/routes.tsx @@ -20,56 +20,56 @@ type CryptoTeamBuilderRouteParams = Parameters[ const DecryptInputScreen = React.lazy(async () => { const {DecryptInput} = await import('./operations/decrypt') return { - default: (p: StaticScreenProps) => , + default: (_p: StaticScreenProps) => , } }) const EncryptInputScreen = React.lazy(async () => { const {EncryptInput} = await import('./operations/encrypt') return { - default: (p: StaticScreenProps) => , + default: (_p: StaticScreenProps) => , } }) const SignInputScreen = React.lazy(async () => { const {SignInput} = await import('./operations/sign') return { - default: (p: StaticScreenProps) => , + default: (_p: StaticScreenProps) => , } }) const VerifyInputScreen = React.lazy(async () => { const {VerifyInput} = await import('./operations/verify') return { - default: (p: StaticScreenProps) => , + default: (_p: StaticScreenProps) => , } }) const DecryptOutputScreen = React.lazy(async () => { const {DecryptOutput} = await import('./operations/decrypt') return { - default: (p: StaticScreenProps) => , + default: (p: StaticScreenProps) => , } }) const EncryptOutputScreen = React.lazy(async () => { const {EncryptOutput} = await import('./operations/encrypt') return { - default: (p: StaticScreenProps) => , + default: (p: StaticScreenProps) => , } }) const SignOutputScreen = React.lazy(async () => { const {SignOutput} = await import('./operations/sign') return { - default: (p: StaticScreenProps) => , + default: (p: StaticScreenProps) => , } }) const VerifyOutputScreen = React.lazy(async () => { const {VerifyOutput} = await import('./operations/verify') return { - default: (p: StaticScreenProps) => , + default: (p: StaticScreenProps) => , } }) diff --git a/shared/crypto/state.tsx b/shared/crypto/state.tsx index 6969895d8351..a52f3d25376e 100644 --- a/shared/crypto/state.tsx +++ b/shared/crypto/state.tsx @@ -1,4 +1,5 @@ -import * as T from '@/constants/types' +import type * as T from '@/constants/types' +import * as RPCGen from '@/constants/rpc/rpc-gen' import {RPCError} from '@/util/errors' export type OutputStatus = 'success' | 'pending' @@ -84,34 +85,34 @@ export const getStatusCodeMessage = ( wrongTypeHelpText = ' Did you mean to verify it?' } - const fields = error.fields as Array<{key: string; value: T.RPCGen.StatusCode}> | undefined + const fields = error.fields as Array<{key: string; value: RPCGen.StatusCode}> | undefined const field = fields?.[1] - const causeStatusCode = field?.key === 'Code' ? field.value : T.RPCGen.StatusCode.scgeneric + const causeStatusCode = field?.key === 'Code' ? field.value : RPCGen.StatusCode.scgeneric const causeStatusCodeToMessage = new Map([ - [T.RPCGen.StatusCode.scapinetworkerror, offlineMessage], + [RPCGen.StatusCode.scapinetworkerror, offlineMessage], [ - T.RPCGen.StatusCode.scdecryptionkeynotfound, + RPCGen.StatusCode.scdecryptionkeynotfound, "This message was encrypted for someone else or for a key you don't have.", ], [ - T.RPCGen.StatusCode.scverificationkeynotfound, + RPCGen.StatusCode.scverificationkeynotfound, "This message couldn't be verified, because the signing key wasn't recognized.", ], - [T.RPCGen.StatusCode.scwrongcryptomsgtype, `This Saltpack format is unexpected.${wrongTypeHelpText}`], + [RPCGen.StatusCode.scwrongcryptomsgtype, `This Saltpack format is unexpected.${wrongTypeHelpText}`], ]) const statusCodeToMessage = new Map([ - [T.RPCGen.StatusCode.scapinetworkerror, offlineMessage], + [RPCGen.StatusCode.scapinetworkerror, offlineMessage], [ - T.RPCGen.StatusCode.scgeneric, + RPCGen.StatusCode.scgeneric, error.message.includes('API network error') ? offlineMessage : genericMessage, ], [ - T.RPCGen.StatusCode.scstreamunknown, + RPCGen.StatusCode.scstreamunknown, `This ${inputType} is not in a valid Saltpack format. Please ${action} Saltpack ${addInput}.`, ], - [T.RPCGen.StatusCode.scsigcannotverify, causeStatusCodeToMessage.get(causeStatusCode) || genericMessage], - [T.RPCGen.StatusCode.scdecryptionerror, causeStatusCodeToMessage.get(causeStatusCode) || genericMessage], + [RPCGen.StatusCode.scsigcannotverify, causeStatusCodeToMessage.get(causeStatusCode) || genericMessage], + [RPCGen.StatusCode.scdecryptionerror, causeStatusCodeToMessage.get(causeStatusCode) || genericMessage], ]) return statusCodeToMessage.get(error.code) ?? genericMessage diff --git a/shared/signup/phone-number/use-verification.tsx b/shared/signup/phone-number/use-verification.tsx index e8b2110e8ac1..d3ba54dff2b1 100644 --- a/shared/signup/phone-number/use-verification.tsx +++ b/shared/signup/phone-number/use-verification.tsx @@ -73,7 +73,7 @@ export const usePhoneVerification = ({ const initialResendDone = React.useRef(false) const mountedRef = useMountedRef() - const resendVerificationForPhone = (phoneNumberToVerify: string) => { + const resendVerificationForPhone = React.useCallback((phoneNumberToVerify: string) => { setError('') resendVerification( [{phoneNumber: phoneNumberToVerify}, C.waitingKeySettingsPhoneResendVerification], @@ -84,9 +84,9 @@ export const usePhoneVerification = ({ } } ) - } + }, [mountedRef, resendVerification]) - const verifyPhoneNumber = (phoneNumberToVerify: string, code: string) => { + const verifyPhoneNumber = React.useCallback((phoneNumberToVerify: string, code: string) => { setError('') verifyPhoneNumberRpc( [{code, phoneNumber: phoneNumberToVerify}, C.waitingKeySettingsPhoneVerifyPhoneNumber], @@ -102,7 +102,7 @@ export const usePhoneVerification = ({ } } ) - } + }, [mountedRef, onSuccess, verifyPhoneNumberRpc]) React.useEffect(() => { if (!initialResend || initialResendDone.current) { diff --git a/shared/signup/routes.tsx b/shared/signup/routes.tsx index 07c920a218ef..fd657c62f973 100644 --- a/shared/signup/routes.tsx +++ b/shared/signup/routes.tsx @@ -65,7 +65,10 @@ export const newRoutes = { } // Some screens in signup show up after we've actually signed up -export const newModalRoutes = { +export const newModalRoutes: Record< + string, + {getOptions: unknown; screen: React.LazyExoticComponent>} +> = { signupEnterEmail: { getOptions: {headerLeft: () => null, headerRight: () => , title: 'Your email address'}, screen: React.lazy(async () => import('./email')), diff --git a/shared/stores/settings-phone.tsx b/shared/stores/settings-phone.tsx index 2b4a2fde8bc7..701a9783e80a 100644 --- a/shared/stores/settings-phone.tsx +++ b/shared/stores/settings-phone.tsx @@ -1,4 +1,5 @@ -import * as T from '@/constants/types' +import type * as T from '@/constants/types' +import * as RPCGen from '@/constants/rpc/rpc-gen' import * as Z from '@/util/zustand' import {RPCError} from '@/util/errors' import type {e164ToDisplay as e164ToDisplayType} from '@/util/phone-numbers' @@ -17,7 +18,7 @@ const toPhoneRow = (p: T.RPCGen.UserPhoneNumber) => { ...makePhoneRow(), displayNumber: e164ToDisplay(p.phoneNumber), e164: p.phoneNumber, - searchable: p.visibility === T.RPCGen.IdentityVisibility.public, + searchable: p.visibility === RPCGen.IdentityVisibility.public, superseded: p.superseded, verified: p.verified, } @@ -25,15 +26,15 @@ const toPhoneRow = (p: T.RPCGen.UserPhoneNumber) => { export const makePhoneError = (e: RPCError) => { switch (e.code) { - case T.RPCGen.StatusCode.scphonenumberwrongverificationcode: + case RPCGen.StatusCode.scphonenumberwrongverificationcode: return 'Incorrect code, please try again.' - case T.RPCGen.StatusCode.scphonenumberunknown: + case RPCGen.StatusCode.scphonenumberunknown: return e.desc - case T.RPCGen.StatusCode.scphonenumberalreadyverified: + case RPCGen.StatusCode.scphonenumberalreadyverified: return 'This phone number is already verified.' - case T.RPCGen.StatusCode.scphonenumberverificationcodeexpired: + case RPCGen.StatusCode.scphonenumberverificationcodeexpired: return 'Verification code expired, resend and try again.' - case T.RPCGen.StatusCode.scratelimit: + case RPCGen.StatusCode.scratelimit: return 'Sorry, tried too many guesses in a short period of time. Please try again later.' default: return e.message @@ -79,14 +80,14 @@ export const useSettingsPhoneState = Z.createZustand('settings-phone', se editPhone: (phoneNumber, del, setSearchable) => { const f = async () => { if (del) { - await T.RPCGen.phoneNumbersDeletePhoneNumberRpcPromise({phoneNumber}) + await RPCGen.phoneNumbersDeletePhoneNumberRpcPromise({phoneNumber}) } if (setSearchable !== undefined) { - await T.RPCGen.phoneNumbersSetVisibilityPhoneNumberRpcPromise({ + await RPCGen.phoneNumbersSetVisibilityPhoneNumberRpcPromise({ phoneNumber, visibility: setSearchable - ? T.RPCChat.Keybase1.IdentityVisibility.public - : T.RPCChat.Keybase1.IdentityVisibility.private, + ? RPCGen.IdentityVisibility.public + : RPCGen.IdentityVisibility.private, }) } } diff --git a/shared/stores/tests/crypto.test.ts b/shared/stores/tests/crypto.test.ts index 8d387cd80b6c..8fdcacaa809e 100644 --- a/shared/stores/tests/crypto.test.ts +++ b/shared/stores/tests/crypto.test.ts @@ -64,11 +64,10 @@ test('teamBuilderResultToRecipients converts SBS assertions', () => { }) test('getStatusCodeMessage maps wrong-format verify errors with the decrypt hint', () => { - const error = new RPCError({ - code: T.RPCGen.StatusCode.scwrongcryptomsgtype, - desc: 'wrong type', - fields: [{key: 'ignored', value: T.RPCGen.StatusCode.scgeneric}, {key: 'Code', value: T.RPCGen.StatusCode.scwrongcryptomsgtype}], - } as any) + const error = new RPCError('wrong type', T.RPCGen.StatusCode.scwrongcryptomsgtype, [ + {key: 'ignored', value: T.RPCGen.StatusCode.scgeneric}, + {key: 'Code', value: T.RPCGen.StatusCode.scwrongcryptomsgtype}, + ]) expect(getStatusCodeMessage(error, 'verify', 'text')).toContain('Did you mean to decrypt it?') }) diff --git a/shared/util/phone-numbers/index.tsx b/shared/util/phone-numbers/index.tsx index f1a15007f5ff..11a1599dc405 100644 --- a/shared/util/phone-numbers/index.tsx +++ b/shared/util/phone-numbers/index.tsx @@ -207,9 +207,9 @@ export const getE164 = (phoneNumber: string, countryCode?: string) => { } } -const loadDefaultPhoneCountry = () => { +const loadDefaultPhoneCountry = async () => { if (_defaultPhoneCountry) { - return Promise.resolve(_defaultPhoneCountry) + return _defaultPhoneCountry } if (!_defaultPhoneCountryPromise) { _defaultPhoneCountryPromise = T.RPCGen.accountGuessCurrentLocationRpcPromise({ From cdd7296d4400cc830a8071fec9d9a6dd81b8e5f3 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Thu, 26 Mar 2026 14:09:55 -0400 Subject: [PATCH 10/19] WIP --- shared/constants/router.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/constants/router.tsx b/shared/constants/router.tsx index 44a48d45d69e..6e00dcc51e70 100644 --- a/shared/constants/router.tsx +++ b/shared/constants/router.tsx @@ -192,7 +192,7 @@ export const navUpToScreen = (name: RouteKeys) => { DEBUG_NAV && console.log('[Nav] navUpToScreen', {name}) const n = _getNavigator() if (!n) return - n.dispatch(StackActions.popTo(name)) + n.dispatch(StackActions.popTo(typeof name === 'string' ? name : String(name))) } export const navigateAppend = (path: PathParam, replace?: boolean) => { From 21f97cca32565699626101f5916dcc31b37f755c Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Thu, 26 Mar 2026 14:16:12 -0400 Subject: [PATCH 11/19] WIP --- shared/constants/router.tsx | 10 ++++++---- shared/crypto/operations/decrypt.tsx | 10 ++++++++-- shared/crypto/operations/encrypt.tsx | 25 ++++++++++++++++++++----- shared/crypto/operations/sign.tsx | 15 ++++++++++++--- shared/crypto/operations/verify.tsx | 10 ++++++++-- shared/signup/phone-number/verify.tsx | 5 +++-- shared/signup/routes.tsx | 5 +---- 7 files changed, 58 insertions(+), 22 deletions(-) diff --git a/shared/constants/router.tsx b/shared/constants/router.tsx index 6e00dcc51e70..8fda1836ef61 100644 --- a/shared/constants/router.tsx +++ b/shared/constants/router.tsx @@ -210,8 +210,9 @@ export const navigateAppend = (path: PathParam, replace?: boolean) => { if (typeof path === 'string') { routeName = path } else { - routeName = typeof path.name === 'string' ? path.name : String(path.name) - params = path.params as object + const nextPath = path as {name: string | number | symbol; params?: object} + routeName = typeof nextPath.name === 'string' ? nextPath.name : String(nextPath.name) + params = nextPath.params } if (!routeName) { DEBUG_NAV && console.log('[Nav] navigateAppend no routeName bail', routeName) @@ -272,9 +273,10 @@ export const navToThread = (conversationIDKey: T.Chat.ConversationIDKey) => { // A single reset on the tab navigator atomically switches tabs and sets params. const tabNavState = rs.routes?.[0]?.state if (!tabNavState?.key) return - const chatTabIndex = tabNavState.routes.findIndex((r: Route) => r.name === Tabs.chatTab) + const tabRoutes = tabNavState.routes as Array + const chatTabIndex = tabRoutes.findIndex(r => r.name === Tabs.chatTab) if (chatTabIndex < 0) return - const updatedRoutes = tabNavState.routes.map((route: Route, i: number) => { + const updatedRoutes = tabRoutes.map((route, i) => { if (i !== chatTabIndex) return route return {...route, state: {...(route.state ?? {}), index: 0, routes: [{name: 'chatRoot', params: {conversationIDKey}}]}} }) diff --git a/shared/crypto/operations/decrypt.tsx b/shared/crypto/operations/decrypt.tsx index d3cf41126c09..81dba676bc03 100644 --- a/shared/crypto/operations/decrypt.tsx +++ b/shared/crypto/operations/decrypt.tsx @@ -159,7 +159,10 @@ const useDecryptState = (params?: CryptoInputRouteParams) => { return type === 'file' ? resetOutput(next) : next }) if (type === 'text' && !C.isMobile) { - C.ignorePromise(decrypt().then(() => undefined)) + const f = async () => { + await decrypt() + } + C.ignorePromise(f()) } }, [clearInput, decrypt] @@ -293,7 +296,10 @@ export const DecryptIO = () => { outputTextType="plain" state={controller.state} onChooseOutputFolder={destinationDir => { - C.ignorePromise(controller.decrypt(destinationDir).then(() => undefined)) + const f = async () => { + await controller.decrypt(destinationDir) + } + C.ignorePromise(f()) }} /> diff --git a/shared/crypto/operations/encrypt.tsx b/shared/crypto/operations/encrypt.tsx index 83b352de9dfd..86688bb58b6b 100644 --- a/shared/crypto/operations/encrypt.tsx +++ b/shared/crypto/operations/encrypt.tsx @@ -220,7 +220,10 @@ const useEncryptScreenState = (params?: EncryptRouteParams) => { return type === 'file' ? resetOutput(next) : next }) if (type === 'text' && !C.isMobile) { - C.ignorePromise(runEncrypt().then(() => undefined)) + const f = async () => { + await runEncrypt() + } + C.ignorePromise(f()) } }, [clearInput, runEncrypt] @@ -242,7 +245,10 @@ const useEncryptScreenState = (params?: EncryptRouteParams) => { (recipients: ReadonlyArray, hasSBS: boolean) => { setState(prev => nextRecipientState(prev, recipients, hasSBS)) if (stateRef.current.inputType === 'text' && !C.isMobile) { - C.ignorePromise(runEncrypt().then(() => undefined)) + const f = async () => { + await runEncrypt() + } + C.ignorePromise(f()) } }, [runEncrypt] @@ -271,7 +277,10 @@ const useEncryptScreenState = (params?: EncryptRouteParams) => { (options: {includeSelf?: boolean; sign?: boolean}, hideIncludeSelf?: boolean) => { setState(prev => nextOptionState(prev, options, hideIncludeSelf)) if (stateRef.current.inputType === 'text' && !C.isMobile) { - C.ignorePromise(runEncrypt().then(() => undefined)) + const f = async () => { + await runEncrypt() + } + C.ignorePromise(f()) } }, [runEncrypt] @@ -613,7 +622,10 @@ export const EncryptIO = () => { outputTextType="cipher" state={controller.state} onChooseOutputFolder={destinationDir => { - C.ignorePromise(controller.runEncrypt(destinationDir).then(() => undefined)) + const f = async () => { + await controller.runEncrypt(destinationDir) + } + C.ignorePromise(f()) }} /> { canSaveAsText={true} state={controller.state} onSaveAsText={() => { - C.ignorePromise(controller.saveOutputAsText().then(() => undefined)) + const f = async () => { + await controller.saveOutputAsText() + } + C.ignorePromise(f()) }} /> diff --git a/shared/crypto/operations/sign.tsx b/shared/crypto/operations/sign.tsx index e2e76359d563..a98883ff0b05 100644 --- a/shared/crypto/operations/sign.tsx +++ b/shared/crypto/operations/sign.tsx @@ -143,7 +143,10 @@ const useSignState = (params?: CryptoInputRouteParams) => { return type === 'file' ? resetOutput(next) : next }) if (type === 'text' && !C.isMobile) { - C.ignorePromise(sign().then(() => undefined)) + const f = async () => { + await sign() + } + C.ignorePromise(f()) } }, [clearInput, sign] @@ -303,7 +306,10 @@ export const SignIO = () => { outputTextType="cipher" state={controller.state} onChooseOutputFolder={destinationDir => { - C.ignorePromise(controller.sign(destinationDir).then(() => undefined)) + const f = async () => { + await controller.sign(destinationDir) + } + C.ignorePromise(f()) }} /> { canSaveAsText={true} state={controller.state} onSaveAsText={() => { - C.ignorePromise(controller.saveOutputAsText().then(() => undefined)) + const f = async () => { + await controller.saveOutputAsText() + } + C.ignorePromise(f()) }} /> diff --git a/shared/crypto/operations/verify.tsx b/shared/crypto/operations/verify.tsx index e700d97b5683..8ddc59b7be46 100644 --- a/shared/crypto/operations/verify.tsx +++ b/shared/crypto/operations/verify.tsx @@ -159,7 +159,10 @@ const useVerifyState = (params?: CryptoInputRouteParams) => { return type === 'file' ? resetOutput(next) : next }) if (type === 'text' && !C.isMobile) { - C.ignorePromise(verify().then(() => undefined)) + const f = async () => { + await verify() + } + C.ignorePromise(f()) } }, [clearInput, verify] @@ -290,7 +293,10 @@ export const VerifyIO = () => { outputTextType="plain" state={controller.state} onChooseOutputFolder={destinationDir => { - C.ignorePromise(controller.verify(destinationDir).then(() => undefined)) + const f = async () => { + await controller.verify(destinationDir) + } + C.ignorePromise(f()) }} /> diff --git a/shared/signup/phone-number/verify.tsx b/shared/signup/phone-number/verify.tsx index 6fd694318c74..bc97b2dcf3e1 100644 --- a/shared/signup/phone-number/verify.tsx +++ b/shared/signup/phone-number/verify.tsx @@ -1,13 +1,14 @@ import * as C from '@/constants' import * as React from 'react' import * as Kb from '@/common-adapters' -import type {ScreenProps} from '@/constants/types/router' import {SignupScreen} from '../common' import {e164ToDisplay} from '@/util/phone-numbers' import VerifyBody from './verify-body' import {usePhoneVerification} from './use-verification' -const Container = ({route}: ScreenProps<'signupVerifyPhoneNumber'>) => { +type Props = {route: {params: {phoneNumber: string}}} + +const Container = ({route}: Props) => { const {phoneNumber} = route.params const resendWaiting = C.Waiting.useAnyWaiting(C.waitingKeySettingsPhoneResendVerification) const verifyWaiting = C.Waiting.useAnyWaiting(C.waitingKeySettingsPhoneVerifyPhoneNumber) diff --git a/shared/signup/routes.tsx b/shared/signup/routes.tsx index fd657c62f973..07c920a218ef 100644 --- a/shared/signup/routes.tsx +++ b/shared/signup/routes.tsx @@ -65,10 +65,7 @@ export const newRoutes = { } // Some screens in signup show up after we've actually signed up -export const newModalRoutes: Record< - string, - {getOptions: unknown; screen: React.LazyExoticComponent>} -> = { +export const newModalRoutes = { signupEnterEmail: { getOptions: {headerLeft: () => null, headerRight: () => , title: 'Your email address'}, screen: React.lazy(async () => import('./email')), From 97f5d78c1cfc3440bc92b35dee59bb9d0895f45a Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 26 Mar 2026 14:17:23 -0400 Subject: [PATCH 12/19] WIP --- shared/crypto/state.tsx | 8 +++----- shared/stores/settings-phone.tsx | 6 ++---- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/shared/crypto/state.tsx b/shared/crypto/state.tsx index a52f3d25376e..4487027e7a5b 100644 --- a/shared/crypto/state.tsx +++ b/shared/crypto/state.tsx @@ -1,6 +1,6 @@ import type * as T from '@/constants/types' import * as RPCGen from '@/constants/rpc/rpc-gen' -import {RPCError} from '@/util/errors' +import type {RPCError} from '@/util/errors' export type OutputStatus = 'success' | 'pending' @@ -69,11 +69,9 @@ export const getStatusCodeMessage = ( kind: CryptoKind, type: T.Crypto.InputTypes ): string => { - const inputType = - type === 'text' ? (kind === 'verify' ? 'signed message' : 'ciphertext') : 'file' + const inputType = type === 'text' ? (kind === 'verify' ? 'signed message' : 'ciphertext') : 'file' const action = type === 'text' ? (kind === 'verify' ? 'enter a' : 'enter') : 'drop a' - const addInput = - type === 'text' ? (kind === 'verify' ? 'signed message' : 'ciphertext') : 'encrypted file' + const addInput = type === 'text' ? (kind === 'verify' ? 'signed message' : 'ciphertext') : 'encrypted file' const offlineMessage = 'You are offline.' const genericMessage = `Failed to ${kind} ${type}.` diff --git a/shared/stores/settings-phone.tsx b/shared/stores/settings-phone.tsx index 701a9783e80a..b827f931c5f4 100644 --- a/shared/stores/settings-phone.tsx +++ b/shared/stores/settings-phone.tsx @@ -1,7 +1,7 @@ import type * as T from '@/constants/types' import * as RPCGen from '@/constants/rpc/rpc-gen' import * as Z from '@/util/zustand' -import {RPCError} from '@/util/errors' +import type {RPCError} from '@/util/errors' import type {e164ToDisplay as e164ToDisplayType} from '@/util/phone-numbers' export const makePhoneRow = (): PhoneRow => ({ @@ -85,9 +85,7 @@ export const useSettingsPhoneState = Z.createZustand('settings-phone', se if (setSearchable !== undefined) { await RPCGen.phoneNumbersSetVisibilityPhoneNumberRpcPromise({ phoneNumber, - visibility: setSearchable - ? RPCGen.IdentityVisibility.public - : RPCGen.IdentityVisibility.private, + visibility: setSearchable ? RPCGen.IdentityVisibility.public : RPCGen.IdentityVisibility.private, }) } } From 68e3719f16d2fbdc5f4215e13eab3a9ddfb4f4a3 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Thu, 26 Mar 2026 14:51:58 -0400 Subject: [PATCH 13/19] WIP --- shared/crypto/operations/decrypt.tsx | 74 ++++++------ shared/crypto/operations/encrypt.tsx | 121 ++++++++++--------- shared/crypto/operations/hooks.test.tsx | 148 ++++++++++++++++++++++++ shared/crypto/operations/sign.tsx | 76 ++++++------ shared/crypto/operations/verify.tsx | 74 ++++++------ 5 files changed, 315 insertions(+), 178 deletions(-) create mode 100644 shared/crypto/operations/hooks.test.tsx diff --git a/shared/crypto/operations/decrypt.tsx b/shared/crypto/operations/decrypt.tsx index 81dba676bc03..6e1c0098ade8 100644 --- a/shared/crypto/operations/decrypt.tsx +++ b/shared/crypto/operations/decrypt.tsx @@ -80,25 +80,28 @@ const onSuccess = ( outputValid, }) -const useDecryptState = (params?: CryptoInputRouteParams) => { +export const useDecryptState = (params?: CryptoInputRouteParams) => { const [state, setState] = React.useState(() => createCommonState(params)) const stateRef = React.useRef(state) - React.useEffect(() => { - stateRef.current = state - }, [state]) + + const commitState = React.useCallback((next: CommonState) => { + stateRef.current = next + setState(next) + return next + }, []) const clearInput = React.useCallback(() => { - setState(prev => ({ - ...resetOutput(prev), + const next = { + ...resetOutput(stateRef.current), input: '', inputType: 'text', outputValid: true, - })) - }, []) + } + commitState(next) + }, [commitState]) - const decrypt = React.useCallback(async (destinationDir = '') => { - const snapshot = stateRef.current - setState(prev => beginRun(prev)) + const decrypt = React.useCallback(async (destinationDir = '', snapshot = stateRef.current) => { + commitState(beginRun(snapshot)) try { if (snapshot.inputType === 'text') { const res = await T.RPCGen.saltpackSaltpackDecryptStringRpcPromise( @@ -114,8 +117,7 @@ const useDecryptState = (params?: CryptoInputRouteParams) => { res.info.sender.username, res.info.sender.fullname ) - setState(next) - return next + return commitState(next) } const res = await T.RPCGen.saltpackSaltpackDecryptFileRpcPromise( @@ -131,16 +133,14 @@ const useDecryptState = (params?: CryptoInputRouteParams) => { res.info.sender.username, res.info.sender.fullname ) - setState(next) - return next + return commitState(next) } catch (_error) { if (!(_error instanceof RPCError)) throw _error logger.error(_error) const next = onError(stateRef.current, getStatusCodeMessage(_error, 'decrypt', snapshot.inputType)) - setState(next) - return next + return commitState(next) } - }, []) + }, [commitState]) const setInput = React.useCallback( (type: T.Crypto.InputTypes, value: string) => { @@ -148,37 +148,35 @@ const useDecryptState = (params?: CryptoInputRouteParams) => { clearInput() return } - setState(prev => { - const outputValid = prev.input === value - const next = { - ...resetWarnings(prev), - input: value, - inputType: type, - outputValid, - } - return type === 'file' ? resetOutput(next) : next - }) + const current = stateRef.current + const outputValid = current.input === value + const next = { + ...resetWarnings(current), + input: value, + inputType: type, + outputValid, + } + const committed = commitState(type === 'file' ? resetOutput(next) : next) if (type === 'text' && !C.isMobile) { const f = async () => { - await decrypt() + await decrypt('', committed) } C.ignorePromise(f()) } }, - [clearInput, decrypt] + [clearInput, commitState, decrypt] ) const openFile = React.useCallback((path: string) => { if (!path) return - setState(prev => { - if (prev.inProgress) return prev - return { - ...resetOutput(prev), - input: path, - inputType: 'file', - } + const current = stateRef.current + if (current.inProgress) return + commitState({ + ...resetOutput(current), + input: path, + inputType: 'file', }) - }, []) + }, [commitState]) React.useEffect(() => { if (!params?.seedInputPath) return diff --git a/shared/crypto/operations/encrypt.tsx b/shared/crypto/operations/encrypt.tsx index 86688bb58b6b..06a9ce40159c 100644 --- a/shared/crypto/operations/encrypt.tsx +++ b/shared/crypto/operations/encrypt.tsx @@ -132,17 +132,18 @@ const nextOptionState = ( } } -const useEncryptScreenState = (params?: EncryptRouteParams) => { +export const useEncryptScreenState = (params?: EncryptRouteParams) => { const [state, setState] = React.useState(() => createEncryptState(params)) const stateRef = React.useRef(state) const handledTeamBuilderNonceRef = React.useRef(undefined) - React.useEffect(() => { - stateRef.current = state - }, [state]) + const commitState = React.useCallback((next: EncryptState) => { + stateRef.current = next + setState(next) + return next + }, []) - const runEncrypt = React.useCallback(async (destinationDir = '') => { - const snapshot = stateRef.current + const runEncrypt = React.useCallback(async (destinationDir = '', snapshot = stateRef.current) => { const username = useCurrentUserState.getState().username const signed = snapshot.options.sign const opts = { @@ -151,7 +152,7 @@ const useEncryptScreenState = (params?: EncryptRouteParams) => { signed, } - setState(prev => beginRun(prev)) + commitState(beginRun(snapshot)) try { let output = '' let unresolvedSBSAssertion = '' @@ -183,25 +184,24 @@ const useEncryptScreenState = (params?: EncryptRouteParams) => { signed, username ) - setState(next) - return next + return commitState(next) } catch (_error) { if (!(_error instanceof RPCError)) throw _error logger.error(_error) const next = onError(stateRef.current, getStatusCodeMessage(_error, 'encrypt', snapshot.inputType)) - setState(next) - return next + return commitState(next) } - }, []) + }, [commitState]) const clearInput = React.useCallback(() => { - setState(prev => ({ - ...resetOutput(prev), + const next = { + ...resetOutput(stateRef.current), input: '', inputType: 'text', outputValid: true, - })) - }, []) + } + commitState(next) + }, [commitState]) const setInput = React.useCallback( (type: T.Crypto.InputTypes, value: string) => { @@ -209,81 +209,77 @@ const useEncryptScreenState = (params?: EncryptRouteParams) => { clearInput() return } - setState(prev => { - const outputValid = prev.input === value - const next = { - ...resetWarnings(prev), - input: value, - inputType: type, - outputValid, - } - return type === 'file' ? resetOutput(next) : next - }) + const current = stateRef.current + const outputValid = current.input === value + const next = { + ...resetWarnings(current), + input: value, + inputType: type, + outputValid, + } + const committed = commitState(type === 'file' ? resetOutput(next) : next) if (type === 'text' && !C.isMobile) { const f = async () => { - await runEncrypt() + await runEncrypt('', committed) } C.ignorePromise(f()) } }, - [clearInput, runEncrypt] + [clearInput, commitState, runEncrypt] ) const openFile = React.useCallback((path: string) => { if (!path) return - setState(prev => { - if (prev.inProgress) return prev - return { - ...resetOutput(prev), - input: path, - inputType: 'file', - } + const current = stateRef.current + if (current.inProgress) return + commitState({ + ...resetOutput(current), + input: path, + inputType: 'file', }) - }, []) + }, [commitState]) const setRecipients = React.useCallback( (recipients: ReadonlyArray, hasSBS: boolean) => { - setState(prev => nextRecipientState(prev, recipients, hasSBS)) - if (stateRef.current.inputType === 'text' && !C.isMobile) { + const committed = commitState(nextRecipientState(stateRef.current, recipients, hasSBS)) + if (committed.inputType === 'text' && !C.isMobile) { const f = async () => { - await runEncrypt() + await runEncrypt('', committed) } C.ignorePromise(f()) } }, - [runEncrypt] + [commitState, runEncrypt] ) const clearRecipients = React.useCallback(() => { - setState(prev => { - const next = resetOutput(prev) - return { - ...next, - meta: { - hasRecipients: false, - hasSBS: false, - hideIncludeSelf: false, - }, - options: { - includeSelf: true, - sign: true, - }, - recipients: [], - } + const next = resetOutput(stateRef.current) + commitState({ + ...next, + meta: { + hasRecipients: false, + hasSBS: false, + hideIncludeSelf: false, + }, + options: { + includeSelf: true, + sign: true, + }, + recipients: [], }) - }, []) + }, [commitState]) const setEncryptOptions = React.useCallback( (options: {includeSelf?: boolean; sign?: boolean}, hideIncludeSelf?: boolean) => { - setState(prev => nextOptionState(prev, options, hideIncludeSelf)) - if (stateRef.current.inputType === 'text' && !C.isMobile) { + const committed = commitState(nextOptionState(stateRef.current, options, hideIncludeSelf)) + if (committed.inputType === 'text' && !C.isMobile) { const f = async () => { - await runEncrypt() + await runEncrypt('', committed) } C.ignorePromise(f()) } }, - [runEncrypt] + [commitState, runEncrypt] ) const saveOutputAsText = React.useCallback(async () => { @@ -296,9 +292,8 @@ const useEncryptScreenState = (params?: EncryptRouteParams) => { outputStatus: 'success' as const, outputType: 'file' as const, } - setState(next) - return next - }, []) + return commitState(next) + }, [commitState]) React.useEffect(() => { if (!params?.seedInputPath) return diff --git a/shared/crypto/operations/hooks.test.tsx b/shared/crypto/operations/hooks.test.tsx new file mode 100644 index 000000000000..971cd025b4e3 --- /dev/null +++ b/shared/crypto/operations/hooks.test.tsx @@ -0,0 +1,148 @@ +/** @jest-environment jsdom */ +/// + +import {afterEach, beforeEach, expect, jest, test} from '@jest/globals' +import * as React from 'react' +import {act, cleanup, render, waitFor} from '@testing-library/react' +import * as T from '@/constants/types' +import {useCurrentUserState} from '@/stores/current-user' +import {useDecryptState} from './decrypt' +import {useEncryptScreenState} from './encrypt' +import {useSignState} from './sign' +import {useVerifyState} from './verify' + +type HookController = { + state: { + errorMessage: string + output: string + outputValid: boolean + } + setInput: (type: T.Crypto.InputTypes, value: string) => void +} + +const mountHook = (useHook: () => Controller) => { + let latest: Controller | undefined + + const Probe = () => { + latest = useHook() + return null + } + + render() + return () => { + if (!latest) { + throw new Error('Hook controller did not mount') + } + return latest + } +} + +beforeEach(() => { + useCurrentUserState.setState({username: 'alice'} as never) +}) + +afterEach(() => { + cleanup() + jest.restoreAllMocks() + useCurrentUserState.getState().dispatch.resetState() +}) + +test('encrypt auto-run uses the latest text snapshot and keeps output valid', async () => { + const encryptSpy = jest + .spyOn(T.RPCGen, 'saltpackSaltpackEncryptStringRpcPromise') + .mockImplementation(async ({plaintext}) => ({ + ciphertext: `cipher:${plaintext}`, + unresolvedSBSAssertion: '', + usedUnresolvedSBS: false, + }) as never) + + const getController = mountHook(() => useEncryptScreenState()) + await act(async () => { + getController().setInput('text', 'secret message') + }) + + await waitFor(() => + expect(encryptSpy).toHaveBeenCalledWith( + expect.objectContaining({plaintext: 'secret message'}), + expect.anything() + ) + ) + await waitFor(() => expect(getController().state.output).toBe('cipher:secret message')) + + expect(getController().state.outputValid).toBe(true) + expect(getController().state.errorMessage).toBe('') +}) + +test('decrypt auto-run uses the pasted ciphertext instead of the previous input', async () => { + const decryptSpy = jest + .spyOn(T.RPCGen, 'saltpackSaltpackDecryptStringRpcPromise') + .mockImplementation(async ({ciphertext}) => ({ + info: {sender: {fullname: 'Bob', username: 'bob'}}, + plaintext: `plain:${ciphertext}`, + signed: true, + }) as never) + + const getController = mountHook(() => useDecryptState()) + await act(async () => { + getController().setInput('text', 'encrypted payload') + }) + + await waitFor(() => + expect(decryptSpy).toHaveBeenCalledWith( + expect.objectContaining({ciphertext: 'encrypted payload'}), + expect.anything() + ) + ) + await waitFor(() => expect(getController().state.output).toBe('plain:encrypted payload')) + + expect(getController().state.outputValid).toBe(true) + expect(getController().state.errorMessage).toBe('') +}) + +test('sign auto-run uses the latest text snapshot and keeps output valid', async () => { + const signSpy = jest + .spyOn(T.RPCGen, 'saltpackSaltpackSignStringRpcPromise') + .mockImplementation(async ({plaintext}) => `signed:${plaintext}` as never) + + const getController = mountHook(() => useSignState()) + await act(async () => { + getController().setInput('text', 'message to sign') + }) + + await waitFor(() => + expect(signSpy).toHaveBeenCalledWith( + expect.objectContaining({plaintext: 'message to sign'}), + expect.anything() + ) + ) + await waitFor(() => expect(getController().state.output).toBe('signed:message to sign')) + + expect(getController().state.outputValid).toBe(true) + expect(getController().state.errorMessage).toBe('') +}) + +test('verify auto-run uses the latest text snapshot and keeps output valid', async () => { + const verifySpy = jest + .spyOn(T.RPCGen, 'saltpackSaltpackVerifyStringRpcPromise') + .mockImplementation(async ({signedMsg}) => ({ + plaintext: `verified:${signedMsg}`, + sender: {fullname: 'Bob', username: 'bob'}, + verified: true, + }) as never) + + const getController = mountHook(() => useVerifyState()) + await act(async () => { + getController().setInput('text', 'signed payload') + }) + + await waitFor(() => + expect(verifySpy).toHaveBeenCalledWith( + expect.objectContaining({signedMsg: 'signed payload'}), + expect.anything() + ) + ) + await waitFor(() => expect(getController().state.output).toBe('verified:signed payload')) + + expect(getController().state.outputValid).toBe(true) + expect(getController().state.errorMessage).toBe('') +}) diff --git a/shared/crypto/operations/sign.tsx b/shared/crypto/operations/sign.tsx index a98883ff0b05..5c9352bf6f01 100644 --- a/shared/crypto/operations/sign.tsx +++ b/shared/crypto/operations/sign.tsx @@ -77,25 +77,28 @@ const onSuccess = ( outputValid, }) -const useSignState = (params?: CryptoInputRouteParams) => { +export const useSignState = (params?: CryptoInputRouteParams) => { const [state, setState] = React.useState(() => createCommonState(params)) const stateRef = React.useRef(state) - React.useEffect(() => { - stateRef.current = state - }, [state]) + + const commitState = React.useCallback((next: CommonState) => { + stateRef.current = next + setState(next) + return next + }, []) const clearInput = React.useCallback(() => { - setState(prev => ({ - ...resetOutput(prev), + const next = { + ...resetOutput(stateRef.current), input: '', inputType: 'text', outputValid: true, - })) - }, []) + } + commitState(next) + }, [commitState]) - const sign = React.useCallback(async (destinationDir = '') => { - const snapshot = stateRef.current - setState(prev => beginRun(prev)) + const sign = React.useCallback(async (destinationDir = '', snapshot = stateRef.current) => { + commitState(beginRun(snapshot)) try { const username = useCurrentUserState.getState().username const output = @@ -115,16 +118,14 @@ const useSignState = (params?: CryptoInputRouteParams) => { snapshot.inputType, username ) - setState(next) - return next + return commitState(next) } catch (_error) { if (!(_error instanceof RPCError)) throw _error logger.error(_error) const next = onError(stateRef.current, getStatusCodeMessage(_error, 'sign', snapshot.inputType)) - setState(next) - return next + return commitState(next) } - }, []) + }, [commitState]) const setInput = React.useCallback( (type: T.Crypto.InputTypes, value: string) => { @@ -132,37 +133,35 @@ const useSignState = (params?: CryptoInputRouteParams) => { clearInput() return } - setState(prev => { - const outputValid = prev.input === value - const next = { - ...resetWarnings(prev), - input: value, - inputType: type, - outputValid, - } - return type === 'file' ? resetOutput(next) : next - }) + const current = stateRef.current + const outputValid = current.input === value + const next = { + ...resetWarnings(current), + input: value, + inputType: type, + outputValid, + } + const committed = commitState(type === 'file' ? resetOutput(next) : next) if (type === 'text' && !C.isMobile) { const f = async () => { - await sign() + await sign('', committed) } C.ignorePromise(f()) } }, - [clearInput, sign] + [clearInput, commitState, sign] ) const openFile = React.useCallback((path: string) => { if (!path) return - setState(prev => { - if (prev.inProgress) return prev - return { - ...resetOutput(prev), - input: path, - inputType: 'file', - } + const current = stateRef.current + if (current.inProgress) return + commitState({ + ...resetOutput(current), + input: path, + inputType: 'file', }) - }, []) + }, [commitState]) const saveOutputAsText = React.useCallback(async () => { const output = await T.RPCGen.saltpackSaltpackSaveSignedMsgToFileRpcPromise({signedMsg: stateRef.current.output}) @@ -172,9 +171,8 @@ const useSignState = (params?: CryptoInputRouteParams) => { outputStatus: 'success' as const, outputType: 'file' as const, } - setState(next) - return next - }, []) + return commitState(next) + }, [commitState]) React.useEffect(() => { if (!params?.seedInputPath) return diff --git a/shared/crypto/operations/verify.tsx b/shared/crypto/operations/verify.tsx index 8ddc59b7be46..f8faa381ca01 100644 --- a/shared/crypto/operations/verify.tsx +++ b/shared/crypto/operations/verify.tsx @@ -80,25 +80,28 @@ const onSuccess = ( outputValid, }) -const useVerifyState = (params?: CryptoInputRouteParams) => { +export const useVerifyState = (params?: CryptoInputRouteParams) => { const [state, setState] = React.useState(() => createCommonState(params)) const stateRef = React.useRef(state) - React.useEffect(() => { - stateRef.current = state - }, [state]) + + const commitState = React.useCallback((next: CommonState) => { + stateRef.current = next + setState(next) + return next + }, []) const clearInput = React.useCallback(() => { - setState(prev => ({ - ...resetOutput(prev), + const next = { + ...resetOutput(stateRef.current), input: '', inputType: 'text', outputValid: true, - })) - }, []) + } + commitState(next) + }, [commitState]) - const verify = React.useCallback(async (destinationDir = '') => { - const snapshot = stateRef.current - setState(prev => beginRun(prev)) + const verify = React.useCallback(async (destinationDir = '', snapshot = stateRef.current) => { + commitState(beginRun(snapshot)) try { if (snapshot.inputType === 'text') { const res = await T.RPCGen.saltpackSaltpackVerifyStringRpcPromise( @@ -114,8 +117,7 @@ const useVerifyState = (params?: CryptoInputRouteParams) => { res.sender.username, res.sender.fullname ) - setState(next) - return next + return commitState(next) } const res = await T.RPCGen.saltpackSaltpackVerifyFileRpcPromise( @@ -131,16 +133,14 @@ const useVerifyState = (params?: CryptoInputRouteParams) => { res.sender.username, res.sender.fullname ) - setState(next) - return next + return commitState(next) } catch (_error) { if (!(_error instanceof RPCError)) throw _error logger.error(_error) const next = onError(stateRef.current, getStatusCodeMessage(_error, 'verify', snapshot.inputType)) - setState(next) - return next + return commitState(next) } - }, []) + }, [commitState]) const setInput = React.useCallback( (type: T.Crypto.InputTypes, value: string) => { @@ -148,37 +148,35 @@ const useVerifyState = (params?: CryptoInputRouteParams) => { clearInput() return } - setState(prev => { - const outputValid = prev.input === value - const next = { - ...resetWarnings(prev), - input: value, - inputType: type, - outputValid, - } - return type === 'file' ? resetOutput(next) : next - }) + const current = stateRef.current + const outputValid = current.input === value + const next = { + ...resetWarnings(current), + input: value, + inputType: type, + outputValid, + } + const committed = commitState(type === 'file' ? resetOutput(next) : next) if (type === 'text' && !C.isMobile) { const f = async () => { - await verify() + await verify('', committed) } C.ignorePromise(f()) } }, - [clearInput, verify] + [clearInput, commitState, verify] ) const openFile = React.useCallback((path: string) => { if (!path) return - setState(prev => { - if (prev.inProgress) return prev - return { - ...resetOutput(prev), - input: path, - inputType: 'file', - } + const current = stateRef.current + if (current.inProgress) return + commitState({ + ...resetOutput(current), + input: path, + inputType: 'file', }) - }, []) + }, [commitState]) React.useEffect(() => { if (!params?.seedInputPath) return From 7a159011aa5a0d987a36db346d0189f11a10bf02 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Thu, 26 Mar 2026 15:03:57 -0400 Subject: [PATCH 14/19] WIP --- shared/crypto/{operations => }/decrypt.tsx | 94 ++++----------- shared/crypto/{operations => }/encrypt.tsx | 110 ++++-------------- shared/crypto/helpers.ts | 100 ++++++++++++++++ shared/crypto/{operations => }/hooks.test.tsx | 0 shared/crypto/routes.tsx | 16 +-- shared/crypto/{operations => }/sign.tsx | 94 ++++----------- shared/crypto/sub-nav/index.desktop.tsx | 8 +- shared/crypto/{operations => }/verify.tsx | 94 ++++----------- 8 files changed, 195 insertions(+), 321 deletions(-) rename shared/crypto/{operations => }/decrypt.tsx (78%) rename shared/crypto/{operations => }/encrypt.tsx (88%) create mode 100644 shared/crypto/helpers.ts rename shared/crypto/{operations => }/hooks.test.tsx (100%) rename shared/crypto/{operations => }/sign.tsx (80%) rename shared/crypto/{operations => }/verify.tsx (78%) diff --git a/shared/crypto/operations/decrypt.tsx b/shared/crypto/decrypt.tsx similarity index 78% rename from shared/crypto/operations/decrypt.tsx rename to shared/crypto/decrypt.tsx index 6e1c0098ade8..e0fa0c93f74a 100644 --- a/shared/crypto/operations/decrypt.tsx +++ b/shared/crypto/decrypt.tsx @@ -3,8 +3,19 @@ import * as Crypto from '@/constants/crypto' import * as Kb from '@/common-adapters' import * as React from 'react' import * as T from '@/constants/types' -import {CryptoBanner, DragAndDrop, Input, InputActionsBar} from '../input' -import {CryptoOutput, CryptoOutputActionsBar, CryptoSignedSender} from '../output' +import {CryptoBanner, DragAndDrop, Input, InputActionsBar} from './input' +import {CryptoOutput, CryptoOutputActionsBar, CryptoSignedSender} from './output' +import { + beginRun, + clearInputState, + maybeAutoRunTextOperation, + nextInputState, + nextOpenedFileState, + resetOutput, + resetWarnings, + useCommittedState, + useSeededCryptoInput, +} from './helpers' import { createCommonState, getStatusCodeMessage, @@ -12,7 +23,7 @@ import { type CommonOutputRouteParams, type CryptoInputRouteParams, type CommonState, -} from '../state' +} from './state' import {RPCError} from '@/util/errors' import logger from '@/logger' import type {RootRouteProps} from '@/router-v2/route-params' @@ -26,34 +37,6 @@ const inputPlaceholder = C.isMobile ? 'Enter text to decrypt' : 'Enter ciphertext, drop an encrypted file or folder, or' -const resetWarnings = (state: CommonState): CommonState => ({ - ...state, - errorMessage: '', - warningMessage: '', -}) - -const resetOutput = (state: CommonState): CommonState => ({ - ...resetWarnings(state), - bytesComplete: 0, - bytesTotal: 0, - output: '', - outputSenderFullname: undefined, - outputSenderUsername: undefined, - outputSigned: false, - outputStatus: undefined, - outputType: undefined, - outputValid: false, -}) - -const beginRun = (state: CommonState): CommonState => ({ - ...resetWarnings(state), - bytesComplete: 0, - bytesTotal: 0, - inProgress: true, - outputStatus: 'pending', - outputValid: false, -}) - const onError = (state: CommonState, errorMessage: string): CommonState => ({ ...resetOutput(state), errorMessage, @@ -81,23 +64,10 @@ const onSuccess = ( }) export const useDecryptState = (params?: CryptoInputRouteParams) => { - const [state, setState] = React.useState(() => createCommonState(params)) - const stateRef = React.useRef(state) - - const commitState = React.useCallback((next: CommonState) => { - stateRef.current = next - setState(next) - return next - }, []) + const {commitState, state, stateRef} = useCommittedState(() => createCommonState(params)) const clearInput = React.useCallback(() => { - const next = { - ...resetOutput(stateRef.current), - input: '', - inputType: 'text', - outputValid: true, - } - commitState(next) + commitState(clearInputState(stateRef.current)) }, [commitState]) const decrypt = React.useCallback(async (destinationDir = '', snapshot = stateRef.current) => { @@ -148,21 +118,8 @@ export const useDecryptState = (params?: CryptoInputRouteParams) => { clearInput() return } - const current = stateRef.current - const outputValid = current.input === value - const next = { - ...resetWarnings(current), - input: value, - inputType: type, - outputValid, - } - const committed = commitState(type === 'file' ? resetOutput(next) : next) - if (type === 'text' && !C.isMobile) { - const f = async () => { - await decrypt('', committed) - } - C.ignorePromise(f()) - } + const committed = commitState(nextInputState(stateRef.current, type, value)) + maybeAutoRunTextOperation(committed, decrypt) }, [clearInput, commitState, decrypt] ) @@ -171,21 +128,10 @@ export const useDecryptState = (params?: CryptoInputRouteParams) => { if (!path) return const current = stateRef.current if (current.inProgress) return - commitState({ - ...resetOutput(current), - input: path, - inputType: 'file', - }) + commitState(nextOpenedFileState(current, path)) }, [commitState]) - React.useEffect(() => { - if (!params?.seedInputPath) return - if ((params.seedInputType ?? 'file') === 'file') { - openFile(params.seedInputPath) - } else { - setInput('text', params.seedInputPath) - } - }, [openFile, params?.entryNonce, params?.seedInputPath, params?.seedInputType, setInput]) + useSeededCryptoInput(params, openFile, setInput) return {clearInput, decrypt, openFile, setInput, state} } diff --git a/shared/crypto/operations/encrypt.tsx b/shared/crypto/encrypt.tsx similarity index 88% rename from shared/crypto/operations/encrypt.tsx rename to shared/crypto/encrypt.tsx index 06a9ce40159c..8590181e9065 100644 --- a/shared/crypto/operations/encrypt.tsx +++ b/shared/crypto/encrypt.tsx @@ -3,10 +3,21 @@ import * as Crypto from '@/constants/crypto' import * as Kb from '@/common-adapters' import * as React from 'react' import * as T from '@/constants/types' -import Recipients from '../recipients' +import Recipients from './recipients' import {openURL} from '@/util/misc' -import {CryptoBanner, DragAndDrop, Input, InputActionsBar} from '../input' -import {CryptoOutput, CryptoOutputActionsBar, CryptoSignedSender, OutputInfoBanner} from '../output' +import {CryptoBanner, DragAndDrop, Input, InputActionsBar} from './input' +import {CryptoOutput, CryptoOutputActionsBar, CryptoSignedSender, OutputInfoBanner} from './output' +import { + beginRun, + clearInputState, + maybeAutoRunTextOperation, + nextInputState, + nextOpenedFileState, + resetOutput, + resetWarnings, + useCommittedState, + useSeededCryptoInput, +} from './helpers' import { createEncryptState, encryptToOutputParams, @@ -16,7 +27,7 @@ import { type EncryptOutputRouteParams, type EncryptRouteParams, type EncryptState, -} from '../state' +} from './state' import {RPCError} from '@/util/errors' import logger from '@/logger' import {useCurrentUserState} from '@/stores/current-user' @@ -32,34 +43,6 @@ const inputPlaceholder = C.isMobile ? 'Enter text to encrypt' : 'Enter text, dro const getWarningMessageForSBS = (sbsAssertion: string) => `Note: Encrypted for "${sbsAssertion}" who is not yet a Keybase user. One of your devices will need to be online after they join Keybase in order for them to decrypt the message.` -const resetWarnings = (state: EncryptState): EncryptState => ({ - ...state, - errorMessage: '', - warningMessage: '', -}) - -const resetOutput = (state: EncryptState): EncryptState => ({ - ...resetWarnings(state), - bytesComplete: 0, - bytesTotal: 0, - output: '', - outputSenderFullname: undefined, - outputSenderUsername: undefined, - outputSigned: false, - outputStatus: undefined, - outputType: undefined, - outputValid: false, -}) - -const beginRun = (state: EncryptState): EncryptState => ({ - ...resetWarnings(state), - bytesComplete: 0, - bytesTotal: 0, - inProgress: true, - outputStatus: 'pending', - outputValid: false, -}) - const onError = (state: EncryptState, errorMessage: string): EncryptState => ({ ...resetOutput(state), errorMessage, @@ -133,16 +116,9 @@ const nextOptionState = ( } export const useEncryptScreenState = (params?: EncryptRouteParams) => { - const [state, setState] = React.useState(() => createEncryptState(params)) - const stateRef = React.useRef(state) + const {commitState, state, stateRef} = useCommittedState(() => createEncryptState(params)) const handledTeamBuilderNonceRef = React.useRef(undefined) - const commitState = React.useCallback((next: EncryptState) => { - stateRef.current = next - setState(next) - return next - }, []) - const runEncrypt = React.useCallback(async (destinationDir = '', snapshot = stateRef.current) => { const username = useCurrentUserState.getState().username const signed = snapshot.options.sign @@ -194,13 +170,7 @@ export const useEncryptScreenState = (params?: EncryptRouteParams) => { }, [commitState]) const clearInput = React.useCallback(() => { - const next = { - ...resetOutput(stateRef.current), - input: '', - inputType: 'text', - outputValid: true, - } - commitState(next) + commitState(clearInputState(stateRef.current)) }, [commitState]) const setInput = React.useCallback( @@ -209,21 +179,8 @@ export const useEncryptScreenState = (params?: EncryptRouteParams) => { clearInput() return } - const current = stateRef.current - const outputValid = current.input === value - const next = { - ...resetWarnings(current), - input: value, - inputType: type, - outputValid, - } - const committed = commitState(type === 'file' ? resetOutput(next) : next) - if (type === 'text' && !C.isMobile) { - const f = async () => { - await runEncrypt('', committed) - } - C.ignorePromise(f()) - } + const committed = commitState(nextInputState(stateRef.current, type, value)) + maybeAutoRunTextOperation(committed, runEncrypt) }, [clearInput, commitState, runEncrypt] ) @@ -232,22 +189,13 @@ export const useEncryptScreenState = (params?: EncryptRouteParams) => { if (!path) return const current = stateRef.current if (current.inProgress) return - commitState({ - ...resetOutput(current), - input: path, - inputType: 'file', - }) + commitState(nextOpenedFileState(current, path)) }, [commitState]) const setRecipients = React.useCallback( (recipients: ReadonlyArray, hasSBS: boolean) => { const committed = commitState(nextRecipientState(stateRef.current, recipients, hasSBS)) - if (committed.inputType === 'text' && !C.isMobile) { - const f = async () => { - await runEncrypt('', committed) - } - C.ignorePromise(f()) - } + maybeAutoRunTextOperation(committed, runEncrypt) }, [commitState, runEncrypt] ) @@ -272,12 +220,7 @@ export const useEncryptScreenState = (params?: EncryptRouteParams) => { const setEncryptOptions = React.useCallback( (options: {includeSelf?: boolean; sign?: boolean}, hideIncludeSelf?: boolean) => { const committed = commitState(nextOptionState(stateRef.current, options, hideIncludeSelf)) - if (committed.inputType === 'text' && !C.isMobile) { - const f = async () => { - await runEncrypt('', committed) - } - C.ignorePromise(f()) - } + maybeAutoRunTextOperation(committed, runEncrypt) }, [commitState, runEncrypt] ) @@ -295,14 +238,7 @@ export const useEncryptScreenState = (params?: EncryptRouteParams) => { return commitState(next) }, [commitState]) - React.useEffect(() => { - if (!params?.seedInputPath) return - if ((params.seedInputType ?? 'file') === 'file') { - openFile(params.seedInputPath) - } else { - setInput('text', params.seedInputPath) - } - }, [openFile, params?.entryNonce, params?.seedInputPath, params?.seedInputType, setInput]) + useSeededCryptoInput(params, openFile, setInput) React.useEffect(() => { if (!params?.teamBuilderNonce || !params.teamBuilderUsers) return diff --git a/shared/crypto/helpers.ts b/shared/crypto/helpers.ts new file mode 100644 index 000000000000..323cc9537829 --- /dev/null +++ b/shared/crypto/helpers.ts @@ -0,0 +1,100 @@ +import * as C from '@/constants' +import * as React from 'react' +import type * as T from '@/constants/types' +import type {CommonState, CryptoInputRouteParams} from './state' + +export const resetWarnings = (state: State): State => + ({ + ...state, + errorMessage: '', + warningMessage: '', + }) as State + +export const resetOutput = (state: State): State => + ({ + ...resetWarnings(state), + bytesComplete: 0, + bytesTotal: 0, + output: '', + outputSenderFullname: undefined, + outputSenderUsername: undefined, + outputSigned: false, + outputStatus: undefined, + outputType: undefined, + outputValid: false, + }) as State + +export const beginRun = (state: State): State => + ({ + ...resetWarnings(state), + bytesComplete: 0, + bytesTotal: 0, + inProgress: true, + outputStatus: 'pending', + outputValid: false, + }) as State + +export const clearInputState = (state: State): State => + ({ + ...resetOutput(state), + input: '', + inputType: 'text', + outputValid: true, + }) as State + +export const nextInputState = ( + state: State, + type: T.Crypto.InputTypes, + value: string +): State => { + const next = { + ...resetWarnings(state), + input: value, + inputType: type, + outputValid: state.input === value, + } + return (type === 'file' ? resetOutput(next) : next) as State +} + +export const nextOpenedFileState = (state: State, path: string): State => + ({ + ...resetOutput(state), + input: path, + inputType: 'file', + }) as State + +export const useCommittedState = (createInitialState: () => State) => { + const [state, setState] = React.useState(createInitialState) + const stateRef = React.useRef(state) + + const commitState = React.useCallback((next: State) => { + stateRef.current = next + setState(next) + return next + }, []) + + return {commitState, state, stateRef} +} + +export const maybeAutoRunTextOperation = ( + snapshot: State, + run: (destinationDir?: string, snapshot?: State) => Promise +) => { + if (snapshot.inputType !== 'text' || C.isMobile) return + C.ignorePromise(run('', snapshot)) +} + +export const useSeededCryptoInput = ( + params: CryptoInputRouteParams | undefined, + openFile: (path: string) => void, + setInput: (type: T.Crypto.InputTypes, value: string) => void +) => { + React.useEffect(() => { + if (!params?.seedInputPath) return + if ((params.seedInputType ?? 'file') === 'file') { + openFile(params.seedInputPath) + } else { + setInput('text', params.seedInputPath) + } + }, [openFile, params?.entryNonce, params?.seedInputPath, params?.seedInputType, setInput]) +} diff --git a/shared/crypto/operations/hooks.test.tsx b/shared/crypto/hooks.test.tsx similarity index 100% rename from shared/crypto/operations/hooks.test.tsx rename to shared/crypto/hooks.test.tsx diff --git a/shared/crypto/routes.tsx b/shared/crypto/routes.tsx index f940df370292..620ef3b216dd 100644 --- a/shared/crypto/routes.tsx +++ b/shared/crypto/routes.tsx @@ -18,56 +18,56 @@ type CryptoTeamBuilderRouteParams = Parameters[ } const DecryptInputScreen = React.lazy(async () => { - const {DecryptInput} = await import('./operations/decrypt') + const {DecryptInput} = await import('./decrypt') return { default: (_p: StaticScreenProps) => , } }) const EncryptInputScreen = React.lazy(async () => { - const {EncryptInput} = await import('./operations/encrypt') + const {EncryptInput} = await import('./encrypt') return { default: (_p: StaticScreenProps) => , } }) const SignInputScreen = React.lazy(async () => { - const {SignInput} = await import('./operations/sign') + const {SignInput} = await import('./sign') return { default: (_p: StaticScreenProps) => , } }) const VerifyInputScreen = React.lazy(async () => { - const {VerifyInput} = await import('./operations/verify') + const {VerifyInput} = await import('./verify') return { default: (_p: StaticScreenProps) => , } }) const DecryptOutputScreen = React.lazy(async () => { - const {DecryptOutput} = await import('./operations/decrypt') + const {DecryptOutput} = await import('./decrypt') return { default: (p: StaticScreenProps) => , } }) const EncryptOutputScreen = React.lazy(async () => { - const {EncryptOutput} = await import('./operations/encrypt') + const {EncryptOutput} = await import('./encrypt') return { default: (p: StaticScreenProps) => , } }) const SignOutputScreen = React.lazy(async () => { - const {SignOutput} = await import('./operations/sign') + const {SignOutput} = await import('./sign') return { default: (p: StaticScreenProps) => , } }) const VerifyOutputScreen = React.lazy(async () => { - const {VerifyOutput} = await import('./operations/verify') + const {VerifyOutput} = await import('./verify') return { default: (p: StaticScreenProps) => , } diff --git a/shared/crypto/operations/sign.tsx b/shared/crypto/sign.tsx similarity index 80% rename from shared/crypto/operations/sign.tsx rename to shared/crypto/sign.tsx index 5c9352bf6f01..5cb68952ab9a 100644 --- a/shared/crypto/operations/sign.tsx +++ b/shared/crypto/sign.tsx @@ -4,8 +4,19 @@ import * as React from 'react' import * as Kb from '@/common-adapters' import * as T from '@/constants/types' import {openURL} from '@/util/misc' -import {CryptoBanner, DragAndDrop, Input, InputActionsBar} from '../input' -import {CryptoOutput, CryptoOutputActionsBar, CryptoSignedSender, OutputInfoBanner} from '../output' +import {CryptoBanner, DragAndDrop, Input, InputActionsBar} from './input' +import {CryptoOutput, CryptoOutputActionsBar, CryptoSignedSender, OutputInfoBanner} from './output' +import { + beginRun, + clearInputState, + maybeAutoRunTextOperation, + nextInputState, + nextOpenedFileState, + resetOutput, + resetWarnings, + useCommittedState, + useSeededCryptoInput, +} from './helpers' import { createCommonState, getStatusCodeMessage, @@ -13,7 +24,7 @@ import { type CommonOutputRouteParams, type CryptoInputRouteParams, type CommonState, -} from '../state' +} from './state' import {RPCError} from '@/util/errors' import logger from '@/logger' import {useCurrentUserState} from '@/stores/current-user' @@ -26,34 +37,6 @@ const inputEmptyWidth = 207 const inputFileIcon = 'icon-file-64' as const const inputPlaceholder = C.isMobile ? 'Enter text to sign' : 'Enter text, drop a file or folder, or' -const resetWarnings = (state: CommonState): CommonState => ({ - ...state, - errorMessage: '', - warningMessage: '', -}) - -const resetOutput = (state: CommonState): CommonState => ({ - ...resetWarnings(state), - bytesComplete: 0, - bytesTotal: 0, - output: '', - outputSenderFullname: undefined, - outputSenderUsername: undefined, - outputSigned: false, - outputStatus: undefined, - outputType: undefined, - outputValid: false, -}) - -const beginRun = (state: CommonState): CommonState => ({ - ...resetWarnings(state), - bytesComplete: 0, - bytesTotal: 0, - inProgress: true, - outputStatus: 'pending', - outputValid: false, -}) - const onError = (state: CommonState, errorMessage: string): CommonState => ({ ...resetOutput(state), errorMessage, @@ -78,23 +61,10 @@ const onSuccess = ( }) export const useSignState = (params?: CryptoInputRouteParams) => { - const [state, setState] = React.useState(() => createCommonState(params)) - const stateRef = React.useRef(state) - - const commitState = React.useCallback((next: CommonState) => { - stateRef.current = next - setState(next) - return next - }, []) + const {commitState, state, stateRef} = useCommittedState(() => createCommonState(params)) const clearInput = React.useCallback(() => { - const next = { - ...resetOutput(stateRef.current), - input: '', - inputType: 'text', - outputValid: true, - } - commitState(next) + commitState(clearInputState(stateRef.current)) }, [commitState]) const sign = React.useCallback(async (destinationDir = '', snapshot = stateRef.current) => { @@ -133,21 +103,8 @@ export const useSignState = (params?: CryptoInputRouteParams) => { clearInput() return } - const current = stateRef.current - const outputValid = current.input === value - const next = { - ...resetWarnings(current), - input: value, - inputType: type, - outputValid, - } - const committed = commitState(type === 'file' ? resetOutput(next) : next) - if (type === 'text' && !C.isMobile) { - const f = async () => { - await sign('', committed) - } - C.ignorePromise(f()) - } + const committed = commitState(nextInputState(stateRef.current, type, value)) + maybeAutoRunTextOperation(committed, sign) }, [clearInput, commitState, sign] ) @@ -156,11 +113,7 @@ export const useSignState = (params?: CryptoInputRouteParams) => { if (!path) return const current = stateRef.current if (current.inProgress) return - commitState({ - ...resetOutput(current), - input: path, - inputType: 'file', - }) + commitState(nextOpenedFileState(current, path)) }, [commitState]) const saveOutputAsText = React.useCallback(async () => { @@ -174,14 +127,7 @@ export const useSignState = (params?: CryptoInputRouteParams) => { return commitState(next) }, [commitState]) - React.useEffect(() => { - if (!params?.seedInputPath) return - if ((params.seedInputType ?? 'file') === 'file') { - openFile(params.seedInputPath) - } else { - setInput('text', params.seedInputPath) - } - }, [openFile, params?.entryNonce, params?.seedInputPath, params?.seedInputType, setInput]) + useSeededCryptoInput(params, openFile, setInput) return {clearInput, openFile, saveOutputAsText, setInput, sign, state} } diff --git a/shared/crypto/sub-nav/index.desktop.tsx b/shared/crypto/sub-nav/index.desktop.tsx index 199aa3a3130f..035482702d50 100644 --- a/shared/crypto/sub-nav/index.desktop.tsx +++ b/shared/crypto/sub-nav/index.desktop.tsx @@ -18,26 +18,26 @@ import type {RouteDef, GetOptionsParams} from '@/constants/types/router' const cryptoSubRoutes = { [Crypto.decryptTab]: { screen: React.lazy(async () => { - const {DecryptIO} = await import('../operations/decrypt') + const {DecryptIO} = await import('../decrypt') return {default: DecryptIO} }), }, [Crypto.encryptTab]: { screen: React.lazy(async () => { - const {EncryptIO} = await import('../operations/encrypt') + const {EncryptIO} = await import('../encrypt') return {default: EncryptIO} }), }, [Crypto.signTab]: { screen: React.lazy(async () => { - const {SignIO} = await import('../operations/sign') + const {SignIO} = await import('../sign') return {default: SignIO} }), }, [Crypto.verifyTab]: { screen: React.lazy(async () => { - const {VerifyIO} = await import('../operations/verify') + const {VerifyIO} = await import('../verify') return {default: VerifyIO} }), }, diff --git a/shared/crypto/operations/verify.tsx b/shared/crypto/verify.tsx similarity index 78% rename from shared/crypto/operations/verify.tsx rename to shared/crypto/verify.tsx index f8faa381ca01..d9dc28cbd289 100644 --- a/shared/crypto/operations/verify.tsx +++ b/shared/crypto/verify.tsx @@ -3,8 +3,19 @@ import * as Crypto from '@/constants/crypto' import * as Kb from '@/common-adapters' import * as React from 'react' import * as T from '@/constants/types' -import {CryptoBanner, DragAndDrop, Input, InputActionsBar} from '../input' -import {CryptoOutput, CryptoOutputActionsBar, CryptoSignedSender} from '../output' +import {CryptoBanner, DragAndDrop, Input, InputActionsBar} from './input' +import {CryptoOutput, CryptoOutputActionsBar, CryptoSignedSender} from './output' +import { + beginRun, + clearInputState, + maybeAutoRunTextOperation, + nextInputState, + nextOpenedFileState, + resetOutput, + resetWarnings, + useCommittedState, + useSeededCryptoInput, +} from './helpers' import { createCommonState, getStatusCodeMessage, @@ -12,7 +23,7 @@ import { type CommonOutputRouteParams, type CryptoInputRouteParams, type CommonState, -} from '../state' +} from './state' import {RPCError} from '@/util/errors' import logger from '@/logger' import type {RootRouteProps} from '@/router-v2/route-params' @@ -26,34 +37,6 @@ const inputPlaceholder = C.isMobile ? 'Enter text to verify' : 'Enter a signed message, drop a signed file or folder, or' -const resetWarnings = (state: CommonState): CommonState => ({ - ...state, - errorMessage: '', - warningMessage: '', -}) - -const resetOutput = (state: CommonState): CommonState => ({ - ...resetWarnings(state), - bytesComplete: 0, - bytesTotal: 0, - output: '', - outputSenderFullname: undefined, - outputSenderUsername: undefined, - outputSigned: false, - outputStatus: undefined, - outputType: undefined, - outputValid: false, -}) - -const beginRun = (state: CommonState): CommonState => ({ - ...resetWarnings(state), - bytesComplete: 0, - bytesTotal: 0, - inProgress: true, - outputStatus: 'pending', - outputValid: false, -}) - const onError = (state: CommonState, errorMessage: string): CommonState => ({ ...resetOutput(state), errorMessage, @@ -81,23 +64,10 @@ const onSuccess = ( }) export const useVerifyState = (params?: CryptoInputRouteParams) => { - const [state, setState] = React.useState(() => createCommonState(params)) - const stateRef = React.useRef(state) - - const commitState = React.useCallback((next: CommonState) => { - stateRef.current = next - setState(next) - return next - }, []) + const {commitState, state, stateRef} = useCommittedState(() => createCommonState(params)) const clearInput = React.useCallback(() => { - const next = { - ...resetOutput(stateRef.current), - input: '', - inputType: 'text', - outputValid: true, - } - commitState(next) + commitState(clearInputState(stateRef.current)) }, [commitState]) const verify = React.useCallback(async (destinationDir = '', snapshot = stateRef.current) => { @@ -148,21 +118,8 @@ export const useVerifyState = (params?: CryptoInputRouteParams) => { clearInput() return } - const current = stateRef.current - const outputValid = current.input === value - const next = { - ...resetWarnings(current), - input: value, - inputType: type, - outputValid, - } - const committed = commitState(type === 'file' ? resetOutput(next) : next) - if (type === 'text' && !C.isMobile) { - const f = async () => { - await verify('', committed) - } - C.ignorePromise(f()) - } + const committed = commitState(nextInputState(stateRef.current, type, value)) + maybeAutoRunTextOperation(committed, verify) }, [clearInput, commitState, verify] ) @@ -171,21 +128,10 @@ export const useVerifyState = (params?: CryptoInputRouteParams) => { if (!path) return const current = stateRef.current if (current.inProgress) return - commitState({ - ...resetOutput(current), - input: path, - inputType: 'file', - }) + commitState(nextOpenedFileState(current, path)) }, [commitState]) - React.useEffect(() => { - if (!params?.seedInputPath) return - if ((params.seedInputType ?? 'file') === 'file') { - openFile(params.seedInputPath) - } else { - setInput('text', params.seedInputPath) - } - }, [openFile, params?.entryNonce, params?.seedInputPath, params?.seedInputType, setInput]) + useSeededCryptoInput(params, openFile, setInput) return {clearInput, openFile, setInput, state, verify} } From 1262ba34558d6c576566b0358cd0de1eb73586ee Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Thu, 26 Mar 2026 15:09:27 -0400 Subject: [PATCH 15/19] WIP --- shared/crypto/decrypt.tsx | 8 +-- shared/crypto/encrypt.tsx | 16 ++--- shared/crypto/helpers.ts | 5 +- shared/crypto/hooks.test.tsx | 112 ++++++++++++++++------------------- shared/crypto/sign.tsx | 10 ++-- shared/crypto/verify.tsx | 8 +-- 6 files changed, 75 insertions(+), 84 deletions(-) diff --git a/shared/crypto/decrypt.tsx b/shared/crypto/decrypt.tsx index e0fa0c93f74a..485643d20d4a 100644 --- a/shared/crypto/decrypt.tsx +++ b/shared/crypto/decrypt.tsx @@ -68,7 +68,7 @@ export const useDecryptState = (params?: CryptoInputRouteParams) => { const clearInput = React.useCallback(() => { commitState(clearInputState(stateRef.current)) - }, [commitState]) + }, [commitState, stateRef]) const decrypt = React.useCallback(async (destinationDir = '', snapshot = stateRef.current) => { commitState(beginRun(snapshot)) @@ -110,7 +110,7 @@ export const useDecryptState = (params?: CryptoInputRouteParams) => { const next = onError(stateRef.current, getStatusCodeMessage(_error, 'decrypt', snapshot.inputType)) return commitState(next) } - }, [commitState]) + }, [commitState, stateRef]) const setInput = React.useCallback( (type: T.Crypto.InputTypes, value: string) => { @@ -121,7 +121,7 @@ export const useDecryptState = (params?: CryptoInputRouteParams) => { const committed = commitState(nextInputState(stateRef.current, type, value)) maybeAutoRunTextOperation(committed, decrypt) }, - [clearInput, commitState, decrypt] + [clearInput, commitState, decrypt, stateRef] ) const openFile = React.useCallback((path: string) => { @@ -129,7 +129,7 @@ export const useDecryptState = (params?: CryptoInputRouteParams) => { const current = stateRef.current if (current.inProgress) return commitState(nextOpenedFileState(current, path)) - }, [commitState]) + }, [commitState, stateRef]) useSeededCryptoInput(params, openFile, setInput) diff --git a/shared/crypto/encrypt.tsx b/shared/crypto/encrypt.tsx index 8590181e9065..9f07f75bb9c7 100644 --- a/shared/crypto/encrypt.tsx +++ b/shared/crypto/encrypt.tsx @@ -167,11 +167,11 @@ export const useEncryptScreenState = (params?: EncryptRouteParams) => { const next = onError(stateRef.current, getStatusCodeMessage(_error, 'encrypt', snapshot.inputType)) return commitState(next) } - }, [commitState]) + }, [commitState, stateRef]) const clearInput = React.useCallback(() => { commitState(clearInputState(stateRef.current)) - }, [commitState]) + }, [commitState, stateRef]) const setInput = React.useCallback( (type: T.Crypto.InputTypes, value: string) => { @@ -182,7 +182,7 @@ export const useEncryptScreenState = (params?: EncryptRouteParams) => { const committed = commitState(nextInputState(stateRef.current, type, value)) maybeAutoRunTextOperation(committed, runEncrypt) }, - [clearInput, commitState, runEncrypt] + [clearInput, commitState, runEncrypt, stateRef] ) const openFile = React.useCallback((path: string) => { @@ -190,14 +190,14 @@ export const useEncryptScreenState = (params?: EncryptRouteParams) => { const current = stateRef.current if (current.inProgress) return commitState(nextOpenedFileState(current, path)) - }, [commitState]) + }, [commitState, stateRef]) const setRecipients = React.useCallback( (recipients: ReadonlyArray, hasSBS: boolean) => { const committed = commitState(nextRecipientState(stateRef.current, recipients, hasSBS)) maybeAutoRunTextOperation(committed, runEncrypt) }, - [commitState, runEncrypt] + [commitState, runEncrypt, stateRef] ) const clearRecipients = React.useCallback(() => { @@ -215,14 +215,14 @@ export const useEncryptScreenState = (params?: EncryptRouteParams) => { }, recipients: [], }) - }, [commitState]) + }, [commitState, stateRef]) const setEncryptOptions = React.useCallback( (options: {includeSelf?: boolean; sign?: boolean}, hideIncludeSelf?: boolean) => { const committed = commitState(nextOptionState(stateRef.current, options, hideIncludeSelf)) maybeAutoRunTextOperation(committed, runEncrypt) }, - [commitState, runEncrypt] + [commitState, runEncrypt, stateRef] ) const saveOutputAsText = React.useCallback(async () => { @@ -236,7 +236,7 @@ export const useEncryptScreenState = (params?: EncryptRouteParams) => { outputType: 'file' as const, } return commitState(next) - }, [commitState]) + }, [commitState, stateRef]) useSeededCryptoInput(params, openFile, setInput) diff --git a/shared/crypto/helpers.ts b/shared/crypto/helpers.ts index 323cc9537829..ffcb5ce6dc12 100644 --- a/shared/crypto/helpers.ts +++ b/shared/crypto/helpers.ts @@ -81,7 +81,10 @@ export const maybeAutoRunTextOperation = ( run: (destinationDir?: string, snapshot?: State) => Promise ) => { if (snapshot.inputType !== 'text' || C.isMobile) return - C.ignorePromise(run('', snapshot)) + const f = async () => { + await run('', snapshot) + } + C.ignorePromise(f()) } export const useSeededCryptoInput = ( diff --git a/shared/crypto/hooks.test.tsx b/shared/crypto/hooks.test.tsx index 971cd025b4e3..219e5ca49be0 100644 --- a/shared/crypto/hooks.test.tsx +++ b/shared/crypto/hooks.test.tsx @@ -2,8 +2,7 @@ /// import {afterEach, beforeEach, expect, jest, test} from '@jest/globals' -import * as React from 'react' -import {act, cleanup, render, waitFor} from '@testing-library/react' +import {act, cleanup, renderHook, waitFor} from '@testing-library/react' import * as T from '@/constants/types' import {useCurrentUserState} from '@/stores/current-user' import {useDecryptState} from './decrypt' @@ -20,23 +19,6 @@ type HookController = { setInput: (type: T.Crypto.InputTypes, value: string) => void } -const mountHook = (useHook: () => Controller) => { - let latest: Controller | undefined - - const Probe = () => { - latest = useHook() - return null - } - - render() - return () => { - if (!latest) { - throw new Error('Hook controller did not mount') - } - return latest - } -} - beforeEach(() => { useCurrentUserState.setState({username: 'alice'} as never) }) @@ -50,15 +32,17 @@ afterEach(() => { test('encrypt auto-run uses the latest text snapshot and keeps output valid', async () => { const encryptSpy = jest .spyOn(T.RPCGen, 'saltpackSaltpackEncryptStringRpcPromise') - .mockImplementation(async ({plaintext}) => ({ - ciphertext: `cipher:${plaintext}`, - unresolvedSBSAssertion: '', - usedUnresolvedSBS: false, - }) as never) - - const getController = mountHook(() => useEncryptScreenState()) - await act(async () => { - getController().setInput('text', 'secret message') + .mockImplementation(({plaintext}) => + Promise.resolve({ + ciphertext: `cipher:${plaintext}`, + unresolvedSBSAssertion: '', + usedUnresolvedSBS: false, + } as never) + ) + + const {result} = renderHook((): HookController => useEncryptScreenState()) + act(() => { + result.current.setInput('text', 'secret message') }) await waitFor(() => @@ -67,24 +51,26 @@ test('encrypt auto-run uses the latest text snapshot and keeps output valid', as expect.anything() ) ) - await waitFor(() => expect(getController().state.output).toBe('cipher:secret message')) + await waitFor(() => expect(result.current.state.output).toBe('cipher:secret message')) - expect(getController().state.outputValid).toBe(true) - expect(getController().state.errorMessage).toBe('') + expect(result.current.state.outputValid).toBe(true) + expect(result.current.state.errorMessage).toBe('') }) test('decrypt auto-run uses the pasted ciphertext instead of the previous input', async () => { const decryptSpy = jest .spyOn(T.RPCGen, 'saltpackSaltpackDecryptStringRpcPromise') - .mockImplementation(async ({ciphertext}) => ({ - info: {sender: {fullname: 'Bob', username: 'bob'}}, - plaintext: `plain:${ciphertext}`, - signed: true, - }) as never) - - const getController = mountHook(() => useDecryptState()) - await act(async () => { - getController().setInput('text', 'encrypted payload') + .mockImplementation(({ciphertext}) => + Promise.resolve({ + info: {sender: {fullname: 'Bob', username: 'bob'}}, + plaintext: `plain:${ciphertext}`, + signed: true, + } as never) + ) + + const {result} = renderHook((): HookController => useDecryptState()) + act(() => { + result.current.setInput('text', 'encrypted payload') }) await waitFor(() => @@ -93,20 +79,20 @@ test('decrypt auto-run uses the pasted ciphertext instead of the previous input' expect.anything() ) ) - await waitFor(() => expect(getController().state.output).toBe('plain:encrypted payload')) + await waitFor(() => expect(result.current.state.output).toBe('plain:encrypted payload')) - expect(getController().state.outputValid).toBe(true) - expect(getController().state.errorMessage).toBe('') + expect(result.current.state.outputValid).toBe(true) + expect(result.current.state.errorMessage).toBe('') }) test('sign auto-run uses the latest text snapshot and keeps output valid', async () => { const signSpy = jest .spyOn(T.RPCGen, 'saltpackSaltpackSignStringRpcPromise') - .mockImplementation(async ({plaintext}) => `signed:${plaintext}` as never) + .mockImplementation(({plaintext}) => Promise.resolve(`signed:${plaintext}` as never)) - const getController = mountHook(() => useSignState()) - await act(async () => { - getController().setInput('text', 'message to sign') + const {result} = renderHook((): HookController => useSignState()) + act(() => { + result.current.setInput('text', 'message to sign') }) await waitFor(() => @@ -115,24 +101,26 @@ test('sign auto-run uses the latest text snapshot and keeps output valid', async expect.anything() ) ) - await waitFor(() => expect(getController().state.output).toBe('signed:message to sign')) + await waitFor(() => expect(result.current.state.output).toBe('signed:message to sign')) - expect(getController().state.outputValid).toBe(true) - expect(getController().state.errorMessage).toBe('') + expect(result.current.state.outputValid).toBe(true) + expect(result.current.state.errorMessage).toBe('') }) test('verify auto-run uses the latest text snapshot and keeps output valid', async () => { const verifySpy = jest .spyOn(T.RPCGen, 'saltpackSaltpackVerifyStringRpcPromise') - .mockImplementation(async ({signedMsg}) => ({ - plaintext: `verified:${signedMsg}`, - sender: {fullname: 'Bob', username: 'bob'}, - verified: true, - }) as never) - - const getController = mountHook(() => useVerifyState()) - await act(async () => { - getController().setInput('text', 'signed payload') + .mockImplementation(({signedMsg}) => + Promise.resolve({ + plaintext: `verified:${signedMsg}`, + sender: {fullname: 'Bob', username: 'bob'}, + verified: true, + } as never) + ) + + const {result} = renderHook((): HookController => useVerifyState()) + act(() => { + result.current.setInput('text', 'signed payload') }) await waitFor(() => @@ -141,8 +129,8 @@ test('verify auto-run uses the latest text snapshot and keeps output valid', asy expect.anything() ) ) - await waitFor(() => expect(getController().state.output).toBe('verified:signed payload')) + await waitFor(() => expect(result.current.state.output).toBe('verified:signed payload')) - expect(getController().state.outputValid).toBe(true) - expect(getController().state.errorMessage).toBe('') + expect(result.current.state.outputValid).toBe(true) + expect(result.current.state.errorMessage).toBe('') }) diff --git a/shared/crypto/sign.tsx b/shared/crypto/sign.tsx index 5cb68952ab9a..57b2df0faf9c 100644 --- a/shared/crypto/sign.tsx +++ b/shared/crypto/sign.tsx @@ -65,7 +65,7 @@ export const useSignState = (params?: CryptoInputRouteParams) => { const clearInput = React.useCallback(() => { commitState(clearInputState(stateRef.current)) - }, [commitState]) + }, [commitState, stateRef]) const sign = React.useCallback(async (destinationDir = '', snapshot = stateRef.current) => { commitState(beginRun(snapshot)) @@ -95,7 +95,7 @@ export const useSignState = (params?: CryptoInputRouteParams) => { const next = onError(stateRef.current, getStatusCodeMessage(_error, 'sign', snapshot.inputType)) return commitState(next) } - }, [commitState]) + }, [commitState, stateRef]) const setInput = React.useCallback( (type: T.Crypto.InputTypes, value: string) => { @@ -106,7 +106,7 @@ export const useSignState = (params?: CryptoInputRouteParams) => { const committed = commitState(nextInputState(stateRef.current, type, value)) maybeAutoRunTextOperation(committed, sign) }, - [clearInput, commitState, sign] + [clearInput, commitState, sign, stateRef] ) const openFile = React.useCallback((path: string) => { @@ -114,7 +114,7 @@ export const useSignState = (params?: CryptoInputRouteParams) => { const current = stateRef.current if (current.inProgress) return commitState(nextOpenedFileState(current, path)) - }, [commitState]) + }, [commitState, stateRef]) const saveOutputAsText = React.useCallback(async () => { const output = await T.RPCGen.saltpackSaltpackSaveSignedMsgToFileRpcPromise({signedMsg: stateRef.current.output}) @@ -125,7 +125,7 @@ export const useSignState = (params?: CryptoInputRouteParams) => { outputType: 'file' as const, } return commitState(next) - }, [commitState]) + }, [commitState, stateRef]) useSeededCryptoInput(params, openFile, setInput) diff --git a/shared/crypto/verify.tsx b/shared/crypto/verify.tsx index d9dc28cbd289..885e49f8794d 100644 --- a/shared/crypto/verify.tsx +++ b/shared/crypto/verify.tsx @@ -68,7 +68,7 @@ export const useVerifyState = (params?: CryptoInputRouteParams) => { const clearInput = React.useCallback(() => { commitState(clearInputState(stateRef.current)) - }, [commitState]) + }, [commitState, stateRef]) const verify = React.useCallback(async (destinationDir = '', snapshot = stateRef.current) => { commitState(beginRun(snapshot)) @@ -110,7 +110,7 @@ export const useVerifyState = (params?: CryptoInputRouteParams) => { const next = onError(stateRef.current, getStatusCodeMessage(_error, 'verify', snapshot.inputType)) return commitState(next) } - }, [commitState]) + }, [commitState, stateRef]) const setInput = React.useCallback( (type: T.Crypto.InputTypes, value: string) => { @@ -121,7 +121,7 @@ export const useVerifyState = (params?: CryptoInputRouteParams) => { const committed = commitState(nextInputState(stateRef.current, type, value)) maybeAutoRunTextOperation(committed, verify) }, - [clearInput, commitState, verify] + [clearInput, commitState, verify, stateRef] ) const openFile = React.useCallback((path: string) => { @@ -129,7 +129,7 @@ export const useVerifyState = (params?: CryptoInputRouteParams) => { const current = stateRef.current if (current.inProgress) return commitState(nextOpenedFileState(current, path)) - }, [commitState]) + }, [commitState, stateRef]) useSeededCryptoInput(params, openFile, setInput) From 7e4e36ed08a6f58e5d55733f49e512a7d2ee681d Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 26 Mar 2026 15:13:04 -0400 Subject: [PATCH 16/19] WIP --- shared/crypto/hooks.test.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/shared/crypto/hooks.test.tsx b/shared/crypto/hooks.test.tsx index 219e5ca49be0..e20753a06336 100644 --- a/shared/crypto/hooks.test.tsx +++ b/shared/crypto/hooks.test.tsx @@ -32,7 +32,7 @@ afterEach(() => { test('encrypt auto-run uses the latest text snapshot and keeps output valid', async () => { const encryptSpy = jest .spyOn(T.RPCGen, 'saltpackSaltpackEncryptStringRpcPromise') - .mockImplementation(({plaintext}) => + .mockImplementation(async ({plaintext}) => Promise.resolve({ ciphertext: `cipher:${plaintext}`, unresolvedSBSAssertion: '', @@ -60,7 +60,7 @@ test('encrypt auto-run uses the latest text snapshot and keeps output valid', as test('decrypt auto-run uses the pasted ciphertext instead of the previous input', async () => { const decryptSpy = jest .spyOn(T.RPCGen, 'saltpackSaltpackDecryptStringRpcPromise') - .mockImplementation(({ciphertext}) => + .mockImplementation(async ({ciphertext}) => Promise.resolve({ info: {sender: {fullname: 'Bob', username: 'bob'}}, plaintext: `plain:${ciphertext}`, @@ -88,7 +88,7 @@ test('decrypt auto-run uses the pasted ciphertext instead of the previous input' test('sign auto-run uses the latest text snapshot and keeps output valid', async () => { const signSpy = jest .spyOn(T.RPCGen, 'saltpackSaltpackSignStringRpcPromise') - .mockImplementation(({plaintext}) => Promise.resolve(`signed:${plaintext}` as never)) + .mockImplementation(async ({plaintext}) => Promise.resolve(`signed:${plaintext}` as never)) const {result} = renderHook((): HookController => useSignState()) act(() => { @@ -110,7 +110,7 @@ test('sign auto-run uses the latest text snapshot and keeps output valid', async test('verify auto-run uses the latest text snapshot and keeps output valid', async () => { const verifySpy = jest .spyOn(T.RPCGen, 'saltpackSaltpackVerifyStringRpcPromise') - .mockImplementation(({signedMsg}) => + .mockImplementation(async ({signedMsg}) => Promise.resolve({ plaintext: `verified:${signedMsg}`, sender: {fullname: 'Bob', username: 'bob'}, From 4411dec73b36a43fa3f3f3860a3a86389f09e58e Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Thu, 26 Mar 2026 16:24:17 -0400 Subject: [PATCH 17/19] WIP --- shared/crypto/decrypt.tsx | 5 +- shared/crypto/encrypt.tsx | 92 ++++++++++++--- shared/crypto/helpers.ts | 101 ++++++++++++++++- shared/crypto/input.tsx | 2 +- shared/crypto/output.tsx | 2 +- shared/crypto/routes.tsx | 6 +- shared/crypto/sign.tsx | 5 +- shared/crypto/state.tsx | 175 ----------------------------- shared/crypto/verify.tsx | 5 +- shared/stores/tests/crypto.test.ts | 6 +- 10 files changed, 191 insertions(+), 208 deletions(-) delete mode 100644 shared/crypto/state.tsx diff --git a/shared/crypto/decrypt.tsx b/shared/crypto/decrypt.tsx index 485643d20d4a..a9b04804c67a 100644 --- a/shared/crypto/decrypt.tsx +++ b/shared/crypto/decrypt.tsx @@ -19,11 +19,10 @@ import { import { createCommonState, getStatusCodeMessage, - outputParamsToCommonState, type CommonOutputRouteParams, type CryptoInputRouteParams, type CommonState, -} from './state' +} from './helpers' import {RPCError} from '@/util/errors' import logger from '@/logger' import type {RootRouteProps} from '@/router-v2/route-params' @@ -180,7 +179,7 @@ export const DecryptInput = (_props: unknown) => { } export const DecryptOutput = ({route}: {route: {params: CommonOutputRouteParams}}) => { - const state = outputParamsToCommonState(route.params) + const state = route.params const content = ( <> {C.isMobile && state.errorMessage ? : null} diff --git a/shared/crypto/encrypt.tsx b/shared/crypto/encrypt.tsx index 9f07f75bb9c7..2c1672ef5f8e 100644 --- a/shared/crypto/encrypt.tsx +++ b/shared/crypto/encrypt.tsx @@ -8,8 +8,12 @@ import {openURL} from '@/util/misc' import {CryptoBanner, DragAndDrop, Input, InputActionsBar} from './input' import {CryptoOutput, CryptoOutputActionsBar, CryptoSignedSender, OutputInfoBanner} from './output' import { + CommonOutputRouteParams, + CryptoInputRouteParams, beginRun, clearInputState, + createCommonState, + getStatusCodeMessage, maybeAutoRunTextOperation, nextInputState, nextOpenedFileState, @@ -18,16 +22,6 @@ import { useCommittedState, useSeededCryptoInput, } from './helpers' -import { - createEncryptState, - encryptToOutputParams, - getStatusCodeMessage, - outputParamsToCommonState, - teamBuilderResultToRecipients, - type EncryptOutputRouteParams, - type EncryptRouteParams, - type EncryptState, -} from './state' import {RPCError} from '@/util/errors' import logger from '@/logger' import {useCurrentUserState} from '@/stores/current-user' @@ -43,6 +37,78 @@ const inputPlaceholder = C.isMobile ? 'Enter text to encrypt' : 'Enter text, dro const getWarningMessageForSBS = (sbsAssertion: string) => `Note: Encrypted for "${sbsAssertion}" who is not yet a Keybase user. One of your devices will need to be online after they join Keybase in order for them to decrypt the message.` +export type EncryptOptions = { + includeSelf: boolean + sign: boolean +} + +export type EncryptMeta = { + hasRecipients: boolean + hasSBS: boolean + hideIncludeSelf: boolean +} + +export type EncryptState = CommonOutputRouteParams & { + meta: EncryptMeta + options: EncryptOptions + recipients: Array +} + +export type CryptoTeamBuilderResult = Array<{ + serviceId: T.TB.ServiceIdWithContact + username: string +}> + +export type EncryptRouteParams = CryptoInputRouteParams & { + teamBuilderNonce?: string + teamBuilderUsers?: CryptoTeamBuilderResult +} + +export type EncryptOutputRouteParams = CommonOutputRouteParams & { + hasRecipients: boolean + includeSelf: boolean + recipients: Array +} + +export const createEncryptState = (params?: EncryptRouteParams): EncryptState => ({ + ...createCommonState(params), + meta: { + hasRecipients: false, + hasSBS: false, + hideIncludeSelf: false, + }, + options: { + includeSelf: true, + sign: true, + }, + recipients: [], +}) + +export const encryptToOutputParams = (state: EncryptState): EncryptOutputRouteParams => ({ + ...state, + hasRecipients: state.meta.hasRecipients, + includeSelf: state.options.includeSelf, + recipients: state.recipients, +}) + +export const teamBuilderResultToRecipients = ( + users: ReadonlyArray<{serviceId: T.TB.ServiceIdWithContact; username: string}> +) => { + let hasSBS = false + const recipients = users.map(user => { + if (user.serviceId === 'email') { + hasSBS = true + return `[${user.username}]@email` + } + if (user.serviceId !== 'keybase') { + hasSBS = true + return `${user.username}@${user.serviceId}` + } + return user.username + }) + return {hasSBS, recipients} +} + const onError = (state: EncryptState, errorMessage: string): EncryptState => ({ ...resetOutput(state), errorMessage, @@ -471,19 +537,19 @@ const EncryptOutputBody = ({params}: {params: EncryptOutputRouteParams}) => ( outputType={params.outputType} recipients={params.recipients} /> - + {C.isMobile ? : null} undefined} /> ) diff --git a/shared/crypto/helpers.ts b/shared/crypto/helpers.ts index ffcb5ce6dc12..ea4ab5192497 100644 --- a/shared/crypto/helpers.ts +++ b/shared/crypto/helpers.ts @@ -1,7 +1,106 @@ import * as C from '@/constants' +import * as RPCGen from '@/constants/rpc/rpc-gen' import * as React from 'react' import type * as T from '@/constants/types' -import type {CommonState, CryptoInputRouteParams} from './state' +import type {RPCError} from '@/util/errors' + +export type OutputStatus = 'success' | 'pending' + +export type CommonState = { + bytesComplete: number + bytesTotal: number + errorMessage: string + inProgress: boolean + input: string + inputType: 'text' | 'file' + output: string + outputSenderFullname?: string + outputSenderUsername?: string + outputSigned: boolean + outputStatus?: OutputStatus + outputType?: 'text' | 'file' + outputValid: boolean + warningMessage: string +} + +export type CryptoInputRouteParams = { + entryNonce?: string + seedInputPath?: string + seedInputType?: 'text' | 'file' +} + +export type CommonOutputRouteParams = CommonState + +type CryptoKind = 'decrypt' | 'encrypt' | 'sign' | 'verify' + +export const getStatusCodeMessage = ( + error: RPCError, + kind: CryptoKind, + type: T.Crypto.InputTypes +): string => { + const inputType = type === 'text' ? (kind === 'verify' ? 'signed message' : 'ciphertext') : 'file' + const action = type === 'text' ? (kind === 'verify' ? 'enter a' : 'enter') : 'drop a' + const addInput = type === 'text' ? (kind === 'verify' ? 'signed message' : 'ciphertext') : 'encrypted file' + + const offlineMessage = 'You are offline.' + const genericMessage = `Failed to ${kind} ${type}.` + + let wrongTypeHelpText = '' + if (kind === 'verify') { + wrongTypeHelpText = ' Did you mean to decrypt it?' + } else if (kind === 'decrypt') { + wrongTypeHelpText = ' Did you mean to verify it?' + } + + const fields = error.fields as Array<{key: string; value: RPCGen.StatusCode}> | undefined + const field = fields?.[1] + const causeStatusCode = field?.key === 'Code' ? field.value : RPCGen.StatusCode.scgeneric + const causeStatusCodeToMessage = new Map([ + [RPCGen.StatusCode.scapinetworkerror, offlineMessage], + [ + RPCGen.StatusCode.scdecryptionkeynotfound, + "This message was encrypted for someone else or for a key you don't have.", + ], + [ + RPCGen.StatusCode.scverificationkeynotfound, + "This message couldn't be verified, because the signing key wasn't recognized.", + ], + [RPCGen.StatusCode.scwrongcryptomsgtype, `This Saltpack format is unexpected.${wrongTypeHelpText}`], + ]) + + const statusCodeToMessage = new Map([ + [RPCGen.StatusCode.scapinetworkerror, offlineMessage], + [ + RPCGen.StatusCode.scgeneric, + error.message.includes('API network error') ? offlineMessage : genericMessage, + ], + [ + RPCGen.StatusCode.scstreamunknown, + `This ${inputType} is not in a valid Saltpack format. Please ${action} Saltpack ${addInput}.`, + ], + [RPCGen.StatusCode.scsigcannotverify, causeStatusCodeToMessage.get(causeStatusCode) || genericMessage], + [RPCGen.StatusCode.scdecryptionerror, causeStatusCodeToMessage.get(causeStatusCode) || genericMessage], + ]) + + return statusCodeToMessage.get(error.code) ?? genericMessage +} + +export const createCommonState = (params?: CryptoInputRouteParams): CommonState => ({ + bytesComplete: 0, + bytesTotal: 0, + errorMessage: '', + inProgress: false, + input: params?.seedInputPath ?? '', + inputType: params?.seedInputType ?? 'text', + output: '', + outputSenderFullname: undefined, + outputSenderUsername: undefined, + outputSigned: false, + outputStatus: undefined, + outputType: undefined, + outputValid: false, + warningMessage: '', +}) export const resetWarnings = (state: State): State => ({ diff --git a/shared/crypto/input.tsx b/shared/crypto/input.tsx index 439655dcfabe..1f11ee4002fa 100644 --- a/shared/crypto/input.tsx +++ b/shared/crypto/input.tsx @@ -1,7 +1,7 @@ import * as C from '@/constants' import * as React from 'react' import type * as T from '@/constants/types' -import type {CommonState} from './state' +import type {CommonState} from './helpers' import * as Kb from '@/common-adapters' import * as FS from '@/constants/fs' import type {IconType} from '@/common-adapters/icon.constants-gen' diff --git a/shared/crypto/output.tsx b/shared/crypto/output.tsx index 0b9d683fe84b..7402b74fdaaf 100644 --- a/shared/crypto/output.tsx +++ b/shared/crypto/output.tsx @@ -4,7 +4,7 @@ import * as Kb from '@/common-adapters' import * as Path from '@/util/path' import * as React from 'react' import type {IconType} from '@/common-adapters/icon.constants-gen' -import type {CommonState} from './state' +import type {CommonState} from './helpers' import {pickFiles} from '@/util/misc' import {useFSState} from '@/stores/fs' import * as FS from '@/constants/fs' diff --git a/shared/crypto/routes.tsx b/shared/crypto/routes.tsx index 620ef3b216dd..ee7517e072e2 100644 --- a/shared/crypto/routes.tsx +++ b/shared/crypto/routes.tsx @@ -7,10 +7,8 @@ import type {StaticScreenProps} from '@react-navigation/core' import type { CommonOutputRouteParams, CryptoInputRouteParams, - CryptoTeamBuilderResult, - EncryptOutputRouteParams, - EncryptRouteParams, -} from './state' +} from './helpers' +import type {CryptoTeamBuilderResult, EncryptOutputRouteParams, EncryptRouteParams} from './encrypt' type CryptoTeamBuilderRouteParams = Parameters[0]['route']['params'] & { teamBuilderNonce?: string diff --git a/shared/crypto/sign.tsx b/shared/crypto/sign.tsx index 57b2df0faf9c..730cbcd9b4dd 100644 --- a/shared/crypto/sign.tsx +++ b/shared/crypto/sign.tsx @@ -20,11 +20,10 @@ import { import { createCommonState, getStatusCodeMessage, - outputParamsToCommonState, type CommonOutputRouteParams, type CryptoInputRouteParams, type CommonState, -} from './state' +} from './helpers' import {RPCError} from '@/util/errors' import logger from '@/logger' import {useCurrentUserState} from '@/stores/current-user' @@ -191,7 +190,7 @@ export const SignInput = (_props: unknown) => { } export const SignOutput = ({route}: {route: {params: CommonOutputRouteParams}}) => { - const state = outputParamsToCommonState(route.params) + const state = route.params const content = ( <> diff --git a/shared/crypto/state.tsx b/shared/crypto/state.tsx deleted file mode 100644 index 4487027e7a5b..000000000000 --- a/shared/crypto/state.tsx +++ /dev/null @@ -1,175 +0,0 @@ -import type * as T from '@/constants/types' -import * as RPCGen from '@/constants/rpc/rpc-gen' -import type {RPCError} from '@/util/errors' - -export type OutputStatus = 'success' | 'pending' - -export type CommonState = { - bytesComplete: number - bytesTotal: number - errorMessage: string - inProgress: boolean - input: string - inputType: 'text' | 'file' - output: string - outputSenderFullname?: string - outputSenderUsername?: string - outputSigned: boolean - outputStatus?: OutputStatus - outputType?: 'text' | 'file' - outputValid: boolean - warningMessage: string -} - -export type EncryptOptions = { - includeSelf: boolean - sign: boolean -} - -export type EncryptMeta = { - hasRecipients: boolean - hasSBS: boolean - hideIncludeSelf: boolean -} - -export type EncryptState = CommonState & { - meta: EncryptMeta - options: EncryptOptions - recipients: Array -} - -export type CryptoInputRouteParams = { - entryNonce?: string - seedInputPath?: string - seedInputType?: 'text' | 'file' -} - -export type CryptoTeamBuilderResult = Array<{ - serviceId: T.TB.ServiceIdWithContact - username: string -}> - -export type EncryptRouteParams = CryptoInputRouteParams & { - teamBuilderNonce?: string - teamBuilderUsers?: CryptoTeamBuilderResult -} - -export type CommonOutputRouteParams = CommonState - -export type EncryptOutputRouteParams = CommonOutputRouteParams & { - hasRecipients: boolean - includeSelf: boolean - recipients: Array -} - -type CryptoKind = 'decrypt' | 'encrypt' | 'sign' | 'verify' - -export const getStatusCodeMessage = ( - error: RPCError, - kind: CryptoKind, - type: T.Crypto.InputTypes -): string => { - const inputType = type === 'text' ? (kind === 'verify' ? 'signed message' : 'ciphertext') : 'file' - const action = type === 'text' ? (kind === 'verify' ? 'enter a' : 'enter') : 'drop a' - const addInput = type === 'text' ? (kind === 'verify' ? 'signed message' : 'ciphertext') : 'encrypted file' - - const offlineMessage = 'You are offline.' - const genericMessage = `Failed to ${kind} ${type}.` - - let wrongTypeHelpText = '' - if (kind === 'verify') { - wrongTypeHelpText = ' Did you mean to decrypt it?' - } else if (kind === 'decrypt') { - wrongTypeHelpText = ' Did you mean to verify it?' - } - - const fields = error.fields as Array<{key: string; value: RPCGen.StatusCode}> | undefined - const field = fields?.[1] - const causeStatusCode = field?.key === 'Code' ? field.value : RPCGen.StatusCode.scgeneric - const causeStatusCodeToMessage = new Map([ - [RPCGen.StatusCode.scapinetworkerror, offlineMessage], - [ - RPCGen.StatusCode.scdecryptionkeynotfound, - "This message was encrypted for someone else or for a key you don't have.", - ], - [ - RPCGen.StatusCode.scverificationkeynotfound, - "This message couldn't be verified, because the signing key wasn't recognized.", - ], - [RPCGen.StatusCode.scwrongcryptomsgtype, `This Saltpack format is unexpected.${wrongTypeHelpText}`], - ]) - - const statusCodeToMessage = new Map([ - [RPCGen.StatusCode.scapinetworkerror, offlineMessage], - [ - RPCGen.StatusCode.scgeneric, - error.message.includes('API network error') ? offlineMessage : genericMessage, - ], - [ - RPCGen.StatusCode.scstreamunknown, - `This ${inputType} is not in a valid Saltpack format. Please ${action} Saltpack ${addInput}.`, - ], - [RPCGen.StatusCode.scsigcannotverify, causeStatusCodeToMessage.get(causeStatusCode) || genericMessage], - [RPCGen.StatusCode.scdecryptionerror, causeStatusCodeToMessage.get(causeStatusCode) || genericMessage], - ]) - - return statusCodeToMessage.get(error.code) ?? genericMessage -} - -export const createCommonState = (params?: CryptoInputRouteParams): CommonState => ({ - bytesComplete: 0, - bytesTotal: 0, - errorMessage: '', - inProgress: false, - input: params?.seedInputPath ?? '', - inputType: params?.seedInputType ?? 'text', - output: '', - outputSenderFullname: undefined, - outputSenderUsername: undefined, - outputSigned: false, - outputStatus: undefined, - outputType: undefined, - outputValid: false, - warningMessage: '', -}) - -export const createEncryptState = (params?: EncryptRouteParams): EncryptState => ({ - ...createCommonState(params), - meta: { - hasRecipients: false, - hasSBS: false, - hideIncludeSelf: false, - }, - options: { - includeSelf: true, - sign: true, - }, - recipients: [], -}) - -export const encryptToOutputParams = (state: EncryptState): EncryptOutputRouteParams => ({ - ...state, - hasRecipients: state.meta.hasRecipients, - includeSelf: state.options.includeSelf, - recipients: state.recipients, -}) - -export const outputParamsToCommonState = (params: CommonOutputRouteParams): CommonState => ({...params}) - -export const teamBuilderResultToRecipients = ( - users: ReadonlyArray<{serviceId: T.TB.ServiceIdWithContact; username: string}> -) => { - let hasSBS = false - const recipients = users.map(user => { - if (user.serviceId === 'email') { - hasSBS = true - return `[${user.username}]@email` - } - if (user.serviceId !== 'keybase') { - hasSBS = true - return `${user.username}@${user.serviceId}` - } - return user.username - }) - return {hasSBS, recipients} -} diff --git a/shared/crypto/verify.tsx b/shared/crypto/verify.tsx index 885e49f8794d..1dd1c27cba15 100644 --- a/shared/crypto/verify.tsx +++ b/shared/crypto/verify.tsx @@ -19,11 +19,10 @@ import { import { createCommonState, getStatusCodeMessage, - outputParamsToCommonState, type CommonOutputRouteParams, type CryptoInputRouteParams, type CommonState, -} from './state' +} from './helpers' import {RPCError} from '@/util/errors' import logger from '@/logger' import type {RootRouteProps} from '@/router-v2/route-params' @@ -178,7 +177,7 @@ export const VerifyInput = (_props: unknown) => { } export const VerifyOutput = ({route}: {route: {params: CommonOutputRouteParams}}) => { - const state = outputParamsToCommonState(route.params) + const state = route.params const content = ( <> {C.isMobile && state.errorMessage ? : null} diff --git a/shared/stores/tests/crypto.test.ts b/shared/stores/tests/crypto.test.ts index 8fdcacaa809e..ee7291d85ae6 100644 --- a/shared/stores/tests/crypto.test.ts +++ b/shared/stores/tests/crypto.test.ts @@ -1,13 +1,11 @@ /// import * as T from '@/constants/types' import RPCError from '@/util/rpcerror' +import {createEncryptState, encryptToOutputParams, teamBuilderResultToRecipients} from '@/crypto/encrypt' import { createCommonState, - createEncryptState, - encryptToOutputParams, getStatusCodeMessage, - teamBuilderResultToRecipients, -} from '@/crypto/state' +} from '@/crypto/helpers' test('createCommonState seeds route-provided input', () => { expect( From b1f0bb24951c6b3c427b5e94f574b1f3bebc0725 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Thu, 26 Mar 2026 16:37:06 -0400 Subject: [PATCH 18/19] WIP --- shared/constants/init/shared.tsx | 6 +- shared/settings/logout.tsx | 4 +- shared/settings/password.tsx | 4 +- shared/settings/use-request-logout.tsx | 35 +++++++ shared/stores/logout.tsx | 25 ----- shared/stores/tests/logout.test.ts | 20 +--- shared/stores/tests/unlock-folders.test.ts | 53 +++++----- shared/stores/unlock-folders.tsx | 99 +++++-------------- shared/unlock-folders/index.desktop.tsx | 5 +- .../references/store-checklist.md | 6 +- 10 files changed, 110 insertions(+), 147 deletions(-) create mode 100644 shared/settings/use-request-logout.tsx diff --git a/shared/constants/init/shared.tsx b/shared/constants/init/shared.tsx index f70246104d71..ca1506ba9728 100644 --- a/shared/constants/init/shared.tsx +++ b/shared/constants/init/shared.tsx @@ -38,7 +38,7 @@ import type * as UseSettingsPasswordStateType from '@/stores/settings-password' import type * as UseSignupStateType from '@/stores/signup' import type * as UseTeamsStateType from '@/stores/teams' import type * as UseTracker2StateType from '@/stores/tracker' -import type * as UseUnlockFoldersStateType from '@/stores/unlock-folders' +import type * as UnlockFoldersType from '@/stores/unlock-folders' import type * as UseUsersStateType from '@/stores/users' import {createTBStore, getTBStore} from '@/stores/team-building' import {getSelectedConversation} from '@/constants/chat/common' @@ -948,8 +948,8 @@ export const _onEngineIncoming = (action: EngineGen.Actions) => { case 'keybase.1.rekeyUI.refresh': case 'keybase.1.rekeyUI.delegateRekeyUI': { - const {useUnlockFoldersState} = require('@/stores/unlock-folders') as typeof UseUnlockFoldersStateType - useUnlockFoldersState.getState().dispatch.onEngineIncomingImpl(action) + const {onUnlockFoldersEngineIncoming} = require('@/stores/unlock-folders') as typeof UnlockFoldersType + onUnlockFoldersEngineIncoming(action) } break default: diff --git a/shared/settings/logout.tsx b/shared/settings/logout.tsx index 94a5deaa5e9d..6675e93226cc 100644 --- a/shared/settings/logout.tsx +++ b/shared/settings/logout.tsx @@ -4,9 +4,9 @@ import * as C from '@/constants' import * as T from '@/constants/types' import * as Kb from '@/common-adapters' import {UpdatePassword, useSubmitNewPassword} from './password' +import {useRequestLogout} from './use-request-logout' import {usePWState} from '@/stores/settings-password' import {useSettingsState} from '@/stores/settings' -import {useLogoutState} from '@/stores/logout' const LogoutContainer = () => { const {checkPassword, checkPasswordIsCorrect, resetCheckPassword} = useSettingsState( @@ -25,7 +25,7 @@ const LogoutContainer = () => { const {error, onSave, waitingForResponse} = useSubmitNewPassword(true) const [hasPGPKeyOnServer, setHasPGPKeyOnServer] = React.useState(undefined) const loadPgpSettings = C.useRPC(T.RPCGen.accountHasServerKeysRpcPromise) - const requestLogout = useLogoutState(s => s.dispatch.requestLogout) + const requestLogout = useRequestLogout() const onBootstrap = loadHasRandomPw const onCheckPassword = checkPassword diff --git a/shared/settings/password.tsx b/shared/settings/password.tsx index 833989a46a1f..1b31aee08dd1 100644 --- a/shared/settings/password.tsx +++ b/shared/settings/password.tsx @@ -2,8 +2,8 @@ import * as React from 'react' import * as Kb from '@/common-adapters' import * as C from '@/constants' import * as T from '@/constants/types' +import {useRequestLogout} from './use-request-logout' import {usePWState} from '@/stores/settings-password' -import {useLogoutState} from '@/stores/logout' type Props = { error: string @@ -179,7 +179,7 @@ export const useSubmitNewPassword = (thenLogout: boolean) => { const [error, setError] = React.useState('') const waitingForResponse = C.Waiting.useAnyWaiting(C.waitingKeySettingsGeneric) const navigateUp = C.useRouterState(s => s.dispatch.navigateUp) - const requestLogout = useLogoutState(s => s.dispatch.requestLogout) + const requestLogout = useRequestLogout() const submitNewPassword = C.useRPC(T.RPCGen.accountPassphraseChangeRpcPromise) const onSave = (password: string) => { diff --git a/shared/settings/use-request-logout.tsx b/shared/settings/use-request-logout.tsx new file mode 100644 index 000000000000..2fb5342ba60a --- /dev/null +++ b/shared/settings/use-request-logout.tsx @@ -0,0 +1,35 @@ +import * as C from '@/constants' +import {navigateAppend} from '@/constants/router' +import {settingsPasswordTab} from '@/constants/settings' +import * as T from '@/constants/types' +import {isMobile} from '@/constants/platform' +import * as Tabs from '@/constants/tabs' +import {useLogoutState} from '@/stores/logout' + +const navigateToLogoutPassword = () => { + if (isMobile) { + navigateAppend(settingsPasswordTab) + } else { + navigateAppend(Tabs.settingsTab) + navigateAppend(settingsPasswordTab) + } +} + +export const useRequestLogout = () => { + const start = useLogoutState(s => s.dispatch.start) + const canLogout = C.useRPC(T.RPCGen.userCanLogoutRpcPromise) + + return () => { + canLogout( + [undefined], + canLogoutRes => { + if (canLogoutRes.canLogout) { + start() + } else { + navigateToLogoutPassword() + } + }, + () => {} + ) + } +} diff --git a/shared/stores/logout.tsx b/shared/stores/logout.tsx index d443043cac0c..43ab125cbbbd 100644 --- a/shared/stores/logout.tsx +++ b/shared/stores/logout.tsx @@ -3,10 +3,6 @@ import {ignorePromise, timeoutPromise} from '@/constants/utils' import * as T from '@/constants/types' // normally util.container but it re-exports from us so break the cycle import * as Z from '@/util/zustand' -import {settingsPasswordTab} from '@/constants/settings' -import {navigateAppend} from '@/constants/router' -import {isMobile} from '@/constants/platform' -import * as Tabs from '@/constants/tabs' // This store has no dependencies on other stores and is safe to import directly from other stores. type Store = T.Immutable<{ @@ -25,32 +21,11 @@ export type State = Store & { resetState: () => void wait: (name: string, version: number, increment: boolean) => void start: () => void - requestLogout: () => void } } export const useLogoutState = Z.createZustand('logout', (set, get) => { const dispatch: State['dispatch'] = { - requestLogout: () => { - // Figure out whether we can log out using CanLogout, if so, - // startLogoutHandshake, else do what's needed - right now only - // redirect to set password screen. - const f = async () => { - const canLogoutRes = await T.RPCGen.userCanLogoutRpcPromise() - if (canLogoutRes.canLogout) { - get().dispatch.start() - return - } else { - if (isMobile) { - navigateAppend(settingsPasswordTab) - } else { - navigateAppend(Tabs.settingsTab) - navigateAppend(settingsPasswordTab) - } - } - } - ignorePromise(f()) - }, resetState: () => { set(s => ({ ...s, diff --git a/shared/stores/tests/logout.test.ts b/shared/stores/tests/logout.test.ts index 78f0235c39b9..d71fbc1b235d 100644 --- a/shared/stores/tests/logout.test.ts +++ b/shared/stores/tests/logout.test.ts @@ -1,10 +1,5 @@ /// -jest.mock('../../constants/router', () => ({ - navigateAppend: jest.fn(), -})) - import * as T from '../../constants/types' -import {navigateAppend} from '../../constants/router' import {resetAllStores} from '../../util/zustand' import {useLogoutState} from '../logout' @@ -41,17 +36,12 @@ describe('logout store', () => { expect(logoutSpy).toHaveBeenCalledWith({force: false, keepSecrets: false}) }) - test('requestLogout routes to password settings when logout is blocked', async () => { - jest.spyOn(T.RPCGen, 'userCanLogoutRpcPromise').mockResolvedValue({ - canLogout: false, - passphraseState: T.RPCGen.PassphraseState.known, - reason: 'password required', - }) + test('start increments the handshake version', () => { + const store = useLogoutState + const version = store.getState().version - useLogoutState.getState().dispatch.requestLogout() - await Promise.resolve() + store.getState().dispatch.start() - expect(navigateAppend).toHaveBeenNthCalledWith(1, 'tabs.settingsTab') - expect(navigateAppend).toHaveBeenNthCalledWith(2, 'settingsTabs.password') + expect(store.getState().version).toBe(version + 1) }) }) diff --git a/shared/stores/tests/unlock-folders.test.ts b/shared/stores/tests/unlock-folders.test.ts index c6f2b17be706..531435cd6bd7 100644 --- a/shared/stores/tests/unlock-folders.test.ts +++ b/shared/stores/tests/unlock-folders.test.ts @@ -1,8 +1,8 @@ /// -import {resetAllStores} from '@/util/zustand' -import {useUnlockFoldersState} from '../unlock-folders' +import {onUnlockFoldersEngineIncoming} from '../unlock-folders' const mockOpenUnlockFolders = jest.fn() +const mockCreateSession = jest.fn() jest.mock('@/stores/config', () => ({ useConfigState: { @@ -14,32 +14,20 @@ jest.mock('@/stores/config', () => ({ }, })) +jest.mock('@/engine/require', () => ({ + getEngine: () => ({ + createSession: mockCreateSession, + }), +})) + afterEach(() => { jest.restoreAllMocks() + mockCreateSession.mockReset() mockOpenUnlockFolders.mockReset() - resetAllStores() -}) - -test('local dispatches move between unlock-folder phases', () => { - const store = useUnlockFoldersState - - store.getState().dispatch.toPaperKeyInput() - expect(store.getState().phase).toBe('paperKeyInput') - - store.getState().dispatch.onBackFromPaperKey() - expect(store.getState().phase).toBe('promptOtherDevice') -}) - -test('replace stores the provided devices', () => { - const devices = [{deviceID: 'device-1', name: 'device-1', type: 'desktop'}] as any - - useUnlockFoldersState.getState().dispatch.replace(devices) - - expect(useUnlockFoldersState.getState().devices).toEqual(devices) }) test('rekey refresh actions forward the device list to config', () => { - useUnlockFoldersState.getState().dispatch.onEngineIncomingImpl({ + onUnlockFoldersEngineIncoming({ payload: { params: { problemSetDevices: { @@ -52,3 +40,24 @@ test('rekey refresh actions forward the device list to config', () => { expect(mockOpenUnlockFolders).toHaveBeenCalledWith([{deviceID: 'device-1', name: 'device-1', type: 'desktop'}]) }) + +test('delegateRekeyUI creates a dangling session and returns its id', () => { + const response = {result: jest.fn()} + mockCreateSession.mockReturnValue({id: 42}) + + onUnlockFoldersEngineIncoming({ + payload: {response}, + type: 'keybase.1.rekeyUI.delegateRekeyUI', + } as any) + + expect(mockCreateSession).toHaveBeenCalledWith( + expect.objectContaining({ + dangling: true, + incomingCallMap: expect.objectContaining({ + 'keybase.1.rekeyUI.refresh': expect.any(Function), + 'keybase.1.rekeyUI.rekeySendEvent': expect.any(Function), + }), + }) + ) + expect(response.result).toHaveBeenCalledWith(42) +}) diff --git a/shared/stores/unlock-folders.tsx b/shared/stores/unlock-folders.tsx index b1e9ca0af66b..d556aa52cf17 100644 --- a/shared/stores/unlock-folders.tsx +++ b/shared/stores/unlock-folders.tsx @@ -1,79 +1,32 @@ import type * as EngineGen from '@/constants/rpc' -import * as T from '@/constants/types' -import * as Z from '@/util/zustand' import logger from '@/logger' import {getEngine} from '@/engine/require' -import {useConfigState, type State as ConfigStore} from '@/stores/config' +import {useConfigState} from '@/stores/config' -type Store = T.Immutable<{ - devices: ConfigStore['unlockFoldersDevices'] - phase: 'dead' | 'promptOtherDevice' | 'paperKeyInput' | 'success' -}> - -const initialStore: Store = { - devices: [], - phase: 'dead', -} - -export type State = Store & { - dispatch: { - onBackFromPaperKey: () => void - onEngineIncomingImpl: (action: EngineGen.Actions) => void - toPaperKeyInput: () => void - replace: (devices: Store['devices']) => void - resetState: () => void - } -} - -// this store is only in play in the remote window, its launched by ConfigConstants.unlockFoldersDevices -export const useUnlockFoldersState = Z.createZustand('unlock-folders', (set, _get) => { - const dispatch: State['dispatch'] = { - onBackFromPaperKey: () => { - set(s => { - s.phase = 'promptOtherDevice' - }) - }, - onEngineIncomingImpl: action => { - switch (action.type) { - case 'keybase.1.rekeyUI.refresh': { - const {problemSetDevices} = action.payload.params - logger.info('Asked for rekey') - useConfigState.getState().dispatch.openUnlockFolders(problemSetDevices.devices ?? []) - break - } - case 'keybase.1.rekeyUI.delegateRekeyUI': { - // we get this with sessionID == 0 if we call openDialog - // Dangling, never gets closed - const session = getEngine().createSession({ - dangling: true, - incomingCallMap: { - 'keybase.1.rekeyUI.refresh': ({problemSetDevices}) => { - useConfigState.getState().dispatch.openUnlockFolders(problemSetDevices.devices ?? []) - }, - 'keybase.1.rekeyUI.rekeySendEvent': () => {}, // ignored debug call from daemon - }, - }) - const {response} = action.payload - response.result(session.id) - break - } - default: - } - }, - replace: devices => { - set(s => { - s.devices = T.castDraft(devices) - }) - }, - resetState: Z.defaultReset, - toPaperKeyInput: () => { - set(s => { - s.phase = 'paperKeyInput' +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 ?? []) + break + } + case 'keybase.1.rekeyUI.delegateRekeyUI': { + // We get this with sessionID == 0 if we call openDialog. + const session = getEngine().createSession({ + dangling: true, + incomingCallMap: { + 'keybase.1.rekeyUI.refresh': ({problemSetDevices}) => { + useConfigState.getState().dispatch.openUnlockFolders(problemSetDevices.devices ?? []) + }, + 'keybase.1.rekeyUI.rekeySendEvent': () => {}, // ignored debug call from daemon + }, }) - }, + const {response} = action.payload + response.result(session.id) + break + } + default: + break } - return { - ...initialStore, - dispatch, - } -}) +} diff --git a/shared/unlock-folders/index.desktop.tsx b/shared/unlock-folders/index.desktop.tsx index af45ee616ebd..fd11a095be76 100644 --- a/shared/unlock-folders/index.desktop.tsx +++ b/shared/unlock-folders/index.desktop.tsx @@ -4,11 +4,12 @@ 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 * as Constants from '@/stores/unlock-folders' import type {State as ConfigStore} from '@/stores/config' +type Phase = 'dead' | 'promptOtherDevice' | 'paperKeyInput' | 'success' + export type Props = { - phase: Constants.State['phase'] + phase: Phase devices: ConfigStore['unlockFoldersDevices'] onClose: () => void toPaperKeyInput: () => void diff --git a/skill/zustand-store-pruning/references/store-checklist.md b/skill/zustand-store-pruning/references/store-checklist.md index 8438fc9cbd85..ba5b911f5218 100644 --- a/skill/zustand-store-pruning/references/store-checklist.md +++ b/skill/zustand-store-pruning/references/store-checklist.md @@ -14,14 +14,14 @@ Status: - [ ] `archive` - [ ] `autoreset` - [ ] `bots` -- [ ] `crypto` +- [x] `crypto` - [ ] `daemon` - [ ] `darkmode` - [ ] `devices` - [ ] `followers` - [ ] `git` - [ ] `inbox-rows` -- [ ] `logout` +- [x] `logout` kept handshake `version`/`waiters` in store; moved can-logout RPC and password redirect into settings hook - [ ] `modal-header` - [ ] `notifications` - [ ] `people` @@ -38,7 +38,7 @@ Status: - [ ] `signup` - [ ] `team-building` - [ ] `tracker` -- [ ] `unlock-folders` +- [x] `unlock-folders` removed dead phase/device state; kept only engine callback forwarding into `config` - [ ] `users` - [ ] `wallets` From d64790dc8d92158782e80d4e128542a4078c573b Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 26 Mar 2026 18:07:54 -0400 Subject: [PATCH 19/19] WIP --- shared/crypto/encrypt.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/shared/crypto/encrypt.tsx b/shared/crypto/encrypt.tsx index 2c1672ef5f8e..f22fcb65fcc5 100644 --- a/shared/crypto/encrypt.tsx +++ b/shared/crypto/encrypt.tsx @@ -8,8 +8,8 @@ import {openURL} from '@/util/misc' import {CryptoBanner, DragAndDrop, Input, InputActionsBar} from './input' import {CryptoOutput, CryptoOutputActionsBar, CryptoSignedSender, OutputInfoBanner} from './output' import { - CommonOutputRouteParams, - CryptoInputRouteParams, + type CommonOutputRouteParams, + type CryptoInputRouteParams, beginRun, clearInputState, createCommonState, @@ -326,7 +326,7 @@ export const useEncryptScreenState = (params?: EncryptRouteParams) => { } } -const EncryptOptions = ({ +const EncryptOptionsPanel = ({ hasRecipients, hasSBS, hideIncludeSelf, @@ -469,7 +469,7 @@ const EncryptInputBody = ({params}: {params?: EncryptRouteParams}) => { const options = C.isMobile ? ( - { /> ) : ( - { onSetInput={controller.setInput} onClearInput={controller.clearInput} /> -