better receive

This commit is contained in:
Paul Miller
2023-04-11 18:13:12 -05:00
parent 378d1e5710
commit d0dc6c4157
6 changed files with 253 additions and 50 deletions

View File

@@ -0,0 +1,82 @@
import { TextField } from "@kobalte/core";
import { Match, Suspense, Switch, createEffect, createMemo, createResource, createSignal } from "solid-js";
import { useMegaStore } from "~/state/megaStore";
import { satsToUsd, usdToSats } from "~/utils/conversions";
export type AmountInputProps = {
initialAmountSats: string;
setAmountSats: (amount: string) => void;
refSetter: (el: HTMLInputElement) => void;
}
type ActiveCurrency = "usd" | "sats"
export function AmountInput(props: AmountInputProps) {
// We need to keep a local amount state because we need to convert between sats and USD
// But we should keep the parent state in sats
const [localAmount, setLocalAmount] = createSignal(props.initialAmountSats || "0");
const [state, _] = useMegaStore()
async function getPrice() {
return await state.node_manager?.get_bitcoin_price()
}
const [activeCurrency, setActiveCurrency] = createSignal<ActiveCurrency>("sats")
const [price] = createResource(getPrice)
const amountInUsd = createMemo(() => satsToUsd(price(), parseInt(localAmount()) || 0, true))
const amountInSats = createMemo(() => usdToSats(price(), parseFloat(localAmount() || "0.00") || 0, true))
createEffect(() => {
// When the local amount changes, update the parent state if we're in sats
if (activeCurrency() === "sats") {
props.setAmountSats(localAmount())
} else {
// If we're in USD, convert the amount to sats
props.setAmountSats(usdToSats(price(), parseFloat(localAmount() || "0.00") || 0, false))
}
})
function toggleActiveCurrency() {
if (activeCurrency() === "sats") {
setActiveCurrency("usd")
// Convert the current amount of sats to USD
const usd = satsToUsd(price() || 0, parseInt(localAmount()) || 0, false)
console.log(`converted ${localAmount()} sats to ${usd} USD`)
setLocalAmount(usd);
} else {
setActiveCurrency("sats")
// Convert the current amount of USD to sats
const sats = usdToSats(price() || 0, parseInt(localAmount()) || 0, false)
console.log(`converted ${localAmount()} usd to ${sats} sats`)
setLocalAmount(sats)
}
}
return (
<div class="">
<TextField.Root
value={localAmount()}
onValueChange={setLocalAmount}
class="flex flex-col gap-2"
>
<pre>{`Bitcoin is ${price()?.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 })}`}</pre>
<TextField.Label class="text-sm font-semibold uppercase" >Amount {activeCurrency() === "sats" ? "(sats)" : "(USD)"}</TextField.Label>
<TextField.Input autofocus ref={(el) => props.refSetter(el)} inputmode={"decimal"} class="w-full p-2 rounded-lg text-black" />
<Suspense>
<Switch fallback={<div>Loading...</div>}>
<Match when={price() && activeCurrency() === "sats"}>
<pre>{`~${amountInUsd()}`}</pre>
</Match>
<Match when={price() && activeCurrency() === "usd"}>
<pre>{`${amountInSats()} sats`}</pre>
</Match>
</Switch>
</Suspense>
</TextField.Root>
<button type="button" onClick={toggleActiveCurrency}>&#x1F500;</button>
</div>
)
}

View File

View File

