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..ca1506ba9728 100644 --- a/shared/constants/init/shared.tsx +++ b/shared/constants/init/shared.tsx @@ -38,10 +38,11 @@ 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' +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 + ) }, } : {}), @@ -934,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/constants/router.tsx b/shared/constants/router.tsx index c0b72d35ae8a..8fda1836ef61 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 @@ -191,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) => { @@ -209,8 +210,9 @@ export const navigateAppend = (path: PathParam, replace?: boolean) => { if (typeof path === 'string') { routeName = path } else { - routeName = 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) @@ -271,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 => 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, i) => { + const updatedRoutes = tabRoutes.map((route, i) => { if (i !== chatTabIndex) return route return {...route, state: {...(route.state ?? {}), index: 0, routes: [{name: 'chatRoot', params: {conversationIDKey}}]}} }) @@ -335,6 +338,7 @@ export const appendEncryptRecipientsBuilder = () => { goButtonLabel: 'Add', namespace: 'crypto', recommendedHideYourself: true, + teamBuilderNonce: makeUUID(), title: 'Recipients', }, }) diff --git a/shared/crypto/decrypt.tsx b/shared/crypto/decrypt.tsx new file mode 100644 index 000000000000..a9b04804c67a --- /dev/null +++ b/shared/crypto/decrypt.tsx @@ -0,0 +1,253 @@ +import * as C from '@/constants' +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 { + beginRun, + clearInputState, + maybeAutoRunTextOperation, + nextInputState, + nextOpenedFileState, + resetOutput, + resetWarnings, + useCommittedState, + useSeededCryptoInput, +} from './helpers' +import { + createCommonState, + getStatusCodeMessage, + type CommonOutputRouteParams, + type CryptoInputRouteParams, + type CommonState, +} from './helpers' +import {RPCError} from '@/util/errors' +import logger from '@/logger' +import type {RootRouteProps} from '@/router-v2/route-params' +import {useRoute} from '@react-navigation/core' + +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 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, +}) + +export const useDecryptState = (params?: CryptoInputRouteParams) => { + const {commitState, state, stateRef} = useCommittedState(() => createCommonState(params)) + + const clearInput = React.useCallback(() => { + commitState(clearInputState(stateRef.current)) + }, [commitState, stateRef]) + + const decrypt = React.useCallback(async (destinationDir = '', snapshot = stateRef.current) => { + commitState(beginRun(snapshot)) + 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 + ) + return commitState(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 + ) + return commitState(next) + } catch (_error) { + if (!(_error instanceof RPCError)) throw _error + logger.error(_error) + const next = onError(stateRef.current, getStatusCodeMessage(_error, 'decrypt', snapshot.inputType)) + return commitState(next) + } + }, [commitState, stateRef]) + + const setInput = React.useCallback( + (type: T.Crypto.InputTypes, value: string) => { + if (!value) { + clearInput() + return + } + const committed = commitState(nextInputState(stateRef.current, type, value)) + maybeAutoRunTextOperation(committed, decrypt) + }, + [clearInput, commitState, decrypt, stateRef] + ) + + const openFile = React.useCallback((path: string) => { + if (!path) return + const current = stateRef.current + if (current.inProgress) return + commitState(nextOpenedFileState(current, path)) + }, [commitState, stateRef]) + + useSeededCryptoInput(params, openFile, 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) { + navigateAppend({name: Crypto.decryptOutput, params: next}) + } + } + C.ignorePromise(f()) + } + + const contents = ( + <> + + + + ) + + return C.isMobile ? ( + + {contents} + + + ) : ( + + {contents} + + ) +} + +export const DecryptOutput = ({route}: {route: {params: CommonOutputRouteParams}}) => { + const state = route.params + const content = ( + <> + {C.isMobile && state.errorMessage ? : null} + + {C.isMobile ? : null} + undefined} + /> + + + ) + + return C.isMobile ? ( + content + ) : ( + + {content} + + ) +} + +export const DecryptIO = () => { + const {params} = useRoute>() + const controller = useDecryptState(params) + return ( + + + + + + + + + + { + const f = async () => { + await controller.decrypt(destinationDir) + } + C.ignorePromise(f()) + }} + /> + + + + + ) +} diff --git a/shared/crypto/encrypt.tsx b/shared/crypto/encrypt.tsx new file mode 100644 index 000000000000..f22fcb65fcc5 --- /dev/null +++ b/shared/crypto/encrypt.tsx @@ -0,0 +1,643 @@ +import * as C from '@/constants' +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 {openURL} from '@/util/misc' +import {CryptoBanner, DragAndDrop, Input, InputActionsBar} from './input' +import {CryptoOutput, CryptoOutputActionsBar, CryptoSignedSender, OutputInfoBanner} from './output' +import { + type CommonOutputRouteParams, + type CryptoInputRouteParams, + beginRun, + clearInputState, + createCommonState, + getStatusCodeMessage, + maybeAutoRunTextOperation, + nextInputState, + nextOpenedFileState, + resetOutput, + resetWarnings, + useCommittedState, + useSeededCryptoInput, +} from './helpers' +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.` + +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, + 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, + } +} + +export const useEncryptScreenState = (params?: EncryptRouteParams) => { + const {commitState, state, stateRef} = useCommittedState(() => createEncryptState(params)) + const handledTeamBuilderNonceRef = React.useRef(undefined) + + const runEncrypt = React.useCallback(async (destinationDir = '', 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, + } + + commitState(beginRun(snapshot)) + 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 + ) + return commitState(next) + } catch (_error) { + if (!(_error instanceof RPCError)) throw _error + logger.error(_error) + const next = onError(stateRef.current, getStatusCodeMessage(_error, 'encrypt', snapshot.inputType)) + return commitState(next) + } + }, [commitState, stateRef]) + + const clearInput = React.useCallback(() => { + commitState(clearInputState(stateRef.current)) + }, [commitState, stateRef]) + + const setInput = React.useCallback( + (type: T.Crypto.InputTypes, value: string) => { + if (!value) { + clearInput() + return + } + const committed = commitState(nextInputState(stateRef.current, type, value)) + maybeAutoRunTextOperation(committed, runEncrypt) + }, + [clearInput, commitState, runEncrypt, stateRef] + ) + + const openFile = React.useCallback((path: string) => { + if (!path) return + const current = stateRef.current + if (current.inProgress) return + commitState(nextOpenedFileState(current, path)) + }, [commitState, stateRef]) + + const setRecipients = React.useCallback( + (recipients: ReadonlyArray, hasSBS: boolean) => { + const committed = commitState(nextRecipientState(stateRef.current, recipients, hasSBS)) + maybeAutoRunTextOperation(committed, runEncrypt) + }, + [commitState, runEncrypt, stateRef] + ) + + const clearRecipients = React.useCallback(() => { + const next = resetOutput(stateRef.current) + commitState({ + ...next, + meta: { + hasRecipients: false, + hasSBS: false, + hideIncludeSelf: false, + }, + options: { + includeSelf: true, + sign: true, + }, + recipients: [], + }) + }, [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, stateRef] + ) + + 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, + } + return commitState(next) + }, [commitState, stateRef]) + + useSeededCryptoInput(params, openFile, 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 EncryptOptionsPanel = ({ + 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}) + } + + const direction = Kb.Styles.isTablet ? 'horizontal' : Kb.Styles.isMobile ? 'vertical' : 'horizontal' + const gap = Kb.Styles.isTablet ? 'medium' : Kb.Styles.isMobile ? 'xtiny' : 'medium' + + return ( + + {hideIncludeSelf ? null : ( + onSetOptions({newIncludeSelf: newValue, newSign: sign})} + /> + )} + onSetOptions({newIncludeSelf: includeSelf, newSign: newValue})} + /> + + ) +} + +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.` + : '' + + const paragraphs: Array> = [] + paragraphs.push( + openURL(Crypto.saltpackDocumentation), + text: 'Saltpack', + }, + '.', + outputType === 'text' ? " It's also called ciphertext." : '', + ]} + /> + ) + if (hasRecipients) { + paragraphs.push( + + ) + } + + return {paragraphs} +} + +const styles = Kb.Styles.styleSheetCreate( + () => + ({ + optionsContainer: Kb.Styles.platformStyles({ + isElectron: { + ...Kb.Styles.padding(Kb.Styles.globalMargins.small, Kb.Styles.globalMargins.small), + alignItems: 'center', + height: 40, + }, + isMobile: { + alignItems: 'flex-start', + }, + isTablet: { + ...Kb.Styles.globalStyles.fullWidth, + alignSelf: 'center', + justifyContent: 'space-between', + maxWidth: 460, + }, + }), + }) as const +) + +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} + + ) + + return C.isMobile ? ( + {content} + ) : ( + + {content} + + ) +} + +const EncryptOutputBody = ({params}: {params: EncryptOutputRouteParams}) => ( + + + + {C.isMobile ? : null} + undefined} + /> + + +) + +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} + { + const f = async () => { + await controller.runEncrypt(destinationDir) + } + C.ignorePromise(f()) + }} + /> + { + const f = async () => { + await controller.saveOutputAsText() + } + C.ignorePromise(f()) + }} + /> + + + + ) +} diff --git a/shared/crypto/helpers.ts b/shared/crypto/helpers.ts new file mode 100644 index 000000000000..ea4ab5192497 --- /dev/null +++ b/shared/crypto/helpers.ts @@ -0,0 +1,202 @@ +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 {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 => + ({ + ...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 + const f = async () => { + await run('', snapshot) + } + C.ignorePromise(f()) +} + +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/hooks.test.tsx b/shared/crypto/hooks.test.tsx new file mode 100644 index 000000000000..e20753a06336 --- /dev/null +++ b/shared/crypto/hooks.test.tsx @@ -0,0 +1,136 @@ +/** @jest-environment jsdom */ +/// + +import {afterEach, beforeEach, expect, jest, test} from '@jest/globals' +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' +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 +} + +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}) => + Promise.resolve({ + ciphertext: `cipher:${plaintext}`, + unresolvedSBSAssertion: '', + usedUnresolvedSBS: false, + } as never) + ) + + const {result} = renderHook((): HookController => useEncryptScreenState()) + act(() => { + result.current.setInput('text', 'secret message') + }) + + await waitFor(() => + expect(encryptSpy).toHaveBeenCalledWith( + expect.objectContaining({plaintext: 'secret message'}), + expect.anything() + ) + ) + await waitFor(() => expect(result.current.state.output).toBe('cipher:secret message')) + + 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}) => + 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(() => + expect(decryptSpy).toHaveBeenCalledWith( + expect.objectContaining({ciphertext: 'encrypted payload'}), + expect.anything() + ) + ) + await waitFor(() => expect(result.current.state.output).toBe('plain:encrypted payload')) + + 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}) => Promise.resolve(`signed:${plaintext}` as never)) + + const {result} = renderHook((): HookController => useSignState()) + act(() => { + result.current.setInput('text', 'message to sign') + }) + + await waitFor(() => + expect(signSpy).toHaveBeenCalledWith( + expect.objectContaining({plaintext: 'message to sign'}), + expect.anything() + ) + ) + await waitFor(() => expect(result.current.state.output).toBe('signed:message to sign')) + + 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}) => + 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(() => + expect(verifySpy).toHaveBeenCalledWith( + expect.objectContaining({signedMsg: 'signed payload'}), + expect.anything() + ) + ) + await waitFor(() => expect(result.current.state.output).toBe('verified:signed payload')) + + expect(result.current.state.outputValid).toBe(true) + expect(result.current.state.errorMessage).toBe('') +}) diff --git a/shared/crypto/input.tsx b/shared/crypto/input.tsx index d4901b87d186..1f11ee4002fa 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 './helpers' 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 deleted file mode 100644 index 61060317259c..000000000000 --- a/shared/crypto/operations/decrypt.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import * as C from '@/constants' -import * as Crypto from '@/stores/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' - -const operation = Crypto.Operations.Decrypt - -export const DecryptInput = () => { - const resetOperation = Crypto.useCryptoState(s => s.dispatch.resetOperation) - React.useEffect(() => { - return () => { - if (C.isMobile) { - resetOperation(operation) - } - } - }, [resetOperation]) - const contents = ( - <> - - - - ) - return C.isMobile ? ( - - {contents} - - - ) : ( - - {contents} - - ) -} - -export const DecryptOutput = () => { - const errorMessage = Crypto.useCryptoState(s => s[operation].errorMessage.stringValue()) - const content = ( - <> - {C.isMobile && errorMessage ? : null} - - {C.isMobile ? : null} - - - - ) - return C.isMobile ? ( - content - ) : ( - - {content} - - ) -} - -export const DecryptIO = () => { - return ( - - - - - - - - ) -} diff --git a/shared/crypto/operations/encrypt.tsx b/shared/crypto/operations/encrypt.tsx deleted file mode 100644 index 7a2f051a276c..000000000000 --- a/shared/crypto/operations/encrypt.tsx +++ /dev/null @@ -1,188 +0,0 @@ -import * as C from '@/constants' -import * as Crypto from '@/stores/crypto' -import * as Kb from '@/common-adapters' -import * as React from 'react' -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} - }) - ) - - const setEncryptOptions = Crypto.useCryptoState(s => s.dispatch.setEncryptOptions) - - const onSetOptions = (opts: {newIncludeSelf: boolean; newSign: boolean}) => { - const {newIncludeSelf, newSign} = opts - setEncryptOptions({includeSelf: newIncludeSelf, sign: newSign}) - } - - const direction = Kb.Styles.isTablet ? 'horizontal' : Kb.Styles.isMobile ? 'vertical' : 'horizontal' - const gap = Kb.Styles.isTablet ? 'medium' : Kb.Styles.isMobile ? 'xtiny' : 'medium' - - return ( - - {hideIncludeSelf ? null : ( - onSetOptions({newIncludeSelf: newValue, newSign: sign})} - /> - )} - onSetOptions({newIncludeSelf: includeSelf, newSign: newValue})} - /> - - ) -} - -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 youAnd = (who: string) => (includeSelf ? `you and ${who}` : who) - const whoCanRead = hasRecipients - ? ` Only ${ - recipients.length > 1 ? youAnd('your recipients') : youAnd(recipients[0] ?? '') - } can decipher it.` - : '' - - const paragraphs: Array> = [] - paragraphs.push( - openURL(Crypto.saltpackDocumentation), - text: 'Saltpack', - }, - '.', - outputType === 'text' ? " It's also called ciphertext." : '', - ]} - /> - ) - if (hasRecipients) { - paragraphs.push( - - ) - } - - return {paragraphs} -} - -const styles = Kb.Styles.styleSheetCreate( - () => - ({ - optionsContainer: Kb.Styles.platformStyles({ - isElectron: { - ...Kb.Styles.padding(Kb.Styles.globalMargins.small, Kb.Styles.globalMargins.small), - alignItems: 'center', - height: 40, - }, - isMobile: { - alignItems: 'flex-start', - }, - isTablet: { - ...Kb.Styles.globalStyles.fullWidth, - alignSelf: 'center', - justifyContent: 'space-between', - maxWidth: 460, - }, - }), - }) as const -) - -export const EncryptInput = () => { - const blurCBRef = React.useRef(() => {}) - const setBlurCB = (cb: () => void) => { - blurCBRef.current = cb - } - - 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} - ) : ( - - {content} - - ) -} - -export const EncryptOutput = () => ( - - - - {C.isMobile ? : null} - - - -) - -export const EncryptIO = () => ( - - - - - - -) diff --git a/shared/crypto/operations/sign.tsx b/shared/crypto/operations/sign.tsx deleted file mode 100644 index 90baa815c9c9..000000000000 --- a/shared/crypto/operations/sign.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import * as C from '@/constants' -import * as Crypto from '@/stores/crypto' -import * as React from 'react' -import * as Kb from '@/common-adapters' -import {openURL} from '@/util/misc' -import {Input, DragAndDrop, OperationBanner, InputActionsBar} from '../input' -import {OutputInfoBanner, OperationOutput, OutputActionsBar, SignedSender} from '../output' - -const operation = Crypto.Operations.Sign - -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. - - - ) -} - -export const SignInput = () => { - const blurCBRef = React.useRef(() => {}) - const setBlurCB = (cb: () => void) => { - blurCBRef.current = cb - } - - const resetOperation = Crypto.useCryptoState(s => s.dispatch.resetOperation) - React.useEffect(() => { - return () => { - if (C.isMobile) { - resetOperation(operation) - } - } - }, [resetOperation]) - - const content = ( - <> - - - {C.isMobile ? : null} - - ) - - return C.isMobile ? ( - {content} - ) : ( - - {content} - - ) -} - -export const SignOutput = () => { - const content = ( - <> - - - {C.isMobile ? : null} - - - - ) - return C.isMobile ? ( - content - ) : ( - - {content} - - ) -} - -export const SignIO = () => { - return ( - - - - - - - ) -} diff --git a/shared/crypto/operations/verify.tsx b/shared/crypto/operations/verify.tsx deleted file mode 100644 index 56decc162d9d..000000000000 --- a/shared/crypto/operations/verify.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import * as C from '@/constants' -import * as Crypto from '@/stores/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' - -const operation = Crypto.Operations.Verify - -export const VerifyInput = () => { - const resetOperation = Crypto.useCryptoState(s => s.dispatch.resetOperation) - React.useEffect(() => { - return () => { - if (C.isMobile) { - resetOperation(operation) - } - } - }, [resetOperation]) - - const content = ( - <> - - - {C.isMobile ? : null} - - ) - - return C.isMobile ? ( - {content} - ) : ( - - {content} - - ) -} -export const VerifyOutput = () => { - const errorMessage = Crypto.useCryptoState(s => s[operation].errorMessage.stringValue()) - const content = ( - <> - {C.isMobile && errorMessage ? : null} - - {C.isMobile ? : null} - - - - ) - return C.isMobile ? ( - content - ) : ( - - {content} - - ) -} - -export const VerifyIO = () => { - return ( - - - - - - - - ) -} diff --git a/shared/crypto/output.tsx b/shared/crypto/output.tsx index 59f87d96776d..7402b74fdaaf 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 './helpers' 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..ee7517e072e2 100644 --- a/shared/crypto/routes.tsx +++ b/shared/crypto/routes.tsx @@ -3,35 +3,101 @@ 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, +} from './helpers' +import type {CryptoTeamBuilderResult, EncryptOutputRouteParams, EncryptRouteParams} from './encrypt' + +type CryptoTeamBuilderRouteParams = Parameters[0]['route']['params'] & { + teamBuilderNonce?: string + teamBuilderUsers?: CryptoTeamBuilderResult +} + +const DecryptInputScreen = React.lazy(async () => { + const {DecryptInput} = await import('./decrypt') + return { + default: (_p: StaticScreenProps) => , + } +}) + +const EncryptInputScreen = React.lazy(async () => { + const {EncryptInput} = await import('./encrypt') + return { + default: (_p: StaticScreenProps) => , + } +}) + +const SignInputScreen = React.lazy(async () => { + const {SignInput} = await import('./sign') + return { + default: (_p: StaticScreenProps) => , + } +}) + +const VerifyInputScreen = React.lazy(async () => { + const {VerifyInput} = await import('./verify') + return { + default: (_p: StaticScreenProps) => , + } +}) + +const DecryptOutputScreen = React.lazy(async () => { + const {DecryptOutput} = await import('./decrypt') + return { + default: (p: StaticScreenProps) => , + } +}) + +const EncryptOutputScreen = React.lazy(async () => { + const {EncryptOutput} = await import('./encrypt') + return { + default: (p: StaticScreenProps) => , + } +}) + +const SignOutputScreen = React.lazy(async () => { + const {SignOutput} = await import('./sign') + return { + default: (p: StaticScreenProps) => , + } +}) + +const VerifyOutputScreen = React.lazy(async () => { + const {VerifyOutput} = await import('./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 +112,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 +124,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 +132,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/sign.tsx b/shared/crypto/sign.tsx new file mode 100644 index 000000000000..730cbcd9b4dd --- /dev/null +++ b/shared/crypto/sign.tsx @@ -0,0 +1,273 @@ +import * as C from '@/constants' +import * as Crypto from '@/constants/crypto' +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 { + beginRun, + clearInputState, + maybeAutoRunTextOperation, + nextInputState, + nextOpenedFileState, + resetOutput, + resetWarnings, + useCommittedState, + useSeededCryptoInput, +} from './helpers' +import { + createCommonState, + getStatusCodeMessage, + type CommonOutputRouteParams, + type CryptoInputRouteParams, + type CommonState, +} from './helpers' +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.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 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, +}) + +export const useSignState = (params?: CryptoInputRouteParams) => { + const {commitState, state, stateRef} = useCommittedState(() => createCommonState(params)) + + const clearInput = React.useCallback(() => { + commitState(clearInputState(stateRef.current)) + }, [commitState, stateRef]) + + const sign = React.useCallback(async (destinationDir = '', snapshot = stateRef.current) => { + commitState(beginRun(snapshot)) + 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 + ) + return commitState(next) + } catch (_error) { + if (!(_error instanceof RPCError)) throw _error + logger.error(_error) + const next = onError(stateRef.current, getStatusCodeMessage(_error, 'sign', snapshot.inputType)) + return commitState(next) + } + }, [commitState, stateRef]) + + const setInput = React.useCallback( + (type: T.Crypto.InputTypes, value: string) => { + if (!value) { + clearInput() + return + } + const committed = commitState(nextInputState(stateRef.current, type, value)) + maybeAutoRunTextOperation(committed, sign) + }, + [clearInput, commitState, sign, stateRef] + ) + + const openFile = React.useCallback((path: string) => { + if (!path) return + const current = stateRef.current + if (current.inProgress) return + commitState(nextOpenedFileState(current, path)) + }, [commitState, stateRef]) + + 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, + } + return commitState(next) + }, [commitState, stateRef]) + + useSeededCryptoInput(params, openFile, setInput) + + return {clearInput, openFile, saveOutputAsText, setInput, sign, state} +} + +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 onRun = () => { + const f = async () => { + const next = await controller.sign() + if (C.isMobile) { + navigateAppend({name: Crypto.signOutput, params: next}) + } + } + C.ignorePromise(f()) + } + + const content = ( + <> + + + {C.isMobile ? : null} + + ) + + return C.isMobile ? ( + {content} + ) : ( + + {content} + + ) +} + +export const SignOutput = ({route}: {route: {params: CommonOutputRouteParams}}) => { + const state = route.params + const content = ( + <> + + + {C.isMobile ? : null} + undefined} + /> + + + ) + + return C.isMobile ? ( + content + ) : ( + + {content} + + ) +} + +export const SignIO = () => { + const {params} = useRoute>() + const controller = useSignState(params) + return ( + + + + + + + + + + { + const f = async () => { + await controller.sign(destinationDir) + } + C.ignorePromise(f()) + }} + /> + { + const f = async () => { + await controller.saveOutputAsText() + } + C.ignorePromise(f()) + }} + /> + + + + ) +} diff --git a/shared/crypto/sub-nav/index.desktop.tsx b/shared/crypto/sub-nav/index.desktop.tsx index 9c6579fd04fd..035482702d50 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 { @@ -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/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/crypto/verify.tsx b/shared/crypto/verify.tsx new file mode 100644 index 000000000000..1dd1c27cba15 --- /dev/null +++ b/shared/crypto/verify.tsx @@ -0,0 +1,250 @@ +import * as C from '@/constants' +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 { + beginRun, + clearInputState, + maybeAutoRunTextOperation, + nextInputState, + nextOpenedFileState, + resetOutput, + resetWarnings, + useCommittedState, + useSeededCryptoInput, +} from './helpers' +import { + createCommonState, + getStatusCodeMessage, + type CommonOutputRouteParams, + type CryptoInputRouteParams, + type CommonState, +} from './helpers' +import {RPCError} from '@/util/errors' +import logger from '@/logger' +import type {RootRouteProps} from '@/router-v2/route-params' +import {useRoute} from '@react-navigation/core' + +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' + +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, +}) + +export const useVerifyState = (params?: CryptoInputRouteParams) => { + const {commitState, state, stateRef} = useCommittedState(() => createCommonState(params)) + + const clearInput = React.useCallback(() => { + commitState(clearInputState(stateRef.current)) + }, [commitState, stateRef]) + + const verify = React.useCallback(async (destinationDir = '', snapshot = stateRef.current) => { + commitState(beginRun(snapshot)) + 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 + ) + return commitState(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 + ) + return commitState(next) + } catch (_error) { + if (!(_error instanceof RPCError)) throw _error + logger.error(_error) + const next = onError(stateRef.current, getStatusCodeMessage(_error, 'verify', snapshot.inputType)) + return commitState(next) + } + }, [commitState, stateRef]) + + const setInput = React.useCallback( + (type: T.Crypto.InputTypes, value: string) => { + if (!value) { + clearInput() + return + } + const committed = commitState(nextInputState(stateRef.current, type, value)) + maybeAutoRunTextOperation(committed, verify) + }, + [clearInput, commitState, verify, stateRef] + ) + + const openFile = React.useCallback((path: string) => { + if (!path) return + const current = stateRef.current + if (current.inProgress) return + commitState(nextOpenedFileState(current, path)) + }, [commitState, stateRef]) + + useSeededCryptoInput(params, openFile, 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) { + navigateAppend({name: Crypto.verifyOutput, params: next}) + } + } + C.ignorePromise(f()) + } + + const content = ( + <> + + + {C.isMobile ? : null} + + ) + + return C.isMobile ? ( + {content} + ) : ( + + {content} + + ) +} + +export const VerifyOutput = ({route}: {route: {params: CommonOutputRouteParams}}) => { + const state = route.params + const content = ( + <> + {C.isMobile && state.errorMessage ? : null} + + {C.isMobile ? : null} + undefined} + /> + + + ) + + return C.isMobile ? ( + content + ) : ( + + {content} + + ) +} + +export const VerifyIO = () => { + const {params} = useRoute>() + const controller = useVerifyState(params) + return ( + + + + + + + + + + { + const f = async () => { + await controller.verify(destinationDir) + } + C.ignorePromise(f()) + }} + /> + + + + + ) +} 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/login/join-or-login.tsx b/shared/login/join-or-login.tsx index 6112c21883b5..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 = () => { @@ -24,7 +24,7 @@ const Intro = () => { const onLogin = () => { startProvision() } - const requestAutoInvite = useSignupState(s => s.dispatch.requestAutoInvite) + const requestAutoInvite = useRequestAutoInvite() const onSignup = () => { requestAutoInvite() } 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/login/relogin/container.tsx b/shared/login/relogin/container.tsx index 248a36d24fad..07188b4ab3f2 100644 --- a/shared/login/relogin/container.tsx +++ b/shared/login/relogin/container.tsx @@ -4,7 +4,7 @@ import {useConfigState} from '@/stores/config' import Login from '.' import sortBy from 'lodash/sortBy' import {useState as useRecoverState} from '@/stores/recover-password' -import {useSignupState} from '@/stores/signup' +import useRequestAutoInvite from '@/signup/use-request-auto-invite' import {useProvisionState} from '@/stores/provision' const needPasswordError = 'passphrase cannot be empty' @@ -22,7 +22,7 @@ const ReloginContainer = () => { navigateAppend('signupSendFeedbackLoggedOut') } const onLogin = useConfigState(s => s.dispatch.login) - const requestAutoInvite = useSignupState(s => s.dispatch.requestAutoInvite) + const requestAutoInvite = useRequestAutoInvite() const onSignup = () => requestAutoInvite() const onSomeoneElse = useProvisionState(s => s.dispatch.startProvision) const error = perror?.desc || '' diff --git a/shared/login/signup/error.tsx b/shared/login/signup/error.tsx index 04ff47e90e7b..21837fb3ed46 100644 --- a/shared/login/signup/error.tsx +++ b/shared/login/signup/error.tsx @@ -1,15 +1,17 @@ import * as C from '@/constants' import * as Kb from '@/common-adapters' import {Wrapper, ContinueButton} from './common' -import {useSignupState} from '@/stores/signup' +import type {StaticScreenProps} from '@react-navigation/core' -const ConnectedSignupError = () => { - 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/people/todo.tsx b/shared/people/todo.tsx index e0f435f617a0..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' @@ -216,12 +215,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 +245,6 @@ const VerifyAllEmailConnector = (props: TodoOwnProps) => { label: hasRecentVerifyEmail ? `Verify again` : 'Verify', onClick: () => onConfirm(meta.email), type: 'Success' as const, - waiting: addingEmail ? addingEmail === meta.email : false, }, ] : []), @@ -265,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, @@ -273,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/provision/username-or-email.tsx b/shared/provision/username-or-email.tsx index d09f5525349f..5969fba481c7 100644 --- a/shared/provision/username-or-email.tsx +++ b/shared/provision/username-or-email.tsx @@ -1,6 +1,6 @@ import * as C from '@/constants' import * as AutoReset from '@/stores/autoreset' -import {useSignupState} from '@/stores/signup' +import useRequestAutoInvite from '@/signup/use-request-auto-invite' import {useSafeSubmit} from '@/util/safe-submit' import * as T from '@/constants/types' import * as React from 'react' @@ -50,8 +50,7 @@ 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 requestAutoInvite = useRequestAutoInvite() const _setUsername = useProvisionState(s => s.dispatch.dynamic.setUsername) const _onSubmit = (username: string) => { !waiting && _setUsername?.(username) @@ -66,7 +65,7 @@ const UsernameOrEmailContainer = (op: OwnProps) => { _onSubmit(username) } const onGoToSignup = () => { - _onGoToSignup(username) + requestAutoInvite(username) } return ( diff --git a/shared/settings/account/add-modals.tsx b/shared/settings/account/add-modals.tsx index db284fefe26e..2928a25f3f6d 100644 --- a/shared/settings/account/add-modals.tsx +++ b/shared/settings/account/add-modals.tsx @@ -5,59 +5,40 @@ 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 {useSettingsEmailState} from '@/stores/settings-email' +import {useDefaultPhoneCountry} from '@/util/phone-numbers' 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 +72,24 @@ export const Email = () => { } /> - - - {!Kb.Styles.isMobile && ( - - )} - - + + + {!Kb.Styles.isMobile && ( + + )} + + ) @@ -116,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) } @@ -211,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/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/account/use-add-email.tsx b/shared/settings/account/use-add-email.tsx new file mode 100644 index 000000000000..8ecc9630c467 --- /dev/null +++ b/shared/settings/account/use-add-email.tsx @@ -0,0 +1,72 @@ +import * as C from '@/constants' +import * as React from 'react' +import * as T from '@/constants/types' +import {useSettingsEmailState} from '@/stores/settings-email' +import type {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) + onSuccess(email) + }, + error_ => { + if (mountedRef.current) { + setError(makeAddEmailError(error_)) + } + } + ) + } + + return {clearError, error, submitEmail, waiting} +} 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..6675e93226cc 100644 --- a/shared/settings/logout.tsx +++ b/shared/settings/logout.tsx @@ -1,11 +1,12 @@ 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 {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( @@ -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(undefined) + const loadPgpSettings = C.useRPC(T.RPCGen.accountHasServerKeysRpcPromise) + const requestLogout = useRequestLogout() 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,26 @@ const LogoutContainer = () => { onBootstrap() }, [onBootstrap]) + React.useEffect( + () => () => { + resetCheckPassword() + }, + [resetCheckPassword] + ) + + React.useEffect(() => { + if (!hasRandomPW) { + return + } + loadPgpSettings( + [undefined], + ({hasServerKeys}) => { + setHasPGPKeyOnServer(hasServerKeys) + }, + () => {} + ) + }, [hasRandomPW, loadPgpSettings]) + const logOut = () => { if (loggingOut) return onLogout() @@ -73,12 +77,9 @@ const LogoutContainer = () => { ) : hasRandomPW ? ( diff --git a/shared/settings/password.tsx b/shared/settings/password.tsx index 7e1f3faff992..1b31aee08dd1 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 {useRequestLogout} from './use-request-logout' import {usePWState} from '@/stores/settings-password' 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 = useRequestLogout() + 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(undefined) + const loadPgpSettings = C.useRPC(T.RPCGen.accountHasServerKeysRpcPromise) + React.useEffect(() => { + loadPgpSettings( + [undefined], + ({hasServerKeys}) => { + setHasPGPKeyOnServer(hasServerKeys) + }, + () => {} + ) + }, [loadPgpSettings]) + const props = { error, hasPGPKeyOnServer, - hasRandomPW, - newPasswordConfirmError, - newPasswordError, - onCancel, onSave, - onUpdatePGPSettings, saveLabel, waitingForResponse, } 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/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/signup/device-name.tsx b/shared/signup/device-name.tsx index fc55f9791f42..c08002cc58c5 100644 --- a/shared/signup/device-name.tsx +++ b/shared/signup/device-name.tsx @@ -4,19 +4,99 @@ import * as React from 'react' import {SignupScreen, errorBanner} from './common' import * as Provision from '@/stores/provision' import {useSignupState} from '@/stores/signup' +import * as T from '@/constants/types' +import {RPCError} from '@/util/errors' +import {ignorePromise} from '@/constants/utils' +import * as Platforms from '@/constants/platform' +import logger from '@/logger' +import type {StaticScreenProps} from '@react-navigation/core' -const ConnectedEnterDevicename = () => { - const error = useSignupState(s => s.devicenameError) +type Props = StaticScreenProps<{inviteCode?: string; username?: string}> + +const ConnectedEnterDevicename = (p: Props) => { 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 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 => ({ + 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, } @@ -25,7 +105,7 @@ const ConnectedEnterDevicename = () => { export default ConnectedEnterDevicename -type Props = { +type EnterDevicenameProps = { error: string initialDevicename?: string onBack: () => void @@ -39,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) => { @@ -58,7 +138,7 @@ const EnterDevicename = (props: Props) => { 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/email.tsx b/shared/signup/email.tsx index 80f741f1f361..1f8ac176b00c 100644 --- a/shared/signup/email.tsx +++ b/shared/signup/email.tsx @@ -2,46 +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 initialEmail = useSignupState(s => s.email) - 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(initialEmail || '') + 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/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..d3ba54dff2b1 --- /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 = React.useCallback((phoneNumberToVerify: string) => { + setError('') + resendVerification( + [{phoneNumber: phoneNumberToVerify}, C.waitingKeySettingsPhoneResendVerification], + () => {}, + error_ => { + if (mountedRef.current) { + setError(makePhoneError(error_)) + } + } + ) + }, [mountedRef, resendVerification]) + + const verifyPhoneNumber = React.useCallback((phoneNumberToVerify: string, code: string) => { + setError('') + verifyPhoneNumberRpc( + [{code, phoneNumber: phoneNumberToVerify}, C.waitingKeySettingsPhoneVerifyPhoneNumber], + () => { + if (mountedRef.current) { + setError('') + onSuccess?.() + } + }, + error_ => { + if (mountedRef.current) { + setError(makePhoneError(error_)) + } + } + ) + }, [mountedRef, onSuccess, verifyPhoneNumberRpc]) + + 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..bc97b2dcf3e1 100644 --- a/shared/signup/phone-number/verify.tsx +++ b/shared/signup/phone-number/verify.tsx @@ -4,22 +4,19 @@ import * as Kb from '@/common-adapters' 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 verifyWaiting = C.Waiting.useAnyWaiting(C.waitingKeySettingsPhoneVerifyPhoneNumber) - - const verifyPhoneNumber = useSettingsPhoneState(s => s.dispatch.verifyPhoneNumber) - const resendVerificationForPhone = useSettingsPhoneState(s => s.dispatch.resendVerificationForPhone) +type Props = {route: {params: {phoneNumber: string}}} - const clearPhoneNumberAdd = useSettingsPhoneState(s => s.dispatch.clearPhoneNumberAdd) +const Container = ({route}: Props) => { + const {phoneNumber} = route.params + const resendWaiting = C.Waiting.useAnyWaiting(C.waitingKeySettingsPhoneResendVerification) + const verifyWaiting = C.Waiting.useAnyWaiting(C.waitingKeySettingsPhoneVerifyPhoneNumber) + 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 +28,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/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 fca50d729add..ab007f881616 100644 --- a/shared/signup/username.tsx +++ b/shared/signup/username.tsx @@ -4,20 +4,62 @@ 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' +import type {StaticScreenProps} from '@react-navigation/core' -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 waiting = C.Waiting.useAnyWaiting(C.waitingKeySignup) - const navigateUp = C.useRouterState(s => s.dispatch.navigateUp) +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 => ({ + 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`) + navigateAppend({name: 'signupEnterDevicename', params: {inviteCode, username}}) + } 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,30 +71,36 @@ const ConnectedEnterUsername = () => { onBack, onContinue, onLogin, + onUsernameChange, usernameTaken, waiting, } return } -type Props = { +type EnterUsernameProps = { error: string initialUsername?: string onBack: () => void onContinue: (username: string) => void onLogin: (username: string) => void + onUsernameChange: () => void usernameTaken?: string 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') const usernameTrimmed = username.trim() const disabled = !usernameTrimmed || usernameTrimmed === props.usernameTaken || !acceptedEULA + const _onChangeUsername = (username: string) => { + onChangeUsername(username) + props.onUsernameChange() + } const onContinue = () => { - if (disabled) { + if (disabled || props.waiting) { return } onChangeUsername(usernameTrimmed) // maybe trim the input @@ -122,8 +170,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/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/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/settings-email.tsx b/shared/stores/settings-email.tsx index 6c7b35b2771e..e813c7350358 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,13 +106,12 @@ export const useSettingsEmailState = Z.createZustand('settings-email', (s s.addedEmail = '' }) }, - resetAddingEmail: () => { + resetState: Z.defaultReset, + setAddedEmail: email => { set(s => { - s.addingEmail = '' - s.error = '' + s.addedEmail = email }) }, - resetState: Z.defaultReset, } return { ...initialStore, 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/settings-phone.tsx b/shared/stores/settings-phone.tsx index 2d46f24caaef..b827f931c5f4 100644 --- a/shared/stores/settings-phone.tsx +++ b/shared/stores/settings-phone.tsx @@ -1,9 +1,7 @@ -import * as T from '@/constants/types' -import * as S from '@/constants/strings' -import {ignorePromise} from '@/constants/utils' +import type * as T from '@/constants/types' +import * as RPCGen from '@/constants/rpc/rpc-gen' import * as Z from '@/util/zustand' -import logger from '@/logger' -import {RPCError} from '@/util/errors' +import type {RPCError} from '@/util/errors' import type {e164ToDisplay as e164ToDisplayType} from '@/util/phone-numbers' export const makePhoneRow = (): PhoneRow => ({ @@ -20,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, } @@ -28,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 @@ -53,158 +51,57 @@ 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) { - 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, + visibility: setSearchable ? RPCGen.IdentityVisibility.public : RPCGen.IdentityVisibility.private, }) } } - 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 +113,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/signup.tsx b/shared/stores/signup.tsx index f2823881bfb4..8672e578e352 100644 --- a/shared/stores/signup.tsx +++ b/shared/stores/signup.tsx @@ -1,37 +1,16 @@ -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 type * 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 Store = T.Immutable<{ devicename: string - devicenameError: string - email: string - inviteCode: string justSignedUpEmail: string - signupError?: RPCError - username: string - usernameError: string - usernameTaken: string }> const initialStore: Store = { devicename: S.defaultDevicename, - devicenameError: '', - email: '', - inviteCode: '', justSignedUpEmail: '', - signupError: undefined, - username: '', - usernameError: '', - usernameTaken: '', } export type State = Store & { @@ -40,154 +19,16 @@ 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 resetState: () => void + setDevicename: (devicename: string) => void setJustSignedUpEmail: (email: 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 +42,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': @@ -218,39 +50,17 @@ export const useSignupState = Z.createZustand('signup', (set, get) => { default: } }, - requestAutoInvite: username => { - set(s => { - 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.inviteCode = inviteCode - }) - } catch { - set(s => { - s.inviteCode = '' - }) - } - navigateUp() - navigateAppend('signupEnterUsername') - } - ignorePromise(f()) - }, resetState: () => { set(s => ({ ...s, ...initialStore, - justSignedUpEmail: '', })) }, + setDevicename: (devicename: string) => { + set(s => { + s.devicename = devicename + }) + }, setJustSignedUpEmail: (email: string) => { set(s => { s.justSignedUpEmail = email diff --git a/shared/stores/tests/crypto.test.ts b/shared/stores/tests/crypto.test.ts index 5a5689eac660..ee7291d85ae6 100644 --- a/shared/stores/tests/crypto.test.ts +++ b/shared/stores/tests/crypto.test.ts @@ -1,626 +1,71 @@ /// 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 {createEncryptState, encryptToOutputParams, teamBuilderResultToRecipients} from '@/crypto/encrypt' +import { + createCommonState, + getStatusCodeMessage, +} from '@/crypto/helpers' + +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, - } as any) - const verifyFile = jest.spyOn(T.RPCGen, 'saltpackSaltpackVerifyFileRpcPromise').mockResolvedValue({ - sender: {fullname: '', username: ''}, - verified: false, - verifiedFilename: '/tmp/verified.txt', - } as any) +test('getStatusCodeMessage maps wrong-format verify errors with the decrypt hint', () => { + const error = new RPCError('wrong type', T.RPCGen.StatusCode.scwrongcryptomsgtype, [ + {key: 'ignored', value: T.RPCGen.StatusCode.scgeneric}, + {key: 'Code', value: T.RPCGen.StatusCode.scwrongcryptomsgtype}, + ]) - 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?') }) 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/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/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/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/stores/tests/signup.test.ts b/shared/stores/tests/signup.test.ts index 67bfa7912f64..1c5b73f575ca 100644 --- a/shared/stores/tests/signup.test.ts +++ b/shared/stores/tests/signup.test.ts @@ -1,52 +1,18 @@ /// -import * as T from '@/constants/types' +import * as S from '@/constants/strings' 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('setDevicename stages the selected signup device name', () => { + useSignupState.getState().dispatch.setDevicename('Phone 2') - useSignupState.getState().dispatch.checkUsername('bad username') - - expect(useSignupState.getState().username).toBe('bad username') - expect(useSignupState.getState().usernameError).not.toBe('') - expect(checkUsername).not.toHaveBeenCalled() -}) - -test('checkUsername accepts a valid username and navigates to device setup', async () => { - jest.spyOn(T.RPCGen, 'signupCheckUsernameAvailableRpcPromise').mockResolvedValue(undefined as any) - - useSignupState.getState().dispatch.checkUsername('alice123') - await flush() - - expect(useSignupState.getState().username).toBe('alice123') - expect(useSignupState.getState().usernameError).toBe('') - expect(useSignupState.getState().usernameTaken).toBe('') - expect(mockNavigateAppend).toHaveBeenCalledWith('signupEnterDevicename') + expect(useSignupState.getState().devicename).toBe('Phone 2') }) test('email verification notifications clear the staged signup email', () => { @@ -59,3 +25,17 @@ 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, + devicename: 'Phone 2', + justSignedUpEmail: 'alice@example.com', + })) + + useSignupState.getState().dispatch.resetState() + + const state = useSignupState.getState() + expect(state.devicename).toBe(S.defaultDevicename) + expect(state.justSignedUpEmail).toBe('') +}) 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/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/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/shared/util/phone-numbers/index.tsx b/shared/util/phone-numbers/index.tsx index 587795dbaf09..11a1599dc405 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 = async () => { + if (_defaultPhoneCountry) { + return _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/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..ba5b911f5218 --- /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` +- [x] `crypto` +- [ ] `daemon` +- [ ] `darkmode` +- [ ] `devices` +- [ ] `followers` +- [ ] `git` +- [ ] `inbox-rows` +- [x] `logout` kept handshake `version`/`waiters` in store; moved can-logout RPC and password redirect into settings hook +- [ ] `modal-header` +- [ ] `notifications` +- [ ] `people` +- [ ] `pinentry` +- [ ] `profile` +- [ ] `provision` +- [ ] `recover-password` +- [ ] `settings` +- [ ] `settings-chat` +- [x] `settings-email` moved add-email submit/error state into components; kept notification-backed `emails`, `addedEmail`, and row actions in store +- [ ] `settings-notifications` +- [x] `settings-password` kept only `randomPW` in store; moved submit/load flows into settings screens +- [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` +- [x] `unlock-folders` removed dead phase/device state; kept only engine callback forwarding into `config` +- [ ] `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.