redesigned send and receive

This commit is contained in:
Paul Miller
2023-05-08 20:55:02 -05:00
parent a440061971
commit 7cb0b0ddb5
21 changed files with 333 additions and 239 deletions

View File

@@ -4,7 +4,6 @@ module.exports = {
"es2021": true
},
"extends": [
"prettier",
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:solid/typescript",

View File

@@ -48,5 +48,13 @@
},
"engines": {
"node": ">=16.8"
},
"prettier": {
"semi": false,
"singleQuote": false,
"trailingComma": "es5",
"printWidth": 80,
"tabWidth": 2,
"useTabs": false
}
}

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 21H8V7h11m0-2H8c-.53043 0-1.03914.21071-1.41421.58579C6.21071 5.96086 6 6.46957 6 7v14c0 .5304.21071 1.0391.58579 1.4142C6.96086 22.7893 7.46957 23 8 23h11c.5304 0 1.0391-.2107 1.4142-.5858S21 21.5304 21 21V7c0-.53043-.2107-1.03914-.5858-1.41421C20.0391 5.21071 19.5304 5 19 5Zm-3-4H4c-.53043 0-1.03914.21071-1.41421.58579C2.21071 1.96086 2 2.46957 2 3v14h2V3h12V1Z" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 478 B

3
src/assets/icons/eye.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5ZM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5Zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3Z" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 318 B

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.707 19.7069 18 10.4139l-4.414-4.41402-9.293 9.29302c-.12794.1281-.21882.2884-.263.464L3 20.9999l5.242-1.03c.176-.044.337-.135.465-.263ZM21 7.41388c.3749-.37506.5856-.88367.5856-1.414s-.2107-1.03895-.5856-1.414l-1.586-1.586c-.3751-.37494-.8837-.58557-1.414-.58557-.5303 0-1.0389.21063-1.414.58557L15 4.58588l4.414 4.414L21 7.41388Z" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 442 B

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 23c-.55 0-1.021-.196-1.413-.588C4.195 22.02 3.99934 21.5493 4 21V10c0-.55.196-1.021.588-1.413C4.98 8.195 5.45067 7.99933 6 8h3v2H6v11h12V10h-3V8h3c.55 0 1.021.196 1.413.588.392.392.5877.86267.587 1.412v11c0 .55-.196 1.021-.588 1.413-.392.392-.8627.5877-1.412.587H6Zm5-7V4.825l-1.6 1.6L8 5l4-4 4 4-1.4 1.425-1.6-1.6V16h-2Z" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 433 B

View File

@@ -0,0 +1,87 @@
import { Match, ParentComponent, Show, Switch, createMemo } from "solid-js";
import { Card, VStack } from "~/components/layout";
import { useMegaStore } from "~/state/megaStore";
import { satsToUsd } from "~/utils/conversions";
import { AmountEditable } from "./AmountEditable";
const KeyValue: ParentComponent<{ key: string, gray?: boolean }> = (props) => {
return (
<div class="flex justify-between items-center" classList={{ "text-neutral-400": props.gray }}>
<div class="font-semibold uppercase">{props.key}</div>
<div class="font-light">{props.children}</div>
</div>
)
}
export const InlineAmount: ParentComponent<{ amount: string, sign?: string, fiat?: boolean }> = (props) => {
const prettyPrint = createMemo(() => {
const parsed = Number(props.amount);
if (isNaN(parsed)) {
return props.amount;
} else {
return parsed.toLocaleString();
}
})
return (<div class="inline-block text-lg">{props.sign ? `${props.sign} ` : ""}{props.fiat ? "$" : ""}{prettyPrint()} <span class="text-sm">{props.fiat ? "USD" : "SATS"}</span></div>)
}
function USDShower(props: { amountSats: string, fee?: string }) {
const [state, _] = useMegaStore()
const amountInUsd = () => satsToUsd(state.price, add(props.amountSats, props.fee), true)
return (
<Show when={!(props.amountSats === "0")}>
<KeyValue gray key="">
<div class="self-end">&#8776; {amountInUsd()}&nbsp;<span class="text-sm">USD</span></div>
</KeyValue>
</Show>
)
}
function add(a: string, b?: string) {
return Number(a || 0) + Number(b || 0)
}
export function AmountCard(props: { amountSats: string, fee?: string, initialOpen?: boolean, isAmountEditable?: boolean, setAmountSats?: (amount: bigint) => void }) {
return (
<Card>
<VStack>
<Switch>
<Match when={props.fee}>
<div class="flex flex-col gap-1">
<KeyValue key="Amount">
<Show when={props.isAmountEditable} fallback={<InlineAmount amount={props.amountSats} />
}>
<AmountEditable initialOpen={props.initialOpen ?? false} initialAmountSats={props.amountSats.toString()} setAmountSats={props.setAmountSats ? props.setAmountSats : () => { }} />
</Show>
</KeyValue>
<KeyValue gray key="+ Fee">
<InlineAmount amount={props.fee || "0"} />
</KeyValue>
</div>
<hr class="border-white/20" />
<div class="flex flex-col gap-1">
<KeyValue key="Total">
<InlineAmount amount={add(props.amountSats, props.fee).toString()} />
</KeyValue>
<USDShower amountSats={props.amountSats} fee={props.fee} />
</div>
</Match>
<Match when={!props.fee}>
<div class="flex flex-col gap-1">
<KeyValue key="Amount">
<Show when={props.isAmountEditable} fallback={<InlineAmount amount={props.amountSats} />
}>
<AmountEditable initialOpen={props.initialOpen ?? false} initialAmountSats={props.amountSats.toString()} setAmountSats={props.setAmountSats ? props.setAmountSats : () => { }} />
</Show>
</KeyValue>
<USDShower amountSats={props.amountSats} />
</div>
</Match>
</Switch>
</VStack>
</Card>
)
}

