Merge pull request #73 from MutinyWallet/onboarding

Onboarding
This commit is contained in:
Paul Miller
2023-05-04 20:51:12 -05:00
committed by GitHub
14 changed files with 869 additions and 747 deletions

View File

@@ -9,9 +9,9 @@
},
"type": "module",
"devDependencies": {
"@types/node": "^18.16.1",
"@typescript-eslint/eslint-plugin": "^5.59.1",
"@typescript-eslint/parser": "^5.59.1",
"@types/node": "^18.16.3",
"@typescript-eslint/eslint-plugin": "^5.59.2",
"@typescript-eslint/parser": "^5.59.2",
"autoprefixer": "^10.4.14",
"esbuild": "^0.14.54",
"eslint": "^8.39.0",
@@ -23,7 +23,7 @@
"solid-start-node": "^0.2.26",
"tailwindcss": "^3.3.2",
"typescript": "^4.9.5",
"vite": "^4.3.3",
"vite": "^4.3.4",
"vite-plugin-pwa": "^0.14.7",
"vite-plugin-wasm": "^3.2.2",
"workbox-window": "^6.5.4"
@@ -31,7 +31,7 @@
"dependencies": {
"@kobalte/core": "^0.8.2",
"@kobalte/tailwindcss": "^0.5.0",
"@modular-forms/solid": "^0.12.0",
"@modular-forms/solid": "^0.13.1",
"@mutinywallet/mutiny-wasm": "^0.2.8",
"@mutinywallet/waila-wasm": "^0.1.5",
"@solidjs/meta": "^0.28.4",
@@ -40,7 +40,7 @@
"class-variance-authority": "^0.4.0",
"nostr-tools": "^1.10.1",
"qr-scanner": "^1.4.2",
"solid-js": "^1.7.3",
"solid-js": "^1.7.4",
"solid-qr-code": "^0.0.8",
"solid-start": "^0.2.26",
"undici": "^5.22.0"

1358
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

BIN
src/assets/icons/megaex.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

View File

@@ -6,6 +6,7 @@ import ReloadPrompt from "~/components/Reload";
import { A } from 'solid-start';
import { Activity } from './Activity';
import settings from '~/assets/icons/settings.svg';
import { OnboardWarning } from './OnboardWarning';
export default function App() {
return (
@@ -16,6 +17,7 @@ export default function App() {
<img src={logo} class="h-10" alt="logo" />
<A class="md:hidden p-2 hover:bg-white/5 rounded-lg active:bg-m-blue" href="/settings"><img src={settings} alt="Settings" /></A>
</header>
<OnboardWarning />
<ReloadPrompt />
<BalanceBox />
<Activity />

View File

@@ -17,54 +17,26 @@ function SyncingIndicator() {
}
export default function BalanceBox() {
const [state, _] = useMegaStore();
const fetchOnchainBalance = async () => {
console.log("Refetching onchain balance");
await state.node_manager?.sync();
const balance = await state.node_manager?.get_balance();
return balance
};
// TODO: it's hacky to do these separately, but ln doesn't need the sync so I don't want to wait
const fetchLnBalance = async () => {
console.log("Refetching ln balance");
const balance = await state.node_manager?.get_balance();
return balance
};
const [onChainBalance, { refetch: refetchOnChainBalance }] = createResource(fetchOnchainBalance);
const [lnBalance, { refetch: refetchLnBalance }] = createResource(fetchLnBalance);
function refetchBalance() {
refetchLnBalance();
refetchOnChainBalance();
}
const [state, actions] = useMegaStore();
return (
<>
<FancyCard title="Lightning">
<Suspense fallback={<Amount amountSats={0} showFiat loading={true} />}>
<Show when={lnBalance()}>
<Amount amountSats={lnBalance()?.lightning} showFiat />
</Show>
</Suspense>
<Amount amountSats={state.balance?.lightning || 0} showFiat />
</FancyCard>
<FancyCard title="On-Chain" tag={onChainBalance.loading && <SyncingIndicator />}>
<Suspense fallback={<Amount amountSats={0} showFiat loading={true} />}>
<div onClick={refetchBalance}>
<Amount amountSats={onChainBalance()?.confirmed} showFiat loading={onChainBalance.loading} />
</div>
</Suspense>
<FancyCard title="On-Chain" tag={state.is_syncing && <SyncingIndicator />}>
<div onClick={actions.sync}>
<Amount amountSats={state.balance?.confirmed} showFiat />
</div>
<Suspense>
<Show when={onChainBalance()?.unconfirmed}>
<Show when={state.balance?.unconfirmed}>
<div class="flex flex-col gap-2">
<header class='text-sm font-semibold uppercase text-white/50'>
Unconfirmed Balance
</header>
<div class="text-white/50">
{prettyPrintAmount(onChainBalance()?.unconfirmed)} <span class='text-sm'>SATS</span>
{prettyPrintAmount(state.balance?.unconfirmed)} <span class='text-sm'>SATS</span>
</div>
</div>
</Show>

View File

@@ -0,0 +1,48 @@
import { Show, createSignal, onMount } from "solid-js";
import { Button, ButtonLink, SmallHeader, VStack } from "./layout";
import { useMegaStore } from "~/state/megaStore";
export function OnboardWarning() {
const [state, actions] = useMegaStore();
const [dismissedBackup, setDismissedBackup] = createSignal(false);
onMount(() => {
actions.sync()
})
function hasMoney() {
return state.balance?.confirmed || state.balance?.lightning || state.balance?.unconfirmed
}
return (
<>
{/* TODO: show this once we have a restore flow */}
<Show when={!state.dismissed_restore_prompt && false}>
<div class='rounded-xl p-4 flex flex-col gap-2 bg-neutral-950 overflow-x-hidden'>
<SmallHeader>Welcome!</SmallHeader>
<VStack>
<p class="text-2xl font-light">
Do you want to restore an existing Mutiny Wallet?
</p>
<div class="w-full flex gap-2">
<Button intent="green" onClick={() => { }}>Restore</Button>
<Button onClick={actions.dismissRestorePrompt}>Nope</Button>
</div>
</VStack>
</div>
</Show>
<Show when={!state.has_backed_up && hasMoney() && !dismissedBackup()}>
<div class='rounded-xl p-4 flex flex-col gap-2 bg-neutral-950 overflow-x-hidden'>
<SmallHeader>Secure your funds</SmallHeader>
<p class="text-2xl font-light">
You have money stored in this browser. Let's make sure you have a backup.
</p>
<div class="w-full flex gap-2">
<ButtonLink intent="blue" href="/backup">Backup</ButtonLink>
<Button onClick={() => { setDismissedBackup(true) }}>Nope</Button>
</div>
</div>
</Show>
</>
)
}

View File

@@ -1,27 +1,36 @@
import { Match, Switch, createSignal } from "solid-js"
import { For, Match, Switch, createMemo, createSignal } from "solid-js"
export function SeedWords(props: { words: string }) {
export function SeedWords(props: { words: string, setHasSeen?: (hasSeen: boolean) => void }) {
const [shouldShow, setShouldShow] = createSignal(false)
function toggleShow() {
setShouldShow(!shouldShow())
if (shouldShow()) {
props.setHasSeen?.(true)
}
}
return (<pre class="flex items-center gap-4 bg-m-red p-4 rounded-xl overflow-hidden">
const splitWords = createMemo(() => props.words.split(" "))
return (<button class="flex items-center gap-4 bg-m-red p-4 rounded-xl overflow-hidden" onClick={toggleShow}>
<Switch>
<Match when={!shouldShow()}>
<div onClick={toggleShow} class="cursor-pointer">
<div class="cursor-pointer">
<code class="text-red">TAP TO REVEAL SEED WORDS</code>
</div>
</Match>
<Match when={shouldShow()}>
<div onClick={toggleShow} class="cursor-pointer overflow-hidden">
<p class="font-mono w-full whitespace-pre-wrap">
{props.words}
</p>
</div>
<ol class="cursor-pointer overflow-hidden grid grid-cols-2 w-full list-decimal list-inside">
<For each={splitWords()}>
{(word) => (
<li class="font-mono text-left">
{word}
</li>
)}
</For>
</ol>
</Match>
</Switch>
</pre >)
</button >)
}

View File

@@ -31,7 +31,7 @@ export function ToastItem(props: { toastId: number, title: string, description:
return (
<Toast.Root toastId={props.toastId} class={`w-[80vw] max-w-[400px] mx-auto p-4 bg-neutral-900/80 backdrop-blur-md shadow-xl rounded-xl border ${props.isError ? "border-m-red/50" : "border-white/10"} `}>
<div class="flex gap-4 w-full justify-between items-start">
<div>
<div class="flex-1">
<Toast.Title>
<SmallHeader>
{props.title}
@@ -43,7 +43,7 @@ export function ToastItem(props: { toastId: number, title: string, description:
</p>
</Toast.Description>
</div>
<Toast.CloseButton class="hover:bg-white/10 rounded-lg active:bg-m-blue w-[5rem]">
<Toast.CloseButton class="hover:bg-white/10 rounded-lg active:bg-m-blue w-[5rem] flex-0">
<img src={close} alt="Close" />
</Toast.CloseButton>
</div>

View File

@@ -103,6 +103,10 @@ const SmallAmount: ParentComponent<{ amount: number | bigint }> = (props) => {
return (<h2 class="font-light text-lg">{props.amount.toLocaleString()} <span class="text-sm">SATS</span></h2>)
}
export const NiceP: ParentComponent = (props) => {
return (<p class="text-2xl font-light">{props.children}</p>)
}
export {
SmallHeader,
Card,

44
src/routes/Backup.tsx Normal file
View File

@@ -0,0 +1,44 @@
import { Button, DefaultMain, LargeHeader, NiceP, NodeManagerGuard, SafeArea, VStack } from "~/components/layout";
import NavBar from "~/components/NavBar";
import { useNavigate } from 'solid-start';
import { BackButton } from '~/components/layout/BackButton';
import { SeedWords } from '~/components/SeedWords';
import { useMegaStore } from '~/state/megaStore';
import { Show, createSignal } from 'solid-js';
export default function App() {
const [store, actions] = useMegaStore();
const navigate = useNavigate();
const [hasSeenBackup, setHasSeenBackup] = createSignal(false);
function wroteDownTheWords() {
actions.setHasBackedUp()
navigate("/")
}
return (
<NodeManagerGuard>
<SafeArea>
<DefaultMain>
<BackButton />
<LargeHeader>Backup</LargeHeader>
<VStack>
<NiceP>Let's get these funds secured.</NiceP>
<NiceP>We'll show you 12 words. You write down the 12 words.</NiceP>
<NiceP>
If you clear your browser history, or lose your device, these 12 words are the only way you can restore your wallet.
</NiceP>
<NiceP>Mutiny is self-custodial. It's all up to you...</NiceP>
<SeedWords words={store.node_manager?.show_seed() || ""} setHasSeen={setHasSeenBackup} />
<Show when={hasSeenBackup()}>
<NiceP>You are responsible for your funds!</NiceP>
</Show>
<Button disabled={!hasSeenBackup()} intent="blue" onClick={wroteDownTheWords}>I wrote down the words</Button>
</VStack>
</DefaultMain>
<NavBar activeTab="none" />
</SafeArea>
</NodeManagerGuard>
);
}

View File

@@ -8,14 +8,14 @@ import { useMegaStore } from "~/state/megaStore";
import { objectToSearchParams } from "~/utils/objectToSearchParams";
import { useCopy } from "~/utils/useCopy";
import mempoolTxUrl from "~/utils/mempoolTxUrl";
import party from '~/assets/hands/handsup.png';
import { Amount } from "~/components/Amount";
import { FullscreenModal } from "~/components/layout/FullscreenModal";
import { BackButton } from "~/components/layout/BackButton";
import { TagEditor, TagItem } from "~/components/TagEditor";
import { StyledRadioGroup } from "~/components/layout/Radio";
import { showToast } from "~/components/Toaster";
import { useNavigate } from "solid-start";
import megacheck from "~/assets/icons/megacheck.png";
type OnChainTx = {
transaction: {
@@ -81,6 +81,7 @@ type PaidState = "lightning_paid" | "onchain_paid";
export default function Receive() {
const [state, _] = useMegaStore()
const navigate = useNavigate();
const [amount, setAmount] = createSignal("")
const [receiveState, setReceiveState] = createSignal<ReceiveState>("edit")
@@ -263,17 +264,27 @@ export default function Receive() {
</Card>
</Match>
<Match when={receiveState() === "paid" && paidState() === "lightning_paid"}>
<FullscreenModal title="Payment Received" open={!!paidState()} setOpen={(open: boolean) => { if (!open) clearAll() }}>
<FullscreenModal
title="Payment Received"
open={!!paidState()}
setOpen={(open: boolean) => { if (!open) clearAll() }}
onConfirm={() => { clearAll(); navigate("/"); }}
>
<div class="flex flex-col items-center gap-8">
<img src={party} alt="party" class="w-1/2 mx-auto max-w-[50vh] aspect-square" />
<img src={megacheck} alt="success" class="w-1/2 mx-auto max-w-[50vh] aspect-square" />
<Amount amountSats={paymentInvoice()?.amount_sats} showFiat />
</div>
</FullscreenModal>
</Match>
<Match when={receiveState() === "paid" && paidState() === "onchain_paid"}>
<FullscreenModal title="Payment Received" open={!!paidState()} setOpen={(open: boolean) => { if (!open) clearAll() }}>
<FullscreenModal
title="Payment Received"
open={!!paidState()}
setOpen={(open: boolean) => { if (!open) clearAll() }}
onConfirm={() => { clearAll(); navigate("/"); }}
>
<div class="flex flex-col items-center gap-8">
<img src={party} alt="party" class="w-1/2 mx-auto max-w-[50vh] aspect-square" />
<img src={megacheck} alt="success" class="w-1/2 mx-auto max-w-[50vh] aspect-square" />
<Amount amountSats={paymentTx()?.received} showFiat />
<a href={mempoolTxUrl(paymentTx()?.txid, "signet")} target="_blank" rel="noreferrer">
Mempool Link

View File

@@ -12,10 +12,11 @@ import { ParsedParams, toParsedParams } from "./Scanner";
import { showToast } from "~/components/Toaster";
import eify from "~/utils/eify";
import { FullscreenModal } from "~/components/layout/FullscreenModal";
import handshake from "~/assets/hands/handshake.png";
import thumbsdown from "~/assets/hands/thumbsdown.png";
import megacheck from "~/assets/icons/megacheck.png"
import megaex from "~/assets/icons/megaex.png";
import mempoolTxUrl from "~/utils/mempoolTxUrl";
import { BackButton } from "~/components/layout/BackButton";
import { useNavigate } from "solid-start";
type SendSource = "lightning" | "onchain";
@@ -29,6 +30,7 @@ type SentDetails = { amount?: bigint, destination?: string, txid?: string, failu
export default function Send() {
const [state, actions] = useMegaStore();
const navigate = useNavigate()
// These can only be set by the user
const [fieldDestination, setFieldDestination] = createSignal("");
@@ -204,16 +206,16 @@ export default function Send() {
confirmText={sentDetails()?.amount ? "Nice" : "Too Bad"}
open={!!sentDetails()}
setOpen={(open: boolean) => { if (!open) setSentDetails(undefined) }}
onConfirm={() => setSentDetails(undefined)}
onConfirm={() => { setSentDetails(undefined); navigate("/"); }}
>
<div class="flex flex-col items-center gap-8 h-full">
<Switch>
<Match when={sentDetails()?.failure_reason}>
<img src={thumbsdown} alt="thumbs down" class="w-1/2 mx-auto max-w-[50vh]" />
<img src={megaex} alt="fail" class="w-1/2 mx-auto max-w-[50vh]" />
<p class="text-xl font-light py-2 px-4 rounded-xl bg-white/10">{sentDetails()?.failure_reason}</p>
</Match>
<Match when={true}>
<img src={handshake} alt="handshake" class="w-1/2 mx-auto max-w-[50vh]" />
<img src={megacheck} alt="success" class="w-1/2 mx-auto max-w-[50vh]" />
<Amount amountSats={sentDetails()?.amount} showFiat />
<Show when={sentDetails()?.txid}>
<a href={mempoolTxUrl(sentDetails()?.txid, state.node_manager?.get_network())} target="_blank" rel="noreferrer">

View File

@@ -1,5 +1,6 @@
// Inspired by https://github.com/solidjs/solid-realworld/blob/main/src/store/index.js
/* @refresh reload */
// Inspired by https://github.com/solidjs/solid-realworld/blob/main/src/store/index.js
import { ParentComponent, createContext, createEffect, onCleanup, onMount, useContext } from "solid-js";
import { createStore } from "solid-js/store";
import { NodeManagerSettingStrings, setupNodeManager } from "~/logic/nodeManagerSetup";
@@ -16,14 +17,19 @@ export type MegaStore = [{
user_status: UserStatus;
scan_result?: ParsedParams;
balance?: MutinyBalance;
is_syncing?: boolean;
last_sync?: number;
price: number
has_backed_up: boolean,
dismissed_restore_prompt: boolean
}, {
fetchUserStatus(): Promise<UserStatus>;
setupNodeManager(settings?: NodeManagerSettingStrings): Promise<void>;
setWaitlistId(waitlist_id: string): void;
setScanResult(scan_result: ParsedParams | undefined): void;
sync(): Promise<void>;
dismissRestorePrompt(): void;
setHasBackedUp(): void;
}];
export const Provider: ParentComponent = (props) => {
@@ -33,7 +39,12 @@ export const Provider: ParentComponent = (props) => {
user_status: undefined as UserStatus,
scan_result: undefined as ParsedParams | undefined,
// TODO: wire this up to real price once we have caching
price: 30000
price: 30000,
has_backed_up: localStorage.getItem("has_backed_up") === "true",
balance: undefined as MutinyBalance | undefined,
last_sync: undefined as number | undefined,
is_syncing: false,
dismissed_restore_prompt: localStorage.getItem("dismissed_restore_prompt") === "true"
});
const actions = {
@@ -69,14 +80,29 @@ export const Provider: ParentComponent = (props) => {
async sync(): Promise<void> {
console.time("BDK Sync Time")
try {
await state.node_manager?.sync()
if (state.node_manager && !state.is_syncing) {
setState({ is_syncing: true })
await state.node_manager?.sync()
const balance = await state.node_manager?.get_balance();
setState({ balance, last_sync: Date.now() })
}
} catch (e) {
console.error(e);
} finally {
setState({ is_syncing: false })
}
console.timeEnd("BDK Sync Time")
},
setScanResult(scan_result: ParsedParams) {
setState({ scan_result })
},
setHasBackedUp() {
localStorage.setItem("has_backed_up", "true")
setState({ has_backed_up: true })
},
dismissRestorePrompt() {
localStorage.setItem("dismissed_restore_prompt", "true")
setState({ dismissed_restore_prompt: true })
}
};
@@ -101,8 +127,8 @@ export const Provider: ParentComponent = (props) => {
});
createEffect(() => {
const interval = setInterval(() => {
if (state.node_manager) actions.sync();
const interval = setInterval(async () => {
await actions.sync();
}, 60 * 1000); // Poll every minute
onCleanup(() => {