mirror of
https://github.com/aljazceru/mutiny-web.git
synced 2026-02-23 07:04:19 +01:00
@@ -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
30
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
82
src/components/AmountInput.tsx
Normal file
82
src/components/AmountInput.tsx
Normal 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}>🔀</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
0
src/components/ReceiveQrShower.tsx
Normal file
0
src/components/ReceiveQrShower.tsx
Normal 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'
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 >
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>✏️</button>
|
||||
</div>
|
||||
<pre>({amountInUsd()})</pre>
|
||||
<SmallHeader>Private Label</SmallHeader>
|
||||
<div class="flex justify-between">
|
||||
<p>{label()} </p><button onClick={editLabel}>✏️</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
39
src/utils/conversions.ts
Normal 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 ""
|
||||
}
|
||||
}
|
||||
7
src/utils/objectToSearchParams.ts
Normal file
7
src/utils/objectToSearchParams.ts
Normal 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("&");
|
||||
}
|
||||
Reference in New Issue
Block a user