diff --git a/package.json b/package.json index ac6e64e..0c14493 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ac2cbed..47a36ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/components/AmountInput.tsx b/src/components/AmountInput.tsx new file mode 100644 index 0000000..b7e5ce6 --- /dev/null +++ b/src/components/AmountInput.tsx @@ -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("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 ( +
+ +
{`Bitcoin is ${price()?.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 })}`}
+ Amount {activeCurrency() === "sats" ? "(sats)" : "(USD)"} + props.refSetter(el)} inputmode={"decimal"} class="w-full p-2 rounded-lg text-black" /> + + Loading...
}> + +
{`~${amountInUsd()}`}
+
+ +
{`${amountInSats()} sats`}
+
+ + + + + + ) +} \ No newline at end of file diff --git a/src/components/KitchenSink.tsx b/src/components/KitchenSink.tsx index e9ab0cc..bf43885 100644 --- a/src/components/KitchenSink.tsx +++ b/src/components/KitchenSink.tsx @@ -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 | 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 | 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(); @@ -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 ( - Tap the Faucet

diff --git a/src/components/Reader.tsx b/src/components/Reader.tsx index 8a88028..6e2b859 100644 --- a/src/components/Reader.tsx +++ b/src/components/Reader.tsx @@ -32,7 +32,7 @@ export default function Scanner(props: { onResult: (result: string) => void }) { return ( <>
- +
); diff --git a/src/components/ReceiveQrShower.tsx b/src/components/ReceiveQrShower.tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/components/Reload.tsx b/src/components/Reload.tsx index 73c4a6b..18cf0dd 100644 --- a/src/components/Reload.tsx +++ b/src/components/Reload.tsx @@ -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' diff --git a/src/components/layout/Button.tsx b/src/components/layout/Button.tsx index fb1a8d9..692c3fe 100644 --- a/src/components/layout/Button.tsx +++ b/src/components/layout/Button.tsx @@ -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", diff --git a/src/components/layout/Linkify.tsx b/src/components/layout/Linkify.tsx index 86d49d7..8ee60c9 100644 --- a/src/components/layout/Linkify.tsx +++ b/src/components/layout/Linkify.tsx @@ -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({link}); } - const remainingText = props.text.slice(lastIndex); + const remainingText = text.slice(lastIndex); if (remainingText) { links.push(remainingText); } diff --git a/src/components/layout/index.tsx b/src/components/layout/index.tsx index b6644d4..c74c0dc 100644 --- a/src/components/layout/index.tsx +++ b/src/components/layout/index.tsx @@ -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 (
- {props.children} + +
+ {props.children} +
+
) diff --git a/src/components/waitlist/Notes.tsx b/src/components/waitlist/Notes.tsx index ead8f95..0da404a 100644 --- a/src/components/waitlist/Notes.tsx +++ b/src/components/waitlist/Notes.tsx @@ -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 ( diff --git a/src/routes/Receive.tsx b/src/routes/Receive.tsx index f1bc189..c5e7382 100644 --- a/src/routes/Receive.tsx +++ b/src/routes/Receive.tsx @@ -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 ( - -
-
- -
- -
-
- - -
-
-
- Address / Invoice -
- {address()} - -
+ + ) +} + +type ReceiveState = "edit" | "show" + +export default function Receive() { + const [state, _] = useMegaStore() + + const [amount, setAmount] = createSignal("") + const [label, setLabel] = createSignal("") + + const [receiveState, setReceiveState] = createSignal("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 ( + +
+ + {/* If I don't have this guard then the node manager only half-works */} + + + +
+ {/* TODO this initial amount is not reactive, hope that's okay? */} + amountInput = el} /> + + Label (private) + labelInput = el} + class="w-full p-2 rounded-lg text-black" /> + + + +
+ +
+ +
+
+ + +
+ + Amount +
+

{amount()} sats

+
+
({amountInUsd()})
+ Private Label +
+

{label()}

+
+
+ + {unified()} + +
+
-
-
+ +
diff --git a/src/utils/conversions.ts b/src/utils/conversions.ts new file mode 100644 index 0000000..ed6fbdd --- /dev/null +++ b/src/utils/conversions.ts @@ -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 "" + } +} diff --git a/src/utils/objectToSearchParams.ts b/src/utils/objectToSearchParams.ts new file mode 100644 index 0000000..03e7cc6 --- /dev/null +++ b/src/utils/objectToSearchParams.ts @@ -0,0 +1,7 @@ +export function objectToSearchParams>(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("&"); +} \ No newline at end of file