Merge pull request #18 from MutinyWallet/better-receive

Better receive
This commit is contained in:
Paul Miller
2023-04-11 20:35:00 -05:00
committed by GitHub
14 changed files with 306 additions and 96 deletions

View File

@@ -30,12 +30,12 @@
"dependencies": {
"@kobalte/core": "^0.8.2",
"@motionone/solid": "^10.16.0",
"@mutinywallet/node-manager": "^0.2.3",
"@mutinywallet/node-manager": "^0.2.4",
"@nostr-dev-kit/ndk": "^0.0.13",
"@solidjs/meta": "^0.28.4",
"@solidjs/router": "^0.8.2",
"class-variance-authority": "^0.4.0",
"nostr-tools": "^1.8.3",
"nostr-tools": "^1.8.4",
"qr-scanner": "^1.4.2",
"solid-js": "^1.7.3",
"solid-qr-code": "^0.0.8",

30
pnpm-lock.yaml generated
View File

@@ -8,8 +8,8 @@ dependencies:
specifier: ^10.16.0
version: 10.16.0(solid-js@1.7.3)
'@mutinywallet/node-manager':
specifier: ^0.2.3
version: 0.2.3
specifier: ^0.2.4
version: 0.2.4
'@nostr-dev-kit/ndk':
specifier: ^0.0.13
version: 0.0.13(eslint-import-resolver-typescript@2.7.1)(typescript@4.9.5)
@@ -23,8 +23,8 @@ dependencies:
specifier: ^0.4.0
version: 0.4.0(typescript@4.9.5)
nostr-tools:
specifier: ^1.8.3
version: 1.8.3
specifier: ^1.8.4
version: 1.8.4
qr-scanner:
specifier: ^1.4.2
version: 1.4.2
@@ -1610,8 +1610,8 @@ packages:
tslib: 2.5.0
dev: false
/@mutinywallet/node-manager@0.2.3:
resolution: {integrity: sha512-xPtVGGbcXJpkbn0rShuLDd2iswdsZaicJac/Umti/VaLxLG19RKOKFvsLCkbUciWVl9kDzgyrFh2p6LLHW0Acg==}
/@mutinywallet/node-manager@0.2.4:
resolution: {integrity: sha512-Zl8Xw5WzlFiKr7mCNT/gqUp4GUPk3ZdS2l/3/zS8V9jwJAly9C0WjMoOmrg0ahO2vZ035DHzH4uQRbkWjUCFzQ==}
dev: false
/@noble/hashes@1.2.0:
@@ -1653,7 +1653,7 @@ packages:
eventemitter3: 5.0.0
light-bolt11-decoder: 3.0.0
node-fetch: 3.3.1
nostr-tools: 1.8.3
nostr-tools: 1.8.4
utf8-buffer: 1.0.0
websocket-polyfill: 0.0.3
transitivePeerDependencies:
@@ -1683,8 +1683,8 @@ packages:
rollup: 2.79.1
dev: true
/@rollup/plugin-commonjs@24.0.1(rollup@3.20.2):
resolution: {integrity: sha512-15LsiWRZk4eOGqvrJyu3z3DaBu5BhXIMeWnijSRvd8irrrg9SHpQ1pH+BUK4H6Z9wL9yOxZJMTLU+Au86XHxow==}
/@rollup/plugin-commonjs@24.1.0(rollup@3.20.2):
resolution: {integrity: sha512-eSL45hjhCWI0jCCXcNtLVqM5N1JlBGvlFfY0m6oOYnLCJ6N0qEXoZql4sY2MOUArzhH4SA/qBpTxvvZp2Sc+DQ==}
engines: {node: '>=14.0.0'}
peerDependencies:
rollup: ^2.68.0||^3.0.0
@@ -2428,7 +2428,7 @@ packages:
hasBin: true
dependencies:
caniuse-lite: 1.0.30001477
electron-to-chromium: 1.4.357
electron-to-chromium: 1.4.359
node-releases: 2.0.10
update-browserslist-db: 1.0.10(browserslist@4.21.5)
@@ -2735,8 +2735,8 @@ packages:
jake: 10.8.5
dev: true
/electron-to-chromium@1.4.357:
resolution: {integrity: sha512-UTkCbNTAcGXABmEnQrGcW4m3cG6fcyBfD4KDF0iyEAlbrGZiY9dmslyDAGOD1Kr5biN2F743Y30aRCOtau35Vw==}
/electron-to-chromium@1.4.359:
resolution: {integrity: sha512-OoVcngKCIuNXtZnsYoqlCvr0Cf3NIPzDIgwUfI9bdTFjXCrr79lI0kwQstLPZ7WhCezLlGksZk/BFAzoXC7GDw==}
/emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
@@ -4167,8 +4167,8 @@ packages:
engines: {node: '>=0.10.0'}
dev: true
/nostr-tools@1.8.3:
resolution: {integrity: sha512-0giVDk0ElhqlGY032ma/8Q8NsIyFL53fCCkndFCpuLabZ2E134Kth0sbnIIIFXLqm7VnYIlgLVtCna8+dUiZUg==}
/nostr-tools@1.8.4:
resolution: {integrity: sha512-oaRgZ8jpLmkMgtvhH9jbUI0k6XeXAUXSDv7qYBNwnIonfWYYZ0C19snJv1YRS+GWGf2gJ8ePJkXMJWlcsNR2yA==}
dependencies:
'@noble/hashes': 1.2.0
'@noble/secp256k1': 1.7.0
@@ -4703,7 +4703,7 @@ packages:
undici: ^5.8.0
vite: '*'
dependencies:
'@rollup/plugin-commonjs': 24.0.1(rollup@3.20.2)
'@rollup/plugin-commonjs': 24.1.0(rollup@3.20.2)
'@rollup/plugin-json': 6.0.0(rollup@3.20.2)
'@rollup/plugin-node-resolve': 15.0.2(rollup@3.20.2)
compression: 1.7.4

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

@@ -1,5 +1,5 @@
import { useMegaStore } from "~/state/megaStore";
import { ButtonLink, Card, Hr, SmallHeader, Button } from "~/components/layout";
import { Card, Hr, SmallHeader, Button } from "~/components/layout";
import PeerConnectModal from "~/components/PeerConnectModal";
import { For, Show, Suspense, createResource, createSignal } from "solid-js";
import { MutinyChannel, MutinyPeer } from "@mutinywallet/node-manager";
@@ -7,6 +7,9 @@ import { TextField } from "@kobalte/core";
import mempoolTxUrl from "~/utils/mempoolTxUrl";
import eify from "~/utils/eify";
// TODO: hopefully I don't have to maintain this type forever but I don't know how to pass it around otherwise
type RefetchPeersType = (info?: unknown) => MutinyPeer[] | Promise<MutinyPeer[] | undefined> | null | undefined
function PeersList() {
const [state, _] = useMegaStore()
@@ -37,7 +40,7 @@ function PeersList() {
)
}
function ConnectPeer(props: { refetchPeers: () => any }) {
function ConnectPeer(props: { refetchPeers: RefetchPeersType }) {
const [state, _] = useMegaStore()
const [value, setValue] = createSignal("");
@@ -73,6 +76,9 @@ function ConnectPeer(props: { refetchPeers: () => any }) {
)
}
type RefetchChannelsListType = (info?: unknown) => MutinyChannel[] | Promise<MutinyChannel[] | undefined> | null | undefined
function ChannelsList() {
const [state, _] = useMegaStore()
@@ -111,7 +117,7 @@ function ChannelsList() {
)
}
function OpenChannel(props: { refetchChannels: () => any }) {
function OpenChannel(props: { refetchChannels: RefetchChannelsListType }) {
const [state, _] = useMegaStore()
const [creationError, setCreationError] = createSignal<Error>();
@@ -188,19 +194,9 @@ function OpenChannel(props: { refetchChannels: () => any }) {
}
export default function KitchenSink() {
const [state, _] = useMegaStore()
// TODO: would be nice if this was just newest unused address
const getNewAddress = async () => {
return await state.node_manager?.get_new_address();
};
const [address] = createResource(getNewAddress);
return (
<Card title="Kitchen Sink">
<PeerConnectModal />
<ButtonLink target="_blank" rel="noopener noreferrer" href={`https://faucet.mutinynet.com/?address=${address()}`}>Tap the Faucet</ButtonLink>
<Hr />
<PeersList />
<Hr />

View File

@@ -32,7 +32,7 @@ export default function Scanner(props: { onResult: (result: string) => void }) {
return (
<>
<div id="video-container">
<video ref={el => container = el} class="w-full h-full fixed object-cover bg-gray"></video>
<video ref={el => container = el} class="w-full h-full fixed object-cover bg-gray" />
</div>
</>
);

View File

View File

@@ -1,5 +1,6 @@
import type { Component } from 'solid-js'
import { Show } from 'solid-js'
// eslint-disable-next-line import/no-unresolved
import { useRegisterSW } from 'virtual:pwa-register/solid'
import { Button, Card } from '~/components/layout'

View File

@@ -7,7 +7,7 @@ const button = cva(["p-4", "rounded-xl", "text-xl", "font-semibold"], {
variants: {
intent: {
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",
red: "bg-[#F61D5B] text-white",
green: "bg-[#1EA67F] text-white",

View File

@@ -1,20 +1,22 @@
import { JSX } from 'solid-js';
interface LinkifyProps {
text: string;
initialText: string;
}
export default function Linkify(props: LinkifyProps): JSX.Element {
// By naming this "initialText" we can prove to eslint that the props won't change
const text = props.initialText;
const links: (string | JSX.Element)[] = [];
const pattern = /((https?:\/\/|www\.)\S+)/gi;
let lastIndex = 0;
let match;
while ((match = pattern.exec(props.text)) !== null) {
while ((match = pattern.exec(text)) !== null) {
const link = match[1];
const href = link.startsWith('http') ? link : `https://${link}`;
const beforeLink = props.text.slice(lastIndex, match.index);
const beforeLink = text.slice(lastIndex, match.index);
lastIndex = pattern.lastIndex;
if (beforeLink) {
@@ -24,7 +26,7 @@ export default function Linkify(props: LinkifyProps): JSX.Element {
links.push(<a href={href} target="_blank" rel="noopener noreferrer">{link}</a>);
}
const remainingText = props.text.slice(lastIndex);
const remainingText = text.slice(lastIndex);
if (remainingText) {
links.push(remainingText);
}

View File

@@ -1,4 +1,4 @@
import { ParentComponent } from "solid-js"
import { ParentComponent, Show } from "solid-js"
import Linkify from "./Linkify"
import { Button, ButtonLink } from "./Button"
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 (
<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]">
{props.children}
<Show when={props.main} fallback={props.children}>
<main class='flex flex-col py-8 px-4 items-center'>
{props.children}
</main>
</Show>
</div>
</div >
)

View File

@@ -1,4 +1,4 @@
import { Component, For } from "solid-js";
import { Component, For, createEffect, createSignal } from "solid-js";
import { Event, nip19 } from "nostr-tools"
import { Linkify } from "~/components/layout";
@@ -8,26 +8,27 @@ type NostrEvent = {
}
const Note: Component<{ e: NostrEvent }> = (props) => {
const e = props.e;
const date = new Date(e.created_at * 1000);
const linkRoot = "https://snort.social/e/";
let noteId;
const [noteId, setNoteId] = createSignal("");
createEffect(() => {
if (props.e.id) {
setNoteId(nip19.noteEncode(props.e.id))
}
})
if (e.id) {
noteId = nip19.noteEncode(e.id)
}
return (
<div class="flex gap-4 border-b border-faint-white py-6 items-start w-full">
<img class="bg-black rounded-xl flex-0" src="../180.png" width={45} height={45} />
<div class="flex flex-col gap-2 flex-1">
<p class="break-words">
<Linkify text={e.content} />
{/* {props.e.content} */}
<Linkify initialText={props.e.content} />
</p>
<a class="no-underline hover:underline hover:decoration-light-text" href={`${linkRoot}${noteId}`}>
<small class="text-light-text">{date.toLocaleString()}</small>
<a class="no-underline hover:underline hover:decoration-light-text" href={`${linkRoot}${noteId()}`}>
<small class="text-light-text">{(new Date(props.e.created_at * 1000)).toLocaleString()}</small>
</a>
</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 { Button, SafeArea } from "~/components/layout";
import { AmountInput } from "~/components/AmountInput";
import { Button, Card, SafeArea, SmallHeader } from "~/components/layout";
import NavBar from "~/components/NavBar";
import { useMegaStore } from "~/state/megaStore";
import { satsToUsd } from "~/utils/conversions";
import { objectToSearchParams } from "~/utils/objectToSearchParams";
import { useCopy } from "~/utils/useCopy";
export default function Receive() {
const [state, _] = useMegaStore()
// 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() {
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) {
copy(address() ?? "");
copied();
console.error("Share not supported")
}
const shareData: ShareData = {
title: "Mutiny Wallet",
text: address(),
text: receiveString,
}
try {
await navigator.share(shareData)
} catch (e) {
@@ -43,27 +27,121 @@ export default function Receive() {
}
return (
<SafeArea>
<main class='flex flex-col py-8 px-4 items-center'>
<div class="max-w-[400px] flex flex-col gap-4">
<Show when={address()}>
<div class="w-full bg-white rounded-xl">
<QRCodeSVG value={address() ?? ""} class="w-full h-full p-8 max-h-[400px]" />
</div>
<div class="flex gap-2 w-full">
<Button onClick={() => copy(address() ?? "")}>{copied() ? "Copied" : "Copy"}</Button>
<Button onClick={share}>Share</Button>
</div>
<div class="rounded-xl p-4 flex flex-col gap-2 bg-[rgba(0,0,0,0.5)]">
<header class='text-sm font-semibold uppercase'>
Address / Invoice
</header>
<code class="break-all">{address()}</code>
<Button onClick={refetchAddress}>Get new address</Button>
</div>
<Button onClick={(_) => share(props.receiveString)}>Share</Button>
)
}
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">
<QRCodeSVG value={unified() ?? ""} class="w-full h-full p-8 max-h-[400px]" />
</div>
<div class="flex gap-2 w-full">
<Button onClick={(_) => copy(unified() ?? "")}>{copied() ? "Copied" : "Copy"}</Button>
<ShareButton receiveString={unified() ?? ""} />
</div>
<Card>
<SmallHeader>Amount</SmallHeader>
<div class="flex justify-between">
<p>{amount()} sats</p><button onClick={editAmount}>&#x270F;&#xFE0F;</button>
</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>
</div>
</main>
</Suspense>
</div>
<NavBar activeTab="none" />
</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 ""
}
}

View File

@@ -0,0 +1,7 @@
export function objectToSearchParams<T extends Record<string, string | undefined>>(obj: T): string {
return Object.entries(obj)
.filter(([_, value]) => value !== undefined)
// Value shouldn't be null we just filtered it out but typescript is dumb
.map(([key, value]) => value ? `${encodeURIComponent(key)}=${encodeURIComponent(value)}` : "")
.join("&");
}