From 9fcc0774dd93d81ecbb3577c36e5ed47f52e6613 Mon Sep 17 00:00:00 2001 From: Daniel Cortes Date: Fri, 3 Nov 2023 23:42:12 -0600 Subject: [PATCH 1/5] Create account during onboarding --- main/api/accounts/handleCreateAccount.ts | 30 +++++++ main/api/accounts/handleExportAccount.ts | 29 ++++++ main/api/accounts/index.ts | 19 ++++ main/api/ironfish/Ironfish.ts | 40 +++++---- main/api/manager.ts | 23 +++-- package.json | 1 + .../CreateAccount/CreateAccount.tsx | 72 +++++++++++++++ .../CreateImportAccount.tsx | 51 +++++++++++ .../ImportAccount/ImportAccount.tsx | 30 +++++++ renderer/images/big-onboarding-fish.svg | 1 + renderer/layouts/OnboardingLayout.tsx | 41 +++++++++ renderer/pages/home.tsx | 34 +++---- renderer/pages/onboarding/create/index.tsx | 10 +++ renderer/pages/onboarding/import/index.tsx | 10 +++ renderer/pages/onboarding/index.tsx | 10 +++ renderer/ui/Forms/FormField/FormField.tsx | 13 ++- .../Forms/MnemonicPhrase/MnemonicPhrase.tsx | 90 +++++++++++++++++++ yarn.lock | 5 ++ 18 files changed, 466 insertions(+), 43 deletions(-) create mode 100644 main/api/accounts/handleCreateAccount.ts create mode 100644 main/api/accounts/handleExportAccount.ts create mode 100644 renderer/components/OnboardingFlow/CreateAccount/CreateAccount.tsx create mode 100644 renderer/components/OnboardingFlow/CreateImportAccount/CreateImportAccount.tsx create mode 100644 renderer/components/OnboardingFlow/ImportAccount/ImportAccount.tsx create mode 100644 renderer/images/big-onboarding-fish.svg create mode 100644 renderer/layouts/OnboardingLayout.tsx create mode 100644 renderer/pages/onboarding/create/index.tsx create mode 100644 renderer/pages/onboarding/import/index.tsx create mode 100644 renderer/pages/onboarding/index.tsx create mode 100644 renderer/ui/Forms/MnemonicPhrase/MnemonicPhrase.tsx diff --git a/main/api/accounts/handleCreateAccount.ts b/main/api/accounts/handleCreateAccount.ts new file mode 100644 index 00000000..d06503c3 --- /dev/null +++ b/main/api/accounts/handleCreateAccount.ts @@ -0,0 +1,30 @@ +import { AccountFormat } from "@ironfish/sdk"; + +import { manager } from "../manager"; + +export async function handleCreateAccount({ name }: { name: string }) { + const ironfish = await manager.getIronfish(); + const rpcClient = await ironfish.rpcClient(); + + const createResponse = await rpcClient.wallet.createAccount({ + name, + }); + + const exportResponse = await rpcClient.wallet.exportAccount({ + account: createResponse.content.name, + viewOnly: false, + format: AccountFormat.Mnemonic, + }); + + const mnemonic = exportResponse.content.account?.toString(); + + if (!mnemonic) { + throw new Error("Failed to get mnemonic phrase"); + } + + return { + name: createResponse.content.name, + publicAddress: createResponse.content.publicAddress, + mnemonic, + }; +} diff --git a/main/api/accounts/handleExportAccount.ts b/main/api/accounts/handleExportAccount.ts new file mode 100644 index 00000000..422dc737 --- /dev/null +++ b/main/api/accounts/handleExportAccount.ts @@ -0,0 +1,29 @@ +import { AccountFormat } from "@ironfish/sdk"; +import * as z from "zod"; + +import { manager } from "../manager"; + +export const handleExportAccountInputs = z.object({ + name: z.string(), + format: z.custom<`${AccountFormat}`>((format) => { + return typeof format === "string" && format in AccountFormat; + }), + viewOnly: z.boolean().optional(), +}); + +export async function handleExportAccount({ + name, + format, + viewOnly = false, +}: z.infer) { + const ironfish = await manager.getIronfish(); + const rpcClient = await ironfish.rpcClient(); + + const exportResponse = await rpcClient.wallet.exportAccount({ + account: name, + format: AccountFormat[format], + viewOnly, + }); + + return exportResponse.content; +} diff --git a/main/api/accounts/index.ts b/main/api/accounts/index.ts index 9741ec62..8b00fb1d 100644 --- a/main/api/accounts/index.ts +++ b/main/api/accounts/index.ts @@ -1,5 +1,10 @@ import { z } from "zod"; +import { handleCreateAccount } from "./handleCreateAccount"; +import { + handleExportAccount, + handleExportAccountInputs, +} from "./handleExportAccount"; import { handleGetAccount } from "./handleGetAccount"; import { handleGetAccounts } from "./handleGetAccounts"; import { manager } from "../manager"; @@ -16,6 +21,20 @@ export const accountRouter = t.router({ return handleGetAccount(opts.input); }), getAccounts: t.procedure.query(handleGetAccounts), + createAccount: t.procedure + .input( + z.object({ + name: z.string(), + }), + ) + .mutation(async (opts) => { + return handleCreateAccount(opts.input); + }), + exportAccount: t.procedure + .input(handleExportAccountInputs) + .query(async (opts) => { + return handleExportAccount(opts.input); + }), isValidPublicAddress: t.procedure .input( z.object({ diff --git a/main/api/ironfish/Ironfish.ts b/main/api/ironfish/Ironfish.ts index 5759e5a3..0af4ec0c 100644 --- a/main/api/ironfish/Ironfish.ts +++ b/main/api/ironfish/Ironfish.ts @@ -1,6 +1,7 @@ import { BoxKeyPair } from "@ironfish/rust-nodejs"; import { ALL_API_NAMESPACES, + FullNode, IronfishSdk, NodeUtils, RpcClient, @@ -29,6 +30,7 @@ export class Ironfish { private rpcClientPromise: SplitPromise = splitPromise(); private sdkPromise: SplitPromise = splitPromise(); + private fullNodePromise: SplitPromise = splitPromise(); private _started: boolean = false; private _initialized: boolean = false; private _dataDir: string; @@ -45,6 +47,10 @@ export class Ironfish { return this.sdkPromise.promise; } + fullNode(): Promise { + return this.fullNodePromise.promise; + } + async downloadSnapshot(): Promise { if (this._started) { throw new Error("Cannot download snapshot after node has started"); @@ -58,6 +64,7 @@ export class Ironfish { if (this._initialized) { return; } + this._initialized = true; console.log("Initializing IronFish SDK..."); @@ -67,22 +74,6 @@ export class Ironfish { pkg: getPackageFrom(packageJson), }); - this.sdkPromise.resolve(sdk); - } - - async start() { - if (this._started) { - return; - } - this._started = true; - - if (this.snapshotManager.started) { - await this.snapshotManager.result(); - } - - console.log("Starting IronFish Node..."); - - const sdk = await this.sdk(); const node = await sdk.node({ privateIdentity: getPrivateIdentity(sdk), autoSeed: true, @@ -102,13 +93,26 @@ export class Ironfish { await node.internal.save(); } - await node.start(); - const rpcClient = new RpcMemoryClient( node.logger, node.rpc.getRouter(ALL_API_NAMESPACES), ); + this.sdkPromise.resolve(sdk); + this.fullNodePromise.resolve(node); this.rpcClientPromise.resolve(rpcClient); } + + async start() { + if (this._started) { + return; + } + + console.log("Starting FullNode..."); + + this._started = true; + + const node = await this.fullNode(); + await node.start(); + } } diff --git a/main/api/manager.ts b/main/api/manager.ts index 137b014b..707c3448 100644 --- a/main/api/manager.ts +++ b/main/api/manager.ts @@ -6,7 +6,7 @@ import { } from "./user-settings/userSettings"; export type InitialState = - | "create-account" + | "onboarding" | "snapshot-download-prompt" | "start-node"; @@ -36,15 +36,24 @@ export class Manager { if (this._initialState) return this._initialState; const ironfish = await this.getIronfish(); - const sdk = await ironfish.sdk(); + const rpcClient = await ironfish.rpcClient(); - if (sdk.internal.get("isFirstRun")) { - this._initialState = "snapshot-download-prompt"; - } else { - this._initialState = "start-node"; + const accountsResponse = await rpcClient.wallet.getAccounts(); + + if (accountsResponse.content.accounts.length === 0) { + return "onboarding"; + } + + const statusResponse = await rpcClient.node.getStatus(); + const headTimestamp = statusResponse.content.blockchain.headTimestamp; + const hoursSinceLastBlock = (Date.now() - headTimestamp) / 1000 / 60 / 60; + + // If the last block was more than a week ago, prompt the user to download a snapshot + if (hoursSinceLastBlock > 24 * 7) { + return "snapshot-download-prompt"; } - return this._initialState; + return "start-node"; } } diff --git a/package.json b/package.json index c1d304b9..627be5ac 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-hook-form": "^7.47.0", + "react-icons": "^4.11.0", "type-fest": "^4.6.0", "typescript": "^4.9.5", "usehooks-ts": "^2.9.1", diff --git a/renderer/components/OnboardingFlow/CreateAccount/CreateAccount.tsx b/renderer/components/OnboardingFlow/CreateAccount/CreateAccount.tsx new file mode 100644 index 00000000..f688fab0 --- /dev/null +++ b/renderer/components/OnboardingFlow/CreateAccount/CreateAccount.tsx @@ -0,0 +1,72 @@ +import { Box, Heading, Text } from "@chakra-ui/react"; +import Link from "next/link"; +import { useEffect } from "react"; + +import { trpcReact } from "@/providers/TRPCProvider"; +import { MnemonicPhrase } from "@/ui/Forms/MnemonicPhrase/MnemonicPhrase"; +import { TextInput } from "@/ui/Forms/TextInput/TextInput"; + +function splitMnemonicPhrase(phrase: string) { + return phrase.split(/\s+/).map((part) => part.trim()); +} + +export function CreateAccount() { + const { data: accountsData, refetch: refetchGetAccounts } = + trpcReact.getAccounts.useQuery(); + + const accountName = accountsData?.[0]?.name; + + const { mutate: createAccount, isIdle: isCreateIdle } = + trpcReact.createAccount.useMutation(); + + const { data: exportData } = trpcReact.exportAccount.useQuery( + { + name: accountName ?? "", + format: "Mnemonic", + }, + { + enabled: !!accountName, + }, + ); + + useEffect(() => { + if (!isCreateIdle || accountsData === undefined) return; + + if (accountsData.length === 0) { + createAccount( + { + name: "default", + }, + { + onSuccess: () => { + refetchGetAccounts(); + }, + }, + ); + } + }, [accountsData, createAccount, isCreateIdle, refetchGetAccounts]); + + const mnemonicPhrase = exportData?.account; + + if (!accountName || typeof mnemonicPhrase !== "string") { + return null; + } + + return ( + + Back + + Create Account + + + + Recovery Phrase + + + Please keep this phrase stored somewhere safe. We will ask you to + re-enter this. + + + + ); +} diff --git a/renderer/components/OnboardingFlow/CreateImportAccount/CreateImportAccount.tsx b/renderer/components/OnboardingFlow/CreateImportAccount/CreateImportAccount.tsx new file mode 100644 index 00000000..85751378 --- /dev/null +++ b/renderer/components/OnboardingFlow/CreateImportAccount/CreateImportAccount.tsx @@ -0,0 +1,51 @@ +import { Box, Heading, Text } from "@chakra-ui/react"; +import { useRouter } from "next/router"; + +import { PillButton } from "@/ui/PillButton/PillButton"; +import { ShadowCard } from "@/ui/ShadowCard/ShadowCard"; +import { LogoLg } from "@/ui/SVGs/LogoLg"; + +export function CreateImportAccount() { + const router = useRouter(); + + return ( + + + + Iron Fish Wallet + + + + Create Account + + + Choose this option if you don't have an existing Iron Fish + account or if you'd like to create a new one. + + { + router.push(`/onboarding/create`); + }} + > + Create Account + + + + + Import Account + + + Already have an account? Enter your recovery credentials and continue + using your account as expected. + + { + router.push("/onboarding/import"); + }} + > + Import Account + + + + ); +} diff --git a/renderer/components/OnboardingFlow/ImportAccount/ImportAccount.tsx b/renderer/components/OnboardingFlow/ImportAccount/ImportAccount.tsx new file mode 100644 index 00000000..289fb174 --- /dev/null +++ b/renderer/components/OnboardingFlow/ImportAccount/ImportAccount.tsx @@ -0,0 +1,30 @@ +import { Box, Heading, Text } from "@chakra-ui/react"; +import Link from "next/link"; + +import { MnemonicPhrase } from "@/ui/Forms/MnemonicPhrase/MnemonicPhrase"; +import { TextInput } from "@/ui/Forms/TextInput/TextInput"; + +const TEST_PHRASE = + "vapor domain left fuel fix enrich tool must virus region acquire sell elder warm space mad cinnamon disorder mother civil travel dream jump few".split( + " ", + ); + +export function ImportAccount() { + return ( + + Back + + Import Account + + + + Recovery Phrase + + + Please enter your recovery phrase. This is the 24 word phrase you were + given when you created your account. + + + + ); +} diff --git a/renderer/images/big-onboarding-fish.svg b/renderer/images/big-onboarding-fish.svg new file mode 100644 index 00000000..d83008d8 --- /dev/null +++ b/renderer/images/big-onboarding-fish.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/renderer/layouts/OnboardingLayout.tsx b/renderer/layouts/OnboardingLayout.tsx new file mode 100644 index 00000000..948dff02 --- /dev/null +++ b/renderer/layouts/OnboardingLayout.tsx @@ -0,0 +1,41 @@ +import { Box, LightMode } from "@chakra-ui/react"; +import Image from "next/image"; +import { ReactNode } from "react"; + +import bigOnboardingFish from "@/images/big-onboarding-fish.svg"; + +export function OnboardingLayout({ children }: { children: ReactNode }) { + return ( + + + + + + + {children} + + + + ); +} diff --git a/renderer/pages/home.tsx b/renderer/pages/home.tsx index d095ee1a..2adc262c 100644 --- a/renderer/pages/home.tsx +++ b/renderer/pages/home.tsx @@ -7,35 +7,39 @@ import { trpcReact } from "@/providers/TRPCProvider"; import { LogoLg } from "@/ui/SVGs/LogoLg"; /** - * This component handles initializing the SDK and determining - * what state the user should be in. + * This component handles initializing the SDK, determining what state + * the user should be in, and routing them to the appropriate page. * - * - @todo: Handle creating an account - * - If the user has not created an account, they should go to the account creation flow. - * - If the user user is behind on syncing, they should be prompted to download a snapshot. - * - If the user is up to date, they should be redirected to the accounts page. + * - If the user does not have an account, they go to the create/import flow. + * - If the user user is behind on syncing, they are prompted to download a snapshot. + * - If the user is up to date, they are redirected to the accounts page. */ export default function Home() { const router = useRouter(); const { data: initialStateData, isLoading: isInitialStateLoading } = trpcReact.getInitialState.useQuery(); - const { mutate: startNode } = trpcReact.startNode.useMutation(); useEffect(() => { - if (isInitialStateLoading || initialStateData !== "start-node") return; + if (initialStateData === "onboarding") { + router.replace("/onboarding"); + } + }, [initialStateData, router]); + + useEffect(() => { + if (initialStateData === "start-node") { + startNode(); + router.replace("/accounts"); + } + }, [router, startNode, initialStateData, isInitialStateLoading]); + const handleSnapshotSuccess = useCallback(() => { startNode(); router.replace("/accounts"); - }, [router, startNode, initialStateData, isInitialStateLoading]); + }, [router, startNode]); const handleSyncFromPeers = useCallback(() => { - // @todo: Handle syncing from peers - console.log("Syncing from peers"); - }, []); - - const handleSnapshotSuccess = useCallback(() => { startNode(); router.replace("/accounts"); }, [router, startNode]); @@ -50,8 +54,8 @@ export default function Home() { {initialStateData === "snapshot-download-prompt" && ( )} diff --git a/renderer/pages/onboarding/create/index.tsx b/renderer/pages/onboarding/create/index.tsx new file mode 100644 index 00000000..eeb0f155 --- /dev/null +++ b/renderer/pages/onboarding/create/index.tsx @@ -0,0 +1,10 @@ +import { CreateAccount } from "@/components/OnboardingFlow/CreateAccount/CreateAccount"; +import { OnboardingLayout } from "@/layouts/OnboardingLayout"; + +export default function Create() { + return ( + + + + ); +} diff --git a/renderer/pages/onboarding/import/index.tsx b/renderer/pages/onboarding/import/index.tsx new file mode 100644 index 00000000..92471508 --- /dev/null +++ b/renderer/pages/onboarding/import/index.tsx @@ -0,0 +1,10 @@ +import { ImportAccount } from "@/components/OnboardingFlow/ImportAccount/ImportAccount"; +import { OnboardingLayout } from "@/layouts/OnboardingLayout"; + +export default function Import() { + return ( + + + + ); +} diff --git a/renderer/pages/onboarding/index.tsx b/renderer/pages/onboarding/index.tsx new file mode 100644 index 00000000..5b12344b --- /dev/null +++ b/renderer/pages/onboarding/index.tsx @@ -0,0 +1,10 @@ +import { CreateImportAccount } from "@/components/OnboardingFlow/CreateImportAccount/CreateImportAccount"; +import { OnboardingLayout } from "@/layouts/OnboardingLayout"; + +export default function Onboarding() { + return ( + + + + ); +} diff --git a/renderer/ui/Forms/FormField/FormField.tsx b/renderer/ui/Forms/FormField/FormField.tsx index 13e6a6bd..fe755e06 100644 --- a/renderer/ui/Forms/FormField/FormField.tsx +++ b/renderer/ui/Forms/FormField/FormField.tsx @@ -9,6 +9,7 @@ export type FormFieldProps = { error?: string | FieldError | FieldErrorsImpl; icon?: ReactNode; triggerProps?: StackProps & { ref: unknown }; + actions?: ReactNode; }; export function FormField({ @@ -17,6 +18,7 @@ export function FormField({ label, icon, triggerProps, + actions, }: FormFieldProps & { children: ReactNode; }) { @@ -34,9 +36,14 @@ export function FormField({ {...triggerProps} > - - {label} - + + + {label} + + {actions && ( + e.preventDefault()}>{actions} + )} + {children} {icon && {icon}} diff --git a/renderer/ui/Forms/MnemonicPhrase/MnemonicPhrase.tsx b/renderer/ui/Forms/MnemonicPhrase/MnemonicPhrase.tsx new file mode 100644 index 00000000..05c0f837 --- /dev/null +++ b/renderer/ui/Forms/MnemonicPhrase/MnemonicPhrase.tsx @@ -0,0 +1,90 @@ +import { + Box, + Flex, + Grid, + GridItem, + HStack, + Input, + InputGroup, + InputLeftElement, +} from "@chakra-ui/react"; +import { useCallback } from "react"; +import { RiEyeCloseLine, RiEyeLine } from "react-icons/ri"; +import { useToggle } from "usehooks-ts"; + +import { COLORS } from "@/ui/colors"; + +import { FormField } from "../FormField/FormField"; + +const WORD_ITEMS = Array.from({ length: 24 }, (_, i) => i + 1); + +type Props = { + phrase: Array; + readOnly?: boolean; +}; + +export function MnemonicPhrase({ phrase, readOnly }: Props) { + const [isHidden, toggleIsHidden] = useToggle(true); + + const handlePaste = useCallback((text: string, inputNumber: number) => { + console.log({ text, inputNumber }); + }, []); + + return ( + + { + console.log("click"); + toggleIsHidden(); + }} + > + {isHidden ? : } + + + } + > + + {WORD_ITEMS.map((num, i) => { + const value = phrase[i] ?? ""; + return ( + + + + + {num} + + + { + const text = event.clipboardData.getData("text"); + handlePaste(text, num); + }} + borderColor={COLORS.BLACK} + _hover={{ + borderColor: COLORS.BLACK, + }} + /> + + + ); + })} + + + ); +} diff --git a/yarn.lock b/yarn.lock index 42c3c26f..6d72f593 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6970,6 +6970,11 @@ react-hook-form@^7.47.0: resolved "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.47.0.tgz#a42f07266bd297ddf1f914f08f4b5f9783262f31" integrity sha512-F/TroLjTICipmHeFlMrLtNLceO2xr1jU3CyiNla5zdwsGUGu2UOxxR4UyJgLlhMwLW/Wzp4cpJ7CPfgJIeKdSg== +react-icons@^4.11.0: + version "4.11.0" + resolved "https://registry.npmjs.org/react-icons/-/react-icons-4.11.0.tgz#4b0e31c9bfc919608095cc429c4f1846f4d66c65" + integrity sha512-V+4khzYcE5EBk/BvcuYRq6V/osf11ODUM2J8hg2FDSswRrGvqiYUYPRy4OdrWaQOBj4NcpJfmHZLNaD+VH0TyA== + react-is@^16.13.1, react-is@^16.7.0: version "16.13.1" resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz" From 387f72ff61bc5b4d3f411d6f383234ad37532e03 Mon Sep 17 00:00:00 2001 From: Daniel Cortes Date: Tue, 7 Nov 2023 16:13:34 -0700 Subject: [PATCH 2/5] Update snapshot download step --- main/api/accounts/handleDeleteAccount.ts | 30 ++++ main/api/accounts/handleRenameAccount.ts | 23 +++ main/api/accounts/index.ts | 9 ++ main/api/ironfish/Ironfish.ts | 3 +- main/api/ironfish/index.ts | 2 +- main/api/snapshot/snapshotManager.ts | 23 +-- .../CreateAccount/ConfirmAccountStep.tsx | 70 +++++++++ .../CreateAccount/CreateAccount.tsx | 87 +++++++---- .../CreateAccount/CreateAccountStep.tsx | 77 ++++++++++ .../ImportAccount/ImportAccount.tsx | 21 +-- .../SnapshotDownloadPrompt.tsx | 143 ++++++++++++++++++ .../SnapshotDownloadModal.tsx | 137 ----------------- renderer/pages/home.tsx | 46 ++---- .../onboarding/snapshot-download/index.tsx | 10 ++ renderer/ui/Forms/FormField/FormField.tsx | 13 +- .../Forms/MnemonicPhrase/MnemonicPhrase.tsx | 126 ++++++++++++--- renderer/utils/formUtils.ts | 29 ++++ renderer/utils/useHasGroupBlur.ts | 29 ++++ 18 files changed, 625 insertions(+), 253 deletions(-) create mode 100644 main/api/accounts/handleDeleteAccount.ts create mode 100644 main/api/accounts/handleRenameAccount.ts create mode 100644 renderer/components/OnboardingFlow/CreateAccount/ConfirmAccountStep.tsx create mode 100644 renderer/components/OnboardingFlow/CreateAccount/CreateAccountStep.tsx create mode 100644 renderer/components/OnboardingFlow/SnapshotDownloadPrompt/SnapshotDownloadPrompt.tsx delete mode 100644 renderer/components/SnapshotDownloadModal/SnapshotDownloadModal.tsx create mode 100644 renderer/pages/onboarding/snapshot-download/index.tsx create mode 100644 renderer/utils/formUtils.ts create mode 100644 renderer/utils/useHasGroupBlur.ts diff --git a/main/api/accounts/handleDeleteAccount.ts b/main/api/accounts/handleDeleteAccount.ts new file mode 100644 index 00000000..d06503c3 --- /dev/null +++ b/main/api/accounts/handleDeleteAccount.ts @@ -0,0 +1,30 @@ +import { AccountFormat } from "@ironfish/sdk"; + +import { manager } from "../manager"; + +export async function handleCreateAccount({ name }: { name: string }) { + const ironfish = await manager.getIronfish(); + const rpcClient = await ironfish.rpcClient(); + + const createResponse = await rpcClient.wallet.createAccount({ + name, + }); + + const exportResponse = await rpcClient.wallet.exportAccount({ + account: createResponse.content.name, + viewOnly: false, + format: AccountFormat.Mnemonic, + }); + + const mnemonic = exportResponse.content.account?.toString(); + + if (!mnemonic) { + throw new Error("Failed to get mnemonic phrase"); + } + + return { + name: createResponse.content.name, + publicAddress: createResponse.content.publicAddress, + mnemonic, + }; +} diff --git a/main/api/accounts/handleRenameAccount.ts b/main/api/accounts/handleRenameAccount.ts new file mode 100644 index 00000000..e0624ba7 --- /dev/null +++ b/main/api/accounts/handleRenameAccount.ts @@ -0,0 +1,23 @@ +import * as z from "zod"; + +import { manager } from "../manager"; + +export const handleRenameAccountInputs = z.object({ + account: z.string(), + newName: z.string(), +}); + +export async function handleRenameAccount({ + account, + newName, +}: z.infer) { + const ironfish = await manager.getIronfish(); + const rpcClient = await ironfish.rpcClient(); + + const renameResponse = await rpcClient.wallet.renameAccount({ + account, + newName, + }); + + return renameResponse.content; +} diff --git a/main/api/accounts/index.ts b/main/api/accounts/index.ts index 8b00fb1d..c1bcb3d1 100644 --- a/main/api/accounts/index.ts +++ b/main/api/accounts/index.ts @@ -7,6 +7,10 @@ import { } from "./handleExportAccount"; import { handleGetAccount } from "./handleGetAccount"; import { handleGetAccounts } from "./handleGetAccounts"; +import { + handleRenameAccountInputs, + handleRenameAccount, +} from "./handleRenameAccount"; import { manager } from "../manager"; import { t } from "../trpc"; @@ -35,6 +39,11 @@ export const accountRouter = t.router({ .query(async (opts) => { return handleExportAccount(opts.input); }), + renameAccount: t.procedure + .input(handleRenameAccountInputs) + .mutation(async (opts) => { + return handleRenameAccount(opts.input); + }), isValidPublicAddress: t.procedure .input( z.object({ diff --git a/main/api/ironfish/Ironfish.ts b/main/api/ironfish/Ironfish.ts index 0af4ec0c..3363d548 100644 --- a/main/api/ironfish/Ironfish.ts +++ b/main/api/ironfish/Ironfish.ts @@ -57,7 +57,8 @@ export class Ironfish { } const sdk = await this.sdk(); - await this.snapshotManager.run(sdk); + const node = await this.fullNode(); + await this.snapshotManager.run(sdk, node); } async init() { diff --git a/main/api/ironfish/index.ts b/main/api/ironfish/index.ts index ed0efaa4..b3895eb6 100644 --- a/main/api/ironfish/index.ts +++ b/main/api/ironfish/index.ts @@ -19,6 +19,6 @@ export const ironfishRouter = t.router({ }), startNode: t.procedure.mutation(async () => { const ironfish = await manager.getIronfish(); - ironfish.start(); + await ironfish.start(); }), }); diff --git a/main/api/snapshot/snapshotManager.ts b/main/api/snapshot/snapshotManager.ts index 2ed348a0..dc0bc161 100644 --- a/main/api/snapshot/snapshotManager.ts +++ b/main/api/snapshot/snapshotManager.ts @@ -1,11 +1,6 @@ import fsAsync from "fs/promises"; -import { - Event, - IronfishSdk, - Meter, - NodeUtils, -} from "@ironfish/sdk"; +import { Event, FullNode, IronfishSdk, Meter } from "@ironfish/sdk"; import { DownloadedSnapshot, @@ -20,18 +15,18 @@ export class SnapshotManager { snapshotPromise: SplitPromise = splitPromise(); started = false; - async run(sdk: IronfishSdk): Promise { - if(this.started) { - return + async run(sdk: IronfishSdk, node: FullNode): Promise { + if (this.started) { + return; } this.started = true; try { - await this._run(sdk) - this.snapshotPromise.resolve() + await this._run(sdk, node); + this.snapshotPromise.resolve(); } catch (e) { - this.snapshotPromise.reject(e) + this.snapshotPromise.reject(e); } } @@ -39,9 +34,7 @@ export class SnapshotManager { return this.snapshotPromise.promise; } - async _run(sdk: IronfishSdk): Promise { - const node = await sdk.node(); - await NodeUtils.waitForOpen(node); + async _run(sdk: IronfishSdk, node: FullNode): Promise { const nodeChainDBVersion = await node.chain.blockchainDb.getVersion(); await node.closeDB(); diff --git a/renderer/components/OnboardingFlow/CreateAccount/ConfirmAccountStep.tsx b/renderer/components/OnboardingFlow/CreateAccount/ConfirmAccountStep.tsx new file mode 100644 index 00000000..b2aec010 --- /dev/null +++ b/renderer/components/OnboardingFlow/CreateAccount/ConfirmAccountStep.tsx @@ -0,0 +1,70 @@ +import { Box, Heading, Text } from "@chakra-ui/react"; +import { useState, useMemo } from "react"; + +import { + EMPTY_PHRASE_ARRAY, + MnemonicPhrase, + PHRASE_ITEM_COUNT, +} from "@/ui/Forms/MnemonicPhrase/MnemonicPhrase"; +import { PillButton } from "@/ui/PillButton/PillButton"; + +export function ConfirmAccountStep({ + mnemonicPhrase, + onNextStep, + onBack, + isLoading, +}: { + accountName: string; + mnemonicPhrase: string; + onNextStep: () => void; + onBack: () => void; + isLoading?: boolean; +}) { + const [confirmValues, setConfirmValues] = + useState>(EMPTY_PHRASE_ARRAY); + + const errorMessage = useMemo(() => { + const valueString = confirmValues.join(" ").replace(/\s+/, " "); + const hasCorrectLength = + valueString.match(/\s/g)?.length === PHRASE_ITEM_COUNT - 1; + + if (!hasCorrectLength) { + return "Please fill out the entire phrase."; + } + + if (mnemonicPhrase !== valueString) { + return "The phrase you entered does not match. Please verify the phrase and try again."; + } + + return undefined; + }, [confirmValues, mnemonicPhrase]); + + const isValid = mnemonicPhrase === confirmValues.join(" "); + + return ( + + + Back + + + Create Account + + + + Confirm Your Recovery Phrase + + Please keep this phrase stored somewhere safe. + { + setConfirmValues(value); + }} + /> + + Continue + + + ); +} diff --git a/renderer/components/OnboardingFlow/CreateAccount/CreateAccount.tsx b/renderer/components/OnboardingFlow/CreateAccount/CreateAccount.tsx index f688fab0..171a645c 100644 --- a/renderer/components/OnboardingFlow/CreateAccount/CreateAccount.tsx +++ b/renderer/components/OnboardingFlow/CreateAccount/CreateAccount.tsx @@ -1,24 +1,19 @@ -import { Box, Heading, Text } from "@chakra-ui/react"; -import Link from "next/link"; -import { useEffect } from "react"; +import { useRouter } from "next/router"; +import { useEffect, useState } from "react"; import { trpcReact } from "@/providers/TRPCProvider"; -import { MnemonicPhrase } from "@/ui/Forms/MnemonicPhrase/MnemonicPhrase"; -import { TextInput } from "@/ui/Forms/TextInput/TextInput"; -function splitMnemonicPhrase(phrase: string) { - return phrase.split(/\s+/).map((part) => part.trim()); -} +import { ConfirmAccountStep } from "./ConfirmAccountStep"; +import { CreateAccountStep } from "./CreateAccountStep"; -export function CreateAccount() { +type Steps = "create" | "confirm"; + +function useMaybeNewAccount() { const { data: accountsData, refetch: refetchGetAccounts } = trpcReact.getAccounts.useQuery(); - - const accountName = accountsData?.[0]?.name; - const { mutate: createAccount, isIdle: isCreateIdle } = trpcReact.createAccount.useMutation(); - + const accountName = accountsData?.[0]?.name; const { data: exportData } = trpcReact.exportAccount.useQuery( { name: accountName ?? "", @@ -48,25 +43,61 @@ export function CreateAccount() { const mnemonicPhrase = exportData?.account; + return { + accountName, + mnemonicPhrase, + }; +} + +export function CreateAccount() { + const router = useRouter(); + const [step, setStep] = useState("create"); + const [editedName, setEditedName] = useState(null); + const { mutate: renameAccount, isLoading: isRenameLoading } = + trpcReact.renameAccount.useMutation(); + + const { accountName, mnemonicPhrase } = useMaybeNewAccount(); + if (!accountName || typeof mnemonicPhrase !== "string") { return null; } + const newAccountName = editedName !== null ? editedName : accountName; + + if (step === "create") { + return ( + { + setEditedName(name); + }} + mnemonicPhrase={mnemonicPhrase} + onBack={() => { + router.back(); + }} + onNextStep={() => { + setStep("confirm"); + }} + /> + ); + } return ( - - Back - - Create Account - - - - Recovery Phrase - - - Please keep this phrase stored somewhere safe. We will ask you to - re-enter this. - - - + { + setStep("create"); + }} + onNextStep={async () => { + if (editedName !== null) { + await renameAccount({ + account: accountName, + newName: editedName, + }); + } + router.push("/onboarding/snapshot-download"); + }} + /> ); } diff --git a/renderer/components/OnboardingFlow/CreateAccount/CreateAccountStep.tsx b/renderer/components/OnboardingFlow/CreateAccount/CreateAccountStep.tsx new file mode 100644 index 00000000..808c277f --- /dev/null +++ b/renderer/components/OnboardingFlow/CreateAccount/CreateAccountStep.tsx @@ -0,0 +1,77 @@ +import { Box, Checkbox, Heading, Text } from "@chakra-ui/react"; +import { useState } from "react"; + +import { MnemonicPhrase } from "@/ui/Forms/MnemonicPhrase/MnemonicPhrase"; +import { TextInput } from "@/ui/Forms/TextInput/TextInput"; +import { PillButton } from "@/ui/PillButton/PillButton"; + +function splitMnemonicPhrase(phrase: string) { + return phrase.split(/\s+/).map((part) => part.trim()); +} + +export function CreateAccountStep({ + accountName, + mnemonicPhrase, + onNameChange, + onNextStep, + onBack, +}: { + accountName: string; + mnemonicPhrase: string; + onNameChange: (name: string) => void; + onNextStep: () => void; + onBack: () => void; +}) { + const [isSavedChecked, setIsSavedChecked] = useState(false); + const hasValidName = accountName.length > 0; + + return ( + + + Back + + + Create Account + + + Internal Account Name + + + This name is how we will refer to your account internally. It is not + visible to anyone else. + + { + onNameChange(e.target.value); + }} + /> + + Recovery Phrase + + + Please keep this phrase stored somewhere safe. We will ask you to + re-enter this. + + + { + setIsSavedChecked(e.target.checked); + }} + > + I saved my recovery phrase + + + Next + + + ); +} diff --git a/renderer/components/OnboardingFlow/ImportAccount/ImportAccount.tsx b/renderer/components/OnboardingFlow/ImportAccount/ImportAccount.tsx index 289fb174..d55e984c 100644 --- a/renderer/components/OnboardingFlow/ImportAccount/ImportAccount.tsx +++ b/renderer/components/OnboardingFlow/ImportAccount/ImportAccount.tsx @@ -1,30 +1,13 @@ -import { Box, Heading, Text } from "@chakra-ui/react"; +import { Box, Heading } from "@chakra-ui/react"; import Link from "next/link"; -import { MnemonicPhrase } from "@/ui/Forms/MnemonicPhrase/MnemonicPhrase"; -import { TextInput } from "@/ui/Forms/TextInput/TextInput"; - -const TEST_PHRASE = - "vapor domain left fuel fix enrich tool must virus region acquire sell elder warm space mad cinnamon disorder mother civil travel dream jump few".split( - " ", - ); - export function ImportAccount() { return ( Back - Import Account - - - - Recovery Phrase + Import Account WIP - - Please enter your recovery phrase. This is the 24 word phrase you were - given when you created your account. - - ); } diff --git a/renderer/components/OnboardingFlow/SnapshotDownloadPrompt/SnapshotDownloadPrompt.tsx b/renderer/components/OnboardingFlow/SnapshotDownloadPrompt/SnapshotDownloadPrompt.tsx new file mode 100644 index 00000000..bc2ce4b4 --- /dev/null +++ b/renderer/components/OnboardingFlow/SnapshotDownloadPrompt/SnapshotDownloadPrompt.tsx @@ -0,0 +1,143 @@ +import { Text, Progress, Box, HStack, Heading } from "@chakra-ui/react"; +import { useRouter } from "next/router"; +import { useEffect, useState, useCallback } from "react"; + +import { trpcReact } from "@/providers/TRPCProvider"; +import { PillButton } from "@/ui/PillButton/PillButton"; + +import { SnapshotUpdate } from "../../../../shared/types"; + +type ProgressSteps = "prompt" | "download" | "complete"; + +function percent(num: number, total: number) { + return Math.floor((num / total) * 100); +} + +function bytesToMb(bytes: number) { + return (bytes / 1024 / 1024).toFixed(2); +} + +function DownloadProgress({ onSuccess }: { onSuccess: () => void }) { + const [snapshotState, setSnapshotState] = useState(); + trpcReact.snapshotProgress.useSubscription(undefined, { + onData: (data) => { + setSnapshotState(data); + }, + onError: (err) => { + // @todo: Handle error + console.log(err); + }, + }); + + useEffect(() => { + if (snapshotState?.step === "complete") { + onSuccess(); + } + }, [onSuccess, snapshotState?.step]); + + return ( + + {snapshotState?.step === "download" && ( + + Download in progress... + + Downloading a snapshot is the fastest way to sync with the network. + + + + + {bytesToMb(snapshotState.currBytes)} /{" "} + {bytesToMb(snapshotState.totalBytes)} mb downloaded + + + + )} + {snapshotState?.step === "unzip" && ( + + Download in progress... + + You'll automatically be redirected to your accounts page once + the snapshot is applied. + + + + Unzip progress:{" "} + {percent(snapshotState.currEntries, snapshotState.totalEntries)}% + + + )} + + ); +} + +function Prompt({ + onPeers, + onSnapshot, +}: { + onPeers: () => void; + onSnapshot: () => void; +}) { + return ( + + + Syncing your chain + + + Choose how to sync your node with the network + + + Download Snapshot: Fast and centralized. Get a complete copy of the + blockchain quickly from a central source. + + + Sync from peers: Slower but decentralized. Retrieve the blockchain from + other users, contributing to network decentralization. While it may take + longer, it strengthens the network&s resilience. + + + Download Snapshot + Sync from Peers + + + ); +} + +export function SnapshotDownloadPrompt() { + const router = useRouter(); + const [currentStep, setCurrentStep] = useState("prompt"); + + const { mutate: startNode } = trpcReact.startNode.useMutation(); + const { mutate: downloadSnapshot } = trpcReact.downloadSnapshot.useMutation(); + + const navigateToAccountsPage = useCallback(() => { + const handleStart = async () => { + await startNode(); + router.replace("/accounts"); + }; + handleStart(); + }, [router, startNode]); + + return ( + + {currentStep === "prompt" && ( + { + downloadSnapshot(); + setCurrentStep("download"); + }} + /> + )} + {currentStep === "download" && ( + + )} + + ); +} diff --git a/renderer/components/SnapshotDownloadModal/SnapshotDownloadModal.tsx b/renderer/components/SnapshotDownloadModal/SnapshotDownloadModal.tsx deleted file mode 100644 index 070d63f1..00000000 --- a/renderer/components/SnapshotDownloadModal/SnapshotDownloadModal.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { - Text, - Modal, - ModalOverlay, - ModalContent, - ModalHeader, - ModalFooter, - ModalBody, - Button, - Progress, - Box, - HStack, -} from "@chakra-ui/react"; -import { useEffect, useState } from "react"; - -import { trpcReact } from "@/providers/TRPCProvider"; - -import { SnapshotUpdate } from "../../../shared/types"; - -type ModalSteps = "prompt" | "download" | "complete"; - -function PromptUser({ - onPeers, - onSnapshot, -}: { - onPeers: () => void; - onSnapshot: () => void; -}) { - return ( - - Sync Node - - How would you like to sync your node - - - - - - - - ); -} - -function percent(num: number, total: number) { - return Math.floor((num / total) * 100); -} - -function bytesToMb(bytes: number) { - return (bytes / 1024 / 1024).toFixed(2); -} - -function DownloadProgress({ onSuccess }: { onSuccess: () => void }) { - const [snapshotState, setSnapshotState] = useState(); - trpcReact.snapshotProgress.useSubscription(undefined, { - onData: (data) => { - setSnapshotState(data); - }, - onError: (err) => { - // @todo: Handle error with error boundary - console.log(err); - }, - }); - - useEffect(() => { - if (snapshotState?.step === "complete") { - onSuccess(); - } - }, [onSuccess, snapshotState?.step]); - - return ( - - Sync Node - - {snapshotState?.step === "download" && ( - - Download in progress... - - - - {bytesToMb(snapshotState.currBytes)} /{" "} - {bytesToMb(snapshotState.totalBytes)} mb downloaded - - - - )} - {snapshotState?.step === "unzip" && ( - - Unzipping... - - - Unzip progress:{" "} - {percent(snapshotState.currEntries, snapshotState.totalEntries)}% - - - )} - - - ); -} - -export function SnapshotDownloadModal({ - onPeers, - onSuccess, -}: { - onPeers: () => void; - onSuccess: () => void; -}) { - const [currentStep, setCurrentStep] = useState("prompt"); - const mutation = trpcReact.downloadSnapshot.useMutation(); - - return ( - null}> - - {currentStep === "prompt" && ( - { - mutation.mutate(); - setCurrentStep("download"); - }} - /> - )} - {currentStep === "download" && } - - ); -} diff --git a/renderer/pages/home.tsx b/renderer/pages/home.tsx index 2adc262c..dc39d751 100644 --- a/renderer/pages/home.tsx +++ b/renderer/pages/home.tsx @@ -1,18 +1,13 @@ import { VStack, Flex, Spinner } from "@chakra-ui/react"; import { useRouter } from "next/router"; -import React, { useCallback, useEffect } from "react"; +import { useEffect } from "react"; -import { SnapshotDownloadModal } from "@/components/SnapshotDownloadModal/SnapshotDownloadModal"; import { trpcReact } from "@/providers/TRPCProvider"; import { LogoLg } from "@/ui/SVGs/LogoLg"; /** * This component handles initializing the SDK, determining what state * the user should be in, and routing them to the appropriate page. - * - * - If the user does not have an account, they go to the create/import flow. - * - If the user user is behind on syncing, they are prompted to download a snapshot. - * - If the user is up to date, they are redirected to the accounts page. */ export default function Home() { const router = useRouter(); @@ -21,12 +16,21 @@ export default function Home() { trpcReact.getInitialState.useQuery(); const { mutate: startNode } = trpcReact.startNode.useMutation(); + // If user has no accounts, go to onboarding useEffect(() => { if (initialStateData === "onboarding") { router.replace("/onboarding"); } }, [initialStateData, router]); + // If user is behind on syncing, go to snapshot download + useEffect(() => { + if (initialStateData === "snapshot-download-prompt") { + router.replace("/onboarding/snapshot-download"); + } + }, [initialStateData, router]); + + // Otherwise, start node and go to accounts page useEffect(() => { if (initialStateData === "start-node") { startNode(); @@ -34,30 +38,12 @@ export default function Home() { } }, [router, startNode, initialStateData, isInitialStateLoading]); - const handleSnapshotSuccess = useCallback(() => { - startNode(); - router.replace("/accounts"); - }, [router, startNode]); - - const handleSyncFromPeers = useCallback(() => { - startNode(); - router.replace("/accounts"); - }, [router, startNode]); - return ( - <> - - - - - - - {initialStateData === "snapshot-download-prompt" && ( - - )} - + + + + + + ); } diff --git a/renderer/pages/onboarding/snapshot-download/index.tsx b/renderer/pages/onboarding/snapshot-download/index.tsx new file mode 100644 index 00000000..57ebec14 --- /dev/null +++ b/renderer/pages/onboarding/snapshot-download/index.tsx @@ -0,0 +1,10 @@ +import { SnapshotDownloadPrompt } from "@/components/OnboardingFlow/SnapshotDownloadPrompt/SnapshotDownloadPrompt"; +import { OnboardingLayout } from "@/layouts/OnboardingLayout"; + +export default function SnapshotDownload() { + return ( + + + + ); +} diff --git a/renderer/ui/Forms/FormField/FormField.tsx b/renderer/ui/Forms/FormField/FormField.tsx index fe755e06..4805a9dc 100644 --- a/renderer/ui/Forms/FormField/FormField.tsx +++ b/renderer/ui/Forms/FormField/FormField.tsx @@ -5,7 +5,7 @@ import { FieldError, FieldErrorsImpl } from "react-hook-form"; import { COLORS } from "@/ui/colors"; export type FormFieldProps = { - label: string; + label: string | ReactNode; error?: string | FieldError | FieldErrorsImpl; icon?: ReactNode; triggerProps?: StackProps & { ref: unknown }; @@ -37,9 +37,14 @@ export function FormField({ > - - {label} - + {typeof label === "string" ? ( + + {label} + + ) : ( + label + )} + {actions && ( e.preventDefault()}>{actions} )} diff --git a/renderer/ui/Forms/MnemonicPhrase/MnemonicPhrase.tsx b/renderer/ui/Forms/MnemonicPhrase/MnemonicPhrase.tsx index 05c0f837..72b8c174 100644 --- a/renderer/ui/Forms/MnemonicPhrase/MnemonicPhrase.tsx +++ b/renderer/ui/Forms/MnemonicPhrase/MnemonicPhrase.tsx @@ -7,38 +7,121 @@ import { Input, InputGroup, InputLeftElement, + Text, + useToast, } from "@chakra-ui/react"; -import { useCallback } from "react"; -import { RiEyeCloseLine, RiEyeLine } from "react-icons/ri"; -import { useToggle } from "usehooks-ts"; +import { get } from "lodash-es"; +import { useCallback, type ClipboardEvent, ChangeEvent } from "react"; +import { RiEyeCloseLine, RiEyeLine, RiFileCopyLine } from "react-icons/ri"; +import { useCopyToClipboard, useToggle } from "usehooks-ts"; import { COLORS } from "@/ui/colors"; +import { useHasGroupBlur } from "@/utils/formUtils"; import { FormField } from "../FormField/FormField"; -const WORD_ITEMS = Array.from({ length: 24 }, (_, i) => i + 1); +export const PHRASE_ITEM_COUNT = 24; +export const EMPTY_PHRASE_ARRAY = Array.from( + { length: PHRASE_ITEM_COUNT }, + () => "", +); type Props = { phrase: Array; readOnly?: boolean; + onChange?: (phrase: Array) => void; + defaultVisible?: boolean; + error?: string; }; -export function MnemonicPhrase({ phrase, readOnly }: Props) { - const [isHidden, toggleIsHidden] = useToggle(true); +export function MnemonicPhrase({ + phrase, + readOnly, + onChange, + defaultVisible, + error, +}: Props) { + const { hasBlur, handleGroupFocus, handleGroupBlur } = useHasGroupBlur(); + const [isHidden, toggleIsHidden] = useToggle(defaultVisible ? false : true); + const [_, copyToClipboard] = useCopyToClipboard(); + const toast = useToast(); - const handlePaste = useCallback((text: string, inputNumber: number) => { - console.log({ text, inputNumber }); - }, []); + const handleChange = useCallback( + (e: ChangeEvent) => { + if (!onChange) return; + + const number = get(e, "target.dataset.number") as unknown; + if (typeof number !== "string") { + throw new Error("data-number not found in mnemonic phrase input"); + } + const index = parseInt(number, 10) - 1; + const nextValues = phrase + .toSpliced(index, 1, e.target.value) + .slice(0, PHRASE_ITEM_COUNT); + onChange(nextValues); + }, + [onChange, phrase], + ); + + const handlePaste = useCallback( + (e: ClipboardEvent) => { + if (!onChange) return; + + e.preventDefault(); + + const number = get(e, "target.dataset.number") as unknown; + if (typeof number !== "string") { + throw new Error("data-number not found in mnemonic phrase input"); + } + + const words = e.clipboardData.getData("text").split(/\s+/g); + const index = parseInt(number, 10) - 1; + + if (words.length === PHRASE_ITEM_COUNT) { + onChange(words); + } + + const nextValues = phrase + .toSpliced(index, words.length, ...words) + .slice(0, PHRASE_ITEM_COUNT); + + onChange(nextValues); + }, + [onChange, phrase], + ); return ( + + Mnemonic Phrase + + {readOnly && ( + { + copyToClipboard(phrase.join(" ")); + toast({ + title: "Mnemonic phrase copied to clipboard!", + status: "info", + position: "bottom-left", + duration: 4000, + isClosable: true, + }); + }} + > + + + )} + + } actions={ { - console.log("click"); toggleIsHidden(); }} > @@ -47,8 +130,16 @@ export function MnemonicPhrase({ phrase, readOnly }: Props) { } > - - {WORD_ITEMS.map((num, i) => { + + {EMPTY_PHRASE_ARRAY.map((_, i) => { + const num = i + 1; const value = phrase[i] ?? ""; return ( @@ -71,11 +162,10 @@ export function MnemonicPhrase({ phrase, readOnly }: Props) { readOnly={readOnly} type={isHidden ? "password" : "text"} value={value} - onPaste={(event) => { - const text = event.clipboardData.getData("text"); - handlePaste(text, num); - }} - borderColor={COLORS.BLACK} + onChange={handleChange} + onPaste={handlePaste} + borderColor={hasBlur && !value ? COLORS.RED : COLORS.BLACK} + placeholder="Empty" _hover={{ borderColor: COLORS.BLACK, }} diff --git a/renderer/utils/formUtils.ts b/renderer/utils/formUtils.ts new file mode 100644 index 00000000..eaedca98 --- /dev/null +++ b/renderer/utils/formUtils.ts @@ -0,0 +1,29 @@ +import { useCallback, useMemo, useState } from "react"; + +export function useHasGroupBlur({ delay = 50 }: { delay?: number } = {}) { + const [blurTimeout, setBlurTimeout] = useState | null>(null); + const [hasBlur, setHasBlur] = useState(false); + + const handleGroupFocus = useCallback(() => { + if (!blurTimeout) return; + clearTimeout(blurTimeout); + }, [blurTimeout]); + + const handleGroupBlur = useCallback(() => { + const timeout = setTimeout(() => { + setHasBlur(true); + }, delay); + setBlurTimeout(timeout); + }, [delay]); + + return useMemo( + () => ({ + hasBlur, + handleGroupFocus, + handleGroupBlur, + }), + [hasBlur, handleGroupFocus, handleGroupBlur], + ); +} diff --git a/renderer/utils/useHasGroupBlur.ts b/renderer/utils/useHasGroupBlur.ts new file mode 100644 index 00000000..7d5bea20 --- /dev/null +++ b/renderer/utils/useHasGroupBlur.ts @@ -0,0 +1,29 @@ +import { useCallback, useMemo, useState } from "react"; + +export function useHasGroupBlur({ delay = 100 }: { delay: number }) { + const [blurTimeout, setBlurTimeout] = useState | null>(null); + const [hasBlur, setHasBlur] = useState(false); + + const onFocus = useCallback(() => { + if (!blurTimeout) return; + clearTimeout(blurTimeout); + }, [blurTimeout]); + + const onBlur = useCallback(() => { + const timeout = setTimeout(() => { + setHasBlur(true); + }, delay); + setBlurTimeout(timeout); + }, [delay]); + + return useMemo( + { + hasBlur, + onFocus, + onBlur, + }, + [], + ); +} From 491daba3e9c8187919a3c3c5756f3b92536e5628 Mon Sep 17 00:00:00 2001 From: Daniel Cortes Date: Tue, 7 Nov 2023 16:23:38 -0700 Subject: [PATCH 3/5] Open DB after snapshot --- main/api/accounts/handleDeleteAccount.ts | 30 ------------------------ main/api/snapshot/snapshotManager.ts | 1 + 2 files changed, 1 insertion(+), 30 deletions(-) delete mode 100644 main/api/accounts/handleDeleteAccount.ts diff --git a/main/api/accounts/handleDeleteAccount.ts b/main/api/accounts/handleDeleteAccount.ts deleted file mode 100644 index d06503c3..00000000 --- a/main/api/accounts/handleDeleteAccount.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { AccountFormat } from "@ironfish/sdk"; - -import { manager } from "../manager"; - -export async function handleCreateAccount({ name }: { name: string }) { - const ironfish = await manager.getIronfish(); - const rpcClient = await ironfish.rpcClient(); - - const createResponse = await rpcClient.wallet.createAccount({ - name, - }); - - const exportResponse = await rpcClient.wallet.exportAccount({ - account: createResponse.content.name, - viewOnly: false, - format: AccountFormat.Mnemonic, - }); - - const mnemonic = exportResponse.content.account?.toString(); - - if (!mnemonic) { - throw new Error("Failed to get mnemonic phrase"); - } - - return { - name: createResponse.content.name, - publicAddress: createResponse.content.publicAddress, - mnemonic, - }; -} diff --git a/main/api/snapshot/snapshotManager.ts b/main/api/snapshot/snapshotManager.ts index dc0bc161..c17c1ca7 100644 --- a/main/api/snapshot/snapshotManager.ts +++ b/main/api/snapshot/snapshotManager.ts @@ -109,5 +109,6 @@ export class SnapshotManager { await downloadedSnapshot.replaceDatabase(); await fsAsync.rm(downloadedSnapshot.file); + await node.openDB(); } } From 5b3c0db3b014c2a3a0ebf0d394da23fb65f04218 Mon Sep 17 00:00:00 2001 From: Daniel Cortes Date: Tue, 7 Nov 2023 16:30:40 -0700 Subject: [PATCH 4/5] Tweak copy --- main/api/snapshot/snapshotManager.ts | 2 -- .../SnapshotDownloadPrompt/SnapshotDownloadPrompt.tsx | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/main/api/snapshot/snapshotManager.ts b/main/api/snapshot/snapshotManager.ts index c17c1ca7..26bb24bb 100644 --- a/main/api/snapshot/snapshotManager.ts +++ b/main/api/snapshot/snapshotManager.ts @@ -62,7 +62,6 @@ export class SnapshotManager { downloadSpeed.start(); await Downloader.download((prev, curr) => { - console.log(`Download progress: ${curr}/${manifest.file_size}`); downloadSpeed.add(curr - prev); this.onProgress.emit({ @@ -92,7 +91,6 @@ export class SnapshotManager { prevExtracted: number, currExtracted: number, ) => { - console.log(`Unzip progress: ${currExtracted}/${totalEntries}`); unzipSpeed.add(currExtracted - prevExtracted); this.onProgress.emit({ diff --git a/renderer/components/OnboardingFlow/SnapshotDownloadPrompt/SnapshotDownloadPrompt.tsx b/renderer/components/OnboardingFlow/SnapshotDownloadPrompt/SnapshotDownloadPrompt.tsx index bc2ce4b4..e0f4c65d 100644 --- a/renderer/components/OnboardingFlow/SnapshotDownloadPrompt/SnapshotDownloadPrompt.tsx +++ b/renderer/components/OnboardingFlow/SnapshotDownloadPrompt/SnapshotDownloadPrompt.tsx @@ -56,7 +56,7 @@ function DownloadProgress({ onSuccess }: { onSuccess: () => void }) { )} {snapshotState?.step === "unzip" && ( - Download in progress... + Unzipping... You'll automatically be redirected to your accounts page once the snapshot is applied. From edbf0f3ffd5eb4c3767b8a060cf7015f613b8d72 Mon Sep 17 00:00:00 2001 From: Daniel Cortes Date: Wed, 8 Nov 2023 12:40:23 -0700 Subject: [PATCH 5/5] Bump TS --- main/api/manager.ts | 4 ++-- package.json | 2 +- renderer/utils/useHasGroupBlur.ts | 6 +++--- yarn.lock | 7 ++++++- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/main/api/manager.ts b/main/api/manager.ts index 707c3448..b0d85b99 100644 --- a/main/api/manager.ts +++ b/main/api/manager.ts @@ -48,8 +48,8 @@ export class Manager { const headTimestamp = statusResponse.content.blockchain.headTimestamp; const hoursSinceLastBlock = (Date.now() - headTimestamp) / 1000 / 60 / 60; - // If the last block was more than a week ago, prompt the user to download a snapshot - if (hoursSinceLastBlock > 24 * 7) { + // If the last block was more than 2 weeks ago, prompt the user to download a snapshot + if (hoursSinceLastBlock > 24 * 7 * 2) { return "snapshot-download-prompt"; } diff --git a/package.json b/package.json index ed5566de..b3ed2e76 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ "react-hook-form": "^7.47.0", "react-icons": "^4.11.0", "type-fest": "^4.6.0", - "typescript": "^4.9.5", + "typescript": "^5.2.2", "usehooks-ts": "^2.9.1", "zod": "^3.22.3" }, diff --git a/renderer/utils/useHasGroupBlur.ts b/renderer/utils/useHasGroupBlur.ts index 7d5bea20..84a8512c 100644 --- a/renderer/utils/useHasGroupBlur.ts +++ b/renderer/utils/useHasGroupBlur.ts @@ -19,11 +19,11 @@ export function useHasGroupBlur({ delay = 100 }: { delay: number }) { }, [delay]); return useMemo( - { + () => ({ hasBlur, onFocus, onBlur, - }, - [], + }), + [hasBlur, onBlur, onFocus], ); } diff --git a/yarn.lock b/yarn.lock index 3ed1dbbb..35388f79 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7868,11 +7868,16 @@ typed-array-length@^1.0.4: for-each "^0.3.3" is-typed-array "^1.1.9" -typescript@^4.0.2, typescript@^4.9.5: +typescript@^4.0.2: version "4.9.5" resolved "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz" integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g== +typescript@^5.2.2: + version "5.2.2" + resolved "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz#5ebb5e5a5b75f085f22bc3f8460fba308310fa78" + integrity sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w== + unbox-primitive@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz"