From ed9114c6de6d2af81c1ba471a8df01aaabd2cc98 Mon Sep 17 00:00:00 2001 From: Inokentii Mazhara Date: Tue, 6 Aug 2024 14:31:08 +0300 Subject: [PATCH 01/12] TW-1449 Remake initial import flow --- public/_locales/en/messages.json | 70 +++- public/_locales/uk/messages.json | 56 +++- src/app/PageRouter.tsx | 21 +- src/app/atoms/AssetField.tsx | 34 +- src/app/atoms/Checkbox.tsx | 17 +- src/app/atoms/FormCheckboxGroup.tsx | 28 -- src/app/atoms/FormField.tsx | 29 +- src/app/atoms/ImportTabSwitcher.tsx | 44 --- src/app/atoms/PlainAssetInput.tsx | 30 +- src/app/atoms/ReadOnlySecretField.tsx | 20 +- src/app/atoms/TextButton.tsx | 58 ++++ src/app/hooks/use-modal-open-search-params.ts | 12 +- src/app/hooks/use-rich-format-tooltip.ts | 6 +- src/app/icons/base/paste_fill.svg | 6 + src/app/icons/bin.svg | 3 - src/app/icons/paperclip.svg | 3 - src/app/icons/select-arrow-down.svg | 3 - src/app/layouts/PageLayout/index.tsx | 2 +- src/app/pages/Home/Home.tsx | 14 +- src/app/pages/ImportAccount/selectors.ts | 7 +- src/app/pages/ImportWallet.tsx | 53 +++ src/app/pages/NewWallet/ImportWallet.tsx | 67 ---- .../pages/NewWallet/LockedWalletExists.tsx | 39 --- .../ImportFromKeystoreFile.selectors.ts | 4 - .../ImportFromKeystoreFile.tsx | 169 ---------- .../ImportFromSeedPhrase.selectors.ts | 4 - .../ImportSeedPhrase/ImportFromSeedPhrase.tsx | 54 --- .../SetWalletPassword.selectors.ts | 10 - .../setWalletPassword/SetWalletPassword.tsx | 311 ------------------ src/app/pages/Receive/Receive.tsx | 10 +- src/app/pages/Welcome/Welcome.tsx | 67 +++- .../components/use-card-number-input.hook.ts | 12 +- src/app/templates/AppHeader/index.tsx | 10 +- .../config.ts | 0 .../templates/CreatePasswordForm/index.tsx | 263 +++++++++++++++ .../selectors.ts | 0 .../templates/CreatePasswordModal/index.tsx | 269 --------------- src/app/templates/ImportSeedForm/index.tsx | 61 ++++ src/app/templates/ImportSeedForm/selectors.ts | 5 + .../verify-seed-phrase-input/word-input.tsx | 11 +- src/app/templates/SearchField.tsx | 36 +- .../SeedLengthSelect/SeedLengthOption.tsx | 32 ++ .../SeedLengthOption/SeedLengthOption.tsx | 52 --- .../seedLengthOption.module.css | 43 --- .../SeedLengthSelect/SeedLengthSelect.tsx | 66 ++-- .../SeedLengthSelect/get-option-label.ts | 5 + .../SeedPhraseInput/SeedWordInput.tsx | 76 +++-- src/app/templates/SeedPhraseInput/index.tsx | 133 +++++--- src/fonts.css | 4 + src/lib/constants.ts | 2 + src/lib/fiat-currency/core.ts | 2 +- src/lib/temple/front/index.ts | 2 - src/lib/temple/front/kukai.ts | 62 ---- src/lib/temple/front/provider.tsx | 8 +- .../front/successful-import-toast-context.ts | 7 + src/lib/ui/hooks/use-focus-handlers.ts | 33 ++ src/lib/ui/index.ts | 1 - tailwind.config.js | 1 + webpack/manifest.ts | 2 +- 59 files changed, 984 insertions(+), 1465 deletions(-) delete mode 100644 src/app/atoms/FormCheckboxGroup.tsx delete mode 100644 src/app/atoms/ImportTabSwitcher.tsx create mode 100644 src/app/atoms/TextButton.tsx create mode 100644 src/app/icons/base/paste_fill.svg delete mode 100644 src/app/icons/bin.svg delete mode 100644 src/app/icons/paperclip.svg delete mode 100644 src/app/icons/select-arrow-down.svg create mode 100644 src/app/pages/ImportWallet.tsx delete mode 100644 src/app/pages/NewWallet/ImportWallet.tsx delete mode 100644 src/app/pages/NewWallet/LockedWalletExists.tsx delete mode 100644 src/app/pages/NewWallet/import/ImportFromKeystoreFile/ImportFromKeystoreFile.selectors.ts delete mode 100644 src/app/pages/NewWallet/import/ImportFromKeystoreFile/ImportFromKeystoreFile.tsx delete mode 100644 src/app/pages/NewWallet/import/ImportSeedPhrase/ImportFromSeedPhrase.selectors.ts delete mode 100644 src/app/pages/NewWallet/import/ImportSeedPhrase/ImportFromSeedPhrase.tsx delete mode 100644 src/app/pages/NewWallet/setWalletPassword/SetWalletPassword.selectors.ts delete mode 100644 src/app/pages/NewWallet/setWalletPassword/SetWalletPassword.tsx rename src/app/templates/{CreatePasswordModal => CreatePasswordForm}/config.ts (100%) create mode 100644 src/app/templates/CreatePasswordForm/index.tsx rename src/app/templates/{CreatePasswordModal => CreatePasswordForm}/selectors.ts (100%) delete mode 100644 src/app/templates/CreatePasswordModal/index.tsx create mode 100644 src/app/templates/ImportSeedForm/index.tsx create mode 100644 src/app/templates/ImportSeedForm/selectors.ts create mode 100644 src/app/templates/SeedPhraseInput/SeedLengthSelect/SeedLengthOption.tsx delete mode 100644 src/app/templates/SeedPhraseInput/SeedLengthSelect/SeedLengthOption/SeedLengthOption.tsx delete mode 100644 src/app/templates/SeedPhraseInput/SeedLengthSelect/SeedLengthOption/seedLengthOption.module.css create mode 100644 src/app/templates/SeedPhraseInput/SeedLengthSelect/get-option-label.ts delete mode 100644 src/lib/temple/front/kukai.ts create mode 100644 src/lib/temple/front/successful-import-toast-context.ts create mode 100644 src/lib/ui/hooks/use-focus-handlers.ts diff --git a/public/_locales/en/messages.json b/public/_locales/en/messages.json index 699b6e7032..7f6189ed13 100644 --- a/public/_locales/en/messages.json +++ b/public/_locales/en/messages.json @@ -694,8 +694,8 @@ "seedPhraseAttention": { "message": "We don't track your derivation path on imported accounts" }, - "seedPhraseTip": { - "message": "You can paste your entire secret Seed phrase into any field" + "seedPhraseLength": { + "message": "Seed phrase length" }, "oops": { "message": "Oops!" @@ -939,6 +939,12 @@ "importYourWallet": { "message": "Import Your Wallet" }, + "importSeedPhrase": { + "message": "Import Seed Phrase" + }, + "mySeedPhraseIs": { + "message": "My seed phrase is" + }, "createYourWallet": { "message": "Create Your Wallet" }, @@ -1354,6 +1360,60 @@ } } }, + "words_one": { + "message": "$amount$ word", + "description": "For plural forms", + "placeholders": { + "amount": { + "content": "$1" + } + } + }, + "words_zero": { + "message": "$amount$ words", + "description": "For plural forms", + "placeholders": { + "amount": { + "content": "$1" + } + } + }, + "words_two": { + "message": "$amount$ words", + "description": "For plural forms", + "placeholders": { + "amount": { + "content": "$1" + } + } + }, + "words_few": { + "message": "$amount$ words", + "description": "For plural forms", + "placeholders": { + "amount": { + "content": "$1" + } + } + }, + "words_many": { + "message": "$amount$ words", + "description": "For plural forms", + "placeholders": { + "amount": { + "content": "$1" + } + } + }, + "words_other": { + "message": "$amount$ words", + "description": "For plural forms", + "placeholders": { + "amount": { + "content": "$1" + } + } + }, "insufficientTezBalance": { "message": "Insufficient TEZ balance" }, @@ -2161,6 +2221,9 @@ "clear": { "message": "Clear" }, + "paste": { + "message": "Paste" + }, "errorChangingAccountName": { "message": "An error occurred while changing account name" }, @@ -3393,6 +3456,9 @@ "backupSuccessful": { "message": "You successfully backed up your wallet" }, + "importSuccessful": { + "message": "You successfully imported your wallet" + }, "walletCreatedSuccessfully": { "message": "Wallet has been created successfully" }, diff --git a/public/_locales/uk/messages.json b/public/_locales/uk/messages.json index 25e4a3c031..f11a85ec86 100644 --- a/public/_locales/uk/messages.json +++ b/public/_locales/uk/messages.json @@ -466,7 +466,7 @@ "message": "Щонайменше 1 велика літера" }, "mnemonicWordsAmountConstraint": { - "message": "12, 15, 18, 21 or 24 слів англійською" + "message": "12, 15, 18, 21 або 24 слів англійською" }, "mnemonicSpacingConstraint": { "message": "Кожне слово відокремлюється одним пробілом" @@ -1860,6 +1860,60 @@ } } }, + "words_one": { + "message": "$amount$ слово", + "description": "For plural forms", + "placeholders": { + "amount": { + "content": "$1" + } + } + }, + "words_zero": { + "message": "$amount$ слів", + "description": "For plural forms", + "placeholders": { + "amount": { + "content": "$1" + } + } + }, + "words_two": { + "message": "$amount$ слова", + "description": "For plural forms", + "placeholders": { + "amount": { + "content": "$1" + } + } + }, + "words_few": { + "message": "$amount$ слова", + "description": "For plural forms", + "placeholders": { + "amount": { + "content": "$1" + } + } + }, + "words_many": { + "message": "$amount$ слів", + "description": "For plural forms", + "placeholders": { + "amount": { + "content": "$1" + } + } + }, + "words_other": { + "message": "$amount$ слів", + "description": "For plural forms", + "placeholders": { + "amount": { + "content": "$1" + } + } + }, "showInfo": { "message": "Деталі" }, "hideZeroBalance": { "message": "Сховати нульові баланси" }, "creator": { "message": "Автор:" }, diff --git a/src/app/PageRouter.tsx b/src/app/PageRouter.tsx index 2106b29de6..7c1dc0e2eb 100644 --- a/src/app/PageRouter.tsx +++ b/src/app/PageRouter.tsx @@ -11,7 +11,6 @@ import DApps from 'app/pages/DApps'; import Delegate from 'app/pages/Delegate'; import Home from 'app/pages/Home/Home'; import ImportAccount from 'app/pages/ImportAccount'; -import { ImportWallet } from 'app/pages/NewWallet/ImportWallet'; import AttentionPage from 'app/pages/Onboarding/pages/AttentionPage'; import { Receive } from 'app/pages/Receive/Receive'; import Send from 'app/pages/Send'; @@ -25,6 +24,7 @@ import { Notifications, NotificationsItem } from 'lib/notifications/components'; import { useTempleClient } from 'lib/temple/front'; import * as Woozie from 'lib/woozie'; +import { ImportWallet } from './pages/ImportWallet'; import { Market } from './pages/Market'; import { StakingPage } from './pages/Staking'; @@ -39,18 +39,17 @@ type RouteFactory = Woozie.ResolveResult; const ROUTE_MAP = Woozie.createMap([ [ - '/import-wallet/:tabSlug?', - (p, ctx) => { - switch (true) { - case ctx.ready: - return Woozie.SKIP; - - case !ctx.fullPage: - return ; + '/import-wallet', + (_p, ctx) => { + if (ctx.ready) { + return Woozie.SKIP; + } - default: - return ; + if (!ctx.fullPage) { + return ; } + + return ; } ], [ diff --git a/src/app/atoms/AssetField.tsx b/src/app/atoms/AssetField.tsx index 7f0e22bc50..c90517323e 100644 --- a/src/app/atoms/AssetField.tsx +++ b/src/app/atoms/AssetField.tsx @@ -3,6 +3,7 @@ import React, { ComponentProps, forwardRef, ReactNode, useCallback, useEffect, u import BigNumber from 'bignumber.js'; import { FormField } from 'app/atoms'; +import { useFocusHandlers } from 'lib/ui/hooks/use-focus-handlers'; interface AssetFieldProps extends Omit, 'onChange'> { value?: number | string; @@ -31,13 +32,14 @@ const AssetField = forwardRef( const valueStr = useMemo(() => (value === undefined ? '' : new BigNumber(value).toFixed()), [value]); const [localValue, setLocalValue] = useState(valueStr); - const [focused, setFocused] = useState(false); + + const { isFocused, onFocus: handleFocus, onBlur: handleBlur } = useFocusHandlers(onFocus, onBlur); useEffect(() => { - if (!focused) { + if (!isFocused) { setLocalValue(valueStr); } - }, [setLocalValue, focused, valueStr]); + }, [setLocalValue, isFocused, valueStr]); const handleChange = useCallback( (evt: React.ChangeEvent & React.ChangeEvent) => { @@ -59,32 +61,6 @@ const AssetField = forwardRef( [assetDecimals, setLocalValue, min, max, onChange] ); - const handleFocus = useCallback( - (evt: React.FocusEvent & React.FocusEvent) => { - setFocused(true); - if (onFocus) { - onFocus(evt); - if (evt.defaultPrevented) { - return; - } - } - }, - [setFocused, onFocus] - ); - - const handleBlur = useCallback( - (evt: React.FocusEvent & React.FocusEvent) => { - setFocused(false); - if (onBlur) { - onBlur(evt); - if (evt.defaultPrevented) { - return; - } - } - }, - [setFocused, onBlur] - ); - return ( ) => focusHandler(e, onFocus!, setLocalFocused), - [onFocus, setLocalFocused] - ); - const handleBlur = useCallback( - (e: React.FocusEvent) => blurHandler(e, onBlur!, setLocalFocused), - [onBlur, setLocalFocused] - ); + const { isFocused: localFocused, onFocus: handleFocus, onBlur: handleBlur } = useFocusHandlers(onFocus, onBlur); return { localChecked, localFocused, handleChange, handleFocus, handleBlur }; }; diff --git a/src/app/atoms/FormCheckboxGroup.tsx b/src/app/atoms/FormCheckboxGroup.tsx deleted file mode 100644 index fc1b70fcde..0000000000 --- a/src/app/atoms/FormCheckboxGroup.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import React, { Children, FC, Fragment, ReactNode } from 'react'; - -import clsx from 'clsx'; - -import Divider from './Divider'; - -interface FormCheckboxGroupProps { - children: ReactNode | ReactNode[]; - isError?: boolean; - className?: string; -} - -export const FormCheckboxGroup: FC = ({ className, children, isError }) => ( -
- {Children.map(children, (child, index) => ( - - {child} - {index < Children.count(children) - 1 && } - - ))} -
-); diff --git a/src/app/atoms/FormField.tsx b/src/app/atoms/FormField.tsx index 8a3221e6b0..28218528d3 100644 --- a/src/app/atoms/FormField.tsx +++ b/src/app/atoms/FormField.tsx @@ -17,7 +17,8 @@ import OldStyleCopyButton from 'app/atoms/OldStyleCopyButton'; import { ReactComponent as CopyIcon } from 'app/icons/monochrome/copy.svg'; import { setTestID, TestIDProperty } from 'lib/analytics'; import { useDidUpdate } from 'lib/ui/hooks'; -import { blurHandler, focusHandler, inputChangeHandler } from 'lib/ui/inputHandlers'; +import { useFocusHandlers } from 'lib/ui/hooks/use-focus-handlers'; +import { inputChangeHandler } from 'lib/ui/inputHandlers'; import { useBlurElementOnTimeout } from 'lib/ui/use-blur-on-timeout'; import useCopyToClipboard from 'lib/ui/useCopyToClipboard'; import { combineRefs } from 'lib/ui/utils'; @@ -42,6 +43,8 @@ export interface FormFieldProps extends TestIDProperty, Omit( labelWarning, errorCaption, shouldShowErrorCaption = true, + warningCaption, + shouldShowWarningCaption = true, containerClassName, labelContainerClassName, textarea, @@ -133,7 +138,7 @@ export const FormField = forwardRef( const [localValue, setLocalValue] = useState(value ?? defaultValue ?? ''); useDidUpdate(() => setLocalValue(value ?? ''), [value]); - const [focused, setFocused] = useState(false); + const { isFocused: focused, onFocus: handleFocus, onBlur: handleBlur } = useFocusHandlers(onFocus, onBlur); const handleChange = useCallback( (e: React.ChangeEvent) => { @@ -142,12 +147,6 @@ export const FormField = forwardRef( [onChange, setLocalValue] ); - const handleFocus = useCallback( - (e: React.FocusEvent) => focusHandler(e, onFocus, setFocused), - [onFocus, setFocused] - ); - const handleBlur = useCallback((e: React.FocusEvent) => blurHandler(e, onBlur, setFocused), [onBlur, setFocused]); - const secretCovered = useMemo( () => Boolean(secret && localValue !== '' && !focused), [secret, localValue, focused] @@ -210,6 +209,7 @@ export const FormField = forwardRef( innerComponent={extraLeftInner} useDefaultWrapper={extraLeftInnerWrapper === 'default'} position="left" + smallPaddings={smallPaddings} /> ( className={clsx( FORM_FIELD_CLASS_NAME, smallPaddings ? 'py-2 pl-2' : 'p-3', - errorCaption ? 'border-error' : 'border-input-low', + errorCaption ? 'border-error' : warningCaption ? 'border-warning' : 'border-input-low', className )} style={fieldStyle} @@ -238,6 +238,7 @@ export const FormField = forwardRef( innerComponent={extraRightInner} useDefaultWrapper={extraRightInnerWrapper === 'default'} position="right" + smallPaddings={smallPaddings} />
= ({ useDefaultWrapper, innerComponent, position }) => { +const ExtraInner: React.FC = ({ useDefaultWrapper, innerComponent, position, smallPaddings }) => { if (useDefaultWrapper) return (
-
{innerComponent}
+
{innerComponent}
); return <>{innerComponent}; @@ -326,6 +329,6 @@ const buildHorizontalPaddingStyle = ( ) => { return { paddingRight: withExtraInnerRight ? 128 : (smallPaddings ? 8 : 12) + (textarea ? 0 : buttonsCount * 28), - paddingLeft: withExtraInnerLeft ? 40 : smallPaddings ? 8 : 12 + paddingLeft: withExtraInnerLeft ? (smallPaddings ? 32 : 40) : smallPaddings ? 8 : 12 }; }; diff --git a/src/app/atoms/ImportTabSwitcher.tsx b/src/app/atoms/ImportTabSwitcher.tsx deleted file mode 100644 index 4eb8e239f4..0000000000 --- a/src/app/atoms/ImportTabSwitcher.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import React, { memo } from 'react'; - -import clsx from 'clsx'; - -import { TID, T } from 'lib/i18n'; -import { Link } from 'lib/woozie'; - -interface ImportTabDescriptor { - slug: string; - i18nKey: TID; -} - -interface Props { - tabs: ImportTabDescriptor[]; - activeTabSlug: string; - urlPrefix: string; -} - -const ImportTabSwitcher = memo(({ tabs, activeTabSlug, urlPrefix }) => ( -
-
- {tabs.map(({ slug, i18nKey }) => { - const active = slug === activeTabSlug; - - return ( - -
- -
- - ); - })} -
-
-)); - -export default ImportTabSwitcher; diff --git a/src/app/atoms/PlainAssetInput.tsx b/src/app/atoms/PlainAssetInput.tsx index e9d74bf7cf..051a2768c7 100644 --- a/src/app/atoms/PlainAssetInput.tsx +++ b/src/app/atoms/PlainAssetInput.tsx @@ -2,6 +2,8 @@ import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; import BigNumber from 'bignumber.js'; +import { useFocusHandlers } from 'lib/ui/hooks/use-focus-handlers'; + type PlainAssetInputProps = Omit, 'onChange'> & { value?: number | string; min?: number; @@ -23,7 +25,7 @@ const PlainAssetInput: FC = ({ const valueStr = useMemo(() => (value === undefined ? '' : new BigNumber(value).toFixed()), [value]); const [localValue, setLocalValue] = useState(valueStr); - const [focused, setFocused] = useState(false); + const { isFocused: focused, onFocus: handleFocus, onBlur: handleBlur } = useFocusHandlers(onFocus, onBlur); useEffect(() => { if (!focused) { @@ -51,32 +53,6 @@ const PlainAssetInput: FC = ({ [assetDecimals, setLocalValue, min, max, onChange] ); - const handleFocus = useCallback( - (evt: React.FocusEvent) => { - setFocused(true); - if (onFocus) { - onFocus(evt); - if (evt.defaultPrevented) { - return; - } - } - }, - [setFocused, onFocus] - ); - - const handleBlur = useCallback( - (evt: React.FocusEvent) => { - setFocused(false); - if (onBlur) { - onBlur(evt); - if (evt.defaultPrevented) { - return; - } - } - }, - [setFocused, onBlur] - ); - return ( ); diff --git a/src/app/atoms/ReadOnlySecretField.tsx b/src/app/atoms/ReadOnlySecretField.tsx index bdfcb3c14d..7e537416b8 100644 --- a/src/app/atoms/ReadOnlySecretField.tsx +++ b/src/app/atoms/ReadOnlySecretField.tsx @@ -1,4 +1,4 @@ -import React, { FC, useState, useCallback, useRef, useEffect } from 'react'; +import React, { FC, useCallback, useRef, useEffect } from 'react'; import clsx from 'clsx'; @@ -6,6 +6,7 @@ import { ReactComponent as CopyIcon } from 'app/icons/base/copy.svg'; import { setTestID, TestIDProperty } from 'lib/analytics'; import { TID, T } from 'lib/i18n'; import { selectNodeContent } from 'lib/ui/content-selection'; +import { useFocusHandlers } from 'lib/ui/hooks/use-focus-handlers'; import { CopyButton } from './CopyButton'; import { FieldLabel } from './FieldLabel'; @@ -29,8 +30,13 @@ export const ReadOnlySecretField: FC = ({ testID, secretCoverTestId }) => { - const [focused, setFocused] = useState(false); - const [copyButtonFocused, setCopyButtonFocused] = useState(false); + const { isFocused: focused, onFocus: handleFocus, onBlur: handleBlur } = useFocusHandlers(); + const { + isFocused: copyButtonFocused, + onFocus: handleCopyButtonFocus, + onBlur: handleCopyButtonBlur + } = useFocusHandlers(); + const fieldRef = useRef(null); const onSecretCoverClick = useCallback(() => { @@ -56,8 +62,8 @@ export const ReadOnlySecretField: FC = ({ ref={fieldRef} tabIndex={0} className={clsx(FORM_FIELD_CLASS_NAME, 'h-40 break-words py-3 px-4 overflow-y-auto border-input-low')} - onFocus={() => setFocused(true)} - onBlur={() => setFocused(false)} + onFocus={handleFocus} + onBlur={handleBlur} {...setTestID(testID)} > {covered ? '' : value} @@ -67,8 +73,8 @@ export const ReadOnlySecretField: FC = ({ text={covered ? '' : value} isSecret className="text-secondary absolute right-3 bottom-3 flex text-font-description-bold items-center px-1 py-0.5" - onFocus={() => setCopyButtonFocused(true)} - onBlur={() => setCopyButtonFocused(false)} + onFocus={handleCopyButtonFocus} + onBlur={handleCopyButtonBlur} > diff --git a/src/app/atoms/TextButton.tsx b/src/app/atoms/TextButton.tsx new file mode 100644 index 0000000000..1de7152444 --- /dev/null +++ b/src/app/atoms/TextButton.tsx @@ -0,0 +1,58 @@ +import React, { forwardRef, memo, useMemo } from 'react'; + +import clsx from 'clsx'; + +import { TestIDProps } from 'lib/analytics'; + +import { Button } from './Button'; +import { IconBase } from './IconBase'; + +type Color = 'black' | 'blue' | 'grey'; + +interface Props extends TestIDProps { + Icon: ImportedSVGComponent; + color?: Color; + onClick?: EmptyFn; +} + +export const TextButton = memo( + forwardRef>( + ({ Icon, color, onClick, testID, testIDProperties, children }, ref) => { + const { textClassName, iconClassName } = useMemo(() => { + switch (color) { + case 'black': + return { + textClassName: 'focus:text-black', + iconClassName: 'text-secondary focus:text-secondary-hover' + }; + case 'blue': + return { + textClassName: 'text-secondary focus:text-secondary-hover', + iconClassName: 'text-secondary focus:text-secondary-hover' + }; + default: + return { + textClassName: 'text-grey-1 focus:text-grey-2', + iconClassName: 'text-grey-2 focus:text-grey-3' + }; + } + }, [color]); + + return ( + + ); + } + ) +); diff --git a/src/app/hooks/use-modal-open-search-params.ts b/src/app/hooks/use-modal-open-search-params.ts index a708580edd..4ce85cc50b 100644 --- a/src/app/hooks/use-modal-open-search-params.ts +++ b/src/app/hooks/use-modal-open-search-params.ts @@ -2,14 +2,14 @@ import { useCallback, useMemo } from 'react'; import { HistoryAction, navigate, useLocation } from 'lib/woozie'; -export const useModalOpenSearchParams = (paramName: string) => { +export const useSearchParamsBoolean = (paramName: string) => { const { search, pathname } = useLocation(); - const isOpen = useMemo(() => { + const value = useMemo(() => { const usp = new URLSearchParams(search); return Boolean(usp.get(paramName)); }, [paramName, search]); - const setModalState = useCallback( + const setValue = useCallback( (newState: boolean) => { const newUsp = new URLSearchParams(search); if (newState) { @@ -22,8 +22,8 @@ export const useModalOpenSearchParams = (paramName: string) => { }, [search, pathname, paramName] ); - const openModal = useCallback(() => setModalState(true), [setModalState]); - const closeModal = useCallback(() => setModalState(false), [setModalState]); + const setTrue = useCallback(() => setValue(true), [setValue]); + const setFalse = useCallback(() => setValue(false), [setValue]); - return { isOpen, openModal, closeModal }; + return { value, setTrue, setFalse }; }; diff --git a/src/app/hooks/use-rich-format-tooltip.ts b/src/app/hooks/use-rich-format-tooltip.ts index 8e2905ef2e..951bdb0501 100644 --- a/src/app/hooks/use-rich-format-tooltip.ts +++ b/src/app/hooks/use-rich-format-tooltip.ts @@ -16,11 +16,9 @@ export const useRichFormatTooltip = ( }), [props, wrapperFactory] ); + const root = useMemo(() => createRoot(tippyProps.content), [tippyProps.content]); - useEffect(() => { - const root = createRoot(tippyProps.content); - root.render(content); - }, [tippyProps.content, content]); + useEffect(() => root.render(content), [root, content]); return useTippy(tippyProps); }; diff --git a/src/app/icons/base/paste_fill.svg b/src/app/icons/base/paste_fill.svg new file mode 100644 index 0000000000..e0364df505 --- /dev/null +++ b/src/app/icons/base/paste_fill.svg @@ -0,0 +1,6 @@ + + + diff --git a/src/app/icons/bin.svg b/src/app/icons/bin.svg deleted file mode 100644 index 2f93ac3d18..0000000000 --- a/src/app/icons/bin.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/app/icons/paperclip.svg b/src/app/icons/paperclip.svg deleted file mode 100644 index eedea6d0c8..0000000000 --- a/src/app/icons/paperclip.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/app/icons/select-arrow-down.svg b/src/app/icons/select-arrow-down.svg deleted file mode 100644 index 204ae3024d..0000000000 --- a/src/app/icons/select-arrow-down.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/app/layouts/PageLayout/index.tsx b/src/app/layouts/PageLayout/index.tsx index 5c9d334eac..2a6b5f3b63 100644 --- a/src/app/layouts/PageLayout/index.tsx +++ b/src/app/layouts/PageLayout/index.tsx @@ -83,7 +83,7 @@ const PageLayout: FC> = ({ - {!shouldBackupMnemonic && ( + {!shouldBackupMnemonic && ready && ( <> diff --git a/src/app/pages/Home/Home.tsx b/src/app/pages/Home/Home.tsx index c930cb0ef2..86977e5d9a 100644 --- a/src/app/pages/Home/Home.tsx +++ b/src/app/pages/Home/Home.tsx @@ -1,4 +1,4 @@ -import React, { memo, useCallback, useLayoutEffect, useMemo } from 'react'; +import React, { memo, useCallback, useContext, useEffect, useLayoutEffect, useMemo } from 'react'; import { isDefined } from '@rnw-community/shared'; @@ -15,6 +15,9 @@ import { useMainnetTokensScamlistSelector } from 'app/store/tezos/assets/selecto import { ActivityTab } from 'app/templates/activity/Activity'; import { AdvertisingBanner } from 'app/templates/advertising/advertising-banner/advertising-banner'; import { AppHeader } from 'app/templates/AppHeader'; +import { toastSuccess } from 'app/toaster'; +import { t } from 'lib/i18n'; +import { SuccessfulImportToastContext } from 'lib/temple/front/successful-import-toast-context'; import { HistoryAction, navigate, useLocation } from 'lib/woozie'; import { CollectiblesTab } from '../Collectibles/CollectiblesTab'; @@ -37,6 +40,15 @@ const Home = memo(props => { const { onboardingCompleted } = useOnboardingProgress(); const { search } = useLocation(); + const [shouldShowImportToast, setShouldShowImportToast] = useContext(SuccessfulImportToastContext); + + useEffect(() => { + if (shouldShowImportToast) { + setShouldShowImportToast(false); + toastSuccess(t('importSuccessful')); + } + }, [setShouldShowImportToast, shouldShowImportToast]); + const assetsSegmentControlRef = useAssetsSegmentControlRef(); const mainnetTokensScamSlugsRecord = useMainnetTokensScamlistSelector(); diff --git a/src/app/pages/ImportAccount/selectors.ts b/src/app/pages/ImportAccount/selectors.ts index 1e30461186..731b3d1399 100644 --- a/src/app/pages/ImportAccount/selectors.ts +++ b/src/app/pages/ImportAccount/selectors.ts @@ -6,7 +6,7 @@ export enum ImportAccountSelectors { mnemonicWordInput = 'Import Account(Mnemonic)/Mnemonic Word Input', mnemonicDropDownButton = 'Import (Account/Wallet)/Mnemonic Drop Down Button', - mnemonicWordsRadioButton = 'Import (Account/Wallet)/Mnemonic Words Radio Button', + mnemonicWordsOption = 'Import (Account/Wallet)/Mnemonic Words Option', defaultAccountButton = 'Import Account(Mnemonic)/Default Account (the first one) Button', customDerivationPathButton = 'Import Account(Mnemonic)/Custom Derivation Path Button', customDerivationPathInput = 'Import Account(Mnemonic)/Custom Derivation Path Input', @@ -23,7 +23,10 @@ export enum ImportAccountSelectors { fundraiserImportButton = 'Import Account(Fundraiser)/Import Button', managedContractInput = 'Import Account(ManagedKT)/Managed Contract Input', - managedKTImportButton = 'Import Account(ManagedKT)/Import Account Button' + managedKTImportButton = 'Import Account(ManagedKT)/Import Account Button', + + ClearSeedPhraseButton = 'Import Account/Clear Seed Phrase Button', + PasteSeedPhraseButton = 'Import Account/Paste Seed Phrase Button' } export enum ImportAccountFormType { diff --git a/src/app/pages/ImportWallet.tsx b/src/app/pages/ImportWallet.tsx new file mode 100644 index 0000000000..4056d9cf85 --- /dev/null +++ b/src/app/pages/ImportWallet.tsx @@ -0,0 +1,53 @@ +import React, { memo, useCallback, useState } from 'react'; + +import { PageModal } from 'app/atoms/PageModal'; +import { SuspenseContainer } from 'app/atoms/SuspenseContainer'; +import PageLayout from 'app/layouts/PageLayout'; +import { CreatePasswordForm } from 'app/templates/CreatePasswordForm'; +import { ImportSeedForm } from 'app/templates/ImportSeedForm'; +import { t } from 'lib/i18n'; +import { useBooleanState } from 'lib/ui/hooks'; +import { navigate } from 'lib/woozie'; + +const EmptyHeader = () => null; +const goHome = () => navigate('/'); + +export const ImportWallet = memo(() => { + const [seedPhrase, setSeedPhrase] = useState(); + const [shouldShowPasswordForm, showPasswordForm, hidePasswordForm] = useBooleanState(false); + + const handleSeedPhraseSubmit = useCallback( + (seed: string) => { + setSeedPhrase(seed); + showPasswordForm(); + }, + [showPasswordForm] + ); + + const handleGoBack = useCallback( + () => void (shouldShowPasswordForm && hidePasswordForm()), + [hidePasswordForm, shouldShowPasswordForm] + ); + + return ( + + + + {shouldShowPasswordForm ? ( + + ) : ( + + )} + + + +
+ + ); +}); diff --git a/src/app/pages/NewWallet/ImportWallet.tsx b/src/app/pages/NewWallet/ImportWallet.tsx deleted file mode 100644 index b1f1cb2170..0000000000 --- a/src/app/pages/NewWallet/ImportWallet.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import React, { FC, useState } from 'react'; - -import ImportTabSwitcher from 'app/atoms/ImportTabSwitcher'; -import PageLayout from 'app/layouts/PageLayout'; -import { TID, t } from 'lib/i18n'; -import { useTempleClient } from 'lib/temple/front'; - -import { ImportFromKeystoreFile } from './import/ImportFromKeystoreFile/ImportFromKeystoreFile'; -import { ImportFromSeedPhrase } from './import/ImportSeedPhrase/ImportFromSeedPhrase'; -import { LockedWalletExists } from './LockedWalletExists'; -import { SetWalletPassword } from './setWalletPassword/SetWalletPassword'; - -interface ImportWalletProps { - tabSlug?: string; -} - -const importWalletOptions: { - slug: string; - i18nKey: TID; -}[] = [ - { - slug: 'seed-phrase', - i18nKey: 'seedPhrase' - }, - { - slug: 'keystore-file', - i18nKey: 'keystoreFile' - } -]; - -export const ImportWallet: FC = ({ tabSlug = 'seed-phrase' }) => { - const { locked } = useTempleClient(); - - const [seedPhrase, setSeedPhrase] = useState(''); - const [keystorePassword, setKeystorePassword] = useState(''); - const [isSeedEntered, setIsSeedEntered] = useState(false); - - const isImportFromSeedPhrase = tabSlug === 'seed-phrase'; - - return ( - - - - - - {isImportFromSeedPhrase ? ( - isSeedEntered ? ( - - ) : ( - - ) - ) : isSeedEntered ? ( - - ) : ( - - )} - - ); -}; diff --git a/src/app/pages/NewWallet/LockedWalletExists.tsx b/src/app/pages/NewWallet/LockedWalletExists.tsx deleted file mode 100644 index fe5868ab12..0000000000 --- a/src/app/pages/NewWallet/LockedWalletExists.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import React, { FC } from 'react'; - -import { Alert } from 'app/atoms'; -import { T, t } from 'lib/i18n'; -import { Link } from 'lib/woozie'; - -interface LockedWalletExistsProps { - locked: boolean; -} - -export const LockedWalletExists: FC = ({ locked }) => - locked ? ( - -

- -

- -

- - {linkLabel => ( - - {linkLabel} - - )} - - ]} - /> -

- - } - className="my-6" - /> - ) : null; diff --git a/src/app/pages/NewWallet/import/ImportFromKeystoreFile/ImportFromKeystoreFile.selectors.ts b/src/app/pages/NewWallet/import/ImportFromKeystoreFile/ImportFromKeystoreFile.selectors.ts deleted file mode 100644 index 06a8b507dc..0000000000 --- a/src/app/pages/NewWallet/import/ImportFromKeystoreFile/ImportFromKeystoreFile.selectors.ts +++ /dev/null @@ -1,4 +0,0 @@ -export enum ImportFromKeystoreFileSelectors { - filePasswordInput = 'Import From Keystore/File Password Input', - nextButton = 'Import From Keystore/Next Button' -} diff --git a/src/app/pages/NewWallet/import/ImportFromKeystoreFile/ImportFromKeystoreFile.tsx b/src/app/pages/NewWallet/import/ImportFromKeystoreFile/ImportFromKeystoreFile.tsx deleted file mode 100644 index 5952d9ffa6..0000000000 --- a/src/app/pages/NewWallet/import/ImportFromKeystoreFile/ImportFromKeystoreFile.tsx +++ /dev/null @@ -1,169 +0,0 @@ -import React, { FC, useCallback } from 'react'; - -import classNames from 'clsx'; -import { Controller, FieldError, NestDataObject, useForm } from 'react-hook-form'; - -import { FileInputProps, FileInput, FormField, FormSubmitButton } from 'app/atoms'; -import { ReactComponent as TrashbinIcon } from 'app/icons/bin.svg'; -import { ReactComponent as PaperclipIcon } from 'app/icons/paperclip.svg'; -import { T, t } from 'lib/i18n'; -import { decryptKukaiSeedPhrase } from 'lib/temple/front'; -import { AlertFn, useAlert } from 'lib/ui'; - -import { ImportFromKeystoreFileSelectors } from './ImportFromKeystoreFile.selectors'; - -interface FormData { - keystoreFile?: FileList; - keystorePassword?: string; -} - -interface ImportFromKeystoreFileProps { - setSeedPhrase: (seed: string) => void; - setKeystorePassword: (password: string) => void; - setIsSeedEntered: (value: boolean) => void; -} - -export const ImportFromKeystoreFile: FC = ({ - setSeedPhrase, - setKeystorePassword, - setIsSeedEntered -}) => { - const customAlert = useAlert(); - const { setValue, control, register, handleSubmit, errors, triggerValidation, formState } = useForm({ - mode: 'onChange' - }); - const submitting = formState.isSubmitting; - - const clearKeystoreFileInput = (event: React.MouseEvent) => { - event.stopPropagation(); - setValue('keystoreFile', undefined); - triggerValidation('keystoreFile'); - }; - - const onSubmit = useCallback( - async (data: FormData) => { - if (submitting) return; - try { - const mnemonic = await decryptKukaiSeedPhrase(await data.keystoreFile!.item(0)!.text(), data.keystorePassword!); - setSeedPhrase(mnemonic); - setKeystorePassword(data.keystorePassword!); - setIsSeedEntered(true); - } catch (err: any) { - handleKukaiWalletError(err, customAlert); - } - }, - [setSeedPhrase, setKeystorePassword, setIsSeedEntered, submitting, customAlert] - ); - - return ( -
- - -
- - -
- - - - - - - ); -}; - -type KeystoreFileInputProps = Pick & { - clearKeystoreFileInput: (e: React.MouseEvent) => void; -}; - -const KeystoreFileInput: React.FC = ({ value, name, clearKeystoreFileInput, onChange }) => { - const keystoreFile = value?.item?.(0); - - return ( - -
-
- - {keystoreFile?.name ?? t('fileInputPrompt')} - - {keystoreFile ? ( - - ) : ( - - )} -
-
- {t('selectFile')} -
-
-
- ); -}; - -const validateKeystoreFile = (value?: FileList) => { - const file = value?.item(0); - - if (file && !file.name.endsWith('.tez')) { - return t('selectedFileFormatNotSupported'); - } - return true; -}; - -const handleKukaiWalletError = (err: any, customAlert: AlertFn) => { - customAlert({ - title: t('errorImportingKukaiWallet'), - children: err instanceof SyntaxError ? t('fileHasSyntaxError') : err.message - }); -}; - -interface ErrorKeystoreComponentProps { - errors: NestDataObject; -} - -const ErrorKeystoreComponent: React.FC = ({ errors }) => - errors.keystoreFile ?
{errors.keystoreFile.message}
: null; diff --git a/src/app/pages/NewWallet/import/ImportSeedPhrase/ImportFromSeedPhrase.selectors.ts b/src/app/pages/NewWallet/import/ImportSeedPhrase/ImportFromSeedPhrase.selectors.ts deleted file mode 100644 index 27a045b5a7..0000000000 --- a/src/app/pages/NewWallet/import/ImportSeedPhrase/ImportFromSeedPhrase.selectors.ts +++ /dev/null @@ -1,4 +0,0 @@ -export enum ImportFromSeedPhraseSelectors { - nextButton = 'Import Existing Seed Phrase/Next button', - wordInput = 'Import Existing Seed Phrase/Word input' -} diff --git a/src/app/pages/NewWallet/import/ImportSeedPhrase/ImportFromSeedPhrase.tsx b/src/app/pages/NewWallet/import/ImportSeedPhrase/ImportFromSeedPhrase.tsx deleted file mode 100644 index 4ddb1e5509..0000000000 --- a/src/app/pages/NewWallet/import/ImportSeedPhrase/ImportFromSeedPhrase.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import React, { FC, useCallback, useState } from 'react'; - -import { useForm } from 'react-hook-form'; - -import { FormSubmitButton } from 'app/atoms'; -import { defaultNumberOfWords } from 'app/pages/ImportAccount/constants'; -import { SeedPhraseInput } from 'app/templates/SeedPhraseInput'; -import { T, t } from 'lib/i18n'; - -import { ImportFromSeedPhraseSelectors } from './ImportFromSeedPhrase.selectors'; - -interface ImportFromSeedPhraseProps { - seedPhrase: string; - setSeedPhrase: (seed: string) => void; - setIsSeedEntered: (value: boolean) => void; -} - -export const ImportFromSeedPhrase: FC = ({ - seedPhrase, - setSeedPhrase, - setIsSeedEntered -}) => { - const { handleSubmit, formState, reset } = useForm(); - const [seedError, setSeedError] = useState(''); - const [numberOfWords, setNumberOfWords] = useState(defaultNumberOfWords); - - const onSubmit = useCallback(() => { - if (seedPhrase && !seedPhrase.split(' ').includes('') && !seedError) { - setIsSeedEntered(true); - } else if (seedError === '') { - setSeedError(t('mnemonicWordsAmountConstraint', [numberOfWords]) as string); - } - }, [seedPhrase, seedError, setIsSeedEntered, numberOfWords]); - - return ( -
- - - - - - - ); -}; diff --git a/src/app/pages/NewWallet/setWalletPassword/SetWalletPassword.selectors.ts b/src/app/pages/NewWallet/setWalletPassword/SetWalletPassword.selectors.ts deleted file mode 100644 index 92f0984616..0000000000 --- a/src/app/pages/NewWallet/setWalletPassword/SetWalletPassword.selectors.ts +++ /dev/null @@ -1,10 +0,0 @@ -export enum setWalletPasswordSelectors { - passwordField = 'Register Form/Password Field', - repeatPasswordField = 'Register Form/Repeat Password Field', - analyticsCheckBox = 'Register Form/Analytics Check Box', - acceptTermsCheckbox = 'Register Form/Accept Terms Checkbox', - importButton = 'Register Form/Import Button', - createButton = 'Register Form/Create Button', - useFilePasswordCheckBox = 'Register Form/Use File Password Check Box', - viewAdsCheckBox = 'Register Form/View Ads Check Box' -} diff --git a/src/app/pages/NewWallet/setWalletPassword/SetWalletPassword.tsx b/src/app/pages/NewWallet/setWalletPassword/SetWalletPassword.tsx deleted file mode 100644 index 1a16b5438e..0000000000 --- a/src/app/pages/NewWallet/setWalletPassword/SetWalletPassword.tsx +++ /dev/null @@ -1,311 +0,0 @@ -import React, { FC, useCallback, useLayoutEffect, useMemo } from 'react'; - -import { Controller, useForm } from 'react-hook-form'; -import { useDispatch } from 'react-redux'; - -import { FormCheckbox, FormField, FormSubmitButton, PASSWORD_ERROR_CAPTION } from 'app/atoms'; -import { FormCheckboxGroup } from 'app/atoms/FormCheckboxGroup'; -import { ValidationLabel } from 'app/atoms/ValidationLabel'; -import { formatMnemonic, PASSWORD_PATTERN, PasswordValidation, passwordValidationRegexes } from 'app/defaults'; -import { togglePartnersPromotionAction } from 'app/store/partners-promotion/actions'; -import { - setIsAnalyticsEnabledAction, - setOnRampPossibilityAction, - setPendingReactivateAdsAction -} from 'app/store/settings/actions'; -import { AnalyticsEventCategory, TestIDProps, useAnalytics } from 'lib/analytics'; -import { WEBSITES_ANALYTICS_ENABLED } from 'lib/constants'; -import { T, TID, t } from 'lib/i18n'; -import { putToStorage } from 'lib/storage'; -import { useTempleClient } from 'lib/temple/front'; -import { navigate } from 'lib/woozie'; - -import { useOnboardingProgress } from '../../Onboarding/hooks/useOnboardingProgress.hook'; - -import { setWalletPasswordSelectors } from './SetWalletPassword.selectors'; - -const validationsLabelsInputs: Array<{ textI18nKey: TID; key: keyof PasswordValidation }> = [ - { textI18nKey: 'minEightCharacters', key: 'minChar' }, - { textI18nKey: 'oneNumber', key: 'number' }, - { textI18nKey: 'oneLowerLetter', key: 'lowerCase' }, - { textI18nKey: 'oneCapitalLetter', key: 'upperCase' }, - { textI18nKey: 'specialCharacter', key: 'specialChar' } -]; - -interface FormData extends TestIDProps { - shouldUseKeystorePassword?: boolean; - password?: string; - repeatPassword?: string; - termsAccepted: boolean; - analytics?: boolean; - earnRewardsWithAds: boolean; - testID?: string; -} - -interface SetWalletPasswordProps { - ownMnemonic?: boolean; - seedPhrase: string; - keystorePassword?: string; - testID?: string; -} - -/** TODO: remove this component after `CreatePasswordModal` is used for importing wallet - * @deprecated - */ -export const SetWalletPassword: FC = ({ - ownMnemonic = false, - seedPhrase, - keystorePassword -}) => { - const { registerWallet } = useTempleClient(); - const { trackEvent } = useAnalytics(); - - const dispatch = useDispatch(); - - const setAnalyticsEnabled = useCallback( - (analyticsEnabled: boolean) => dispatch(setIsAnalyticsEnabledAction(analyticsEnabled)), - [dispatch] - ); - const setAdsViewEnabled = useCallback( - (adsViewEnabled: boolean) => dispatch(togglePartnersPromotionAction(adsViewEnabled)), - [dispatch] - ); - - const { setOnboardingCompleted } = useOnboardingProgress(); - - const isImportFromKeystoreFile = Boolean(keystorePassword); - - const isKeystorePasswordWeak = isImportFromKeystoreFile && !PASSWORD_PATTERN.test(keystorePassword!); - - const { control, watch, register, handleSubmit, errors, triggerValidation, formState } = useForm({ - defaultValues: { - shouldUseKeystorePassword: !isKeystorePasswordWeak, - analytics: true, - earnRewardsWithAds: true - }, - mode: 'onChange' - }); - const { password: passwordError, ...restErrors } = errors; - const { isSubmitting: submitting, submitCount } = formState; - const wasSubmitted = submitCount > 0; - const shouldDisableSubmit = Object.keys(restErrors).length > 0 || (passwordError && wasSubmitted); - - const shouldUseKeystorePassword = watch('shouldUseKeystorePassword'); - - const passwordValue = watch('password'); - - useLayoutEffect(() => { - if (formState.dirtyFields.has('repeatPassword')) { - triggerValidation('repeatPassword'); - } - }, [triggerValidation, formState.dirtyFields, passwordValue]); - - const passwordValidation = useMemo( - () => - Object.fromEntries( - Object.entries(passwordValidationRegexes).map(([key, regex]) => [key, regex.test(passwordValue ?? '')]) - ) as PasswordValidation, - [passwordValue] - ); - - const onSubmit = useCallback( - async (data: FormData) => { - if (submitting) return; - if (shouldUseKeystorePassword && isKeystorePasswordWeak) return; - - const password = ownMnemonic - ? data.shouldUseKeystorePassword - ? keystorePassword - : data.password - : data.password; - try { - const shouldEnableAnalytics = Boolean(data.analytics); - const adsViewEnabled = data.earnRewardsWithAds; - setAdsViewEnabled(adsViewEnabled); - setAnalyticsEnabled(shouldEnableAnalytics); - await putToStorage(WEBSITES_ANALYTICS_ENABLED, adsViewEnabled); - - await setOnboardingCompleted(true); - - const accountPkh = await registerWallet(password!, formatMnemonic(seedPhrase)); - trackEvent('AnalyticsEnabled', AnalyticsEventCategory.General, { accountPkh }, shouldEnableAnalytics); - trackEvent('AdsEnabled', AnalyticsEventCategory.General, { accountPkh }, adsViewEnabled); - - navigate('/loading'); - - !ownMnemonic && dispatch(setOnRampPossibilityAction(true)); - // For those that had extension installed, but didn't create wallet - dispatch(setPendingReactivateAdsAction(false)); - } catch (err: any) { - console.error(err); - - alert(err.message); - } - }, - [ - submitting, - shouldUseKeystorePassword, - isKeystorePasswordWeak, - ownMnemonic, - keystorePassword, - setAdsViewEnabled, - setAnalyticsEnabled, - setOnboardingCompleted, - registerWallet, - seedPhrase, - trackEvent, - dispatch - ] - ); - - return ( -
- {ownMnemonic && isImportFromKeystoreFile && ( -
- - {shouldUseKeystorePassword && isKeystorePasswordWeak && ( -
- -
- )} -
- )} - - {(!shouldUseKeystorePassword || !isImportFromKeystoreFile) && ( - <> - - -
- {validationsLabelsInputs.map(({ textI18nKey, key }) => ( - - ))} -
- - val === passwordValue || t('mustBeEqualToPasswordAbove') - })} - label={t('repeatPassword')} - labelDescription={t('repeatPasswordInputDescription')} - id="newwallet-repassword" - type="password" - name="repeatPassword" - placeholder="********" - errorCaption={errors.repeatPassword?.message} - containerClassName="mt-6 mb-8" - testID={setWalletPasswordSelectors.repeatPasswordField} - /> - - )} - - - - - - ]} - /> - } - testID={setWalletPasswordSelectors.analyticsCheckBox} - /> - - } - testID={setWalletPasswordSelectors.viewAdsCheckBox} - /> - - - - val || t('confirmTermsError') - })} - name="termsAccepted" - label={t('acceptTerms')} - testID={setWalletPasswordSelectors.acceptTermsCheckbox} - labelDescription={ - - - , - - - - ]} - /> - } - /> - - - - - - - ); -}; diff --git a/src/app/pages/Receive/Receive.tsx b/src/app/pages/Receive/Receive.tsx index 6a1c1a6955..d46d24cf6b 100644 --- a/src/app/pages/Receive/Receive.tsx +++ b/src/app/pages/Receive/Receive.tsx @@ -1,7 +1,7 @@ import React, { memo, useCallback, useState } from 'react'; import { PageTitle } from 'app/atoms'; -import { useModalOpenSearchParams } from 'app/hooks/use-modal-open-search-params'; +import { useSearchParamsBoolean } from 'app/hooks/use-modal-open-search-params'; import PageLayout from 'app/layouts/PageLayout'; import { AccountsModal } from 'app/templates/AppHeader/AccountsModal'; import { T, t } from 'lib/i18n'; @@ -18,10 +18,10 @@ export const Receive = memo(() => { const tezosAddress = useAccountAddressForTezos(); const evmAddress = useAccountAddressForEvm(); const { - isOpen: accountsModalIsOpen, - openModal: openAccountsModal, - closeModal: closeAccountsModal - } = useModalOpenSearchParams('accountsModal'); + value: accountsModalIsOpen, + setTrue: openAccountsModal, + setFalse: closeAccountsModal + } = useSearchParamsBoolean('accountsModal'); const [receivePayload, setReceivePayload] = useState(null); const resetReceivePayload = useCallback(() => setReceivePayload(null), []); diff --git a/src/app/pages/Welcome/Welcome.tsx b/src/app/pages/Welcome/Welcome.tsx index 75636f719d..f37fd92abc 100644 --- a/src/app/pages/Welcome/Welcome.tsx +++ b/src/app/pages/Welcome/Welcome.tsx @@ -3,16 +3,21 @@ import React, { memo, useCallback, useState } from 'react'; import { IconBase } from 'app/atoms'; import { Lines } from 'app/atoms/Lines'; import { Logo } from 'app/atoms/Logo'; +import { PageModal } from 'app/atoms/PageModal'; import { SocialButton } from 'app/atoms/SocialButton'; import { StyledButton } from 'app/atoms/StyledButton'; -import { StyledButtonLikeLink } from 'app/atoms/StyledButtonLikeLink'; +import { SuspenseContainer } from 'app/atoms/SuspenseContainer'; import { useABTestingLoading } from 'app/hooks/use-ab-testing-loading'; +import { useSearchParamsBoolean } from 'app/hooks/use-modal-open-search-params'; import { ReactComponent as GoogleDriveIcon } from 'app/icons/base/google_drive.svg'; import { ReactComponent as ImportedIcon } from 'app/icons/base/imported.svg'; import { ReactComponent as PlusIcon } from 'app/icons/base/plus.svg'; import PageLayout from 'app/layouts/PageLayout'; -import { CreatePasswordModal } from 'app/templates/CreatePasswordModal'; -import { T } from 'lib/i18n'; +import { CreatePasswordForm } from 'app/templates/CreatePasswordForm'; +import { ImportSeedForm } from 'app/templates/ImportSeedForm'; +import { t, T } from 'lib/i18n'; +import { useBooleanState } from 'lib/ui/hooks'; +import { goBack, useLocation } from 'lib/woozie'; import { WelcomeSelectors } from './Welcome.selectors'; @@ -20,14 +25,54 @@ const EmptyHeader = () => null; const Welcome = memo(() => { useABTestingLoading(); + const { historyPosition } = useLocation(); - const [shouldShowPasswordModal, setShouldShowPasswordModal] = useState(false); - const handleCreateNewWalletClick = useCallback(() => setShouldShowPasswordModal(true), []); - const handleRequestClose = useCallback(() => setShouldShowPasswordModal(false), []); + const { value: isImport, setTrue: switchToImport, setFalse: cancelImport } = useSearchParamsBoolean('import'); + + const [seedPhrase, setSeedPhrase] = useState(); + + const [shouldShowPasswordForm, showPasswordForm, hidePasswordForm] = useBooleanState(false); + + const handleSeedPhraseSubmit = useCallback( + (seed: string) => { + setSeedPhrase(seed); + showPasswordForm(); + }, + [showPasswordForm] + ); + + const closeModal = useCallback(() => { + if (historyPosition === 0) { + setSeedPhrase(undefined); + hidePasswordForm(); + cancelImport(); + } else { + goBack(); + } + }, [cancelImport, hidePasswordForm, historyPosition]); + + const handleGoBack = useCallback( + () => void (shouldShowPasswordForm && hidePasswordForm()), + [hidePasswordForm, shouldShowPasswordForm] + ); return ( - + + + {isImport && !shouldShowPasswordForm ? ( + + ) : ( + + )} + +
@@ -55,25 +100,25 @@ const Welcome = memo(() => { size="L" color="primary" testID={WelcomeSelectors.createNewWallet} - onClick={handleCreateNewWalletClick} + onClick={showPasswordForm} > - - +
diff --git a/src/app/pages/Withdraw/Debit/AliceBob/components/use-card-number-input.hook.ts b/src/app/pages/Withdraw/Debit/AliceBob/components/use-card-number-input.hook.ts index 3c97e34862..b56067c542 100644 --- a/src/app/pages/Withdraw/Debit/AliceBob/components/use-card-number-input.hook.ts +++ b/src/app/pages/Withdraw/Debit/AliceBob/components/use-card-number-input.hook.ts @@ -1,14 +1,14 @@ -import React, { useMemo, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { isDefined } from '@rnw-community/shared'; import { t } from 'lib/i18n'; +import { useFocusHandlers } from 'lib/ui/hooks/use-focus-handlers'; export const useCardNumberInput = (isFormSubmitted: boolean) => { const [value, setValue] = useState(''); const [customError, setCustomError] = useState(undefined); - const [isFocused, setIsFocused] = useState(false); const [isTouched, setIsTouched] = useState(false); const error = useMemo(() => { @@ -39,11 +39,9 @@ export const useCardNumberInput = (isFormSubmitted: boolean) => { setCustomError(undefined); setValue(event.target.value); }; - const onBlur = () => { - setIsFocused(false); - setIsTouched(true); - }; - const onFocus = () => setIsFocused(true); + + const touchField = useCallback(() => setIsTouched(true), []); + const { isFocused, onFocus, onBlur } = useFocusHandlers(undefined, touchField); return { value, diff --git a/src/app/templates/AppHeader/index.tsx b/src/app/templates/AppHeader/index.tsx index 615a249a74..c6c23e8d91 100644 --- a/src/app/templates/AppHeader/index.tsx +++ b/src/app/templates/AppHeader/index.tsx @@ -6,7 +6,7 @@ import { IconBase } from 'app/atoms'; import { AccountAvatar } from 'app/atoms/AccountAvatar'; import { AccountName } from 'app/atoms/AccountName'; import { Button } from 'app/atoms/Button'; -import { useModalOpenSearchParams } from 'app/hooks/use-modal-open-search-params'; +import { useSearchParamsBoolean } from 'app/hooks/use-modal-open-search-params'; import { ReactComponent as BurgerIcon } from 'app/icons/base/menu.svg'; import Popper from 'lib/ui/Popper'; import { useAccount } from 'temple/front'; @@ -20,10 +20,10 @@ export const AppHeader = memo(() => { const account = useAccount(); const { - isOpen: accountsModalIsOpen, - openModal: openAccountsModal, - closeModal: closeAccountsModal - } = useModalOpenSearchParams('accountsModal'); + value: accountsModalIsOpen, + setTrue: openAccountsModal, + setFalse: closeAccountsModal + } = useSearchParamsBoolean('accountsModal'); return (
diff --git a/src/app/templates/CreatePasswordModal/config.ts b/src/app/templates/CreatePasswordForm/config.ts similarity index 100% rename from src/app/templates/CreatePasswordModal/config.ts rename to src/app/templates/CreatePasswordForm/config.ts diff --git a/src/app/templates/CreatePasswordForm/index.tsx b/src/app/templates/CreatePasswordForm/index.tsx new file mode 100644 index 0000000000..1a715eae19 --- /dev/null +++ b/src/app/templates/CreatePasswordForm/index.tsx @@ -0,0 +1,263 @@ +import React, { memo, useCallback, useContext, useLayoutEffect, useMemo, useState } from 'react'; + +import { generateMnemonic } from 'bip39'; +import { Controller, useForm } from 'react-hook-form'; +import { useDispatch } from 'react-redux'; + +import { FormField, PASSWORD_ERROR_CAPTION } from 'app/atoms'; +import { ActionsButtonsBox } from 'app/atoms/PageModal/actions-buttons-box'; +import { ScrollView } from 'app/atoms/PageModal/scroll-view'; +import { SettingsCheckbox } from 'app/atoms/SettingsCheckbox'; +import { StyledButton } from 'app/atoms/StyledButton'; +import { ValidationLabel } from 'app/atoms/ValidationLabel'; +import { PASSWORD_PATTERN, PasswordValidation, formatMnemonic, passwordValidationRegexes } from 'app/defaults'; +import { useOnboardingProgress } from 'app/pages/Onboarding/hooks/useOnboardingProgress.hook'; +import { togglePartnersPromotionAction } from 'app/store/partners-promotion/actions'; +import { setIsAnalyticsEnabledAction, setOnRampPossibilityAction } from 'app/store/settings/actions'; +import { toastError } from 'app/toaster'; +import { AnalyticsEventCategory, useAnalytics } from 'lib/analytics'; +import { SHOULD_BACKUP_MNEMONIC_STORAGE_KEY, WEBSITES_ANALYTICS_ENABLED } from 'lib/constants'; +import { T, TID, t } from 'lib/i18n'; +import { putToStorage } from 'lib/storage'; +import { useStorage, useTempleClient } from 'lib/temple/front'; +import { setMnemonicToBackup } from 'lib/temple/front/mnemonic-to-backup-keeper'; +import { SuccessfulImportToastContext } from 'lib/temple/front/successful-import-toast-context'; +import { navigate } from 'lib/woozie'; + +import { TEMPLE_ANALYTICS_LINK, TEMPLE_PRIVACY_POLICY_LINK, TEMPLE_TERMS_LINK } from './config'; +import { createPasswordSelectors } from './selectors'; + +interface FormData { + password?: string; + repeatPassword?: string; + analytics: boolean; + getRewards: boolean; +} + +interface CreatePasswordFormProps { + seedPhrase?: string; +} + +const validationsLabelsInputs: Array<{ textI18nKey: TID; key: keyof PasswordValidation }> = [ + { textI18nKey: 'eightCharacters', key: 'minChar' }, + { textI18nKey: 'atLeastOneNumber', key: 'number' }, + { textI18nKey: 'specialCharacter', key: 'specialChar' }, + { textI18nKey: 'atLeastOneCapital', key: 'upperCase' }, + { textI18nKey: 'atLeastOneLowercase', key: 'lowerCase' } +]; + +export const CreatePasswordForm = memo(({ seedPhrase: seedPhraseToImport }) => { + const { registerWallet } = useTempleClient(); + const { trackEvent } = useAnalytics(); + const [, setShouldBackupMnemonic] = useStorage(SHOULD_BACKUP_MNEMONIC_STORAGE_KEY); + const [bottomEdgeIsVisible, setBottomEdgeIsVisible] = useState(true); + const { setOnboardingCompleted } = useOnboardingProgress(); + const [, setShouldShowImportToast] = useContext(SuccessfulImportToastContext); + + const dispatch = useDispatch(); + + const { control, watch, register, handleSubmit, errors, triggerValidation, formState } = useForm({ + defaultValues: { + analytics: true, + getRewards: true + }, + mode: 'onChange' + }); + const submitting = formState.isSubmitting; + const wasSubmitted = formState.submitCount > 0; + + const passwordValue = watch('password'); + const repeatPasswordValue = watch('repeatPassword'); + + const passwordValidation = useMemo( + () => + Object.fromEntries( + Object.entries(passwordValidationRegexes).map(([key, regex]) => [key, regex.test(passwordValue ?? '')]) + ) as PasswordValidation, + [passwordValue] + ); + + const seedPhrase = useMemo(() => seedPhraseToImport ?? generateMnemonic(128), [seedPhraseToImport]); + + useLayoutEffect(() => { + if (formState.dirtyFields.has('repeatPassword')) { + triggerValidation('repeatPassword'); + } + }, [triggerValidation, formState.dirtyFields, passwordValue]); + + const onSubmit = useCallback( + async (data: FormData) => { + // TODO: enable onboarding when it is reimplemented + await setOnboardingCompleted(true); + + if (submitting) return; + + try { + dispatch(togglePartnersPromotionAction(data.getRewards)); + dispatch(setIsAnalyticsEnabledAction(data.analytics)); + const shouldEnableWebsiteAnalytics = data.getRewards && data.analytics; + await putToStorage(WEBSITES_ANALYTICS_ENABLED, shouldEnableWebsiteAnalytics); + + const accountPkh = await registerWallet(data.password!, formatMnemonic(seedPhrase)); + if (shouldEnableWebsiteAnalytics) { + trackEvent('AnalyticsAndAdsEnabled', AnalyticsEventCategory.General, { accountPkh }, data.analytics); + } + if (seedPhraseToImport) { + setShouldShowImportToast(true); + } else { + await setShouldBackupMnemonic(true); + setMnemonicToBackup(seedPhrase); + } + dispatch(setOnRampPossibilityAction(!seedPhraseToImport)); + navigate('/loading'); + } catch (err: any) { + console.error(err); + + toastError(err.message); + } + }, + [ + dispatch, + registerWallet, + seedPhrase, + seedPhraseToImport, + setOnboardingCompleted, + setShouldBackupMnemonic, + setShouldShowImportToast, + submitting, + trackEvent + ] + ); + + const buttonNameI18nKey = seedPhraseToImport ? 'importWallet' : 'createWallet'; + + return ( +
+ +
+ } + id="newwallet-password" + type="password" + name="password" + placeholder="••••••••••" + errorCaption={errors.password?.message} + shouldShowErrorCaption={false} + fieldWrapperBottomMargin={false} + cleanable={passwordValue ? passwordValue.length > 0 : false} + containerClassName="mb-2" + shouldShowRevealWhenEmpty + testID={createPasswordSelectors.passwordField} + /> +
+ {validationsLabelsInputs.map(({ textI18nKey, key }) => ( + + ))} +
+ val === passwordValue || t('mustBeEqualToPasswordAbove') + })} + label={} + id="newwallet-repassword" + type="password" + name="repeatPassword" + placeholder="••••••••••" + errorCaption={errors.repeatPassword?.message} + cleanable={repeatPasswordValue ? repeatPasswordValue.length > 0 : false} + containerClassName="my-4" + shouldShowRevealWhenEmpty + testID={createPasswordSelectors.repeatPasswordField} + /> +
+
+ } + tooltip={ + + + + ]} + /> + } + testID={createPasswordSelectors.analyticsCheckBox} + /> + + } + tooltip={} + testID={createPasswordSelectors.getRewardsCheckBox} + /> +
+ + + + , + + + , + + + + ]} + /> + +
+ + + + + +
+ ); +}); diff --git a/src/app/templates/CreatePasswordModal/selectors.ts b/src/app/templates/CreatePasswordForm/selectors.ts similarity index 100% rename from src/app/templates/CreatePasswordModal/selectors.ts rename to src/app/templates/CreatePasswordForm/selectors.ts diff --git a/src/app/templates/CreatePasswordModal/index.tsx b/src/app/templates/CreatePasswordModal/index.tsx deleted file mode 100644 index cf4f04175e..0000000000 --- a/src/app/templates/CreatePasswordModal/index.tsx +++ /dev/null @@ -1,269 +0,0 @@ -import React, { memo, useCallback, useLayoutEffect, useMemo, useState } from 'react'; - -import { generateMnemonic } from 'bip39'; -import { Controller, useForm } from 'react-hook-form'; -import { useDispatch } from 'react-redux'; - -import { FormField, PASSWORD_ERROR_CAPTION } from 'app/atoms'; -import { PageModal } from 'app/atoms/PageModal'; -import { ActionsButtonsBox } from 'app/atoms/PageModal/actions-buttons-box'; -import { ScrollView } from 'app/atoms/PageModal/scroll-view'; -import { SettingsCheckbox } from 'app/atoms/SettingsCheckbox'; -import { StyledButton } from 'app/atoms/StyledButton'; -import { ValidationLabel } from 'app/atoms/ValidationLabel'; -import { PASSWORD_PATTERN, PasswordValidation, formatMnemonic, passwordValidationRegexes } from 'app/defaults'; -import { useOnboardingProgress } from 'app/pages/Onboarding/hooks/useOnboardingProgress.hook'; -import { togglePartnersPromotionAction } from 'app/store/partners-promotion/actions'; -import { setIsAnalyticsEnabledAction, setOnRampPossibilityAction } from 'app/store/settings/actions'; -import { toastError } from 'app/toaster'; -import { AnalyticsEventCategory, useAnalytics } from 'lib/analytics'; -import { SHOULD_BACKUP_MNEMONIC_STORAGE_KEY, WEBSITES_ANALYTICS_ENABLED } from 'lib/constants'; -import { T, TID, t } from 'lib/i18n'; -import { putToStorage } from 'lib/storage'; -import { useStorage, useTempleClient } from 'lib/temple/front'; -import { setMnemonicToBackup } from 'lib/temple/front/mnemonic-to-backup-keeper'; -import { navigate } from 'lib/woozie'; - -import { TEMPLE_ANALYTICS_LINK, TEMPLE_PRIVACY_POLICY_LINK, TEMPLE_TERMS_LINK } from './config'; -import { createPasswordSelectors } from './selectors'; - -interface FormData { - password?: string; - repeatPassword?: string; - analytics: boolean; - getRewards: boolean; -} - -interface CreatePasswordModalProps { - opened: boolean; - onRequestClose: EmptyFn; - seedPhrase?: string; -} - -const validationsLabelsInputs: Array<{ textI18nKey: TID; key: keyof PasswordValidation }> = [ - { textI18nKey: 'eightCharacters', key: 'minChar' }, - { textI18nKey: 'atLeastOneNumber', key: 'number' }, - { textI18nKey: 'specialCharacter', key: 'specialChar' }, - { textI18nKey: 'atLeastOneCapital', key: 'upperCase' }, - { textI18nKey: 'atLeastOneLowercase', key: 'lowerCase' } -]; - -export const CreatePasswordModal = memo( - ({ opened, seedPhrase: seedPhraseToImport, onRequestClose }) => { - const { registerWallet } = useTempleClient(); - const { trackEvent } = useAnalytics(); - const [, setShouldBackupMnemonic] = useStorage(SHOULD_BACKUP_MNEMONIC_STORAGE_KEY); - const [bottomEdgeIsVisible, setBottomEdgeIsVisible] = useState(true); - const { setOnboardingCompleted } = useOnboardingProgress(); - - const dispatch = useDispatch(); - - const { control, watch, register, handleSubmit, errors, triggerValidation, formState } = useForm({ - defaultValues: { - analytics: true, - getRewards: true - }, - mode: 'onChange' - }); - const submitting = formState.isSubmitting; - const wasSubmitted = formState.submitCount > 0; - - const passwordValue = watch('password'); - const repeatPasswordValue = watch('repeatPassword'); - - const passwordValidation = useMemo( - () => - Object.fromEntries( - Object.entries(passwordValidationRegexes).map(([key, regex]) => [key, regex.test(passwordValue ?? '')]) - ) as PasswordValidation, - [passwordValue] - ); - - const seedPhrase = useMemo(() => seedPhraseToImport ?? generateMnemonic(128), [seedPhraseToImport]); - - useLayoutEffect(() => { - if (formState.dirtyFields.has('repeatPassword')) { - triggerValidation('repeatPassword'); - } - }, [triggerValidation, formState.dirtyFields, passwordValue]); - - const onSubmit = useCallback( - async (data: FormData) => { - // TODO: enable onboarding when it is reimplemented - await setOnboardingCompleted(true); - - if (submitting) return; - - try { - dispatch(togglePartnersPromotionAction(data.getRewards)); - dispatch(setIsAnalyticsEnabledAction(data.analytics)); - const shouldEnableWebsiteAnalytics = data.getRewards && data.analytics; - await putToStorage(WEBSITES_ANALYTICS_ENABLED, shouldEnableWebsiteAnalytics); - - const accountPkh = await registerWallet(data.password!, formatMnemonic(seedPhrase)); - if (shouldEnableWebsiteAnalytics) { - trackEvent('AnalyticsAndAdsEnabled', AnalyticsEventCategory.General, { accountPkh }, data.analytics); - } - if (!seedPhraseToImport) { - await setShouldBackupMnemonic(true); - setMnemonicToBackup(seedPhrase); - } - dispatch(setOnRampPossibilityAction(!seedPhraseToImport)); - navigate('/loading'); - } catch (err: any) { - console.error(err); - - toastError(err.message); - } - }, - [ - dispatch, - registerWallet, - seedPhrase, - seedPhraseToImport, - setOnboardingCompleted, - setShouldBackupMnemonic, - submitting, - trackEvent - ] - ); - - const buttonNameI18nKey = seedPhraseToImport ? 'importWallet' : 'createWallet'; - - return ( - -
- -
- } - id="newwallet-password" - type="password" - name="password" - placeholder="••••••••••" - errorCaption={errors.password?.message} - shouldShowErrorCaption={false} - fieldWrapperBottomMargin={false} - cleanable={passwordValue ? passwordValue.length > 0 : false} - containerClassName="mb-2" - shouldShowRevealWhenEmpty - testID={createPasswordSelectors.passwordField} - /> -
- {validationsLabelsInputs.map(({ textI18nKey, key }) => ( - - ))} -
- val === passwordValue || t('mustBeEqualToPasswordAbove') - })} - label={} - id="newwallet-repassword" - type="password" - name="repeatPassword" - placeholder="••••••••••" - errorCaption={errors.repeatPassword?.message} - cleanable={repeatPasswordValue ? repeatPasswordValue.length > 0 : false} - containerClassName="my-4" - shouldShowRevealWhenEmpty - testID={createPasswordSelectors.repeatPasswordField} - /> -
-
- } - tooltip={ - - - - ]} - /> - } - testID={createPasswordSelectors.analyticsCheckBox} - /> - - } - tooltip={} - testID={createPasswordSelectors.getRewardsCheckBox} - /> -
- - - - , - - - , - - - - ]} - /> - -
- - - - - -
-
- ); - } -); diff --git a/src/app/templates/ImportSeedForm/index.tsx b/src/app/templates/ImportSeedForm/index.tsx new file mode 100644 index 0000000000..f66bdb2437 --- /dev/null +++ b/src/app/templates/ImportSeedForm/index.tsx @@ -0,0 +1,61 @@ +import React, { memo, useCallback, useState } from 'react'; + +import { useForm } from 'react-hook-form'; + +import { ActionsButtonsBox } from 'app/atoms/PageModal/actions-buttons-box'; +import { ScrollView } from 'app/atoms/PageModal/scroll-view'; +import { StyledButton } from 'app/atoms/StyledButton'; +import { DEFAULT_SEED_PHRASE_WORDS_AMOUNT } from 'lib/constants'; +import { t, T } from 'lib/i18n'; + +import { SeedPhraseInput } from '../SeedPhraseInput'; + +import { ImportSeedFormSelectors } from './selectors'; + +interface ImportSeedFormProps { + next: SyncFn; + onCancel: EmptyFn; +} + +export const ImportSeedForm = memo(({ next, onCancel }) => { + const [bottomEdgeIsVisible, setBottomEdgeIsVisible] = useState(true); + + const { handleSubmit, formState, reset } = useForm(); + const [seedPhrase, setSeedPhrase] = useState(''); + const [seedError, setSeedError] = useState(''); + const [numberOfWords, setNumberOfWords] = useState(DEFAULT_SEED_PHRASE_WORDS_AMOUNT); + + const onSubmit = useCallback(() => { + if (seedPhrase && !seedPhrase.split(' ').includes('') && !seedError) { + next(seedPhrase); + } else if (seedError === '') { + setSeedError(t('mnemonicWordsAmountConstraint', [numberOfWords]) as string); + } + }, [seedPhrase, seedError, next, numberOfWords]); + + return ( +
+ + + + + + + + + + + +
+ ); +}); diff --git a/src/app/templates/ImportSeedForm/selectors.ts b/src/app/templates/ImportSeedForm/selectors.ts new file mode 100644 index 0000000000..063479355a --- /dev/null +++ b/src/app/templates/ImportSeedForm/selectors.ts @@ -0,0 +1,5 @@ +export enum ImportSeedFormSelectors { + wordInput = 'Import Seed Form/Word Input', + cancelButton = 'Import Seed Form/Cancel Button', + nextButton = 'Import Seed Form/Next Button' +} diff --git a/src/app/templates/ManualBackupModal/verify-seed-phrase-input/word-input.tsx b/src/app/templates/ManualBackupModal/verify-seed-phrase-input/word-input.tsx index 707acfd13f..1867f017d5 100644 --- a/src/app/templates/ManualBackupModal/verify-seed-phrase-input/word-input.tsx +++ b/src/app/templates/ManualBackupModal/verify-seed-phrase-input/word-input.tsx @@ -15,13 +15,16 @@ interface WordInputProps extends TestIDProps { export const WordInput = memo(({ wordIndex, active, value, testID }) => ( - {wordIndex + 1}. - +
+ + {wordIndex + 1}. + +
} value={value} readOnly diff --git a/src/app/templates/SearchField.tsx b/src/app/templates/SearchField.tsx index ec8a476659..59dfd5a20e 100644 --- a/src/app/templates/SearchField.tsx +++ b/src/app/templates/SearchField.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, InputHTMLAttributes, memo, useCallback, useRef, useState } from 'react'; +import React, { FocusEvent, forwardRef, InputHTMLAttributes, memo, useCallback, useRef } from 'react'; import { emptyFn } from '@rnw-community/shared'; import clsx from 'clsx'; @@ -7,6 +7,9 @@ import { IconBase } from 'app/atoms'; import CleanButton, { CLEAN_BUTTON_ID } from 'app/atoms/CleanButton'; import { ReactComponent as SearchIcon } from 'app/icons/base/search.svg'; import { setTestID, TestIDProps } from 'lib/analytics'; +import { useFocusHandlers } from 'lib/ui/hooks/use-focus-handlers'; + +const shouldHandleBlur = (e: FocusEvent) => e.relatedTarget?.id !== CLEAN_BUTTON_ID; interface Props extends InputHTMLAttributes, TestIDProps { value: string; @@ -34,7 +37,12 @@ const SearchField = forwardRef( ref ) => { const inputLocalRef = useRef(null); - const [focused, setFocused] = useState(false); + const { + isFocused: focused, + onFocus: handleFocus, + onBlur: handleBlur, + setIsFocused + } = useFocusHandlers(onFocus, onBlur, undefined, shouldHandleBlur); const handleChange = useCallback( (evt: React.ChangeEvent) => { @@ -43,37 +51,17 @@ const SearchField = forwardRef( [onValueChange] ); - const handleFocus = useCallback( - (evt: React.FocusEvent) => { - setFocused(true); - onFocus(evt); - }, - [onFocus] - ); - - const handleBlur = useCallback( - (evt: React.FocusEvent) => { - if (evt.relatedTarget?.id === CLEAN_BUTTON_ID) { - return; - } - - setFocused(false); - onBlur(evt); - }, - [onBlur] - ); - const handleClean = useCallback(() => { if (value) { inputLocalRef.current?.focus(); onValueChange(''); } else { inputLocalRef.current?.blur(); - setFocused(false); + setIsFocused(false); } onCleanButtonClick(); - }, [onCleanButtonClick, onValueChange, value]); + }, [onCleanButtonClick, onValueChange, setIsFocused, value]); const notEmpty = Boolean(focused || value); diff --git a/src/app/templates/SeedPhraseInput/SeedLengthSelect/SeedLengthOption.tsx b/src/app/templates/SeedPhraseInput/SeedLengthSelect/SeedLengthOption.tsx new file mode 100644 index 0000000000..a06cbd680b --- /dev/null +++ b/src/app/templates/SeedPhraseInput/SeedLengthSelect/SeedLengthOption.tsx @@ -0,0 +1,32 @@ +import React, { FC, memo, useCallback } from 'react'; + +import { emptyFn } from '@rnw-community/shared'; +import clsx from 'clsx'; + +import { ImportAccountSelectors } from 'app/pages/ImportAccount/selectors'; +import { setAnotherSelector, setTestID } from 'lib/analytics'; + +interface Props { + option: string; + selectedOption: string; + onClick?: (option: string) => void; +} + +export const SeedLengthOption: FC = memo(({ option, selectedOption, onClick = emptyFn }) => { + const handleClick = useCallback(() => onClick(option), [onClick, option]); + + return ( +
  • + {option} +
  • + ); +}); diff --git a/src/app/templates/SeedPhraseInput/SeedLengthSelect/SeedLengthOption/SeedLengthOption.tsx b/src/app/templates/SeedPhraseInput/SeedLengthSelect/SeedLengthOption/SeedLengthOption.tsx deleted file mode 100644 index f1350fa966..0000000000 --- a/src/app/templates/SeedPhraseInput/SeedLengthSelect/SeedLengthOption/SeedLengthOption.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import React, { FC, memo, useCallback } from 'react'; - -import { emptyFn } from '@rnw-community/shared'; -import classNames from 'clsx'; - -import { ImportAccountSelectors } from 'app/pages/ImportAccount/selectors'; -import { setAnotherSelector, setTestID } from 'lib/analytics'; - -import styles from './seedLengthOption.module.css'; - -interface Props { - option: string; - selectedOption: string; - onClick?: (option: string) => void; - onChange?: (option: string) => void; -} - -export const SeedLengthOption: FC = memo(({ option, selectedOption, onClick = emptyFn, onChange = emptyFn }) => { - const handleClick = useCallback(() => onClick(option), [onClick, option]); - const handleChange = useCallback((e: React.ChangeEvent) => onChange(e.target.value), [onChange]); - - return ( -
  • - -
  • - ); -}); diff --git a/src/app/templates/SeedPhraseInput/SeedLengthSelect/SeedLengthOption/seedLengthOption.module.css b/src/app/templates/SeedPhraseInput/SeedLengthSelect/SeedLengthOption/seedLengthOption.module.css deleted file mode 100644 index 68fc8e6116..0000000000 --- a/src/app/templates/SeedPhraseInput/SeedLengthSelect/SeedLengthOption/seedLengthOption.module.css +++ /dev/null @@ -1,43 +0,0 @@ -.input { - -webkit-appearance: none; - appearance: none; - border: 1px solid #ED8936; - height: 16px; - width: 16px; - display: inline-block; - border-radius: 100%; - vertical-align: text-bottom; - position: relative; -} - - -.input::before { - content: ''; - position: absolute; - margin: auto; - left: 0; - right: 0; - bottom: 0; - overflow: hidden; - top: 0; - } - - .input:focus { - outline: 2px solid; - outline-offset: 2px; - } - - - .input::before { - height: 0; - width: 0; -} - -.input:checked::before { - border: 5px solid #ED8936; - border-radius: 100%; - background: #ED8936; -} - - - diff --git a/src/app/templates/SeedPhraseInput/SeedLengthSelect/SeedLengthSelect.tsx b/src/app/templates/SeedPhraseInput/SeedLengthSelect/SeedLengthSelect.tsx index 4d85ee4243..5878f9882a 100644 --- a/src/app/templates/SeedPhraseInput/SeedLengthSelect/SeedLengthSelect.tsx +++ b/src/app/templates/SeedPhraseInput/SeedLengthSelect/SeedLengthSelect.tsx @@ -1,13 +1,16 @@ import React, { FC, useCallback, useEffect, useRef, useState } from 'react'; -import classNames from 'clsx'; +import clsx from 'clsx'; -import { ReactComponent as SelectArrowDownIcon } from 'app/icons/select-arrow-down.svg'; +import { IconBase } from 'app/atoms'; +import { StyledButton } from 'app/atoms/StyledButton'; +import { ReactComponent as CompactDownIcon } from 'app/icons/base/compact_down.svg'; import { ImportAccountSelectors } from 'app/pages/ImportAccount/selectors'; -import { setTestID } from 'lib/analytics'; -import { t } from 'lib/i18n'; +import { T } from 'lib/i18n'; +import { useBooleanState } from 'lib/ui/hooks'; -import { SeedLengthOption } from './SeedLengthOption/SeedLengthOption'; +import { getOptionLabel } from './get-option-label'; +import { SeedLengthOption } from './SeedLengthOption'; interface SeedLengthSelectProps { options: Array; @@ -18,7 +21,7 @@ interface SeedLengthSelectProps { export const SeedLengthSelect: FC = ({ options, currentOption, defaultOption, onChange }) => { const [selectedOption, setSelectedOption] = useState(defaultOption ?? ''); - const [isOpen, setIsOpen] = useState(false); + const [isOpen, , close, toggleOpen] = useBooleanState(false); const selectRef = useRef(null); @@ -31,7 +34,7 @@ export const SeedLengthSelect: FC = ({ options, currentOp useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (selectRef.current && !selectRef.current.contains(event.target as Node)) { - setIsOpen(false); + close(); } }; window.addEventListener('mousedown', handleClickOutside); @@ -39,42 +42,37 @@ export const SeedLengthSelect: FC = ({ options, currentOp return () => { window.removeEventListener('mousedown', handleClickOutside); }; - }, [selectRef]); + }, [close, selectRef]); const handleClick = useCallback( (option: string) => { - setIsOpen(false); + close(); setSelectedOption(option); onChange(option); }, - [onChange] + [close, onChange] ); return ( -
    -
    setIsOpen(!isOpen)}> - - {t('seedInputNumberOfWords', [`${selectedOption}`])}{' '} - - -
    -
    ), - [searchValue, startWalletCreation] + [goToImportModal, goToWatchOnlyModal, searchValue, startWalletCreation] ); useEffect(() => setHeaderChildren(headerChildren), [headerChildren, setHeaderChildren]); useEffect(() => { return () => setHeaderChildren(null); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return ( @@ -189,10 +229,12 @@ export const AccountsManagement = memo(({ setHeaderChil group={group} key={group.id} searchValue={searchValue} + showAccountAlreadyExistsWarning={showAccountAlreadyExistsWarning} onDeleteClick={handleDeleteClick} onRenameClick={handleRenameClick} onRevealSeedPhraseClick={handleRevealSeedPhraseClick} - showAccountAlreadyExistsWarning={showAccountAlreadyExistsWarning} + goToImportModal={goToImportModal} + goToWatchOnlyModal={goToWatchOnlyModal} /> ))}
    diff --git a/src/app/templates/AppHeader/AccountsModal.tsx b/src/app/templates/AppHeader/AccountsModal.tsx index 93ac4a79b2..d6b26d380f 100644 --- a/src/app/templates/AppHeader/AccountsModal.tsx +++ b/src/app/templates/AppHeader/AccountsModal.tsx @@ -19,6 +19,7 @@ import { useAllAccountsReactiveOnAddition } from 'app/hooks/use-all-accounts-rea import { ReactComponent as SettingsIcon } from 'app/icons/base/settings.svg'; import { NewWalletActionsPopper } from 'app/templates/NewWalletActionsPopper'; import { SearchBarField } from 'app/templates/SearchField'; +import { T } from 'lib/i18n'; import { StoredAccount } from 'lib/temple/types'; import { useScrollIntoViewOnMount } from 'lib/ui/use-scroll-into-view'; import { navigate } from 'lib/woozie'; @@ -26,6 +27,7 @@ import { searchAndFilterAccounts, useAccountsGroups, useCurrentAccountId, useVis import { useSetAccountId } from 'temple/front/ready'; import { CreateHDWalletModal } from '../CreateHDWalletModal'; +import { ImportAccountModal, ImportOptionSlug } from '../ImportAccountModal'; import { AccountsModalSelectors } from './selectors'; @@ -34,14 +36,21 @@ interface Props { onRequestClose: EmptyFn; } +enum AccountsModalSubmodals { + CreateHDWallet = 'create-hd-wallet', + ImportAccount = 'import-account', + WatchOnly = 'watch-only' +} + export const AccountsModal = memo(({ opened, onRequestClose }) => { const allAccounts = useVisibleAccounts(); const currentAccountId = useCurrentAccountId(); const [searchValue, setSearchValue] = useState(''); - const [shouldShowCreateWalletFlow, setShouldShowCreateWalletFlow] = useState(false); const [topEdgeIsVisible, setTopEdgeIsVisible] = useState(true); const [bottomEdgeIsVisible, setBottomEdgeIsVisible] = useState(true); + const [activeSubmodal, setActiveSubmodal] = useState(undefined); + const [importOptionSlug, setImportOptionSlug] = useState(); useAllAccountsReactiveOnAddition(); useShortcutAccountSelectModalIsOpened(onRequestClose); @@ -59,12 +68,60 @@ export const AccountsModal = memo(({ opened, onRequestClose }) => { else if (!opened) setAttractSelectedAccount(true); }, [opened, searchValue]); - const startWalletCreation = useCallback(() => setShouldShowCreateWalletFlow(true), []); - const onCreateWalletFlowEnd = useCallback(() => setShouldShowCreateWalletFlow(false), []); + const closeSubmodal = useCallback(() => { + setActiveSubmodal(undefined); + setImportOptionSlug(undefined); + }, []); + + const startWalletCreation = useCallback(() => setActiveSubmodal(AccountsModalSubmodals.CreateHDWallet), []); + + const goToImportModal = useCallback(() => { + setActiveSubmodal(AccountsModalSubmodals.ImportAccount); + setImportOptionSlug(undefined); + }, []); + const goToWatchOnlyModal = useCallback(() => setActiveSubmodal(AccountsModalSubmodals.WatchOnly), []); + const handleSeedPhraseImportOptionSelect = useCallback(() => setImportOptionSlug('mnemonic'), []); + const handlePrivateKeyImportOptionSelect = useCallback(() => setImportOptionSlug('private-key'), []); + + const submodal = useMemo(() => { + switch (activeSubmodal) { + case AccountsModalSubmodals.CreateHDWallet: + return ; + case AccountsModalSubmodals.ImportAccount: + return ( + + ); + case AccountsModalSubmodals.WatchOnly: + return ( + + ); + default: + return null; + } + }, [ + activeSubmodal, + closeSubmodal, + goToImportModal, + handlePrivateKeyImportOptionSelect, + handleSeedPhraseImportOptionSelect, + importOptionSlug + ]); return ( <> - {shouldShowCreateWalletFlow && } + {submodal}
    (({ opened, onRequestClose }) => {
    @@ -124,7 +183,7 @@ export const AccountsModal = memo(({ opened, onRequestClose }) => { onClick={onRequestClose} testID={AccountsModalSelectors.cancelButton} > - Cancel +
    diff --git a/src/app/templates/AppHeader/selectors.ts b/src/app/templates/AppHeader/selectors.ts index d53633b3d7..9dd475e1de 100644 --- a/src/app/templates/AppHeader/selectors.ts +++ b/src/app/templates/AppHeader/selectors.ts @@ -2,5 +2,6 @@ export enum AccountsModalSelectors { searchField = 'Accounts Modal/Search Field', accountsManagementButton = 'Accounts Modal/Accounts Management Button', newWalletActionsButton = 'Accounts Modal/New Wallet Actions Button', - cancelButton = 'Accounts Modal/Cancel Button' + cancelButton = 'Accounts Modal/Cancel Button', + nextButton = 'Accounts Modal/Next Button' } diff --git a/src/app/templates/ImportAccountModal/forms/mnemonic.tsx b/src/app/templates/ImportAccountModal/forms/mnemonic.tsx new file mode 100644 index 0000000000..5adadd4299 --- /dev/null +++ b/src/app/templates/ImportAccountModal/forms/mnemonic.tsx @@ -0,0 +1,142 @@ +import React, { memo, ReactNode, useCallback, useState } from 'react'; + +import { startCase } from 'lodash'; +import { useForm } from 'react-hook-form'; + +import { FormField } from 'app/atoms'; +import { ActionsButtonsBox } from 'app/atoms/PageModal/actions-buttons-box'; +import { ScrollView } from 'app/atoms/PageModal/scroll-view'; +import { StyledButton } from 'app/atoms/StyledButton'; +import { formatMnemonic } from 'app/defaults'; +import { AccountsModalSelectors } from 'app/templates/AppHeader/selectors'; +import { isSeedPhraseFilled, SeedPhraseInput } from 'app/templates/SeedPhraseInput'; +import { useFormAnalytics } from 'lib/analytics'; +import { DEFAULT_SEED_PHRASE_WORDS_AMOUNT, DEFAULT_TEZOS_DERIVATION_PATH } from 'lib/constants'; +import { t, T } from 'lib/i18n'; +import { useTempleClient, validateDerivationPath } from 'lib/temple/front'; + +import { ImportAccountFormType, ImportAccountSelectors } from '../selectors'; +import { ImportAccountFormProps } from '../types'; + +interface RestMnemonicFormData { + derivationPath: string; +} + +const defaultValues = { + derivationPath: '' +}; + +export const MnemonicForm = memo(({ onSuccess, onCancel }) => { + const { createOrImportWallet, importMnemonicAccount } = useTempleClient(); + const formAnalytics = useFormAnalytics(ImportAccountFormType.Mnemonic); + + const [seedPhrase, setSeedPhrase] = useState(''); + const [seedError, setSeedError] = useState(''); + const [error, setError] = useState(null); + const { errors, register, handleSubmit, formState, reset } = useForm({ defaultValues }); + const { isSubmitting, submitCount } = formState; + const wasSubmitted = submitCount > 0; + + const [bottomEdgeIsVisible, setBottomEdgeIsVisible] = useState(false); + + const [numberOfWords, setNumberOfWords] = useState(DEFAULT_SEED_PHRASE_WORDS_AMOUNT); + + const onSubmit = useCallback( + async (values: RestMnemonicFormData) => { + if (isSubmitting) { + return; + } + + if (!seedError && isSeedPhraseFilled(seedPhrase)) { + formAnalytics.trackSubmit(); + + try { + if (values.derivationPath) { + await importMnemonicAccount(formatMnemonic(seedPhrase), undefined, values.derivationPath); + } else { + await createOrImportWallet(formatMnemonic(seedPhrase)); + } + formAnalytics.trackSubmitSuccess(); + onSuccess(); + } catch (err: any) { + formAnalytics.trackSubmitFail(); + + console.error(err); + + setError(err.message); + } + } else if (seedError === '') { + setSeedError(t('mnemonicWordsAmountConstraint', [String(numberOfWords)])); + } + }, + [ + createOrImportWallet, + formAnalytics, + importMnemonicAccount, + isSubmitting, + numberOfWords, + onSuccess, + seedError, + seedPhrase, + setSeedError + ] + ); + + const updateSeedError = useCallback((error: string) => { + setSeedError(error); + setError(null); + }, []); + + return ( + + + + + + + + + + + + 0 || Boolean(seedError)) && wasSubmitted)} + type="submit" + testID={AccountsModalSelectors.nextButton} + > + + + + + ); +}); diff --git a/src/app/templates/ImportAccountModal/forms/private-key.tsx b/src/app/templates/ImportAccountModal/forms/private-key.tsx new file mode 100644 index 0000000000..08542f31a6 --- /dev/null +++ b/src/app/templates/ImportAccountModal/forms/private-key.tsx @@ -0,0 +1,163 @@ +import React, { memo, ReactNode, useCallback, useMemo, useState } from 'react'; + +import { Prefix } from '@taquito/utils'; +import { useForm } from 'react-hook-form'; + +import { FormField } from 'app/atoms'; +import { ActionsButtonsBox } from 'app/atoms/PageModal/actions-buttons-box'; +import { ScrollView } from 'app/atoms/PageModal/scroll-view'; +import { StyledButton } from 'app/atoms/StyledButton'; +import { TextButton } from 'app/atoms/TextButton'; +import { ReactComponent as PasteFillIcon } from 'app/icons/base/paste_fill.svg'; +import { useFormAnalytics } from 'lib/analytics'; +import { T, t } from 'lib/i18n'; +import { useTempleClient } from 'lib/temple/front'; +import { clearClipboard, readClipboard } from 'lib/ui/utils'; +import { TempleChainKind } from 'temple/types'; + +import { ImportAccountSelectors, ImportAccountFormType } from '../selectors'; +import { ImportAccountFormProps } from '../types'; + +interface ByPrivateKeyFormData { + privateKey: string; + encPassword?: string; +} + +export const PrivateKeyForm = memo(({ onSuccess }) => { + const { importAccount } = useTempleClient(); + const formAnalytics = useFormAnalytics(ImportAccountFormType.PrivateKey); + + const { register, handleSubmit, errors, formState, watch, setValue, triggerValidation } = + useForm(); + const [bottomEdgeIsVisible, setBottomEdgeIsVisible] = useState(true); + const [error, setError] = useState(null); + + const onSubmit = useCallback( + async ({ privateKey, encPassword }: ByPrivateKeyFormData) => { + if (formState.isSubmitting) return; + + formAnalytics.trackSubmit(); + setError(null); + let chain: TempleChainKind | undefined; + try { + const [finalPrivateKey, chain] = toPrivateKeyWithChain(privateKey.replace(/\s/g, '')); + + await importAccount(chain, finalPrivateKey, encPassword); + + formAnalytics.trackSubmitSuccess({ chain }); + onSuccess(); + } catch (err: any) { + formAnalytics.trackSubmitFail({ chain }); + + console.error(err); + + setError(err.message); + } + }, + [formState.isSubmitting, formAnalytics, importAccount, onSuccess] + ); + + const pastePrivateKey = useCallback( + () => + readClipboard() + .then(value => { + setValue('privateKey', value); + clearClipboard(); + }) + .catch(console.error), + [setValue] + ); + const cleanPrivateKeyField = useCallback(() => { + setValue('privateKey', ''); + setValue('encPassword', undefined); + triggerValidation('privateKey'); + triggerValidation('encPassword'); + }, [setValue, triggerValidation]); + + const keyValue = watch('privateKey') as string | undefined; + const encrypted = useMemo(() => isTezosPrivateKey(keyValue) && keyValue.substring(2, 3) === 'e', [keyValue]); + + return ( +
    + + + + + ) + } + onPaste={clearClipboard} + testID={ImportAccountSelectors.privateKeyInput} + /> + + {encrypted && ( + + + + + + + } + placeholder="••••••••••" + errorCaption={errors.encPassword?.message} + /> + )} + + + + + + + +
    + ); +}); + +function toPrivateKeyWithChain(value: string): [string, TempleChainKind] { + if (isTezosPrivateKey(value)) return [value, TempleChainKind.Tezos]; + + if (!value.startsWith('0x')) value = `0x${value}`; + + return [value, TempleChainKind.EVM]; +} + +const secretKeyPrefixes = [Prefix.EDSK, Prefix.SPSK, Prefix.P2SK, Prefix.EDESK, Prefix.SPESK, Prefix.P2ESK] as const; +type TezosSecretKey = `${(typeof secretKeyPrefixes)[number]}${string}`; + +const isTezosPrivateKey = (value?: string): value is TezosSecretKey => + secretKeyPrefixes.some(p => value?.startsWith(p)); diff --git a/src/app/templates/ImportAccountModal/forms/watch-only.tsx b/src/app/templates/ImportAccountModal/forms/watch-only.tsx new file mode 100644 index 0000000000..f8c996ea43 --- /dev/null +++ b/src/app/templates/ImportAccountModal/forms/watch-only.tsx @@ -0,0 +1,189 @@ +import React, { memo, ReactNode, useCallback, useMemo, useState } from 'react'; + +import { useForm } from 'react-hook-form'; +import * as Viem from 'viem'; + +import { FormField } from 'app/atoms'; +import { ActionsButtonsBox } from 'app/atoms/PageModal/actions-buttons-box'; +import { ScrollView } from 'app/atoms/PageModal/scroll-view'; +import { StyledButton } from 'app/atoms/StyledButton'; +import { TextButton } from 'app/atoms/TextButton'; +import { ReactComponent as PasteFillIcon } from 'app/icons/base/paste_fill.svg'; +import { useFormAnalytics } from 'lib/analytics'; +import { dipdupNetworksChainIds, searchForTezosAccount } from 'lib/apis/dipdup-search'; +import { T, t } from 'lib/i18n'; +import { useTempleClient, validateDelegate } from 'lib/temple/front'; +import { isValidTezosAddress, isTezosContractAddress } from 'lib/tezos'; +import { readClipboard } from 'lib/ui/utils'; +import { useEnabledTezosChains } from 'temple/front'; +import { getTezosDomainsClient, useTezosAddressByDomainName } from 'temple/front/tezos'; +import { TempleChainKind } from 'temple/types'; + +import { ImportAccountSelectors, ImportAccountFormType } from '../selectors'; + +interface WatchOnlyFormData { + address: string; +} + +export const WatchOnlyForm = memo(() => { + const { importWatchOnlyAccount } = useTempleClient(); + + const tezosChains = useEnabledTezosChains(); + const domainsClients = useMemo( + () => + tezosChains + .map(chain => getTezosDomainsClient(chain.chainId, chain.rpcBaseURL)) + .filter(client => client.isSupported), + [tezosChains] + ); + + const formAnalytics = useFormAnalytics(ImportAccountFormType.WatchOnly); + + const { watch, handleSubmit, errors, register, formState, setValue, triggerValidation } = useForm({ + mode: 'onChange' + }); + const [error, setError] = useState(null); + const [bottomEdgeIsVisible, setBottomEdgeIsVisible] = useState(true); + + const addressValue = watch('address'); + + const { data: tezAddressFromTzDomainName } = useTezosAddressByDomainName(addressValue); + + const resolvedAddress = useMemo( + () => tezAddressFromTzDomainName || addressValue, + [addressValue, tezAddressFromTzDomainName] + ); + + const pasteAddress = useCallback( + async () => + readClipboard() + .then(newAddress => setValue('address', newAddress)) + .catch(console.error), + [setValue] + ); + const cleanAddressField = useCallback(() => { + setValue('address', ''); + triggerValidation('address'); + }, [setValue, triggerValidation]); + + const onSubmit = useCallback(async () => { + if (formState.isSubmitting) return; + + setError(null); + + formAnalytics.trackSubmit(); + let chain: TempleChainKind | nullish; + try { + chain = getChainFromAddress(resolvedAddress); + + if (!chain) { + throw new Error(t('invalidAddress')); + } + + let tezosChainId: string | undefined; + + if (chain === TempleChainKind.Tezos && isTezosContractAddress(resolvedAddress)) { + const { items: contractDipdupEntries } = await searchForTezosAccount(resolvedAddress); + const networkName = contractDipdupEntries[0]?.body.Network; + tezosChainId = networkName && dipdupNetworksChainIds[networkName]; + } + + const finalAddress = chain === TempleChainKind.Tezos ? resolvedAddress : Viem.getAddress(resolvedAddress); + + await importWatchOnlyAccount(chain, finalAddress, tezosChainId); + + formAnalytics.trackSubmitSuccess({ chain }); + } catch (err: any) { + formAnalytics.trackSubmitFail({ chain }); + + console.error(err); + + setError(err.message); + } + }, [resolvedAddress, formState.isSubmitting, setError, formAnalytics, importWatchOnlyAccount]); + + return ( +
    + + { + if (value && Viem.isAddress(value)) return true; + + const validationsResults = await Promise.allSettled( + domainsClients.map(client => validateDelegate(value, client)) + ); + + if (validationsResults.some(result => result.status === 'fulfilled' && result.value === true)) { + return true; + } + + const resultWithValidationError = validationsResults.find( + (result): result is PromiseFulfilledResult => result.status === 'fulfilled' + ); + + return ( + resultWithValidationError?.value ?? (validationsResults[0] as PromiseRejectedResult).reason?.message + ); + } + })} + type="text" + name="address" + id="watch-address" + label={t('address')} + placeholder={t('watchOnlyAddressInputPlaceholder')} + errorCaption={errors.address?.message ?? error} + shouldShowErrorCaption + className="resize-none" + containerClassName="mb-8" + cleanable={Boolean(addressValue)} + labelDescription={t('watchOnlyAddressInputDescription')} + onClean={cleanAddressField} + additonalActionButtons={ + addressValue ? null : ( + + + + ) + } + testID={ImportAccountSelectors.watchOnlyInput} + /> + + {tezAddressFromTzDomainName && ( +
    + Resolved Tezos address: + {tezAddressFromTzDomainName} +
    + )} +
    + + + + + + +
    + ); +}); + +function getChainFromAddress(address: string) { + if (isValidTezosAddress(address)) return TempleChainKind.Tezos; + + if (Viem.isAddress(address)) return TempleChainKind.EVM; + + return null; +} diff --git a/src/app/templates/ImportAccountModal/index.tsx b/src/app/templates/ImportAccountModal/index.tsx new file mode 100644 index 0000000000..c5fc8cecc1 --- /dev/null +++ b/src/app/templates/ImportAccountModal/index.tsx @@ -0,0 +1,118 @@ +import React, { FC, memo, useCallback } from 'react'; + +import { ModalInfoBlock } from 'app/atoms/ModalInfoBlock'; +import { PageModal } from 'app/atoms/PageModal'; +import { ActionsButtonsBox } from 'app/atoms/PageModal/actions-buttons-box'; +import { ScrollView } from 'app/atoms/PageModal/scroll-view'; +import { StyledButton } from 'app/atoms/StyledButton'; +import { useAllAccountsReactiveOnAddition } from 'app/hooks/use-all-accounts-reactive'; +import { ReactComponent as DocumentsIcon } from 'app/icons/base/documents.svg'; +import { ReactComponent as KeyIcon } from 'app/icons/base/key.svg'; +import PageLayout from 'app/layouts/PageLayout'; +import { toastSuccess } from 'app/toaster'; +import { T, TID, t } from 'lib/i18n'; +import { navigate } from 'lib/woozie'; + +import { MnemonicForm } from './forms/mnemonic'; +import { PrivateKeyForm } from './forms/private-key'; +import { WatchOnlyForm } from './forms/watch-only'; +import { ImportAccountSelectors } from './selectors'; +import { ImportAccountFormProps } from './types'; + +export type ImportOptionSlug = 'private-key' | 'mnemonic' | 'watch-only'; + +interface ImportAccountModalProps { + optionSlug?: ImportOptionSlug; + shouldShowBackButton?: boolean; + onGoBack?: EmptyFn; + onRequestClose: EmptyFn; + onSeedPhraseSelect?: EmptyFn; + onPrivateKeySelect?: EmptyFn; +} + +interface OptionContents { + titleI18nKey: TID; + form: FC; +} + +const options: Record = { + 'private-key': { + titleI18nKey: 'importPrivateKey', + form: PrivateKeyForm + }, + mnemonic: { + titleI18nKey: 'importSeedPhrase', + form: MnemonicForm + }, + 'watch-only': { + titleI18nKey: 'watchOnlyAccount', + form: WatchOnlyForm + } +}; + +export const ImportAccountModal = memo( + ({ optionSlug, shouldShowBackButton, onGoBack, onRequestClose, onSeedPhraseSelect, onPrivateKeySelect }) => { + useAllAccountsReactiveOnAddition(); + + const option = optionSlug ? options[optionSlug] : undefined; + + const handleSuccess = useCallback(() => { + onRequestClose(); + navigate('/'); + toastSuccess(t('importSuccessful')); + }, [onRequestClose]); + + return ( + + + {option ? ( + + ) : ( + <> + +
    + + + + + + +
    + + } + description={} + onClick={onSeedPhraseSelect} + /> + + } + description={} + onClick={onPrivateKeySelect} + /> +
    + + + + + + + )} +
    +
    + ); + } +); diff --git a/src/app/pages/ImportAccount/selectors.ts b/src/app/templates/ImportAccountModal/selectors.ts similarity index 88% rename from src/app/pages/ImportAccount/selectors.ts rename to src/app/templates/ImportAccountModal/selectors.ts index 1ff8bb14ce..6229885742 100644 --- a/src/app/pages/ImportAccount/selectors.ts +++ b/src/app/templates/ImportAccountModal/selectors.ts @@ -1,5 +1,5 @@ export enum ImportAccountSelectors { - tabSwitcher = 'Import Account/Tabs', + cancelButton = 'Import Account/Cancel Button', privateKeyInput = 'Import Account(Private Key)/Private Key Input', privateKeyImportButton = 'Import Account(Private Key)/Private Key Import Button', @@ -18,7 +18,9 @@ export enum ImportAccountSelectors { watchOnlyImportButton = 'Import Account(Watch-Only)/Watch Only Import Button', ClearSeedPhraseButton = 'Import Account/Clear Seed Phrase Button', - PasteSeedPhraseButton = 'Import Account/Paste Seed Phrase Button' + PasteSeedPhraseButton = 'Import Account/Paste Seed Phrase Button', + PastePrivateKeyButton = 'Import Account/Paste Private Key Button', + PasteAddressButton = 'Import Account/Paste Address Button' } export enum ImportAccountFormType { diff --git a/src/app/templates/ImportAccountModal/types.ts b/src/app/templates/ImportAccountModal/types.ts new file mode 100644 index 0000000000..7d9ae81f77 --- /dev/null +++ b/src/app/templates/ImportAccountModal/types.ts @@ -0,0 +1,4 @@ +export interface ImportAccountFormProps { + onCancel?: EmptyFn; + onSuccess: EmptyFn; +} diff --git a/src/app/templates/ImportSeedForm/index.tsx b/src/app/templates/ImportSeedForm/index.tsx index 9f8f807f2a..c6ed0da175 100644 --- a/src/app/templates/ImportSeedForm/index.tsx +++ b/src/app/templates/ImportSeedForm/index.tsx @@ -21,6 +21,7 @@ export const ImportSeedForm = memo(({ next, onCancel }) => const [bottomEdgeIsVisible, setBottomEdgeIsVisible] = useState(true); const { handleSubmit, formState, reset } = useForm(); + const wasSubmitted = formState.submitCount !== 0; const [seedPhrase, setSeedPhrase] = useState(''); const [seedError, setSeedError] = useState(''); const [numberOfWords, setNumberOfWords] = useState(DEFAULT_SEED_PHRASE_WORDS_AMOUNT); @@ -38,7 +39,7 @@ export const ImportSeedForm = memo(({ next, onCancel }) => (({ next, onCancel }) => ( - ({ opened, setOpened, startWalletCreation }) => ( + ({ opened, setOpened, startWalletCreation, goToImportModal, goToWatchOnlyModal }) => ( @@ -44,7 +47,8 @@ const NewWalletActionsDropdown = memo @@ -53,11 +57,23 @@ const NewWalletActionsDropdown = memo = ({ startWalletCreation, ...testIDProps }) => ( +export const NewWalletActionsPopper: FC = ({ + startWalletCreation, + goToImportModal, + goToWatchOnlyModal, + ...testIDProps +}) => ( } + popup={popperProps => ( + + )} > {({ ref, opened, toggleOpened }) => ( diff --git a/src/app/templates/SeedPhraseInput/SeedLengthSelect/SeedLengthOption.tsx b/src/app/templates/SeedPhraseInput/SeedLengthSelect/SeedLengthOption.tsx index a06cbd680b..346a532f29 100644 --- a/src/app/templates/SeedPhraseInput/SeedLengthSelect/SeedLengthOption.tsx +++ b/src/app/templates/SeedPhraseInput/SeedLengthSelect/SeedLengthOption.tsx @@ -3,7 +3,7 @@ import React, { FC, memo, useCallback } from 'react'; import { emptyFn } from '@rnw-community/shared'; import clsx from 'clsx'; -import { ImportAccountSelectors } from 'app/pages/ImportAccount/selectors'; +import { ImportAccountSelectors } from 'app/templates/ImportAccountModal/selectors'; import { setAnotherSelector, setTestID } from 'lib/analytics'; interface Props { diff --git a/src/app/templates/SeedPhraseInput/SeedLengthSelect/SeedLengthSelect.tsx b/src/app/templates/SeedPhraseInput/SeedLengthSelect/SeedLengthSelect.tsx index 5878f9882a..a2600fbb8b 100644 --- a/src/app/templates/SeedPhraseInput/SeedLengthSelect/SeedLengthSelect.tsx +++ b/src/app/templates/SeedPhraseInput/SeedLengthSelect/SeedLengthSelect.tsx @@ -5,7 +5,7 @@ import clsx from 'clsx'; import { IconBase } from 'app/atoms'; import { StyledButton } from 'app/atoms/StyledButton'; import { ReactComponent as CompactDownIcon } from 'app/icons/base/compact_down.svg'; -import { ImportAccountSelectors } from 'app/pages/ImportAccount/selectors'; +import { ImportAccountSelectors } from 'app/templates/ImportAccountModal/selectors'; import { T } from 'lib/i18n'; import { useBooleanState } from 'lib/ui/hooks'; diff --git a/src/app/templates/SeedPhraseInput/index.tsx b/src/app/templates/SeedPhraseInput/index.tsx index 4ed043f569..de99127f95 100644 --- a/src/app/templates/SeedPhraseInput/index.tsx +++ b/src/app/templates/SeedPhraseInput/index.tsx @@ -9,12 +9,11 @@ import { TextButton } from 'app/atoms/TextButton'; import { formatMnemonic } from 'app/defaults'; import { ReactComponent as PasteFillIcon } from 'app/icons/base/paste_fill.svg'; import { ReactComponent as XCircleFillIcon } from 'app/icons/base/x_circle_fill.svg'; +import { ImportAccountSelectors } from 'app/templates/ImportAccountModal/selectors'; import { setTestID, TestIDProperty } from 'lib/analytics'; import { DEFAULT_SEED_PHRASE_WORDS_AMOUNT } from 'lib/constants'; import { T, t } from 'lib/i18n'; -import { clearClipboard } from 'lib/ui/utils'; - -import { ImportAccountSelectors } from '../../pages/ImportAccount/selectors'; +import { clearClipboard, readClipboard } from 'lib/ui/utils'; import { SeedLengthSelect } from './SeedLengthSelect/SeedLengthSelect'; import { SeedWordInput, SeedWordInputProps } from './SeedWordInput'; @@ -66,7 +65,7 @@ export const SeedPhraseInput: FC = ({ } if (newDraftSeed.some(word => word === '')) { - newSeedError = t('mnemonicWordsAmountConstraint', [numberOfWords]) as string; + newSeedError = t('mnemonicWordsAmountConstraint', [String(numberOfWords)]); } if (!validateMnemonic(formatMnemonic(joinedDraftSeed))) { @@ -135,7 +134,13 @@ export const SeedPhraseInput: FC = ({ ); const pasteMnemonic = useCallback( - () => window.navigator.clipboard.readText().then(onSeedPaste).catch(console.error), + () => + readClipboard() + .then(value => { + onSeedPaste(value); + clearClipboard(); + }) + .catch(console.error), [onSeedPaste] ); diff --git a/src/lib/apis/dipdup-search.ts b/src/lib/apis/dipdup-search.ts new file mode 100644 index 0000000000..96069a029a --- /dev/null +++ b/src/lib/apis/dipdup-search.ts @@ -0,0 +1,54 @@ +import axios from 'axios'; + +import { TempleTezosChainId } from 'lib/temple/types'; + +const dipdupSearchApi = axios.create({ baseURL: 'https://search.dipdup.net/v1/search' }); +const networksPriority = ['mainnet', 'ghostnet']; + +// 'parisnet' option is available too but it is dropped to decrease maintenance cost +type DipdupSearchNetwork = 'mainnet' | 'ghostnet'; + +export const dipdupNetworksChainIds: Record = { + mainnet: TempleTezosChainId.Mainnet, + ghostnet: TempleTezosChainId.Ghostnet +}; + +interface TezosAccountItem { + type: 'account'; + body: { + Address: string; + IsContract: boolean; + Network: DipdupSearchNetwork; + }; +} + +interface TezosAccountSearchResponse { + total: number; + items: TezosAccountItem[]; +} + +export const searchForTezosAccount = async (address: string) => { + const { data } = await dipdupSearchApi.post('/', { + query: address, + size: 10, + offset: 0, + disable_highlight: true, + filters: { + search: { + tags: [], + creators: [], + minters: [], + mime_types: [], + network: ['mainnet', 'ghostnet'], + index: ['accounts'] + } + } + }); + + return { + ...data, + items: data.items + .filter(item => item.body.Address === address) + .sort((a, b) => networksPriority.indexOf(a.body.Network) - networksPriority.indexOf(b.body.Network)) + }; +}; diff --git a/src/lib/ui/utils.ts b/src/lib/ui/utils.ts index f5c8c2885a..6080f7b70f 100644 --- a/src/lib/ui/utils.ts +++ b/src/lib/ui/utils.ts @@ -24,3 +24,5 @@ export const combineRefs = ( export const clearClipboard = () => { window.navigator.clipboard.writeText(''); }; + +export const readClipboard = () => window.navigator.clipboard.readText(); diff --git a/src/temple/front/tezos/tzdns.ts b/src/temple/front/tezos/tzdns.ts index bdd55b6c57..b2e09902d5 100644 --- a/src/temple/front/tezos/tzdns.ts +++ b/src/temple/front/tezos/tzdns.ts @@ -4,6 +4,7 @@ import memoizee from 'memoizee'; import { useTypedSWR } from 'lib/swr'; import { TEZOS_MAINNET_CHAIN_ID } from 'lib/temple/types'; +import { useEnabledTezosChains } from 'temple/front/ready'; import { MAX_MEMOIZED_TOOLKITS } from 'temple/misc'; import { TezosNetworkEssentials } from 'temple/networks'; import { getReadOnlyTezos } from 'temple/tezos'; @@ -23,13 +24,24 @@ export function isTezosDomainsNameValid(name: string, client: TaquitoTezosDomain return client.validator.validateDomainName(name, { minLevel: 2 }) === DomainNameValidationResult.VALID; } -export function useTezosAddressByDomainName(domainName: string, network: TezosNetworkEssentials | nullish) { +export function useTezosAddressByDomainName(domainName: string, network?: TezosNetworkEssentials) { + const enabledTezosChains = useEnabledTezosChains(); + const tezosChains = network ? [network] : enabledTezosChains; + return useTypedSWR( - network ? ['tzdns-address', domainName, network.chainId, network.rpcBaseURL] : null, - () => - network - ? getTezosDomainsClient(network.chainId, network.rpcBaseURL).resolver.resolveNameToAddress(domainName) - : null, + ['tzdns-address', domainName, ...tezosChains.map(({ rpcBaseURL, chainId }) => `${chainId}_${rpcBaseURL}`)], + async () => { + const results = await Promise.allSettled( + tezosChains.map(({ chainId, rpcBaseURL }) => + getTezosDomainsClient(chainId, rpcBaseURL).resolver.resolveNameToAddress(domainName) + ) + ); + + return ( + results.find((result): result is PromiseFulfilledResult => result.status === 'fulfilled') + ?.value ?? null + ); + }, { shouldRetryOnError: false, revalidateOnFocus: false From abb7939204bf38edb177292964bae9514adb71d3 Mon Sep 17 00:00:00 2001 From: Inokentii Mazhara Date: Mon, 26 Aug 2024 16:06:26 +0300 Subject: [PATCH 05/12] TW-1449 Some bugfixes --- public/_locales/en/messages.json | 8 --- ...params.ts => use-search-params-boolean.ts} | 0 src/app/pages/Receive/Receive.tsx | 2 +- src/app/pages/Welcome/Welcome.tsx | 2 +- .../group-actions-popper.tsx | 4 +- .../templates/AccountsManagement/index.tsx | 22 ++++--- src/app/templates/AppHeader/index.tsx | 2 +- .../ImportAccountModal/forms/mnemonic.tsx | 18 +++-- .../ImportAccountModal/forms/private-key.tsx | 8 ++- .../ImportAccountModal/forms/watch-only.tsx | 66 ++++++++++++------- .../templates/ImportAccountModal/index.tsx | 2 - .../verify-seed-phrase-input/index.tsx | 4 +- .../SeedLengthSelect/SeedLengthOption.tsx | 4 +- src/lib/temple/front/validate-delegate.ts | 2 +- src/lib/ui/should-disable-submit-button.ts | 13 ++++ src/temple/front/evm/helpers.ts | 45 ++++++++++++- 16 files changed, 142 insertions(+), 60 deletions(-) rename src/app/hooks/{use-modal-open-search-params.ts => use-search-params-boolean.ts} (100%) create mode 100644 src/lib/ui/should-disable-submit-button.ts diff --git a/public/_locales/en/messages.json b/public/_locales/en/messages.json index acaac07c07..6e301c2ce0 100644 --- a/public/_locales/en/messages.json +++ b/public/_locales/en/messages.json @@ -653,14 +653,6 @@ "mnemonicWordsError": { "message": "Make sure the words spelled correctly" }, - "seedInputNumberOfWords": { - "message": "My Seed phrase is $count$ words", - "placeholders": { - "count": { - "content": "$1" - } - } - }, "justValidPreGeneratedMnemonic": { "message": "Invalid Seed Phrase" }, diff --git a/src/app/hooks/use-modal-open-search-params.ts b/src/app/hooks/use-search-params-boolean.ts similarity index 100% rename from src/app/hooks/use-modal-open-search-params.ts rename to src/app/hooks/use-search-params-boolean.ts diff --git a/src/app/pages/Receive/Receive.tsx b/src/app/pages/Receive/Receive.tsx index d46d24cf6b..54b406058b 100644 --- a/src/app/pages/Receive/Receive.tsx +++ b/src/app/pages/Receive/Receive.tsx @@ -1,7 +1,7 @@ import React, { memo, useCallback, useState } from 'react'; import { PageTitle } from 'app/atoms'; -import { useSearchParamsBoolean } from 'app/hooks/use-modal-open-search-params'; +import { useSearchParamsBoolean } from 'app/hooks/use-search-params-boolean'; import PageLayout from 'app/layouts/PageLayout'; import { AccountsModal } from 'app/templates/AppHeader/AccountsModal'; import { T, t } from 'lib/i18n'; diff --git a/src/app/pages/Welcome/Welcome.tsx b/src/app/pages/Welcome/Welcome.tsx index f37fd92abc..49097253d7 100644 --- a/src/app/pages/Welcome/Welcome.tsx +++ b/src/app/pages/Welcome/Welcome.tsx @@ -8,7 +8,7 @@ import { SocialButton } from 'app/atoms/SocialButton'; import { StyledButton } from 'app/atoms/StyledButton'; import { SuspenseContainer } from 'app/atoms/SuspenseContainer'; import { useABTestingLoading } from 'app/hooks/use-ab-testing-loading'; -import { useSearchParamsBoolean } from 'app/hooks/use-modal-open-search-params'; +import { useSearchParamsBoolean } from 'app/hooks/use-search-params-boolean'; import { ReactComponent as GoogleDriveIcon } from 'app/icons/base/google_drive.svg'; import { ReactComponent as ImportedIcon } from 'app/icons/base/imported.svg'; import { ReactComponent as PlusIcon } from 'app/icons/base/plus.svg'; diff --git a/src/app/templates/AccountsManagement/group-actions-popper.tsx b/src/app/templates/AccountsManagement/group-actions-popper.tsx index fd25127da7..07bb90c995 100644 --- a/src/app/templates/AccountsManagement/group-actions-popper.tsx +++ b/src/app/templates/AccountsManagement/group-actions-popper.tsx @@ -136,9 +136,9 @@ const GroupActionsDropdown = memo( key: 'import', children: t(group.type === TempleAccountType.Imported ? 'importAccount' : 'createAccount'), Icon: ImportedIcon, - onClick: () => + onClick: group.type === TempleAccountType.Ledger - ? navigate('/connect-ledger') + ? () => navigate('/connect-ledger') : group.type === TempleAccountType.Imported ? goToImportModal : goToWatchOnlyModal, diff --git a/src/app/templates/AccountsManagement/index.tsx b/src/app/templates/AccountsManagement/index.tsx index 08d4dd2d22..189d72ce35 100644 --- a/src/app/templates/AccountsManagement/index.tsx +++ b/src/app/templates/AccountsManagement/index.tsx @@ -30,6 +30,7 @@ enum AccountsManagementModal { AccountAlreadyExistsWarning = 'account-already-exists-warning', CreateHDWalletFlow = 'create-hd-wallet-flow', ImportAccount = 'import-account', + ImportWallet = 'import-wallet', WatchOnly = 'watch-only' } @@ -110,10 +111,14 @@ export const AccountsManagement = memo(({ setHeaderChil } }, [createAccount, customAlert, selectedGroup]); - const goToImportModal = useCallback(() => { - setActiveModal(AccountsManagementModal.ImportAccount); + const goToImportWalletModal = useCallback(() => { + setActiveModal(AccountsManagementModal.ImportWallet); setImportOptionSlug(undefined); }, []); + const goToImportAccountModal = useCallback(() => { + setActiveModal(AccountsManagementModal.ImportAccount); + setImportOptionSlug('private-key'); + }, []); const goToWatchOnlyModal = useCallback(() => setActiveModal(AccountsManagementModal.WatchOnly), []); const handleSeedPhraseImportOptionSelect = useCallback(() => setImportOptionSlug('mnemonic'), []); const handlePrivateKeyImportOptionSelect = useCallback(() => setImportOptionSlug('private-key'), []); @@ -151,12 +156,13 @@ export const AccountsManagement = memo(({ setHeaderChil ); case AccountsManagementModal.CreateHDWalletFlow: return ; + case AccountsManagementModal.ImportWallet: case AccountsManagementModal.ImportAccount: return ( (({ setHeaderChil } }, [ activeModal, - goToImportModal, + goToImportWalletModal, handleAccountAlreadyExistsWarnClose, handleModalClose, handlePrivateKeyImportOptionSelect, @@ -202,12 +208,12 @@ export const AccountsManagement = memo(({ setHeaderChil
    ), - [goToImportModal, goToWatchOnlyModal, searchValue, startWalletCreation] + [goToImportWalletModal, goToWatchOnlyModal, searchValue, startWalletCreation] ); useEffect(() => setHeaderChildren(headerChildren), [headerChildren, setHeaderChildren]); @@ -233,7 +239,7 @@ export const AccountsManagement = memo(({ setHeaderChil onDeleteClick={handleDeleteClick} onRenameClick={handleRenameClick} onRevealSeedPhraseClick={handleRevealSeedPhraseClick} - goToImportModal={goToImportModal} + goToImportModal={goToImportAccountModal} goToWatchOnlyModal={goToWatchOnlyModal} /> ))} diff --git a/src/app/templates/AppHeader/index.tsx b/src/app/templates/AppHeader/index.tsx index c6c23e8d91..fe0b5bd7ed 100644 --- a/src/app/templates/AppHeader/index.tsx +++ b/src/app/templates/AppHeader/index.tsx @@ -6,7 +6,7 @@ import { IconBase } from 'app/atoms'; import { AccountAvatar } from 'app/atoms/AccountAvatar'; import { AccountName } from 'app/atoms/AccountName'; import { Button } from 'app/atoms/Button'; -import { useSearchParamsBoolean } from 'app/hooks/use-modal-open-search-params'; +import { useSearchParamsBoolean } from 'app/hooks/use-search-params-boolean'; import { ReactComponent as BurgerIcon } from 'app/icons/base/menu.svg'; import Popper from 'lib/ui/Popper'; import { useAccount } from 'temple/front'; diff --git a/src/app/templates/ImportAccountModal/forms/mnemonic.tsx b/src/app/templates/ImportAccountModal/forms/mnemonic.tsx index 5adadd4299..fe1b65f5d8 100644 --- a/src/app/templates/ImportAccountModal/forms/mnemonic.tsx +++ b/src/app/templates/ImportAccountModal/forms/mnemonic.tsx @@ -14,6 +14,7 @@ import { useFormAnalytics } from 'lib/analytics'; import { DEFAULT_SEED_PHRASE_WORDS_AMOUNT, DEFAULT_TEZOS_DERIVATION_PATH } from 'lib/constants'; import { t, T } from 'lib/i18n'; import { useTempleClient, validateDerivationPath } from 'lib/temple/front'; +import { shouldDisableSubmitButton } from 'lib/ui/should-disable-submit-button'; import { ImportAccountFormType, ImportAccountSelectors } from '../selectors'; import { ImportAccountFormProps } from '../types'; @@ -35,7 +36,6 @@ export const MnemonicForm = memo(({ onSuccess, onCancel const [error, setError] = useState(null); const { errors, register, handleSubmit, formState, reset } = useForm({ defaultValues }); const { isSubmitting, submitCount } = formState; - const wasSubmitted = submitCount > 0; const [bottomEdgeIsVisible, setBottomEdgeIsVisible] = useState(false); @@ -91,7 +91,7 @@ export const MnemonicForm = memo(({ onSuccess, onCancel
    0} seedError={seedError || error} setSeedError={updateSeedError} onChange={setSeedPhrase} @@ -108,10 +108,18 @@ export const MnemonicForm = memo(({ onSuccess, onCancel })} name="derivationPath" id="derivationPath" - label={startCase(t('customDerivationPath'))} + labelContainerClassName="w-full flex justify-between items-center" + label={ + <> + {startCase(t('customDerivationPath'))} + + + + + } placeholder={DEFAULT_TEZOS_DERIVATION_PATH} errorCaption={errors.derivationPath?.message} - containerClassName="mt-8" + containerClassName="mt-3" testID={ImportAccountSelectors.customDerivationPathInput} /> @@ -130,7 +138,7 @@ export const MnemonicForm = memo(({ onSuccess, onCancel className="w-full" size="L" color="primary" - disabled={isSubmitting || ((Object.keys(errors).length > 0 || Boolean(seedError)) && wasSubmitted)} + disabled={shouldDisableSubmitButton(errors, formState, seedError)} type="submit" testID={AccountsModalSelectors.nextButton} > diff --git a/src/app/templates/ImportAccountModal/forms/private-key.tsx b/src/app/templates/ImportAccountModal/forms/private-key.tsx index 08542f31a6..838175a146 100644 --- a/src/app/templates/ImportAccountModal/forms/private-key.tsx +++ b/src/app/templates/ImportAccountModal/forms/private-key.tsx @@ -12,6 +12,7 @@ import { ReactComponent as PasteFillIcon } from 'app/icons/base/paste_fill.svg'; import { useFormAnalytics } from 'lib/analytics'; import { T, t } from 'lib/i18n'; import { useTempleClient } from 'lib/temple/front'; +import { shouldDisableSubmitButton } from 'lib/ui/should-disable-submit-button'; import { clearClipboard, readClipboard } from 'lib/ui/utils'; import { TempleChainKind } from 'temple/types'; @@ -28,7 +29,7 @@ export const PrivateKeyForm = memo(({ onSuccess }) => { const formAnalytics = useFormAnalytics(ImportAccountFormType.PrivateKey); const { register, handleSubmit, errors, formState, watch, setValue, triggerValidation } = - useForm(); + useForm({ mode: 'onChange' }); const [bottomEdgeIsVisible, setBottomEdgeIsVisible] = useState(true); const [error, setError] = useState(null); @@ -63,9 +64,10 @@ export const PrivateKeyForm = memo(({ onSuccess }) => { .then(value => { setValue('privateKey', value); clearClipboard(); + triggerValidation('privateKey'); }) .catch(console.error), - [setValue] + [setValue, triggerValidation] ); const cleanPrivateKeyField = useCallback(() => { setValue('privateKey', ''); @@ -137,7 +139,7 @@ export const PrivateKeyForm = memo(({ onSuccess }) => { diff --git a/src/app/templates/ImportAccountModal/forms/watch-only.tsx b/src/app/templates/ImportAccountModal/forms/watch-only.tsx index f8c996ea43..e302ebaf13 100644 --- a/src/app/templates/ImportAccountModal/forms/watch-only.tsx +++ b/src/app/templates/ImportAccountModal/forms/watch-only.tsx @@ -2,6 +2,7 @@ import React, { memo, ReactNode, useCallback, useMemo, useState } from 'react'; import { useForm } from 'react-hook-form'; import * as Viem from 'viem'; +import { normalize } from 'viem/ens'; import { FormField } from 'app/atoms'; import { ActionsButtonsBox } from 'app/atoms/PageModal/actions-buttons-box'; @@ -14,18 +15,21 @@ import { dipdupNetworksChainIds, searchForTezosAccount } from 'lib/apis/dipdup-s import { T, t } from 'lib/i18n'; import { useTempleClient, validateDelegate } from 'lib/temple/front'; import { isValidTezosAddress, isTezosContractAddress } from 'lib/tezos'; +import { shouldDisableSubmitButton } from 'lib/ui/should-disable-submit-button'; import { readClipboard } from 'lib/ui/utils'; import { useEnabledTezosChains } from 'temple/front'; +import { useEvmAddressByDomainName } from 'temple/front/evm/helpers'; import { getTezosDomainsClient, useTezosAddressByDomainName } from 'temple/front/tezos'; import { TempleChainKind } from 'temple/types'; import { ImportAccountSelectors, ImportAccountFormType } from '../selectors'; +import { ImportAccountFormProps } from '../types'; interface WatchOnlyFormData { address: string; } -export const WatchOnlyForm = memo(() => { +export const WatchOnlyForm = memo(({ onSuccess }) => { const { importWatchOnlyAccount } = useTempleClient(); const tezosChains = useEnabledTezosChains(); @@ -48,10 +52,11 @@ export const WatchOnlyForm = memo(() => { const addressValue = watch('address'); const { data: tezAddressFromTzDomainName } = useTezosAddressByDomainName(addressValue); + const { data: evmAddressFromDomainName } = useEvmAddressByDomainName(addressValue); const resolvedAddress = useMemo( - () => tezAddressFromTzDomainName || addressValue, - [addressValue, tezAddressFromTzDomainName] + () => evmAddressFromDomainName || tezAddressFromTzDomainName || addressValue, + [addressValue, evmAddressFromDomainName, tezAddressFromTzDomainName] ); const pasteAddress = useCallback( @@ -93,6 +98,7 @@ export const WatchOnlyForm = memo(() => { await importWatchOnlyAccount(chain, finalAddress, tezosChainId); formAnalytics.trackSubmitSuccess({ chain }); + onSuccess(); } catch (err: any) { formAnalytics.trackSubmitFail({ chain }); @@ -100,7 +106,37 @@ export const WatchOnlyForm = memo(() => { setError(err.message); } - }, [resolvedAddress, formState.isSubmitting, setError, formAnalytics, importWatchOnlyAccount]); + }, [formState.isSubmitting, formAnalytics, resolvedAddress, importWatchOnlyAccount, onSuccess]); + + const validateAddress = useCallback( + async (value: any) => { + let isNormalizableEns = false; + try { + if (value) { + normalize(value); + isNormalizableEns = true; + } + // eslint-disable-next-line no-empty + } catch {} + + if (value && (Viem.isAddress(value) || isNormalizableEns)) return true; + + const validationsResults = await Promise.allSettled( + domainsClients.map(client => validateDelegate(value, client)) + ); + + if (validationsResults.some(result => result.status === 'fulfilled' && result.value === true)) { + return true; + } + + const resultWithValidationError = validationsResults.find( + (result): result is PromiseFulfilledResult => result.status === 'fulfilled' + ); + + return resultWithValidationError?.value ?? (validationsResults[0] as PromiseRejectedResult).reason.message; + }, + [domainsClients] + ); return ( @@ -110,25 +146,7 @@ export const WatchOnlyForm = memo(() => { rows={5} ref={register({ required: t('required'), - validate: async (value: any) => { - if (value && Viem.isAddress(value)) return true; - - const validationsResults = await Promise.allSettled( - domainsClients.map(client => validateDelegate(value, client)) - ); - - if (validationsResults.some(result => result.status === 'fulfilled' && result.value === true)) { - return true; - } - - const resultWithValidationError = validationsResults.find( - (result): result is PromiseFulfilledResult => result.status === 'fulfilled' - ); - - return ( - resultWithValidationError?.value ?? (validationsResults[0] as PromiseRejectedResult).reason?.message - ); - } + validate: validateAddress })} type="text" name="address" @@ -169,7 +187,7 @@ export const WatchOnlyForm = memo(() => { diff --git a/src/app/templates/ImportAccountModal/index.tsx b/src/app/templates/ImportAccountModal/index.tsx index c5fc8cecc1..db34a81213 100644 --- a/src/app/templates/ImportAccountModal/index.tsx +++ b/src/app/templates/ImportAccountModal/index.tsx @@ -11,7 +11,6 @@ import { ReactComponent as KeyIcon } from 'app/icons/base/key.svg'; import PageLayout from 'app/layouts/PageLayout'; import { toastSuccess } from 'app/toaster'; import { T, TID, t } from 'lib/i18n'; -import { navigate } from 'lib/woozie'; import { MnemonicForm } from './forms/mnemonic'; import { PrivateKeyForm } from './forms/private-key'; @@ -58,7 +57,6 @@ export const ImportAccountModal = memo( const handleSuccess = useCallback(() => { onRequestClose(); - navigate('/'); toastSuccess(t('importSuccessful')); }, [onRequestClose]); diff --git a/src/app/templates/ManualBackupModal/verify-seed-phrase-input/index.tsx b/src/app/templates/ManualBackupModal/verify-seed-phrase-input/index.tsx index 99ef4099fa..92d47b7fc4 100644 --- a/src/app/templates/ManualBackupModal/verify-seed-phrase-input/index.tsx +++ b/src/app/templates/ManualBackupModal/verify-seed-phrase-input/index.tsx @@ -36,8 +36,8 @@ export const VerifySeedPhraseInput = memo( />
    {wordsIndices.map((wordIndex, inputIndex) => ( diff --git a/src/app/templates/SeedPhraseInput/SeedLengthSelect/SeedLengthOption.tsx b/src/app/templates/SeedPhraseInput/SeedLengthSelect/SeedLengthOption.tsx index 346a532f29..cb7919095e 100644 --- a/src/app/templates/SeedPhraseInput/SeedLengthSelect/SeedLengthOption.tsx +++ b/src/app/templates/SeedPhraseInput/SeedLengthSelect/SeedLengthOption.tsx @@ -6,6 +6,8 @@ import clsx from 'clsx'; import { ImportAccountSelectors } from 'app/templates/ImportAccountModal/selectors'; import { setAnotherSelector, setTestID } from 'lib/analytics'; +import { getOptionLabel } from './get-option-label'; + interface Props { option: string; selectedOption: string; @@ -26,7 +28,7 @@ export const SeedLengthOption: FC = memo(({ option, selectedOption, onCli {...setTestID(ImportAccountSelectors.mnemonicWordsOption)} {...setAnotherSelector('words', option)} > - {option} + {getOptionLabel(option)} ); }); diff --git a/src/lib/temple/front/validate-delegate.ts b/src/lib/temple/front/validate-delegate.ts index d8a389c134..6e5174dc9a 100644 --- a/src/lib/temple/front/validate-delegate.ts +++ b/src/lib/temple/front/validate-delegate.ts @@ -10,7 +10,7 @@ function validateAnyAddress(value: string) { return true; case isValidTezosAddress(value): - return 'invalidAddress'; + return t('invalidAddress'); default: return true; diff --git a/src/lib/ui/should-disable-submit-button.ts b/src/lib/ui/should-disable-submit-button.ts new file mode 100644 index 0000000000..6642a17979 --- /dev/null +++ b/src/lib/ui/should-disable-submit-button.ts @@ -0,0 +1,13 @@ +import { ReactNode } from 'react'; + +import { FormContextValues } from 'react-hook-form'; + +export const shouldDisableSubmitButton = ( + errors: FormContextValues['errors'], + formState: FormContextValues['formState'], + ...otherErrors: ReactNode[] +) => { + const { isSubmitting, submitCount } = formState; + + return isSubmitting || (submitCount > 0 && (Object.keys(errors).length > 0 || otherErrors.some(Boolean))); +}; diff --git a/src/temple/front/evm/helpers.ts b/src/temple/front/evm/helpers.ts index 4e054d51e2..b5c72269ca 100644 --- a/src/temple/front/evm/helpers.ts +++ b/src/temple/front/evm/helpers.ts @@ -1,5 +1,48 @@ -import { isAddress } from 'viem'; +import { uniq } from 'lodash'; +import memoizee from 'memoizee'; +import { GetEnsAddressReturnType, createPublicClient, http, isAddress } from 'viem'; +import * as ViemChains from 'viem/chains'; +import { normalize } from 'viem/ens'; import { getMessage } from 'lib/i18n'; +import { useTypedSWR } from 'lib/swr'; + +import { EvmChain } from '../chains'; +import { useEnabledEvmChains } from '../ready'; export const validateEvmContractAddress = (value: string) => (isAddress(value) ? true : getMessage('invalidAddress')); + +const ensCapableChains = Object.values(ViemChains).filter(chain => chain.contracts && 'ensRegistry' in chain.contracts); + +const getEnsCapableEnabledChainsReadOnlyEvms = memoizee( + (enabledChains: EvmChain[]) => + ensCapableChains + .filter(chain => enabledChains.some(({ chainId }) => chain.id === chainId)) + .map(chain => createPublicClient({ chain, transport: http(chain.rpcUrls.default.http[0]) })), + { normalizer: ([chains]) => uniq(chains.map(({ chainId }) => chainId)).join('_') } +); + +export function useEvmAddressByDomainName(domainName: string) { + const enabledEvmChains = useEnabledEvmChains(); + + return useTypedSWR( + ['ens-address', domainName, ...enabledEvmChains.map(({ rpcBaseURL, chainId }) => `${chainId}_${rpcBaseURL}`)], + async () => { + const ensCapableChainsReadOnlyEvms = getEnsCapableEnabledChainsReadOnlyEvms(enabledEvmChains); + const results = await Promise.allSettled( + ensCapableChainsReadOnlyEvms.map(evm => evm.getEnsAddress({ name: normalize(domainName) })) + ); + + return ( + results.find( + (result): result is PromiseFulfilledResult => result.status === 'fulfilled' + )?.value ?? null + ); + }, + { + dedupingInterval: 1000, + shouldRetryOnError: false, + revalidateOnFocus: false + } + ); +} From 7e55d1564119eb03442e789e0c5776dc7e2e1399 Mon Sep 17 00:00:00 2001 From: Inokentii Mazhara Date: Thu, 29 Aug 2024 16:13:16 +0300 Subject: [PATCH 06/12] TW-1449 Refactoring according to comments --- public/_locales/de/messages.json | 3 -- public/_locales/en/messages.json | 4 +- public/_locales/en_GB/messages.json | 3 -- public/_locales/fr/messages.json | 3 -- public/_locales/ja/messages.json | 3 -- public/_locales/ko/messages.json | 3 -- public/_locales/pt/messages.json | 3 -- public/_locales/tr/messages.json | 3 -- public/_locales/uk/messages.json | 3 -- public/_locales/zh_CN/messages.json | 3 -- public/_locales/zh_TW/messages.json | 3 -- src/app/atoms/FormField.tsx | 2 - .../ImportAccountModal/forms/private-key.tsx | 2 +- .../ImportAccountModal/forms/watch-only.tsx | 46 ++++++++++++++++--- .../templates/ImportAccountModal/index.tsx | 10 ++-- .../templates/ImportAccountModal/selectors.ts | 8 ++-- .../SeedLengthSelect/SeedLengthSelect.tsx | 6 ++- .../SeedPhraseInput/SeedWordInput.tsx | 2 - src/app/templates/SeedPhraseInput/index.tsx | 9 ++-- src/lib/apis/dipdup-search.ts | 3 +- webpack/manifest.ts | 2 +- 21 files changed, 62 insertions(+), 62 deletions(-) diff --git a/public/_locales/de/messages.json b/public/_locales/de/messages.json index 8e8eebcbac..4a5241e719 100644 --- a/public/_locales/de/messages.json +++ b/public/_locales/de/messages.json @@ -356,9 +356,6 @@ "invalidAddress": { "message": "Ungültige Adresse" }, - "contractNotExistOnNetwork": { - "message": "Dieser Smart-Kontrakt existiert nicht im aktuellen Netzwerk" - }, "onlyKTContractAddressAllowed": { "message": "Nur KT... Vertragsadresse erlaubt", "description": "KT not translated" diff --git a/public/_locales/en/messages.json b/public/_locales/en/messages.json index 6e301c2ce0..a87bf03eea 100644 --- a/public/_locales/en/messages.json +++ b/public/_locales/en/messages.json @@ -483,8 +483,8 @@ "invalidAddress": { "message": "Invalid address" }, - "contractNotExistOnNetwork": { - "message": "This smart contract doesn't exist on current network" + "contractNotExistOnKnownNetworks": { + "message": "This smart contract doesn't exist on known networks" }, "onlyKTContractAddressAllowed": { "message": "Only KT... contract address allowed", diff --git a/public/_locales/en_GB/messages.json b/public/_locales/en_GB/messages.json index 0e341f51ca..dcdcc86e60 100644 --- a/public/_locales/en_GB/messages.json +++ b/public/_locales/en_GB/messages.json @@ -352,9 +352,6 @@ "invalidAddress": { "message": "Invalid address" }, - "contractNotExistOnNetwork": { - "message": "This smart contract doesn't exist on current network" - }, "onlyKTContractAddressAllowed": { "message": "Only KT... contract address allowed", "description": "KT not translated" diff --git a/public/_locales/fr/messages.json b/public/_locales/fr/messages.json index d3e6d6d6f4..a6530a530f 100644 --- a/public/_locales/fr/messages.json +++ b/public/_locales/fr/messages.json @@ -338,9 +338,6 @@ "invalidAddress": { "message": "Adresse invalide" }, - "contractNotExistOnNetwork": { - "message": "Ce contrat intelligent n'existe pas sur le réseau actuel" - }, "onlyKTContractAddressAllowed": { "message": "Seules les adresses de contrat KT… sont autorisées", "description": "KT not translated" diff --git a/public/_locales/ja/messages.json b/public/_locales/ja/messages.json index 3eb130ee30..3a6dfca56e 100644 --- a/public/_locales/ja/messages.json +++ b/public/_locales/ja/messages.json @@ -338,9 +338,6 @@ "invalidAddress": { "message": "無効なアドレス" }, - "contractNotExistOnNetwork": { - "message": "このスマートコントラクトは現在のネットワークには存在しません" - }, "onlyKTContractAddressAllowed": { "message": "KTのみ...コントラクトアドレス可", "description": "KT not translated" diff --git a/public/_locales/ko/messages.json b/public/_locales/ko/messages.json index 4a9d1c19f0..b0565e9179 100644 --- a/public/_locales/ko/messages.json +++ b/public/_locales/ko/messages.json @@ -338,9 +338,6 @@ "invalidAddress": { "message": "유효하지 않은 주소" }, - "contractNotExistOnNetwork": { - "message": "이 스마트 계약은 현재 네트워크에 존재하지 않습니다" - }, "onlyKTContractAddressAllowed": { "message": "KT... 계약 주소만 허용됨", "description": "KT not translated" diff --git a/public/_locales/pt/messages.json b/public/_locales/pt/messages.json index 0f50f45edb..b5a56146ca 100644 --- a/public/_locales/pt/messages.json +++ b/public/_locales/pt/messages.json @@ -356,9 +356,6 @@ "invalidAddress": { "message": "Endereço inválido" }, - "contractNotExistOnNetwork": { - "message": "Este contrato inteligente não existe na rede atual" - }, "onlyKTContractAddressAllowed": { "message": "Apenas o endereço do contrato KT... é permitido", "description": "KT not translated" diff --git a/public/_locales/tr/messages.json b/public/_locales/tr/messages.json index d4acb6da2b..37795aea75 100644 --- a/public/_locales/tr/messages.json +++ b/public/_locales/tr/messages.json @@ -356,9 +356,6 @@ "invalidAddress": { "message": "Geçersiz adres" }, - "contractNotExistOnNetwork": { - "message": "Bu akıllı sözleşme mevcut ağda yok" - }, "onlyKTContractAddressAllowed": { "message": "Yalnızca KT... sözleşme adresine izin verilir", "description": "KT not translated" diff --git a/public/_locales/uk/messages.json b/public/_locales/uk/messages.json index acebfce6da..70f3b6df81 100644 --- a/public/_locales/uk/messages.json +++ b/public/_locales/uk/messages.json @@ -355,9 +355,6 @@ "invalidAddress": { "message": "Невалідна адреса" }, - "contractNotExistOnNetwork": { - "message": "Цей смарт контракт не існує на заданній мережі" - }, "onlyKTContractAddressAllowed": { "message": "Тільки KT... адреси контрактів дозволені", "description": "KT not translated" diff --git a/public/_locales/zh_CN/messages.json b/public/_locales/zh_CN/messages.json index d0042d8667..4caf8a85b2 100644 --- a/public/_locales/zh_CN/messages.json +++ b/public/_locales/zh_CN/messages.json @@ -338,9 +338,6 @@ "invalidAddress": { "message": "地址无效" }, - "contractNotExistOnNetwork": { - "message": "此智能合约不在当前网络上" - }, "onlyKTContractAddressAllowed": { "message": "仅可使用KT……合约地址", "description": "KT not translated" diff --git a/public/_locales/zh_TW/messages.json b/public/_locales/zh_TW/messages.json index db187a1300..42e9d43294 100644 --- a/public/_locales/zh_TW/messages.json +++ b/public/_locales/zh_TW/messages.json @@ -338,9 +338,6 @@ "invalidAddress": { "message": "無效的地址" }, - "contractNotExistOnNetwork": { - "message": "目前的網路上不存在此智慧合約" - }, "onlyKTContractAddressAllowed": { "message": "僅允許 KT… 合約地址", "description": "KT not translated" diff --git a/src/app/atoms/FormField.tsx b/src/app/atoms/FormField.tsx index f0ed566039..ea84b8e2c5 100644 --- a/src/app/atoms/FormField.tsx +++ b/src/app/atoms/FormField.tsx @@ -44,7 +44,6 @@ export interface FormFieldProps extends TestIDProperty, Omit( errorCaption, shouldShowErrorCaption = true, warningCaption, - shouldShowWarningCaption = true, containerClassName, labelContainerClassName, textarea, diff --git a/src/app/templates/ImportAccountModal/forms/private-key.tsx b/src/app/templates/ImportAccountModal/forms/private-key.tsx index 838175a146..aaa433aadc 100644 --- a/src/app/templates/ImportAccountModal/forms/private-key.tsx +++ b/src/app/templates/ImportAccountModal/forms/private-key.tsx @@ -104,7 +104,7 @@ export const PrivateKeyForm = memo(({ onSuccess }) => { color="blue" Icon={PasteFillIcon} onClick={pastePrivateKey} - testID={ImportAccountSelectors.PastePrivateKeyButton} + testID={ImportAccountSelectors.pastePrivateKeyButton} > diff --git a/src/app/templates/ImportAccountModal/forms/watch-only.tsx b/src/app/templates/ImportAccountModal/forms/watch-only.tsx index e302ebaf13..56e545237d 100644 --- a/src/app/templates/ImportAccountModal/forms/watch-only.tsx +++ b/src/app/templates/ImportAccountModal/forms/watch-only.tsx @@ -17,9 +17,10 @@ import { useTempleClient, validateDelegate } from 'lib/temple/front'; import { isValidTezosAddress, isTezosContractAddress } from 'lib/tezos'; import { shouldDisableSubmitButton } from 'lib/ui/should-disable-submit-button'; import { readClipboard } from 'lib/ui/utils'; -import { useEnabledTezosChains } from 'temple/front'; +import { TezosChain, useEnabledTezosChains } from 'temple/front'; import { useEvmAddressByDomainName } from 'temple/front/evm/helpers'; import { getTezosDomainsClient, useTezosAddressByDomainName } from 'temple/front/tezos'; +import { getReadOnlyTezos } from 'temple/tezos'; import { TempleChainKind } from 'temple/types'; import { ImportAccountSelectors, ImportAccountFormType } from '../selectors'; @@ -88,9 +89,11 @@ export const WatchOnlyForm = memo(({ onSuccess }) => { let tezosChainId: string | undefined; if (chain === TempleChainKind.Tezos && isTezosContractAddress(resolvedAddress)) { - const { items: contractDipdupEntries } = await searchForTezosAccount(resolvedAddress); - const networkName = contractDipdupEntries[0]?.body.Network; - tezosChainId = networkName && dipdupNetworksChainIds[networkName]; + tezosChainId = await getTezosChainId(resolvedAddress, tezosChains); + + if (!tezosChainId) { + throw new Error(t('contractNotExistOnKnownNetworks')); + } } const finalAddress = chain === TempleChainKind.Tezos ? resolvedAddress : Viem.getAddress(resolvedAddress); @@ -106,7 +109,7 @@ export const WatchOnlyForm = memo(({ onSuccess }) => { setError(err.message); } - }, [formState.isSubmitting, formAnalytics, resolvedAddress, importWatchOnlyAccount, onSuccess]); + }, [formState.isSubmitting, formAnalytics, resolvedAddress, importWatchOnlyAccount, onSuccess, tezosChains]); const validateAddress = useCallback( async (value: any) => { @@ -166,7 +169,7 @@ export const WatchOnlyForm = memo(({ onSuccess }) => { color="blue" Icon={PasteFillIcon} onClick={pasteAddress} - testID={ImportAccountSelectors.PasteAddressButton} + testID={ImportAccountSelectors.pasteAddressButton} > @@ -198,6 +201,37 @@ export const WatchOnlyForm = memo(({ onSuccess }) => { ); }); +async function getTezosChainId(contractAddress: string, tezosChains: TezosChain[]) { + let dipdupSearchFailed = false; + try { + const { items: contractDipdupEntries } = await searchForTezosAccount(contractAddress); + const networkName = contractDipdupEntries[0]?.body.Network; + const dipdupChainId = networkName && dipdupNetworksChainIds[networkName]; + + if (dipdupChainId) { + return dipdupChainId; + } + } catch { + dipdupSearchFailed = true; + } + + const rpcContractSearchResults = await Promise.allSettled( + tezosChains + .filter(({ chainId }) => dipdupSearchFailed || !Object.values(dipdupNetworksChainIds).includes(chainId)) + .map(async ({ rpcBaseURL, chainId }) => { + const tezos = getReadOnlyTezos(rpcBaseURL); + + await tezos.contract.at(contractAddress); + + return chainId; + }) + ); + + return rpcContractSearchResults.find( + (result): result is PromiseFulfilledResult => result.status === 'fulfilled' + )?.value; +} + function getChainFromAddress(address: string) { if (isValidTezosAddress(address)) return TempleChainKind.Tezos; diff --git a/src/app/templates/ImportAccountModal/index.tsx b/src/app/templates/ImportAccountModal/index.tsx index db34a81213..5d0b4922a8 100644 --- a/src/app/templates/ImportAccountModal/index.tsx +++ b/src/app/templates/ImportAccountModal/index.tsx @@ -31,21 +31,21 @@ interface ImportAccountModalProps { interface OptionContents { titleI18nKey: TID; - form: FC; + Form: FC; } const options: Record = { 'private-key': { titleI18nKey: 'importPrivateKey', - form: PrivateKeyForm + Form: PrivateKeyForm }, mnemonic: { titleI18nKey: 'importSeedPhrase', - form: MnemonicForm + Form: MnemonicForm }, 'watch-only': { titleI18nKey: 'watchOnlyAccount', - form: WatchOnlyForm + Form: WatchOnlyForm } }; @@ -70,7 +70,7 @@ export const ImportAccountModal = memo( onGoBack={onGoBack} > {option ? ( - + ) : ( <> diff --git a/src/app/templates/ImportAccountModal/selectors.ts b/src/app/templates/ImportAccountModal/selectors.ts index 6229885742..c029eb2c1c 100644 --- a/src/app/templates/ImportAccountModal/selectors.ts +++ b/src/app/templates/ImportAccountModal/selectors.ts @@ -17,10 +17,10 @@ export enum ImportAccountSelectors { watchOnlyInput = 'Import Account(Watch-Only)/Watch Only Input', watchOnlyImportButton = 'Import Account(Watch-Only)/Watch Only Import Button', - ClearSeedPhraseButton = 'Import Account/Clear Seed Phrase Button', - PasteSeedPhraseButton = 'Import Account/Paste Seed Phrase Button', - PastePrivateKeyButton = 'Import Account/Paste Private Key Button', - PasteAddressButton = 'Import Account/Paste Address Button' + clearSeedPhraseButton = 'Import Account/Clear Seed Phrase Button', + pasteSeedPhraseButton = 'Import Account/Paste Seed Phrase Button', + pastePrivateKeyButton = 'Import Account/Paste Private Key Button', + pasteAddressButton = 'Import Account/Paste Address Button' } export enum ImportAccountFormType { diff --git a/src/app/templates/SeedPhraseInput/SeedLengthSelect/SeedLengthSelect.tsx b/src/app/templates/SeedPhraseInput/SeedLengthSelect/SeedLengthSelect.tsx index a2600fbb8b..fe979a1101 100644 --- a/src/app/templates/SeedPhraseInput/SeedLengthSelect/SeedLengthSelect.tsx +++ b/src/app/templates/SeedPhraseInput/SeedLengthSelect/SeedLengthSelect.tsx @@ -1,4 +1,4 @@ -import React, { FC, useCallback, useEffect, useRef, useState } from 'react'; +import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import clsx from 'clsx'; @@ -53,6 +53,8 @@ export const SeedLengthSelect: FC = ({ options, currentOp [close, onChange] ); + const optionLabel = useMemo(() => getOptionLabel(selectedOption), [selectedOption]); + return (
    = ({ options, currentOp onClick={toggleOpen} testID={ImportAccountSelectors.mnemonicDropDownButton} > - {getOptionLabel(selectedOption)} + {optionLabel}