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.