View File

@@ -1,10 +1,11 @@
import { For, Show, createMemo, createSignal } from 'solid-js';
import { For, ParentComponent, Show, createMemo, createSignal } from 'solid-js';
import { Button } from '~/components/layout';
import { useMegaStore } from '~/state/megaStore';
import { satsToUsd } from '~/utils/conversions';
import { Amount } from './Amount';
import { Dialog } from '@kobalte/core';
import close from "~/assets/icons/close.svg";
import pencil from "~/assets/icons/pencil.svg";
import { InlineAmount } from './AmountCard';
const CHARACTERS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", ".", "0", "DEL"];
@@ -25,8 +26,8 @@ function SingleDigitButton(props: { character: string, onClick: (c: string) => v
);
}
export function AmountEditable(props: { initialAmountSats: string, setAmountSats: (s: bigint) => void }) {
const [isOpen, setIsOpen] = createSignal(false);
export const AmountEditable: ParentComponent<{ initialAmountSats: string, initialOpen: boolean, setAmountSats: (s: bigint) => void }> = (props) => {
const [isOpen, setIsOpen] = createSignal(props.initialOpen);
const [displayAmount, setDisplayAmount] = createSignal(props.initialAmountSats || "0");
@@ -135,8 +136,13 @@ export function AmountEditable(props: { initialAmountSats: string, setAmountSats
return (
<Dialog.Root isOpen={isOpen()}>
<button onClick={() => setIsOpen(true)} class="p-4 rounded-xl border-2 border-m-blue">
<Amount amountSats={Number(displayAmount())} showFiat />
<button onClick={() => setIsOpen(true)} class="px-4 py-2 rounded-xl border-2 border-m-blue flex gap-2 items-center">
{/* <Amount amountSats={Number(displayAmount())} showFiat /><span>&#x270F;&#xFE0F;</span> */}
<Show when={displayAmount() !== "0"} fallback={<div class="inline-block font-semibold">Set amount</div>}>
<InlineAmount amount={displayAmount()} />
</Show>
<img src={pencil} alt="Edit" />
{/* {props.children} */}
</button>
<Dialog.Portal>
{/* <Dialog.Overlay class={OVERLAY} /> */}

View File

@@ -1,5 +1,5 @@
import { Show, Suspense } from "solid-js";
import { ButtonLink, FancyCard } from "~/components/layout";
import { ButtonLink, FancyCard, Indicator } from "~/components/layout";
import { useMegaStore } from "~/state/megaStore";
import { Amount } from "./Amount";
@@ -10,12 +10,6 @@ function prettyPrintAmount(n?: number | bigint): string {
return n.toLocaleString()
}
function SyncingIndicator() {
return (
<div class="box-border animate-pulse px-2 py-1 -my-1 bg-white/70 rounded text-xs uppercase text-black">Syncing</div>
)
}
export default function BalanceBox() {
const [state, actions] = useMegaStore();
@@ -25,7 +19,7 @@ export default function BalanceBox() {
<Amount amountSats={state.balance?.lightning || 0} showFiat />
</FancyCard>
<FancyCard title="On-Chain" tag={state.is_syncing && <SyncingIndicator />}>
<FancyCard title="On-Chain" tag={state.is_syncing && <Indicator>Syncing</Indicator>}>
<div onClick={actions.sync}>
<Amount amountSats={state.balance?.confirmed} showFiat />
</div>

View File

@@ -1,5 +1,5 @@
import { Match, Switch, createSignal, createUniqueId } from 'solid-js';
import { SmallHeader } from '~/components/layout';
import { SmallHeader, TinyButton } from '~/components/layout';
import { Dialog } from '@kobalte/core';
import close from "~/assets/icons/close.svg";
import { SubmitHandler } from '@modular-forms/solid';
@@ -39,7 +39,7 @@ export function ContactEditor(props: { createContact: (contact: ContactItem) =>
</button>
</Match>
<Match when={!props.list}>
<button onClick={() => setIsOpen(true)} class="border border-l-white/50 border-r-white/50 border-t-white/75 border-b-white/25 bg-black px-1 py-[0.5] rounded cursor-pointer hover:outline-white hover:outline-1">+ Add Contact</button>
<TinyButton onClick={() => setIsOpen(true)}>+ Add Contact</TinyButton>
</Match>
</Switch>
<Dialog.Portal>

View File

@@ -5,7 +5,7 @@ import { useCopy } from "~/utils/useCopy";
const OVERLAY = "fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
const DIALOG_POSITIONER = "fixed inset-0 z-50 flex items-center justify-center"
const DIALOG_CONTENT = "max-w-[600px] max-h-full p-4 bg-gray/50 backdrop-blur-md shadow-xl rounded-xl border border-white/10"
const DIALOG_CONTENT = "max-w-[600px] max-h-full mx-4 p-4 bg-neutral-900/50 backdrop-blur-md shadow-xl rounded-xl border border-white/10"
export function JsonModal(props: { title: string, open: boolean, data?: unknown, setOpen: (open: boolean) => void, children?: JSX.Element }) {
const json = createMemo(() => JSON.stringify(props.data, null, 2));

View File

@@ -0,0 +1,71 @@
import { Card, VStack } from "~/components/layout";
import { useCopy } from "~/utils/useCopy";
import copyIcon from "~/assets/icons/copy.svg"
import shareIcon from "~/assets/icons/share.svg"
import eyeIcon from "~/assets/icons/eye.svg"
import { Show, createSignal } from "solid-js";
import { JsonModal } from "./JsonModal";
const STYLE = "px-4 py-2 rounded-xl border-2 border-white flex gap-2 items-center font-semibold"
function ShareButton(props: { receiveString: string }) {
async function share(receiveString: string) {
// If the browser doesn't support share we can just copy the address
if (!navigator.share) {
console.error("Share not supported")
}
const shareData: ShareData = {
title: "Mutiny Wallet",
text: receiveString,
}
try {
await navigator.share(shareData)
} catch (e) {
console.error(e)
}
}
return (
<button class={STYLE} onClick={(_) => share(props.receiveString)}><span>Share</span><img src={shareIcon} alt="share" /></button>
)
}
export function StringShower(props: { text: string }) {
const [open, setOpen] = createSignal(false);
return (
<>
<JsonModal open={open()} data={props.text} title="Details" setOpen={setOpen} />
<div class="flex gap-2">
<pre class="truncate text-neutral-400">{props.text}</pre>
<button class="w-[16rem]" onClick={() => setOpen(true)}>
<img src={eyeIcon} alt="eye" />
</button>
</div>
</>
)
}
export function ShareCard(props: { text?: string }) {
const [copy, copied] = useCopy({ copiedTimeout: 1000 });
function handleCopy() {
copy(props.text ?? "")
}
return (
<Card>
<StringShower text={props.text ?? ""} />
<VStack>
<div class="flex gap-4 justify-center">
<button class={STYLE} onClick={handleCopy}>{copied() ? "Copied" : "Copy"}<img src={copyIcon} alt="copy" /></button>
<Show when={navigator.share}>
<ShareButton receiveString={props.text ?? ""} />
</Show>
</div>
</VStack>
</Card >
)
}

View File

@@ -1,22 +1,21 @@
import { Select, createOptions } from "@thisbeyond/solid-select";
import "~/styles/solid-select.css"
import { SmallHeader } from "./layout";
import { For, createUniqueId } from "solid-js";
import { ContactEditor } from "./ContactEditor";
import { ContactItem, TagItem, TextItem, addContact } from "~/state/contacts";
import { TinyButton } from "./layout";
// take two arrays, subtract the second from the first, then return the first
function subtract<T>(a: T[], b: T[]) {
const set = new Set(b);
return a.filter(x => !set.has(x));
};
}
const createValue = (name: string): TextItem => {
return { id: createUniqueId(), name, kind: "text" };
};
export function TagEditor(props: { title: string, values: TagItem[], setValues: (values: TagItem[]) => void, selectedValues: TagItem[], setSelectedValues: (values: TagItem[]) => void, placeholder: string }) {
console.log(props.values);
export function TagEditor(props: { values: TagItem[], setValues: (values: TagItem[]) => void, selectedValues: TagItem[], setSelectedValues: (values: TagItem[]) => void, placeholder: string }) {
const onChange = (selected: TagItem[]) => {
props.setSelectedValues(selected);
@@ -42,7 +41,7 @@ export function TagEditor(props: { title: string, values: TagItem[], setValues:
return (
<div class="flex flex-col gap-2 flex-grow flex-shrink flex-1" >
<SmallHeader>{props.title}</SmallHeader>
{/* FIXME this is causing overflow scroll for now good reason */}
<Select
multiple
initialValue={props.selectedValues}
@@ -53,15 +52,12 @@ export function TagEditor(props: { title: string, values: TagItem[], setValues:
<div class="flex gap-2 flex-wrap">
<For each={subtract(props.values, props.selectedValues).slice(0, 3)}>
{(tag) => (
<div onClick={() => onChange([...props.selectedValues, tag])}
class="border border-l-white/50 border-r-white/50 border-t-white/75 border-b-white/25 px-1 py-[0.5] rounded cursor-pointer hover:outline-white hover:outline-1"
classList={{ "bg-black": tag.kind === "text", "bg-m-blue": tag.kind === "contact" && tag.color === "blue", "bg-m-green": tag.kind === "contact" && tag.color === "green", "bg-m-red": tag.kind === "contact" && tag.color === "red", "bg-[#898989]": tag.kind === "contact" && tag.color === "gray" }}
<TinyButton onClick={() => onChange([...props.selectedValues, tag])}
>
{tag.name}
</div>
</TinyButton>
)}
</For>
{/* <button class="border border-l-white/50 border-r-white/50 border-t-white/75 border-b-white/25 bg-black px-1 py-[0.5] rounded cursor-pointer hover:outline-white hover:outline-1">+ Add Contact</button> */}
<ContactEditor createContact={newContact} />
</div>
</div >

View File

@@ -1,11 +1,11 @@
import { Dialog } from "@kobalte/core";
import { JSX } from "solid-js";
import { Button, LargeHeader, SmallHeader } from "~/components/layout";
import { Button, LargeHeader } from "~/components/layout";
import close from "~/assets/icons/close.svg";
const DIALOG_POSITIONER = "fixed inset-0 safe-top safe-bottom z-50"
const DIALOG_CONTENT = "h-full flex flex-col justify-between p-4 bg-gray/50 backdrop-blur-md bg-black/80"
const DIALOG_CONTENT = "h-full flex flex-col justify-between p-4 backdrop-blur-md bg-neutral-900/50"
type FullscreenModalProps = {
title: string,

View File

@@ -4,13 +4,19 @@ import { For, Show } from "solid-js";
type Choices = { value: string, label: string, caption: string }[]
// TODO: how could would it be if we could just pass the estimated fees in here?
export function StyledRadioGroup(props: { value: string, choices: Choices, onValueChange: (value: string) => void, small?: boolean, red?: boolean }) {
export function StyledRadioGroup(props: { value: string, choices: Choices, onValueChange: (value: string) => void, small?: boolean, accent?: "red" | "white" }) {
return (
// TODO: rewrite this with CVA, props are bad for tailwind
<RadioGroup.Root value={props.value} onValueChange={(e) => props.onValueChange(e)} class={`grid w-full gap-${props.small ? "2" : "4"} grid-cols-${props.choices.length.toString()}`}>
<RadioGroup.Root value={props.value} onValueChange={(e) => props.onValueChange(e)}
class={"grid w-full gap-4"}
classList={{ "grid-cols-2": props.choices.length === 2, "grid-cols-3": props.choices.length === 3, "gap-2": props.small }}
>
<For each={props.choices}>
{choice =>
<RadioGroup.Item value={choice.value} class={`ui-checked:bg-neutral-950 bg-white/10 rounded outline outline-black/50 ${props.red ? "ui-checked:outline-m-red" : "ui-checked:outline-m-blue"} ui-checked:outline-2`}>
<RadioGroup.Item value={choice.value}
class={`ui-checked:bg-neutral-950 bg-white/10 rounded outline outline-black/50 ui-checked:outline-m-blue ui-checked:outline-2`}
classList={{ "ui-checked:outline-m-red": props.accent === "red", "ui-checked:outline-white": props.accent === "white" }}
>
<div class={props.small ? "py-2 px-2" : "py-3 px-4"}>
<RadioGroup.ItemInput />
<RadioGroup.ItemControl >

View File

@@ -16,7 +16,7 @@ export const SmallHeader: ParentComponent<{ class?: string }> = (props) => {
export const Card: ParentComponent<{ title?: string, titleElement?: JSX.Element }> = (props) => {
return (
<div class='rounded-xl p-4 flex flex-col gap-2 bg-neutral-950 overflow-x-hidden w-full'>
<div class='rounded-xl p-4 flex flex-col gap-2 bg-neutral-950/50 overflow-x-hidden w-full'>
{props.title && <SmallHeader>{props.title}</SmallHeader>}
{props.titleElement && props.titleElement}
{props.children}
@@ -97,7 +97,7 @@ export const Hr = () => <Separator.Root class="my-4 border-white/20" />
export const LargeHeader: ParentComponent<{ action?: JSX.Element }> = (props) => {
return (
<header class="w-full flex justify-between items-center mt-4 mb-2">
<h1 class="text-4xl font-semibold">{props.children}</h1>
<h1 class="text-3xl font-semibold">{props.children}</h1>
<Show when={props.action}>
{props.action}
</Show>
@@ -120,3 +120,17 @@ export const SmallAmount: ParentComponent<{ amount: number | bigint, sign?: stri
export const NiceP: ParentComponent = (props) => {
return (<p class="text-2xl font-light">{props.children}</p>)
}
export const TinyButton: ParentComponent<{ onClick: () => void }> = (props) => {
return (
<button class="py-1 px-2 rounded-lg bg-white/10" onClick={props.onClick}>
{props.children}
</button>
)
}
export const Indicator: ParentComponent = (props) => {
return (
<div class="box-border animate-pulse px-2 py-1 -my-1 bg-white/70 rounded text-xs uppercase text-black">{props.children}</div>
)
}

View File

@@ -28,7 +28,7 @@ export default function Root() {
content="width=device-width, initial-scale=1.0 height=device-height viewport-fit=cover user-scalable=no"
/>
<Link rel="manifest" href="/manifest.webmanifest" />
<Meta name="theme-color" content="#000000" />
<Meta name="theme-color" content="rgb(23,23,23)" />
<Meta name="description" content="Lightning wallet for the web" />
<Link rel="icon" href="/favicon.ico" />
<Link rel="apple-touch-icon" href="/images/icon.png" sizes="512x512" />

View File

@@ -1,10 +1,10 @@
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';
import { BackLink } from "~/components/layout/BackLink";
export default function App() {
const [store, actions] = useMegaStore();
@@ -21,7 +21,7 @@ export default function App() {
<NodeManagerGuard>
<SafeArea>
<DefaultMain>
<BackButton />
<BackLink />
<LargeHeader>Backup</LargeHeader>
<VStack>
<NiceP>Let's get these funds secured.</NiceP>

View File

@@ -1,12 +1,10 @@
import { MutinyBip21RawMaterials, MutinyInvoice } from "@mutinywallet/mutiny-wasm";
import { createEffect, createResource, createSignal, For, Match, onCleanup, onMount, Switch } from "solid-js";
import { createEffect, createMemo, createResource, createSignal, Match, onCleanup, onMount, Show, Switch } from "solid-js";
import { QRCodeSVG } from "solid-qr-code";
import { AmountEditable } from "~/components/AmountEditable";
import { Button, Card, LargeHeader, MutinyWalletGuard, SafeArea, SmallHeader } from "~/components/layout";
import { Button, Card, Indicator, LargeHeader, MutinyWalletGuard, SafeArea } from "~/components/layout";
import NavBar from "~/components/NavBar";
import { useMegaStore } from "~/state/megaStore";
import { objectToSearchParams } from "~/utils/objectToSearchParams";
import { useCopy } from "~/utils/useCopy";
import mempoolTxUrl from "~/utils/mempoolTxUrl";
import { Amount } from "~/components/Amount";
import { FullscreenModal } from "~/components/layout/FullscreenModal";
@@ -17,6 +15,9 @@ import { showToast } from "~/components/Toaster";
import { useNavigate } from "solid-start";
import megacheck from "~/assets/icons/megacheck.png";
import { TagItem, listTags } from "~/state/contacts";
import { AmountCard } from "~/components/AmountCard";
import { ShareCard } from "~/components/ShareCard";
import { BackButton } from "~/components/layout/BackButton";
type OnChainTx = {
transaction: {
@@ -44,39 +45,9 @@ type OnChainTx = {
const createUniqueId = () => Math.random().toString(36).substr(2, 9);
const fakeContacts: TagItem[] = [
{ id: createUniqueId(), name: "Unknown", kind: "text" },
// { id: createUniqueId(), name: "Alice", kind: "contact" },
// { id: createUniqueId(), name: "Bob", kind: "contact" },
// { id: createUniqueId(), name: "Carol", kind: "contact" },
]
const RECEIVE_FLAVORS = [{ value: "unified", label: "Unified", caption: "Sender decides" }, { value: "lightning", label: "Lightning", caption: "Fast and cool" }, { value: "onchain", label: "On-chain", caption: "Just like Satoshi did it" }]
type ReceiveFlavor = "unified" | "lightning" | "onchain"
function ShareButton(props: { receiveString: string }) {
async function share(receiveString: string) {
// If the browser doesn't support share we can just copy the address
if (!navigator.share) {
console.error("Share not supported")
}
const shareData: ShareData = {
title: "Mutiny Wallet",
text: receiveString,
}
try {
await navigator.share(shareData)
} catch (e) {
console.error(e)
}
}
return (
<Button onClick={(_) => share(props.receiveString)}>Share</Button>
)
}
type ReceiveState = "edit" | "show" | "paid"
type PaidState = "lightning_paid" | "onchain_paid";
@@ -88,17 +59,12 @@ export default function Receive() {
const [receiveState, setReceiveState] = createSignal<ReceiveState>("edit")
const [bip21Raw, setBip21Raw] = createSignal<MutinyBip21RawMaterials>();
const [unified, setUnified] = createSignal("")
const [shouldShowAmountEditor, setShouldShowAmountEditor] = createSignal(true)
// Tagging stuff
const [selectedValues, setSelectedValues] = createSignal<TagItem[]>([]);
const [values, setValues] = createSignal<TagItem[]>([{ id: createUniqueId(), name: "Unknown", kind: "text" }]);
onMount(() => {
listTags().then((tags) => {
setValues(prev => [...prev, ...tags || []])
});
})
// The data we get after a payment
const [paymentTx, setPaymentTx] = createSignal<OnChainTx>();
const [paymentInvoice, setPaymentInvoice] = createSignal<MutinyInvoice>();
@@ -106,6 +72,25 @@ export default function Receive() {
// The flavor of the receive
const [flavor, setFlavor] = createSignal<ReceiveFlavor>("unified");
const receiveString = createMemo(() => {
if (unified() && receiveState() === "show") {
if (flavor() === "unified") {
return unified();
} else if (flavor() === "lightning") {
return bip21Raw()?.invoice ?? "";
} else if (flavor() === "onchain") {
return bip21Raw()?.address ?? "";
}
}
})
onMount(() => {
listTags().then((tags) => {
setValues(prev => [...prev, ...tags || []])
});
})
function clearAll() {
setAmount("")
setReceiveState("edit")
@@ -116,33 +101,6 @@ export default function Receive() {
setSelectedValues([])
}
let amountInput!: HTMLInputElement;
let labelInput!: HTMLInputElement;
function editAmount(e: Event) {
e.preventDefault();
setReceiveState("edit")
amountInput.focus();
}
function editLabel(e: Event) {
e.preventDefault();
setReceiveState("edit")
labelInput.focus();
}
const [copy, copied] = useCopy({ copiedTimeout: 1000 });
function handleCopy() {
if (flavor() === "unified") {
copy(unified() ?? "")
} else if (flavor() === "lightning") {
copy(bip21Raw()?.invoice ?? "")
} else if (flavor() === "onchain") {
copy(bip21Raw()?.address ?? "")
}
}
async function getUnifiedQr(amount: string) {
const bigAmount = BigInt(amount);
try {
@@ -171,6 +129,7 @@ export default function Receive() {
setUnified(unifiedQr || "")
setReceiveState("show")
setShouldShowAmountEditor(false)
}
async function checkIfPaid(bip21?: MutinyBip21RawMaterials): Promise<PaidState | undefined> {
@@ -211,63 +170,31 @@ export default function Receive() {
return (
<MutinyWalletGuard>
<SafeArea>
<main class="max-w-[600px] flex flex-col gap-4 mx-auto p-4">
<BackLink />
<LargeHeader>Receive Bitcoin</LargeHeader>
<main class="max-w-[600px] flex flex-col flex-1 gap-4 mx-auto p-4 overflow-y-auto">
<Show when={receiveState() === "show"} fallback={<BackLink />}>
<BackButton onClick={() => setReceiveState("edit")} title="Edit" />
</Show>
<LargeHeader action={receiveState() === "show" && <Indicator>Checking</Indicator>}>Receive Bitcoin</LargeHeader>
<Switch>
<Match when={!unified() || receiveState() === "edit"}>
<dl>
<dd>
<AmountEditable initialAmountSats={amount() || "0"} setAmountSats={setAmount} />
</dd>
<dd>
<TagEditor title="Tag the origin" values={values()} setValues={setValues} selectedValues={selectedValues()} setSelectedValues={setSelectedValues} placeholder="Where's it coming from?" />
</dd>
</dl>
<Button class="w-full" disabled={!amount() || !selectedValues().length} intent="green" onClick={onSubmit}>Create Invoice</Button>
<div class="flex flex-col flex-1 gap-8">
<AmountCard initialOpen={shouldShowAmountEditor()} amountSats={amount() || "0"} setAmountSats={setAmount} isAmountEditable />
<Card title="Tag the origin">
<TagEditor values={values()} setValues={setValues} selectedValues={selectedValues()} setSelectedValues={setSelectedValues} placeholder="Where's it coming from?" />
</Card>
<div class="flex-1" />
<Button class="w-full flex-grow-0 mb-4" style="" disabled={!amount() || !selectedValues().length} intent="green" onClick={onSubmit}>Create Invoice</Button>
</div>
</Match>
<Match when={unified() && receiveState() === "show"}>
<StyledRadioGroup small value={flavor()} onValueChange={setFlavor} choices={RECEIVE_FLAVORS} />
<StyledRadioGroup small value={flavor()} onValueChange={setFlavor} choices={RECEIVE_FLAVORS} accent="white" />
<div class="w-full bg-white rounded-xl">
<Switch>
<Match when={flavor() === "unified"}>
<QRCodeSVG value={unified() ?? ""} class="w-full h-full p-8 max-h-[400px]" />
</Match>
<Match when={flavor() === "lightning"}>
<QRCodeSVG value={bip21Raw()?.invoice ?? ""} class="w-full h-full p-8 max-h-[400px]" />
</Match>
<Match when={flavor() === "onchain"}>
<QRCodeSVG value={bip21Raw()?.address ?? ""} class="w-full h-full p-8 max-h-[400px]" />
</Match>
</Switch>
<QRCodeSVG value={receiveString() ?? ""} class="w-full h-full p-8 max-h-[400px]" />
</div>
<div class="flex gap-2 w-full">
<Button onClick={handleCopy}>{copied() ? "Copied" : "Copy"}</Button>
<ShareButton receiveString={unified() ?? ""} />
</div>
<Card>
<SmallHeader>Amount</SmallHeader>
<div class="flex justify-between">
<Amount amountSats={parseInt(amount()) || 0} showFiat={true} />
<button onClick={editAmount}>&#x270F;&#xFE0F;</button>
</div>
<SmallHeader>Tags</SmallHeader>
<div class="flex justify-between">
<div class="flex flex-wrap">
<For each={selectedValues()}>
{(tag) => (
<div class=" bg-white/20 rounded px-1">
{tag.name}
</div>)}
</For>
</div>
{/* <pre>{JSON.stringify(selectedValues(), null, 2)}</pre> */}
<button onClick={editLabel}>&#x270F;&#xFE0F;</button>
</div>
</Card>
<Card title="Bip21">
<code class="break-all">{unified()}</code>
</Card>
<p class="text-neutral-400 text-center">Show or share this code with the sender</p>
<ShareCard text={receiveString() ?? ""} />
</Match>
<Match when={receiveState() === "paid" && paidState() === "lightning_paid"}>
<FullscreenModal

View File

@@ -1,12 +1,11 @@
import { Match, Show, Switch, createEffect, createMemo, createSignal, onMount } from "solid-js";
import { Amount } from "~/components/Amount";
import NavBar from "~/components/NavBar";
import { Button, ButtonLink, DefaultMain, HStack, LargeHeader, MutinyWalletGuard, SafeArea, SmallAmount, SmallHeader, VStack } from "~/components/layout";
import { Button, ButtonLink, Card, DefaultMain, HStack, LargeHeader, MutinyWalletGuard, SafeArea, SmallHeader, VStack } from "~/components/layout";
import { Paste } from "~/assets/svg/Paste";
import { Scan } from "~/assets/svg/Scan";
import { useMegaStore } from "~/state/megaStore";
import { MutinyInvoice } from "@mutinywallet/mutiny-wasm";
import { AmountEditable } from "~/components/AmountEditable";
import { StyledRadioGroup } from "~/components/layout/Radio";
import { ParsedParams, toParsedParams } from "./Scanner";
import { showToast } from "~/components/Toaster";
@@ -19,35 +18,29 @@ import { BackLink } from "~/components/layout/BackLink";
import { useNavigate } from "solid-start";
import { TagEditor } from "~/components/TagEditor";
import { TagItem, createUniqueId, listTags } from "~/state/contacts";
import { StringShower } from "~/components/ShareCard";
import { AmountCard } from "~/components/AmountCard";
type SendSource = "lightning" | "onchain";
const PAYMENT_METHODS = [{ value: "lightning", label: "Lightning", caption: "Fast and cool" }, { value: "onchain", label: "On-chain", caption: "Just like Satoshi did it" }]
const TEST_DEST = "bitcoin:tb1pdh43en28jmhnsrhxkusja46aufdlae5qnfrhucw5jvefw9flce3sdxfcwe?amount=0.00001&label=heyo&lightning=lntbs10u1pjrwrdedq8dpjhjmcnp4qd60w268ve0jencwzhz048ruprkxefhj0va2uspgj4q42azdg89uupp5gngy2pqte5q5uvnwcxwl2t8fsdlla5s6xl8aar4xcsvxeus2w2pqsp5n5jp3pz3vpu92p3uswttxmw79a5lc566herwh3f2amwz2sp6f9tq9qyysgqcqpcxqrpwugv5m534ww5ukcf6sdw2m75f2ntjfh3gzeqay649256yvtecgnhjyugf74zakaf56sdh66ec9fqep2kvu6xv09gcwkv36rrkm38ylqsgpw3yfjl"
const TEST_DEST_ADDRESS = "tb1pdh43en28jmhnsrhxkusja46aufdlae5qnfrhucw5jvefw9flce3sdxfcwe"
// const TEST_DEST = "bitcoin:tb1pdh43en28jmhnsrhxkusja46aufdlae5qnfrhucw5jvefw9flce3sdxfcwe?amount=0.00001&label=heyo&lightning=lntbs10u1pjrwrdedq8dpjhjmcnp4qd60w268ve0jencwzhz048ruprkxefhj0va2uspgj4q42azdg89uupp5gngy2pqte5q5uvnwcxwl2t8fsdlla5s6xl8aar4xcsvxeus2w2pqsp5n5jp3pz3vpu92p3uswttxmw79a5lc566herwh3f2amwz2sp6f9tq9qyysgqcqpcxqrpwugv5m534ww5ukcf6sdw2m75f2ntjfh3gzeqay649256yvtecgnhjyugf74zakaf56sdh66ec9fqep2kvu6xv09gcwkv36rrkm38ylqsgpw3yfjl"
// const TEST_DEST_ADDRESS = "tb1pdh43en28jmhnsrhxkusja46aufdlae5qnfrhucw5jvefw9flce3sdxfcwe"
// TODO: better success / fail type
type SentDetails = { amount?: bigint, destination?: string, txid?: string, failure_reason?: string }
function MethodChooser(props: { source: SendSource, setSource: (source: string) => void }) {
const [store, actions] = useMegaStore();
const [store, _actions] = useMegaStore();
const methods = createMemo(() => {
return [
{ value: "lightning", label: "Lightning", caption: store.balance?.lightning ? `${store.balance?.lightning.toLocaleString()} SATS` : "No balance" },
{ value: "onchain", label: "On-chain", caption: store.balance?.confirmed ? `${store.balance?.confirmed.toLocaleString()} SATS` : "No balance" }
]
const amount = createMemo(() => {
if (props.source === "lightning") {
return store.balance?.lightning
} else {
return store.balance?.confirmed
}
})
return (
<VStack>
<SmallHeader>
Payment Method
</SmallHeader>
<StyledRadioGroup value={props.source} onValueChange={props.setSource} choices={PAYMENT_METHODS} />
<SmallHeader class="flex items-center gap-2">Current balance <SmallAmount amount={amount() || 0n} /></SmallHeader>
</VStack>
<StyledRadioGroup accent="white" value={props.source} onValueChange={props.setSource} choices={methods()} />
)
}
@@ -89,62 +82,18 @@ function DestinationShower(props: {
clearAll: () => void,
}) {
return (
<VStack>
<SmallHeader>Destination</SmallHeader>
<div class="flex gap-2 items-center">
<Show when={props.address && props.source === "onchain"}>
<code class="truncate text-sm break-all">{"Address: "} {props.address}
<Show when={props.description}>
<br />
{"Description:"} {props.description}
</Show>
</code>
</Show>
<Show when={props.invoice && props.source === "lightning"}>
<code class="truncate text-sm break-all">{"Invoice: "} {props.invoice?.bolt11}
<Show when={props.description}>
<br />
{"Description:"} {props.description}
</Show>
</code>
</Show>
<Show when={props.nodePubkey && props.source === "lightning"}>
<code class="truncate text-sm break-all">{"Node Pubkey: "} {props.nodePubkey}
<Switch>
<Match when={props.address && props.source === "onchain"}>
<StringShower text={props.address || ""} />
</Match>
<Match when={props.invoice && props.source === "lightning"}>
<StringShower text={props.invoice?.bolt11 || ""} />
</Match>
<Match when={props.nodePubkey && props.source === "lightning"}>
<StringShower text={props.nodePubkey || ""} />
</Match>
</Switch>
</code>
</Show>
<Button class="flex-0" intent="glowy" layout="xs" onClick={props.clearAll}>Clear</Button>
</div>
</VStack>
)
}
function AmountThing(props: { invoice?: MutinyInvoice, amountSats: bigint, fakeFee: bigint, setAmountSats: (amount: bigint) => void }) {
return (
<VStack>
<SmallHeader>Amount</SmallHeader>
<div class="flex gap-2 items-center">
{/* if the amount came with the invoice we can't allow setting it */}
<Show when={!(props.invoice?.amount_sats)} fallback={<Amount amountSats={props.amountSats} showFiat />}>
<AmountEditable initialAmountSats={props.amountSats.toString()} setAmountSats={props.setAmountSats} />
</Show>
<div class="bg-white/10 px-4 py-2 rounded-xl">
<div class="flex gap-2 items-center">
<h2 class="text-neutral-400 font-semibold uppercase">+ Fee</h2>
<h3 class="text-xl font-light text-neutral-300">
{props.fakeFee.toLocaleString()}&nbsp;<span class='text-lg'>SATS</span>
</h3>
</div>
<div class="flex gap-2 items-center">
<h2 class="font-semibold uppercase text-white">Total</h2>
<h3 class="text-xl font-light text-white">
{(props.amountSats.valueOf() + props.fakeFee.valueOf()).toLocaleString()}&nbsp;<span class='text-lg'>SATS</span>
</h3>
</div>
</div>
</div>
</VStack>
)
}
@@ -160,7 +109,7 @@ function SendTags() {
})
return (
<TagEditor title="Tag the receiver" values={values()} setValues={setValues} selectedValues={selectedValues()} setSelectedValues={setSelectedValues} placeholder="Where's it going to?" />
<TagEditor values={values()} setValues={setValues} selectedValues={selectedValues()} setSelectedValues={setSelectedValues} placeholder="Where's it going to?" />
)
}
@@ -170,9 +119,8 @@ export default function Send() {
const navigate = useNavigate()
// These can only be set by the user
const [fieldDestination, setFieldDestination] = createSignal(TEST_DEST);
const [fieldDestination, setFieldDestination] = createSignal("");
const [destination, setDestination] = createSignal<ParsedParams>();
const [privateLabel, setPrivateLabel] = createSignal("");
// These can be derived from the "destination" signal or set by the user
const [amountSats, setAmountSats] = createSignal(0n);
@@ -190,7 +138,6 @@ export default function Send() {
function clearAll() {
setDestination(undefined);
setPrivateLabel("");
setAmountSats(0n);
setSource("lightning");
setInvoice(undefined);
@@ -281,7 +228,7 @@ export default function Send() {
try {
setSending(true);
const bolt11 = invoice()?.bolt11;
let sentDetails: Partial<SentDetails> = {};
const sentDetails: Partial<SentDetails> = {};
if (source() === "lightning" && invoice() && bolt11) {
const nodes = await state.mutiny_wallet?.list_nodes();
const firstNode = nodes[0] as string || ""
@@ -367,12 +314,16 @@ export default function Send() {
<VStack biggap>
<Switch>
<Match when={address() || invoice() || nodePubkey()}>
<SendTags />
<DestinationShower source={source()} description={description()} invoice={invoice()} address={address()} nodePubkey={nodePubkey()} clearAll={clearAll} />
<Show when={address() && invoice()}>
<MethodChooser source={source()} setSource={setSource} />
</Show>
<AmountThing amountSats={amountSats()} invoice={invoice()} fakeFee={fakeFee()} setAmountSats={setAmountSats} />
<Card>
<VStack>
<DestinationShower source={source()} description={description()} invoice={invoice()} address={address()} nodePubkey={nodePubkey()} clearAll={clearAll} />
<SendTags />
</VStack>
</Card>
<AmountCard amountSats={amountSats().toString()} setAmountSats={setAmountSats} fee={fakeFee().toString()} isAmountEditable={!(invoice()?.amount_sats)} />
</Match>
<Match when={true}>
<DestinationInput fieldDestination={fieldDestination()} setFieldDestination={setFieldDestination} handleDecode={handleDecode} handlePaste={handlePaste} />

23
src/routes/Storybook.tsx Normal file
View File

@@ -0,0 +1,23 @@
import { AmountCard } from "~/components/AmountCard";
import NavBar from "~/components/NavBar";
import { ShareCard } from "~/components/ShareCard";
import { DefaultMain, LargeHeader, SafeArea, VStack } from "~/components/layout";
const SAMPLE = "bitcoin:tb1prqm8xtlgme0vmw5s30lgf0a4f5g4mkgsqundwmpu6thrg8zr6uvq2qrhzq?amount=0.001&lightning=lntbs1m1pj9n9xjsp5xgdrmvprtm67p7nq4neparalexlhlmtxx87zx6xeqthsplu842zspp546d6zd2seyaxpapaxx62m88yz3xueqtjmn9v6wj8y56np8weqsxqdqqnp4qdn2hj8tfknpuvdg6tz9yrf3e27ltrx9y58c24jh89lnm43yjwfc5xqrpwjcqpj9qrsgq5sdgh0m3ur5mu5hrmmag4mx9yvy86f83pd0x9ww80kgck6tac3thuzkj0mrtltaxwnlfea95h2re7tj4qsnwzxlvrdmyq2h9mgapnycpppz6k6"
export default function Admin() {
return (
<SafeArea>
<DefaultMain>
<LargeHeader>Storybook</LargeHeader>
<VStack>
<AmountCard amountSats={"100000"} fee={"69"} />
<AmountCard amountSats={"100000"} />
<AmountCard amountSats={"100000"} isAmountEditable />
<AmountCard amountSats={"0"} isAmountEditable />
<ShareCard text={SAMPLE} />
</VStack>
</DefaultMain>
<NavBar activeTab="none" />
</SafeArea>
)
}