@@ -7,7 +7,7 @@ const button = cva(["p-4", "rounded-xl", "text-xl", "font-semibold"], {
variants: { variants: {
intent: { intent: {
active: "bg-white text-black", active: "bg-white text-black",
inactive: "bg-black text-white border border-white", inactive: "bg-black text-white border border-white disabled:opacity-50",
blue: "bg-[#3B6CCC] text-white", blue: "bg-[#3B6CCC] text-white",
red: "bg-[#F61D5B] text-white", red: "bg-[#F61D5B] text-white",
green: "bg-[#1EA67F] text-white", green: "bg-[#1EA67F] text-white",

View File

@@ -1,4 +1,4 @@
import { ParentComponent } from "solid-js" import { ParentComponent, Show } from "solid-js"
import Linkify from "./Linkify" import Linkify from "./Linkify"
import { Button, ButtonLink } from "./Button" import { Button, ButtonLink } from "./Button"
import { Separator } from "@kobalte/core" import { Separator } from "@kobalte/core"
@@ -15,11 +15,15 @@ const Card: ParentComponent<{ title?: string }> = (props) => {
) )
} }
const SafeArea: ParentComponent = (props) => { const SafeArea: ParentComponent<{ main?: boolean }> = (props) => {
return ( return (
<div class="safe-top safe-left safe-right safe-bottom"> <div class="safe-top safe-left safe-right safe-bottom">
<div class="disable-scrollbars max-h-screen h-full overflow-y-scroll md:pl-[8rem] md:pr-[6rem]"> <div class="disable-scrollbars max-h-screen h-full overflow-y-scroll md:pl-[8rem] md:pr-[6rem]">
<Show when={props.main} fallback={props.children}>
<main class='flex flex-col py-8 px-4 items-center'>
{props.children} {props.children}
</main>
</Show>
</div> </div>
</div > </div >
) )

View File

@@ -1,40 +1,24 @@
import { createResource, Show } from "solid-js"; import { TextField } from "@kobalte/core";
import { createMemo, createResource, createSignal, Match, Show, Suspense, Switch } from "solid-js";
import { QRCodeSVG } from "solid-qr-code"; import { QRCodeSVG } from "solid-qr-code";
import { Button, SafeArea } from "~/components/layout"; import { AmountInput } from "~/components/AmountInput";
import { Button, Card, SafeArea, SmallHeader } from "~/components/layout";
import NavBar from "~/components/NavBar"; import NavBar from "~/components/NavBar";
import { useMegaStore } from "~/state/megaStore"; import { useMegaStore } from "~/state/megaStore";
import { satsToUsd } from "~/utils/conversions";
import { objectToSearchParams } from "~/utils/objectToSearchParams";
import { useCopy } from "~/utils/useCopy"; import { useCopy } from "~/utils/useCopy";
export default function Receive() { function ShareButton(props: { receiveString: string }) {
const [state, _] = useMegaStore() async function share(receiveString: string) {
// TODO: would be nice if this was just newest unused address
const getNewAddress = async () => {
if (state.node_manager) {
console.log("Getting new address");
const address = await state.node_manager?.get_new_address();
return address
} else {
return undefined
}
};
const [address, { refetch: refetchAddress }] = createResource(getNewAddress);
const [copy, copied] = useCopy({ copiedTimeout: 1000 });
async function share() {
// If the browser doesn't support share we can just copy the address // If the browser doesn't support share we can just copy the address
if (!navigator.share) { if (!navigator.share) {
copy(address() ?? ""); console.error("Share not supported")
copied();
} }
const shareData: ShareData = { const shareData: ShareData = {
title: "Mutiny Wallet", title: "Mutiny Wallet",
text: address(), text: receiveString,
} }
try { try {
await navigator.share(shareData) await navigator.share(shareData)
} catch (e) { } catch (e) {
@@ -43,27 +27,121 @@ export default function Receive() {
} }
return ( return (
<SafeArea> <Button onClick={(_) => share(props.receiveString)}>Share</Button>
<main class='flex flex-col py-8 px-4 items-center'> )
<div class="max-w-[400px] flex flex-col gap-4"> }
<Show when={address()}>
type ReceiveState = "edit" | "show"
export default function Receive() {
const [state, _] = useMegaStore()
const [amount, setAmount] = createSignal("")
const [label, setLabel] = createSignal("")
const [receiveState, setReceiveState] = createSignal<ReceiveState>("edit")
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 [unified, setUnified] = createSignal("")
const [copy, copied] = useCopy({ copiedTimeout: 1000 });
async function getUnifiedQr(amount: string, label: string) {
const bigAmount = BigInt(amount);
const bip21Raw = await state.node_manager?.create_bip21(bigAmount, label);
const params = objectToSearchParams({
amount: bip21Raw?.btc_amount,
label: bip21Raw?.description,
lightning: bip21Raw?.invoice
})
return `bitcoin:${bip21Raw?.address}?${params}`
}
async function onSubmit(e: Event) {
e.preventDefault();
const unifiedQr = await getUnifiedQr(amount(), label())
setUnified(unifiedQr)
setReceiveState("show")
}
async function getPrice() {
return await state.node_manager?.get_bitcoin_price()
}
const [price] = createResource(getPrice)
const amountInUsd = createMemo(() => satsToUsd(price(), parseInt(amount()) || 0, true))
return (
<SafeArea main>
<div class="w-full max-w-[400px] flex flex-col gap-4">
<Suspense fallback={"..."}>
{/* If I don't have this guard then the node manager only half-works */}
<Show when={state.node_manager}>
<Switch>
<Match when={!unified() || receiveState() === "edit"}>
<form class="border border-white/20 rounded-xl p-2 flex flex-col gap-4" onSubmit={onSubmit} >
{/* TODO this initial amount is not reactive, hope that's okay? */}
<AmountInput initialAmountSats={amount()} setAmountSats={setAmount} refSetter={el => amountInput = el} />
<TextField.Root
value={label()}
onValueChange={setLabel}
class="flex flex-col gap-2"
>
<TextField.Label class="text-sm font-semibold uppercase" >Label (private)</TextField.Label>
<TextField.Input
autofocus
ref={el => labelInput = el}
class="w-full p-2 rounded-lg text-black" />
</TextField.Root>
<Button disabled={!amount() || !label()} layout="small" type="submit">Create Invoice</Button>
</form >
</Match>
<Match when={unified() && receiveState() === "show"}>
<div class="w-full bg-white rounded-xl"> <div class="w-full bg-white rounded-xl">
<QRCodeSVG value={address() ?? ""} class="w-full h-full p-8 max-h-[400px]" /> <QRCodeSVG value={unified() ?? ""} class="w-full h-full p-8 max-h-[400px]" />
</div> </div>
<div class="flex gap-2 w-full"> <div class="flex gap-2 w-full">
<Button onClick={() => copy(address() ?? "")}>{copied() ? "Copied" : "Copy"}</Button> <Button onClick={(_) => copy(unified() ?? "")}>{copied() ? "Copied" : "Copy"}</Button>
<Button onClick={share}>Share</Button> <ShareButton receiveString={unified() ?? ""} />
</div> </div>
<div class="rounded-xl p-4 flex flex-col gap-2 bg-[rgba(0,0,0,0.5)]"> <Card>
<header class='text-sm font-semibold uppercase'> <SmallHeader>Amount</SmallHeader>
Address / Invoice <div class="flex justify-between">
</header> <p>{amount()} sats</p><button onClick={editAmount}>&#x270F;&#xFE0F;</button>
<code class="break-all">{address()}</code>
<Button onClick={refetchAddress}>Get new address</Button>
</div> </div>
<pre>({amountInUsd()})</pre>
<SmallHeader>Private Label</SmallHeader>
<div class="flex justify-between">
<p>{label()} </p><button onClick={editLabel}>&#x270F;&#xFE0F;</button>
</div>
</Card>
<Card title="Bip21">
<code class="break-all">{unified()}</code>
</Card>
</Match>
</Switch>
</Show> </Show>
</Suspense>
</div> </div>
</main>
<NavBar activeTab="none" /> <NavBar activeTab="none" />
</SafeArea > </SafeArea >

39
src/utils/conversions.ts Normal file
View File

@@ -0,0 +1,39 @@
import { NodeManager } from "@mutinywallet/node-manager";
export function satsToUsd(amount: number | undefined, price: number, formatted: boolean): string {
if (typeof amount !== "number" || isNaN(amount)) {
return ""
}
try {
const btc = NodeManager.convert_sats_to_btc(BigInt(Math.floor(amount)));
const usd = btc * price;
if (formatted) {
return usd.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
} else {
return usd.toFixed(2);
}
} catch (e) {
console.error(e);
return ""
}
}
export function usdToSats(amount: number | undefined, price: number, formatted: boolean): string {
if (typeof amount !== "number" || isNaN(amount)) {
return ""
}
try {
const btc = price / amount;
const sats = NodeManager.convert_btc_to_sats(btc);
if (formatted) {
return parseInt(sats.toString()).toLocaleString();
} else {
return sats.toString();
}
} catch (e) {
console.error(e);
return ""
}
}