mirror of
https://github.com/aljazceru/mutiny-web.git
synced 2025-12-18 14:54:26 +01:00
swap to ln
This commit is contained in:
3
src/assets/icons/info.svg
Normal file
3
src/assets/icons/info.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11 17h2v-6h-2v6Zm1-8c.2833 0 .521-.096.713-.288.192-.192.2877-.42933.287-.712 0-.28333-.096-.521-.288-.713-.192-.192-.4293-.28767-.712-.287-.2833 0-.521.096-.713.288-.192.192-.2877.42933-.287.712 0 .28333.096.521.288.713.192.192.4293.28767.712.287Zm0 13c-1.3833 0-2.68333-.2627-3.9-.788-1.21667-.5253-2.275-1.2377-3.175-2.137-.9-.9-1.61233-1.9583-2.137-3.175S2.00067 13.3833 2 12c0-1.3833.26267-2.68333.788-3.9.52533-1.21667 1.23767-2.275 2.137-3.175.9-.9 1.95833-1.61233 3.175-2.137S10.6167 2.00067 12 2c1.3833 0 2.6833.26267 3.9.788 1.2167.52533 2.275 1.23767 3.175 2.137.9.9 1.6127 1.95833 2.138 3.175.5253 1.21667.7877 2.5167.787 3.9 0 1.3833-.2627 2.6833-.788 3.9-.5253 1.2167-1.2377 2.275-2.137 3.175-.9.9-1.9583 1.6127-3.175 2.138-1.2167.5253-2.5167.7877-3.9.787Zm0-2c2.2333 0 4.125-.775 5.675-2.325C19.225 16.125 20 14.2333 20 12c0-2.23333-.775-4.125-2.325-5.675C16.125 4.775 14.2333 4 12 4c-2.23333 0-4.125.775-5.675 2.325C4.775 7.875 4 9.76667 4 12c0 2.2333.775 4.125 2.325 5.675C7.875 19.225 9.76667 20 12 20Z" fill="#fff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
3
src/assets/icons/shuffle.svg
Normal file
3
src/assets/icons/shuffle.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4 8.99985h3.5c.736 0 1.393.391 1.851 1.00095.32529-.60198.7255-1.16042 1.191-1.66195-.803-.823-1.866-1.339-3.042-1.339H4c-.26522 0-.51957.10536-.70711.29289C3.10536 7.48028 3 7.73463 3 7.99985s.10536.51957.29289.70711c.18754.18753.44189.29289.70711.29289Zm7.685 3.11095c.551-1.657 2.256-3.11095 3.649-3.11095h1.838l-1.293 1.29295c-.0928.0929-.1665.2031-.2167.3244-.0503.1213-.0761.2513-.0761.3826 0 .1314.0258.2614.0761.3827.0502.1213.1239.2315.2167.3243.0928.0929.2031.1665.3244.2168.1213.0502.2513.0761.3826.0761.1313 0 .2613-.0259.3826-.0761.1213-.0503.2316-.1239.3244-.2168L21 7.99985l-3.707-3.707c-.0928-.09285-.2031-.16649-.3244-.21674C16.8473 4.02586 16.7173 4 16.586 4c-.1313 0-.2613.02586-.3826.07611-.1213.05025-.2316.12389-.3244.21674-.0928.09284-.1665.20307-.2167.32437-.0503.12131-.0761.25133-.0761.38263 0 .1313.0258.26132.0761.38262.0502.12131.1239.23153.2167.32438l1.293 1.293h-1.838c-2.274 0-4.711 1.967-5.547 4.47895l-.472 1.411c-.641 1.926-2.072 3.11-2.815 3.11H4c-.26522 0-.51957.1054-.70711.2929-.18753.1876-.29289.4419-.29289.7071 0 .2653.10536.5196.29289.7072.18754.1875.44189.2928.70711.2928h2.5c1.837 0 3.863-1.925 4.713-4.479l.472-1.41Zm4.194 1.182c-.0929.0928-.1667.203-.217.3244-.0503.1213-.0762.2513-.0762.3826 0 .1314.0259.2614.0762.3827.0503.1214.1241.2316.217.3243l1.293 1.293h-2.338c-1.268 0-2.33-.891-2.691-2.108-.2661.773-.6326 1.5076-1.09 2.185.886 1.162 2.243 1.923 3.781 1.923h2.338l-1.293 1.293c-.0928.0929-.1665.2031-.2167.3244-.0503.1213-.0761.2513-.0761.3826 0 .1314.0258.2614.0761.3827.0502.1213.1239.2315.2167.3244.0928.0928.2031.1664.3244.2167.1213.0502.2513.0761.3826.0761.1313 0 .2613-.0259.3826-.0761.1213-.0503.2316-.1239.3244-.2167L21 16.9998l-3.707-3.707c-.0928-.0929-.203-.1666-.3243-.2169-.1213-.0504-.2514-.0763-.3827-.0763-.1313 0-.2614.0259-.3827.0763-.1213.0503-.2315.124-.3243.2169Z" fill="#fff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -5,87 +5,136 @@ import { satsToUsd } from "~/utils/conversions";
|
||||
import { AmountEditable } from "./AmountEditable";
|
||||
|
||||
const noop = () => {
|
||||
// do nothing
|
||||
}
|
||||
// do nothing
|
||||
};
|
||||
|
||||
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>
|
||||
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">
|
||||
≈ {amountInUsd()} <span class="text-sm">USD</span>
|
||||
</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">≈ {amountInUsd()} <span class="text-sm">USD</span></div>
|
||||
</KeyValue>
|
||||
</Show>
|
||||
)
|
||||
|
||||
</KeyValue>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
||||
function add(a: string, b?: string) {
|
||||
return Number(a || 0) + Number(b || 0)
|
||||
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 : noop} />
|
||||
</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 : noop} />
|
||||
</Show>
|
||||
</KeyValue>
|
||||
<USDShower amountSats={props.amountSats} />
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
</VStack>
|
||||
</Card>
|
||||
)
|
||||
function subtract(a: string, b?: string) {
|
||||
return Number(a || 0) - Number(b || 0);
|
||||
}
|
||||
|
||||
export function AmountCard(props: {
|
||||
amountSats: string;
|
||||
fee?: string;
|
||||
reserve?: 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 : noop}
|
||||
/>
|
||||
</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.reserve}>
|
||||
<div class="flex flex-col gap-1">
|
||||
<KeyValue key="Channel size">
|
||||
<InlineAmount amount={add(props.amountSats, props.reserve).toString()} />
|
||||
</KeyValue>
|
||||
<KeyValue gray key="- Channel Reserve">
|
||||
<InlineAmount amount={props.reserve || "0"} />
|
||||
</KeyValue>
|
||||
</div>
|
||||
<hr class="border-white/20" />
|
||||
<div class="flex flex-col gap-1">
|
||||
<KeyValue key="Spendable">
|
||||
<InlineAmount amount={props.amountSats} />
|
||||
</KeyValue>
|
||||
<USDShower amountSats={props.amountSats} fee={props.reserve} />
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={!props.fee && !props.reserve}>
|
||||
<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 : noop}
|
||||
/>
|
||||
</Show>
|
||||
</KeyValue>
|
||||
<USDShower amountSats={props.amountSats} />
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,8 @@ import { Show, Suspense } from "solid-js";
|
||||
import { Button, ButtonLink, FancyCard, Indicator } from "~/components/layout";
|
||||
import { useMegaStore } from "~/state/megaStore";
|
||||
import { Amount } from "./Amount";
|
||||
import { useNavigate } from "solid-start";
|
||||
import { A, useNavigate } from "solid-start";
|
||||
import shuffle from "~/assets/icons/shuffle.svg"
|
||||
|
||||
function prettyPrintAmount(n?: number | bigint): string {
|
||||
if (!n || n.valueOf() === 0) {
|
||||
@@ -22,6 +23,8 @@ export function LoadingShimmer() {
|
||||
</div>)
|
||||
}
|
||||
|
||||
const STYLE = "px-2 py-1 rounded-xl border border-neutral-400 text-sm flex gap-2 items-center font-semibold"
|
||||
|
||||
export default function BalanceBox(props: { loading?: boolean }) {
|
||||
const [state, actions] = useMegaStore();
|
||||
|
||||
@@ -39,8 +42,13 @@ export default function BalanceBox(props: { loading?: boolean }) {
|
||||
|
||||
<FancyCard title="On-Chain" tag={state.is_syncing && <Indicator>Syncing</Indicator>}>
|
||||
<Show when={!props.loading} fallback={<LoadingShimmer />}>
|
||||
<div onClick={actions.sync}>
|
||||
<div class="flex justify-between">
|
||||
<Amount amountSats={state.balance?.confirmed} showFiat />
|
||||
<div class="self-end justify-self-end">
|
||||
<A href="/swap" class={STYLE}>
|
||||
<img src={shuffle} alt="swap" />
|
||||
</A>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Suspense>
|
||||
|
||||
19
src/components/InfoBox.tsx
Normal file
19
src/components/InfoBox.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { ParentComponent } from "solid-js";
|
||||
import { ButtonLink, SmallHeader } from "~/components/layout"
|
||||
import info from "~/assets/icons/info.svg"
|
||||
|
||||
export const InfoBox: ParentComponent<{ accent: "red" | "blue" | "green" | "white" }> = (props) => {
|
||||
return (
|
||||
<div class="grid grid-cols-[auto_minmax(0,_1fr)] rounded-xl p-4 gap-4 bg-neutral-950/50 border"
|
||||
classList={{"border-m-red": props.accent === "red", "border-m-blue": props.accent === "blue", "border-m-green": props.accent === "green", "border-white": props.accent === "white"}}>
|
||||
<div class="self-center">
|
||||
<img src={info} alt="info" class="w-8 h-8" />
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<p class="text-base font-light">
|
||||
{props.children}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -112,7 +112,7 @@ function ConnectPeer(props: { refetchPeers: RefetchPeersType }) {
|
||||
<TextField.Root
|
||||
value={value()}
|
||||
onChange={setValue}
|
||||
validationState={(value() == "" || value().startsWith("mutiny:")) ? "valid" : "invalid"}
|
||||
validationState={(value() == "") ? "valid" : "invalid"}
|
||||
class="flex flex-col gap-4"
|
||||
>
|
||||
<TextField.Label class="text-sm font-semibold uppercase" >Connect Peer</TextField.Label>
|
||||
|
||||
0
src/components/SimpleSelect.tsx
Normal file
0
src/components/SimpleSelect.tsx
Normal file
@@ -15,7 +15,7 @@ export function StyledRadioGroup(props: { value: string, choices: Choices, onVal
|
||||
{choice =>
|
||||
<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" }}
|
||||
classList={{ "ui-checked:outline-m-red": props.accent === "red", "ui-checked:outline-white": props.accent === "white", "ui-checked:outline-black/50 ui-checked:bg-white/10": props.choices.length === 1 }}
|
||||
>
|
||||
<div class={props.small ? "py-2 px-2" : "py-3 px-4"}>
|
||||
<RadioGroup.ItemInput />
|
||||
|
||||
12
src/root.css
12
src/root.css
@@ -39,3 +39,15 @@ a {
|
||||
#video-container .scan-region-highlight-svg {
|
||||
display: none;
|
||||
}
|
||||
|
||||
select {
|
||||
@apply appearance-none;
|
||||
@apply block;
|
||||
@apply border-[2px] focus:outline-none focus:ring-2 focus:ring-offset-2 ring-offset-black;
|
||||
@apply font-light text-lg;
|
||||
@apply py-4 pl-4 pr-8;
|
||||
background-image: url("data:image/svg+xml,%3Csvg aria-hidden='true' class='w-4 h-4 ml-1' fill='white' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' d='M5.293 7.293a1 1 0 0 1 1.414 0L10 10.586l3.293-3.293a1 1 0 1 1 1.414 1.414l-4 4a1 1 0 0 1-1.414 0l-4-4a1 1 0 0 1 0-1.414z' clip-rule='evenodd'/%3E%3C/svg%3E");
|
||||
background-position: right 0.75rem center;
|
||||
background-size: 20px 20px;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import { Match, Show, Switch, createEffect, createMemo, createSignal, onMount } from "solid-js";
|
||||
import { Amount } from "~/components/Amount";
|
||||
import NavBar from "~/components/NavBar";
|
||||
import { Button, ButtonLink, Card, DefaultMain, HStack, LargeHeader, MutinyWalletGuard, SafeArea, 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";
|
||||
@@ -11,7 +22,7 @@ import { ParsedParams, toParsedParams } from "./Scanner";
|
||||
import { showToast } from "~/components/Toaster";
|
||||
import eify from "~/utils/eify";
|
||||
import { FullscreenModal } from "~/components/layout/FullscreenModal";
|
||||
import megacheck from "~/assets/icons/megacheck.png"
|
||||
import megacheck from "~/assets/icons/megacheck.png";
|
||||
import megaex from "~/assets/icons/megaex.png";
|
||||
import mempoolTxUrl from "~/utils/mempoolTxUrl";
|
||||
import { BackLink } from "~/components/layout/BackLink";
|
||||
@@ -22,379 +33,480 @@ import { AmountCard } from "~/components/AmountCard";
|
||||
import { MutinyTagItem } from "~/utils/tags";
|
||||
import { BackButton } from "~/components/layout/BackButton";
|
||||
|
||||
type SendSource = "lightning" | "onchain";
|
||||
export type SendSource = "lightning" | "onchain";
|
||||
|
||||
// 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 }
|
||||
type SentDetails = {
|
||||
amount?: bigint;
|
||||
destination?: string;
|
||||
txid?: string;
|
||||
failure_reason?: string;
|
||||
};
|
||||
|
||||
function MethodChooser(props: { source: SendSource, setSource: (source: string) => void, both?: boolean }) {
|
||||
const [store, _actions] = useMegaStore();
|
||||
export function MethodChooser(props: {
|
||||
source: SendSource;
|
||||
setSource: (source: string) => void;
|
||||
both?: boolean;
|
||||
}) {
|
||||
const [store, _actions] = useMegaStore();
|
||||
|
||||
const methods = createMemo(() => {
|
||||
return [
|
||||
{ value: "lightning", label: "Lightning Balance", caption: store.balance?.lightning ? `${store.balance?.lightning.toLocaleString()} SATS` : "No balance" },
|
||||
{ value: "onchain", label: "On-chain Balance", caption: store.balance?.confirmed ? `${store.balance?.confirmed.toLocaleString()} SATS` : "No balance" }
|
||||
]
|
||||
|
||||
})
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.both}>
|
||||
<StyledRadioGroup accent="white" value={props.source} onValueChange={props.setSource} choices={methods()} />
|
||||
</Match>
|
||||
<Match when={props.source === "lightning"}>
|
||||
<StyledRadioGroup accent="white" value={props.source} onValueChange={props.setSource} choices={[methods()[0]]} />
|
||||
</Match>
|
||||
<Match when={props.source === "onchain"}>
|
||||
<StyledRadioGroup accent="white" value={props.source} onValueChange={props.setSource} choices={[methods()[1]]} />
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
const methods = createMemo(() => {
|
||||
return [
|
||||
{
|
||||
value: "lightning",
|
||||
label: "Lightning Balance",
|
||||
caption: store.balance?.lightning
|
||||
? `${store.balance?.lightning.toLocaleString()} SATS`
|
||||
: "No balance"
|
||||
},
|
||||
{
|
||||
value: "onchain",
|
||||
label: "On-chain Balance",
|
||||
caption: store.balance?.confirmed
|
||||
? `${store.balance?.confirmed.toLocaleString()} SATS`
|
||||
: "No balance"
|
||||
}
|
||||
];
|
||||
});
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.both}>
|
||||
<StyledRadioGroup
|
||||
accent="white"
|
||||
value={props.source}
|
||||
onValueChange={props.setSource}
|
||||
choices={methods()}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.source === "lightning"}>
|
||||
<StyledRadioGroup
|
||||
accent="white"
|
||||
value={props.source}
|
||||
onValueChange={props.setSource}
|
||||
choices={[methods()[0]]}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.source === "onchain"}>
|
||||
<StyledRadioGroup
|
||||
accent="white"
|
||||
value={props.source}
|
||||
onValueChange={props.setSource}
|
||||
choices={[methods()[1]]}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
function DestinationInput(props: {
|
||||
fieldDestination: string,
|
||||
setFieldDestination: (destination: string) => void,
|
||||
handleDecode: () => void,
|
||||
handlePaste: () => void,
|
||||
fieldDestination: string;
|
||||
setFieldDestination: (destination: string) => void;
|
||||
handleDecode: () => void;
|
||||
handlePaste: () => void;
|
||||
}) {
|
||||
return (
|
||||
<VStack>
|
||||
<SmallHeader>Destination</SmallHeader>
|
||||
<textarea value={props.fieldDestination} onInput={(e) => props.setFieldDestination(e.currentTarget.value)} placeholder="bitcoin:..." class="p-2 rounded-lg bg-white/10 placeholder-neutral-400" />
|
||||
<Button disabled={!props.fieldDestination} intent="blue" onClick={props.handleDecode}>Decode</Button>
|
||||
<HStack>
|
||||
<Button onClick={props.handlePaste}>
|
||||
<div class="flex flex-col gap-2 items-center">
|
||||
<Paste />
|
||||
<span>Paste</span>
|
||||
</div>
|
||||
</Button>
|
||||
<ButtonLink href="/scanner">
|
||||
<div class="flex flex-col gap-2 items-center">
|
||||
<Scan />
|
||||
<span>Scan QR</span>
|
||||
</div>
|
||||
</ButtonLink>
|
||||
</HStack>
|
||||
</VStack>
|
||||
)
|
||||
return (
|
||||
<VStack>
|
||||
<SmallHeader>Destination</SmallHeader>
|
||||
<textarea
|
||||
value={props.fieldDestination}
|
||||
onInput={(e) => props.setFieldDestination(e.currentTarget.value)}
|
||||
placeholder="bitcoin:..."
|
||||
class="p-2 rounded-lg bg-white/10 placeholder-neutral-400"
|
||||
/>
|
||||
<Button disabled={!props.fieldDestination} intent="blue" onClick={props.handleDecode}>
|
||||
Decode
|
||||
</Button>
|
||||
<HStack>
|
||||
<Button onClick={props.handlePaste}>
|
||||
<div class="flex flex-col gap-2 items-center">
|
||||
<Paste />
|
||||
<span>Paste</span>
|
||||
</div>
|
||||
</Button>
|
||||
<ButtonLink href="/scanner">
|
||||
<div class="flex flex-col gap-2 items-center">
|
||||
<Scan />
|
||||
<span>Scan QR</span>
|
||||
</div>
|
||||
</ButtonLink>
|
||||
</HStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function DestinationShower(props: {
|
||||
source: SendSource,
|
||||
description?: string,
|
||||
address?: string,
|
||||
invoice?: MutinyInvoice,
|
||||
nodePubkey?: string,
|
||||
lnurl?: string,
|
||||
clearAll: () => void,
|
||||
source: SendSource;
|
||||
description?: string;
|
||||
address?: string;
|
||||
invoice?: MutinyInvoice;
|
||||
nodePubkey?: string;
|
||||
lnurl?: string;
|
||||
clearAll: () => void;
|
||||
}) {
|
||||
return (
|
||||
<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>
|
||||
<Match when={props.lnurl && props.source === "lightning"}>
|
||||
<StringShower text={props.lnurl || ""} />
|
||||
</Match>
|
||||
</Switch>
|
||||
|
||||
)
|
||||
return (
|
||||
<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>
|
||||
<Match when={props.lnurl && props.source === "lightning"}>
|
||||
<StringShower text={props.lnurl || ""} />
|
||||
</Match>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Send() {
|
||||
const [state, actions] = useMegaStore();
|
||||
const navigate = useNavigate()
|
||||
const [state, actions] = useMegaStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// These can only be set by the user
|
||||
const [fieldDestination, setFieldDestination] = createSignal("");
|
||||
const [destination, setDestination] = createSignal<ParsedParams>();
|
||||
// These can only be set by the user
|
||||
const [fieldDestination, setFieldDestination] = createSignal("");
|
||||
const [destination, setDestination] = createSignal<ParsedParams>();
|
||||
|
||||
// These can be derived from the "destination" signal or set by the user
|
||||
const [amountSats, setAmountSats] = createSignal(0n);
|
||||
const [source, setSource] = createSignal<SendSource>("lightning");
|
||||
// These can be derived from the "destination" signal or set by the user
|
||||
const [amountSats, setAmountSats] = createSignal(0n);
|
||||
const [source, setSource] = createSignal<SendSource>("lightning");
|
||||
|
||||
// These can only be derived from the "destination" signal
|
||||
const [invoice, setInvoice] = createSignal<MutinyInvoice>();
|
||||
const [nodePubkey, setNodePubkey] = createSignal<string>();
|
||||
const [lnurlp, setLnurlp] = createSignal<string>();
|
||||
const [address, setAddress] = createSignal<string>();
|
||||
const [description, setDescription] = createSignal<string>();
|
||||
// These can only be derived from the "destination" signal
|
||||
const [invoice, setInvoice] = createSignal<MutinyInvoice>();
|
||||
const [nodePubkey, setNodePubkey] = createSignal<string>();
|
||||
const [lnurlp, setLnurlp] = createSignal<string>();
|
||||
const [address, setAddress] = createSignal<string>();
|
||||
const [description, setDescription] = createSignal<string>();
|
||||
|
||||
// Is sending / sent
|
||||
const [sending, setSending] = createSignal(false);
|
||||
const [sentDetails, setSentDetails] = createSignal<SentDetails>();
|
||||
// Is sending / sent
|
||||
const [sending, setSending] = createSignal(false);
|
||||
const [sentDetails, setSentDetails] = createSignal<SentDetails>();
|
||||
|
||||
// Tagging stuff
|
||||
const [selectedContacts, setSelectedContacts] = createSignal<Partial<MutinyTagItem>[]>([]);
|
||||
// Tagging stuff
|
||||
const [selectedContacts, setSelectedContacts] = createSignal<Partial<MutinyTagItem>[]>([]);
|
||||
|
||||
function clearAll() {
|
||||
setDestination(undefined);
|
||||
setAmountSats(0n);
|
||||
setSource("lightning");
|
||||
setInvoice(undefined);
|
||||
setAddress(undefined);
|
||||
setDescription(undefined);
|
||||
setNodePubkey(undefined);
|
||||
setLnurlp(undefined);
|
||||
setFieldDestination("");
|
||||
function clearAll() {
|
||||
setDestination(undefined);
|
||||
setAmountSats(0n);
|
||||
setSource("lightning");
|
||||
setInvoice(undefined);
|
||||
setAddress(undefined);
|
||||
setDescription(undefined);
|
||||
setNodePubkey(undefined);
|
||||
setLnurlp(undefined);
|
||||
setFieldDestination("");
|
||||
}
|
||||
|
||||
const feeEstimate = () => {
|
||||
if (source() === "lightning") return undefined;
|
||||
|
||||
if (source() === "onchain" && amountSats() && amountSats() > 0n && address()) {
|
||||
return state.mutiny_wallet?.estimate_tx_fee(address()!, amountSats(), undefined);
|
||||
}
|
||||
|
||||
const feeEstimate = () => {
|
||||
if (source() === "lightning") return undefined;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
if (source() === "onchain" && amountSats() && amountSats() > 0n && address()) {
|
||||
return state.mutiny_wallet?.estimate_tx_fee(address()!, amountSats(), undefined);
|
||||
}
|
||||
|
||||
return undefined
|
||||
onMount(() => {
|
||||
if (state.scan_result) {
|
||||
setDestination(state.scan_result);
|
||||
actions.setScanResult(undefined);
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
if (state.scan_result) {
|
||||
setDestination(state.scan_result);
|
||||
actions.setScanResult(undefined);
|
||||
}
|
||||
})
|
||||
// Rerun every time the destination changes
|
||||
createEffect(() => {
|
||||
const source = destination();
|
||||
if (!source) return undefined;
|
||||
try {
|
||||
if (source.address) setAddress(source.address);
|
||||
if (source.memo) setDescription(source.memo);
|
||||
|
||||
// Rerun every time the destination changes
|
||||
createEffect(() => {
|
||||
const source = destination();
|
||||
if (!source) return undefined;
|
||||
try {
|
||||
if (source.address) setAddress(source.address)
|
||||
if (source.memo) setDescription(source.memo);
|
||||
|
||||
if (source.invoice) {
|
||||
state.mutiny_wallet?.decode_invoice(source.invoice).then(invoice => {
|
||||
if (invoice?.amount_sats) setAmountSats(invoice.amount_sats);
|
||||
setInvoice(invoice)
|
||||
setSource("lightning")
|
||||
});
|
||||
} else if (source.node_pubkey) {
|
||||
setAmountSats(source.amount_sats || 0n);
|
||||
setNodePubkey(source.node_pubkey);
|
||||
setSource("lightning")
|
||||
} else if (source.lnurl) {
|
||||
state.mutiny_wallet?.decode_lnurl(source.lnurl).then((lnurlParams) => {
|
||||
if (lnurlParams.tag === "payRequest") {
|
||||
setAmountSats(source.amount_sats || 0n);
|
||||
setLnurlp(source.lnurl);
|
||||
setSource("lightning")
|
||||
}
|
||||
})
|
||||
} else {
|
||||
setAmountSats(source.amount_sats || 0n);
|
||||
setSource("onchain")
|
||||
}
|
||||
// Return the source just to trigger `decodedDestination` as not undefined
|
||||
return source
|
||||
} catch (e) {
|
||||
console.error("error", e)
|
||||
clearAll();
|
||||
}
|
||||
})
|
||||
|
||||
function parsePaste(text: string) {
|
||||
if (text) {
|
||||
const network = state.mutiny_wallet?.get_network() || "signet";
|
||||
const result = toParsedParams(text || "", network);
|
||||
if (!result.ok) {
|
||||
showToast(result.error);
|
||||
return;
|
||||
} else {
|
||||
if (result.value?.address || result.value?.invoice || result.value?.node_pubkey || result.value?.lnurl) {
|
||||
setDestination(result.value);
|
||||
// Important! we need to clear the scan result once we've used it
|
||||
actions.setScanResult(undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleDecode() {
|
||||
const text = fieldDestination();
|
||||
parsePaste(text);
|
||||
}
|
||||
|
||||
function handlePaste() {
|
||||
if (!navigator.clipboard.readText) return showToast(new Error("Clipboard not supported"));
|
||||
|
||||
navigator.clipboard.readText().then(text => {
|
||||
setFieldDestination(text);
|
||||
parsePaste(text);
|
||||
}).catch((e) => {
|
||||
showToast(new Error("Failed to read clipboard: " + e.message))
|
||||
if (source.invoice) {
|
||||
state.mutiny_wallet?.decode_invoice(source.invoice).then((invoice) => {
|
||||
if (invoice?.amount_sats) setAmountSats(invoice.amount_sats);
|
||||
setInvoice(invoice);
|
||||
setSource("lightning");
|
||||
});
|
||||
} else if (source.node_pubkey) {
|
||||
setAmountSats(source.amount_sats || 0n);
|
||||
setNodePubkey(source.node_pubkey);
|
||||
setSource("lightning");
|
||||
} else if (source.lnurl) {
|
||||
state.mutiny_wallet?.decode_lnurl(source.lnurl).then((lnurlParams) => {
|
||||
if (lnurlParams.tag === "payRequest") {
|
||||
setAmountSats(source.amount_sats || 0n);
|
||||
setLnurlp(source.lnurl);
|
||||
setSource("lightning");
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setAmountSats(source.amount_sats || 0n);
|
||||
setSource("onchain");
|
||||
}
|
||||
// Return the source just to trigger `decodedDestination` as not undefined
|
||||
return source;
|
||||
} catch (e) {
|
||||
console.error("error", e);
|
||||
clearAll();
|
||||
}
|
||||
});
|
||||
|
||||
async function processContacts(contacts: Partial<MutinyTagItem>[]): Promise<string[]> {
|
||||
console.log("Processing contacts", contacts)
|
||||
|
||||
if (contacts.length) {
|
||||
const first = contacts![0];
|
||||
|
||||
if (!first.name) {
|
||||
console.error("Something went wrong with contact creation, proceeding anyway")
|
||||
return []
|
||||
}
|
||||
|
||||
if (!first.id && first.name) {
|
||||
console.error("Creating new contact", first.name)
|
||||
const c = new Contact(first.name, undefined, undefined, undefined);
|
||||
const newContactId = await state.mutiny_wallet?.create_new_contact(c);
|
||||
if (newContactId) {
|
||||
return [newContactId];
|
||||
}
|
||||
}
|
||||
|
||||
if (first.id) {
|
||||
console.error("Using existing contact", first.name, first.id)
|
||||
return [first.id];
|
||||
}
|
||||
|
||||
function parsePaste(text: string) {
|
||||
if (text) {
|
||||
const network = state.mutiny_wallet?.get_network() || "signet";
|
||||
const result = toParsedParams(text || "", network);
|
||||
if (!result.ok) {
|
||||
showToast(result.error);
|
||||
return;
|
||||
} else {
|
||||
if (
|
||||
result.value?.address ||
|
||||
result.value?.invoice ||
|
||||
result.value?.node_pubkey ||
|
||||
result.value?.lnurl
|
||||
) {
|
||||
setDestination(result.value);
|
||||
// Important! we need to clear the scan result once we've used it
|
||||
actions.setScanResult(undefined);
|
||||
}
|
||||
|
||||
console.error("Something went wrong with contact creation, proceeding anyway")
|
||||
return []
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSend() {
|
||||
try {
|
||||
setSending(true);
|
||||
const bolt11 = invoice()?.bolt11;
|
||||
const sentDetails: Partial<SentDetails> = {};
|
||||
function handleDecode() {
|
||||
const text = fieldDestination();
|
||||
parsePaste(text);
|
||||
}
|
||||
|
||||
const tags = await processContacts(selectedContacts());
|
||||
function handlePaste() {
|
||||
if (!navigator.clipboard.readText) return showToast(new Error("Clipboard not supported"));
|
||||
|
||||
if (source() === "lightning" && invoice() && bolt11) {
|
||||
const nodes = await state.mutiny_wallet?.list_nodes();
|
||||
const firstNode = nodes[0] as string || ""
|
||||
sentDetails.destination = bolt11;
|
||||
// If the invoice has sats use that, otherwise we pass the user-defined amount
|
||||
if (invoice()?.amount_sats) {
|
||||
await state.mutiny_wallet?.pay_invoice(firstNode, bolt11, undefined, tags);
|
||||
sentDetails.amount = invoice()?.amount_sats;
|
||||
} else {
|
||||
await state.mutiny_wallet?.pay_invoice(firstNode, bolt11, amountSats(), tags);
|
||||
sentDetails.amount = amountSats();
|
||||
}
|
||||
} else if (source() === "lightning" && nodePubkey()) {
|
||||
const nodes = await state.mutiny_wallet?.list_nodes();
|
||||
const firstNode = nodes[0] as string || ""
|
||||
const payment = await state.mutiny_wallet?.keysend(firstNode, nodePubkey()!, amountSats(), tags);
|
||||
navigator.clipboard
|
||||
.readText()
|
||||
.then((text) => {
|
||||
setFieldDestination(text);
|
||||
parsePaste(text);
|
||||
})
|
||||
.catch((e) => {
|
||||
showToast(new Error("Failed to read clipboard: " + e.message));
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: handle timeouts
|
||||
if (!payment?.paid) {
|
||||
throw new Error("Keysend failed")
|
||||
} else {
|
||||
sentDetails.amount = amountSats();
|
||||
}
|
||||
} else if (source() === "lightning" && lnurlp()) {
|
||||
const nodes = await state.mutiny_wallet?.list_nodes();
|
||||
const firstNode = nodes[0] as string || ""
|
||||
const payment = await state.mutiny_wallet?.lnurl_pay(firstNode, lnurlp()!, amountSats(), tags);
|
||||
async function processContacts(contacts: Partial<MutinyTagItem>[]): Promise<string[]> {
|
||||
console.log("Processing contacts", contacts);
|
||||
|
||||
if (!payment?.paid) {
|
||||
throw new Error("Lnurl Pay failed")
|
||||
} else {
|
||||
sentDetails.amount = amountSats();
|
||||
}
|
||||
} else if (source() === "onchain" && address()) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const txid = await state.mutiny_wallet?.send_to_address(address()!, amountSats(), tags);
|
||||
sentDetails.amount = amountSats();
|
||||
sentDetails.destination = address();
|
||||
// TODO: figure out if this is necessary, it takes forever
|
||||
await actions.sync();
|
||||
sentDetails.txid = txid;
|
||||
}
|
||||
setSentDetails(sentDetails as SentDetails);
|
||||
clearAll();
|
||||
} catch (e) {
|
||||
const error = eify(e)
|
||||
setSentDetails({ failure_reason: error.message });
|
||||
// TODO: figure out ux of when we want to show toast vs error screen
|
||||
// showToast(eify(e))
|
||||
console.error(e);
|
||||
} finally {
|
||||
setSending(false);
|
||||
if (contacts.length) {
|
||||
const first = contacts![0];
|
||||
|
||||
if (!first.name) {
|
||||
console.error("Something went wrong with contact creation, proceeding anyway");
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!first.id && first.name) {
|
||||
console.error("Creating new contact", first.name);
|
||||
const c = new Contact(first.name, undefined, undefined, undefined);
|
||||
const newContactId = await state.mutiny_wallet?.create_new_contact(c);
|
||||
if (newContactId) {
|
||||
return [newContactId];
|
||||
}
|
||||
}
|
||||
|
||||
if (first.id) {
|
||||
console.error("Using existing contact", first.name, first.id);
|
||||
return [first.id];
|
||||
}
|
||||
}
|
||||
|
||||
const sendButtonDisabled = createMemo(() => {
|
||||
return !destination() || sending() || amountSats() === 0n;
|
||||
})
|
||||
console.error("Something went wrong with contact creation, proceeding anyway");
|
||||
return [];
|
||||
}
|
||||
|
||||
return (
|
||||
<MutinyWalletGuard>
|
||||
<SafeArea>
|
||||
<DefaultMain>
|
||||
<Show when={address() || invoice() || nodePubkey() || lnurlp()} fallback={<BackLink />}>
|
||||
<BackButton onClick={() => clearAll()} title="Start Over" />
|
||||
</Show>
|
||||
<LargeHeader>Send Bitcoin</LargeHeader>
|
||||
<FullscreenModal
|
||||
title={sentDetails()?.amount ? "Sent" : "Payment Failed"}
|
||||
confirmText={sentDetails()?.amount ? "Nice" : "Too Bad"}
|
||||
open={!!sentDetails()}
|
||||
setOpen={(open: boolean) => { if (!open) setSentDetails(undefined) }}
|
||||
onConfirm={() => { setSentDetails(undefined); navigate("/"); }}
|
||||
async function handleSend() {
|
||||
try {
|
||||
setSending(true);
|
||||
const bolt11 = invoice()?.bolt11;
|
||||
const sentDetails: Partial<SentDetails> = {};
|
||||
|
||||
const tags = await processContacts(selectedContacts());
|
||||
|
||||
if (source() === "lightning" && invoice() && bolt11) {
|
||||
const nodes = await state.mutiny_wallet?.list_nodes();
|
||||
const firstNode = (nodes[0] as string) || "";
|
||||
sentDetails.destination = bolt11;
|
||||
// If the invoice has sats use that, otherwise we pass the user-defined amount
|
||||
if (invoice()?.amount_sats) {
|
||||
await state.mutiny_wallet?.pay_invoice(firstNode, bolt11, undefined, tags);
|
||||
sentDetails.amount = invoice()?.amount_sats;
|
||||
} else {
|
||||
await state.mutiny_wallet?.pay_invoice(firstNode, bolt11, amountSats(), tags);
|
||||
sentDetails.amount = amountSats();
|
||||
}
|
||||
} else if (source() === "lightning" && nodePubkey()) {
|
||||
const nodes = await state.mutiny_wallet?.list_nodes();
|
||||
const firstNode = (nodes[0] as string) || "";
|
||||
const payment = await state.mutiny_wallet?.keysend(
|
||||
firstNode,
|
||||
nodePubkey()!,
|
||||
amountSats(),
|
||||
tags
|
||||
);
|
||||
|
||||
// TODO: handle timeouts
|
||||
if (!payment?.paid) {
|
||||
throw new Error("Keysend failed");
|
||||
} else {
|
||||
sentDetails.amount = amountSats();
|
||||
}
|
||||
} else if (source() === "lightning" && lnurlp()) {
|
||||
const nodes = await state.mutiny_wallet?.list_nodes();
|
||||
const firstNode = (nodes[0] as string) || "";
|
||||
const payment = await state.mutiny_wallet?.lnurl_pay(
|
||||
firstNode,
|
||||
lnurlp()!,
|
||||
amountSats(),
|
||||
tags
|
||||
);
|
||||
|
||||
if (!payment?.paid) {
|
||||
throw new Error("Lnurl Pay failed");
|
||||
} else {
|
||||
sentDetails.amount = amountSats();
|
||||
}
|
||||
} else if (source() === "onchain" && address()) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const txid = await state.mutiny_wallet?.send_to_address(address()!, amountSats(), tags);
|
||||
sentDetails.amount = amountSats();
|
||||
sentDetails.destination = address();
|
||||
// TODO: figure out if this is necessary, it takes forever
|
||||
await actions.sync();
|
||||
sentDetails.txid = txid;
|
||||
}
|
||||
setSentDetails(sentDetails as SentDetails);
|
||||
clearAll();
|
||||
} catch (e) {
|
||||
const error = eify(e);
|
||||
setSentDetails({ failure_reason: error.message });
|
||||
// TODO: figure out ux of when we want to show toast vs error screen
|
||||
// showToast(eify(e))
|
||||
console.error(e);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
const sendButtonDisabled = createMemo(() => {
|
||||
return !destination() || sending() || amountSats() === 0n;
|
||||
});
|
||||
|
||||
return (
|
||||
<MutinyWalletGuard>
|
||||
<SafeArea>
|
||||
<DefaultMain>
|
||||
<Show when={address() || invoice() || nodePubkey() || lnurlp()} fallback={<BackLink />}>
|
||||
<BackButton onClick={() => clearAll()} title="Start Over" />
|
||||
</Show>
|
||||
<LargeHeader>Send Bitcoin</LargeHeader>
|
||||
<FullscreenModal
|
||||
title={sentDetails()?.amount ? "Sent" : "Payment Failed"}
|
||||
confirmText={sentDetails()?.amount ? "Nice" : "Too Bad"}
|
||||
open={!!sentDetails()}
|
||||
setOpen={(open: boolean) => {
|
||||
if (!open) setSentDetails(undefined);
|
||||
}}
|
||||
onConfirm={() => {
|
||||
setSentDetails(undefined);
|
||||
navigate("/");
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col items-center gap-8 h-full">
|
||||
<Switch>
|
||||
<Match when={sentDetails()?.failure_reason}>
|
||||
<img src={megaex} alt="fail" class="w-1/2 mx-auto max-w-[50vh]" />
|
||||
<p class="text-xl font-light py-2 px-4 rounded-xl bg-white/10">
|
||||
{sentDetails()?.failure_reason}
|
||||
</p>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<img src={megacheck} alt="success" class="w-1/2 mx-auto max-w-[50vh]" />
|
||||
<Amount amountSats={sentDetails()?.amount} showFiat />
|
||||
<Show when={sentDetails()?.txid}>
|
||||
<a
|
||||
href={mempoolTxUrl(sentDetails()?.txid, state.mutiny_wallet?.get_network())}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-8 h-full">
|
||||
<Switch>
|
||||
<Match when={sentDetails()?.failure_reason}>
|
||||
<img src={megaex} alt="fail" class="w-1/2 mx-auto max-w-[50vh]" />
|
||||
<p class="text-xl font-light py-2 px-4 rounded-xl bg-white/10">{sentDetails()?.failure_reason}</p>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<img src={megacheck} alt="success" class="w-1/2 mx-auto max-w-[50vh]" />
|
||||
<Amount amountSats={sentDetails()?.amount} showFiat />
|
||||
<Show when={sentDetails()?.txid}>
|
||||
<a href={mempoolTxUrl(sentDetails()?.txid, state.mutiny_wallet?.get_network())} target="_blank" rel="noreferrer">
|
||||
Mempool Link
|
||||
</a>
|
||||
</Show>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</FullscreenModal>
|
||||
<VStack biggap>
|
||||
<Switch>
|
||||
<Match when={address() || invoice() || nodePubkey() || lnurlp()}>
|
||||
|
||||
<MethodChooser source={source()} setSource={setSource} both={!!address() && !!invoice()} />
|
||||
<Card>
|
||||
<VStack>
|
||||
<DestinationShower source={source()} description={description()} invoice={invoice()} address={address()} nodePubkey={nodePubkey()} lnurl={lnurlp()} clearAll={clearAll} />
|
||||
<SmallHeader>Private tags</SmallHeader>
|
||||
<TagEditor selectedValues={selectedContacts()} setSelectedValues={setSelectedContacts} placeholder="Add the receiver for your records" />
|
||||
</VStack>
|
||||
</Card>
|
||||
<AmountCard amountSats={amountSats().toString()} setAmountSats={setAmountSats} fee={feeEstimate()?.toString()} isAmountEditable={!(invoice()?.amount_sats)} />
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<DestinationInput fieldDestination={fieldDestination()} setFieldDestination={setFieldDestination} handleDecode={handleDecode} handlePaste={handlePaste} />
|
||||
</Match>
|
||||
</Switch>
|
||||
<Show when={destination()}>
|
||||
<Button class="w-full flex-grow-0" disabled={sendButtonDisabled()} intent="blue" onClick={handleSend} loading={sending()}>{sending() ? "Sending..." : "Confirm Send"}</Button>
|
||||
</Show>
|
||||
</VStack>
|
||||
</DefaultMain>
|
||||
<NavBar activeTab="send" />
|
||||
</SafeArea >
|
||||
</MutinyWalletGuard >
|
||||
)
|
||||
Mempool Link
|
||||
</a>
|
||||
</Show>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</FullscreenModal>
|
||||
<VStack biggap>
|
||||
<Switch>
|
||||
<Match when={address() || invoice() || nodePubkey() || lnurlp()}>
|
||||
<MethodChooser
|
||||
source={source()}
|
||||
setSource={setSource}
|
||||
both={!!address() && !!invoice()}
|
||||
/>
|
||||
<Card>
|
||||
<VStack>
|
||||
<DestinationShower
|
||||
source={source()}
|
||||
description={description()}
|
||||
invoice={invoice()}
|
||||
address={address()}
|
||||
nodePubkey={nodePubkey()}
|
||||
lnurl={lnurlp()}
|
||||
clearAll={clearAll}
|
||||
/>
|
||||
<SmallHeader>Private tags</SmallHeader>
|
||||
<TagEditor
|
||||
selectedValues={selectedContacts()}
|
||||
setSelectedValues={setSelectedContacts}
|
||||
placeholder="Add the receiver for your records"
|
||||
/>
|
||||
</VStack>
|
||||
</Card>
|
||||
<AmountCard
|
||||
amountSats={amountSats().toString()}
|
||||
setAmountSats={setAmountSats}
|
||||
fee={feeEstimate()?.toString()}
|
||||
isAmountEditable={!invoice()?.amount_sats}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<DestinationInput
|
||||
fieldDestination={fieldDestination()}
|
||||
setFieldDestination={setFieldDestination}
|
||||
handleDecode={handleDecode}
|
||||
handlePaste={handlePaste}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
<Show when={destination()}>
|
||||
<Button
|
||||
class="w-full flex-grow-0"
|
||||
disabled={sendButtonDisabled()}
|
||||
intent="blue"
|
||||
onClick={handleSend}
|
||||
loading={sending()}
|
||||
>
|
||||
{sending() ? "Sending..." : "Confirm Send"}
|
||||
</Button>
|
||||
</Show>
|
||||
</VStack>
|
||||
</DefaultMain>
|
||||
<NavBar activeTab="send" />
|
||||
</SafeArea>
|
||||
</MutinyWalletGuard>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +1,98 @@
|
||||
import { Match, Show, Switch } from "solid-js";
|
||||
import { ActivityItem } from "~/components/ActivityItem";
|
||||
import { Amount } from "~/components/Amount";
|
||||
import { AmountCard } from "~/components/AmountCard";
|
||||
import NavBar from "~/components/NavBar";
|
||||
import { OnboardWarning } from "~/components/OnboardWarning";
|
||||
import { ShareCard } from "~/components/ShareCard";
|
||||
import { Card, DefaultMain, LargeHeader, SafeArea, VStack } from "~/components/layout";
|
||||
import { Card, DefaultMain, LargeHeader, SafeArea, SmallHeader, VStack } from "~/components/layout";
|
||||
import { FullscreenModal } from "~/components/layout/FullscreenModal";
|
||||
import mempoolTxUrl from "~/utils/mempoolTxUrl";
|
||||
import megaex from "~/assets/icons/megaex.png";
|
||||
import megacheck from "~/assets/icons/megacheck.png";
|
||||
|
||||
const SAMPLE = "bitcoin:tb1prqm8xtlgme0vmw5s30lgf0a4f5g4mkgsqundwmpu6thrg8zr6uvq2qrhzq?amount=0.001&lightning=lntbs1m1pj9n9xjsp5xgdrmvprtm67p7nq4neparalexlhlmtxx87zx6xeqthsplu842zspp546d6zd2seyaxpapaxx62m88yz3xueqtjmn9v6wj8y56np8weqsxqdqqnp4qdn2hj8tfknpuvdg6tz9yrf3e27ltrx9y58c24jh89lnm43yjwfc5xqrpwjcqpj9qrsgq5sdgh0m3ur5mu5hrmmag4mx9yvy86f83pd0x9ww80kgck6tac3thuzkj0mrtltaxwnlfea95h2re7tj4qsnwzxlvrdmyq2h9mgapnycpppz6k6"
|
||||
const SAMPLE =
|
||||
"bitcoin:tb1prqm8xtlgme0vmw5s30lgf0a4f5g4mkgsqundwmpu6thrg8zr6uvq2qrhzq?amount=0.001&lightning=lntbs1m1pj9n9xjsp5xgdrmvprtm67p7nq4neparalexlhlmtxx87zx6xeqthsplu842zspp546d6zd2seyaxpapaxx62m88yz3xueqtjmn9v6wj8y56np8weqsxqdqqnp4qdn2hj8tfknpuvdg6tz9yrf3e27ltrx9y58c24jh89lnm43yjwfc5xqrpwjcqpj9qrsgq5sdgh0m3ur5mu5hrmmag4mx9yvy86f83pd0x9ww80kgck6tac3thuzkj0mrtltaxwnlfea95h2re7tj4qsnwzxlvrdmyq2h9mgapnycpppz6k6";
|
||||
export default function Admin() {
|
||||
return (
|
||||
<SafeArea>
|
||||
<DefaultMain>
|
||||
<LargeHeader>Storybook</LargeHeader>
|
||||
<OnboardWarning />
|
||||
<VStack>
|
||||
<AmountCard amountSats={"100000"} fee={"69"} />
|
||||
<ShareCard text={SAMPLE} />
|
||||
<Card title="Activity">
|
||||
const channelOpenResult = () => {
|
||||
return {
|
||||
channel: {
|
||||
balance: 100000n,
|
||||
reserve: 1000n,
|
||||
outpoint: "123:0"
|
||||
},
|
||||
failure_reason: undefined
|
||||
};
|
||||
};
|
||||
|
||||
const setChannelOpenResult = (result: any) => {};
|
||||
|
||||
return (
|
||||
<SafeArea>
|
||||
<DefaultMain>
|
||||
<LargeHeader>Storybook</LargeHeader>
|
||||
<OnboardWarning />
|
||||
<VStack>
|
||||
<AmountCard amountSats={"100000"} fee={"69"} />
|
||||
<ShareCard text={SAMPLE} />
|
||||
{/* <Card title="Activity">
|
||||
<ActivityItem kind="lightning" labels={["benthecarman"]} amount={100000} date={1683664966} />
|
||||
<ActivityItem kind="onchain" labels={["tony"]} amount={42000000} positive date={1683664966} />
|
||||
<ActivityItem kind="onchain" labels={["a fake name thati is too long"]} amount={42000000} date={1683664966} />
|
||||
<ActivityItem kind="onchain" labels={["a fake name thati is too long"]} amount={42000000} date={1683664966} />
|
||||
</Card>
|
||||
</VStack>
|
||||
</DefaultMain>
|
||||
<NavBar activeTab="none" />
|
||||
</SafeArea>
|
||||
)
|
||||
</Card> */}
|
||||
<FullscreenModal
|
||||
title={channelOpenResult()?.channel ? "Channel Opened" : "Channel Open Failed"}
|
||||
confirmText={channelOpenResult()?.channel ? "Nice" : "Too Bad"}
|
||||
open={!!channelOpenResult()}
|
||||
setOpen={(open: boolean) => {
|
||||
if (!open) setChannelOpenResult(undefined);
|
||||
}}
|
||||
onConfirm={() => {
|
||||
setChannelOpenResult(undefined);
|
||||
// navigate("/");
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col items-center gap-8 pb-8">
|
||||
<Switch>
|
||||
<Match when={channelOpenResult()?.failure_reason}>
|
||||
<img src={megaex} alt="fail" class="w-1/2 mx-auto max-w-[30vh] flex-shrink" />
|
||||
|
||||
<p class="text-xl font-light py-2 px-4 rounded-xl bg-white/10">
|
||||
{channelOpenResult()?.failure_reason?.message}
|
||||
</p>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<img
|
||||
src={megacheck}
|
||||
alt="success"
|
||||
class="w-1/2 mx-auto max-w-[30vh] flex-shrink"
|
||||
/>
|
||||
<AmountCard
|
||||
amountSats={channelOpenResult()?.channel?.balance.toString()}
|
||||
reserve={"1000"}
|
||||
/>
|
||||
<Show when={channelOpenResult()?.channel?.outpoint}>
|
||||
<a
|
||||
class=""
|
||||
href={mempoolTxUrl(
|
||||
channelOpenResult()?.channel?.outpoint?.split(":")[0],
|
||||
"signet"
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Mempool Link
|
||||
</a>
|
||||
</Show>
|
||||
{/* <pre>{JSON.stringify(channelOpenResult()?.channel?.value, null, 2)}</pre> */}
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</FullscreenModal>
|
||||
</VStack>
|
||||
</DefaultMain>
|
||||
<NavBar activeTab="none" />
|
||||
</SafeArea>
|
||||
);
|
||||
}
|
||||
299
src/routes/Swap.tsx
Normal file
299
src/routes/Swap.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
import { createForm, required } from "@modular-forms/solid";
|
||||
import { MutinyChannel, MutinyPeer } from "@mutinywallet/mutiny-wasm";
|
||||
import { For, Match, Show, Switch, createResource, createSignal } from "solid-js";
|
||||
import { AmountCard } from "~/components/AmountCard";
|
||||
import NavBar from "~/components/NavBar";
|
||||
import { showToast } from "~/components/Toaster";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
DefaultMain,
|
||||
LargeHeader,
|
||||
MutinyWalletGuard,
|
||||
SafeArea,
|
||||
VStack
|
||||
} from "~/components/layout";
|
||||
import { BackLink } from "~/components/layout/BackLink";
|
||||
import { TextField } from "~/components/layout/TextField";
|
||||
import { MethodChooser, SendSource } from "~/routes/Send";
|
||||
import { useMegaStore } from "~/state/megaStore";
|
||||
import eify from "~/utils/eify";
|
||||
import megaex from "~/assets/icons/megaex.png";
|
||||
import megacheck from "~/assets/icons/megacheck.png";
|
||||
import { InfoBox } from "~/components/InfoBox";
|
||||
import { FullscreenModal } from "~/components/layout/FullscreenModal";
|
||||
import { useNavigate } from "solid-start";
|
||||
import mempoolTxUrl from "~/utils/mempoolTxUrl";
|
||||
import { Network } from "~/logic/mutinyWalletSetup";
|
||||
|
||||
const CHANNEL_FEE_ESTIMATE_ADDRESS =
|
||||
"bc1qf7546vg73ddsjznzq57z3e8jdn6gtw6au576j07kt6d9j7nz8mzsyn6lgf";
|
||||
|
||||
type PeerConnectForm = {
|
||||
peer: string;
|
||||
};
|
||||
|
||||
type ChannelOpenDetails = {
|
||||
channel?: MutinyChannel;
|
||||
failure_reason?: Error;
|
||||
};
|
||||
|
||||
export default function Swap() {
|
||||
const [state, actions] = useMegaStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [source, setSource] = createSignal<SendSource>("onchain");
|
||||
const [amountSats, setAmountSats] = createSignal(0n);
|
||||
const [useLsp, setUseLsp] = createSignal(true);
|
||||
const [isConnecting, setIsConnecting] = createSignal(false);
|
||||
|
||||
const [selectedPeer, setSelectedPeer] = createSignal<string>("");
|
||||
|
||||
const [channelOpenResult, setChannelOpenResult] = createSignal<ChannelOpenDetails>();
|
||||
|
||||
const feeEstimate = () => {
|
||||
if (amountSats()) {
|
||||
try {
|
||||
return state.mutiny_wallet?.estimate_tx_fee(
|
||||
CHANNEL_FEE_ESTIMATE_ADDRESS,
|
||||
amountSats(),
|
||||
undefined
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
// showToast(eify(new Error("Unsufficient funds")))
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const hasLsp = () => {
|
||||
return !!localStorage.getItem("MUTINY_SETTINGS_lsp") || !!import.meta.env.VITE_LSP;
|
||||
};
|
||||
|
||||
const getPeers = async () => {
|
||||
return (await state.mutiny_wallet?.list_peers()) as Promise<MutinyPeer[]>;
|
||||
};
|
||||
|
||||
const [peers, { refetch }] = createResource(getPeers);
|
||||
|
||||
const [_peerForm, { Form, Field }] = createForm<PeerConnectForm>();
|
||||
|
||||
const onSubmit = async (values: PeerConnectForm) => {
|
||||
setIsConnecting(true);
|
||||
try {
|
||||
const peerConnectString = values.peer.trim();
|
||||
const nodes = await state.mutiny_wallet?.list_nodes();
|
||||
const firstNode = (nodes[0] as string) || "";
|
||||
|
||||
await state.mutiny_wallet?.connect_to_peer(firstNode, peerConnectString);
|
||||
|
||||
await refetch();
|
||||
|
||||
// If peers list contains the peer we just connected to, select it
|
||||
const peer = peers()?.find((p) => p.pubkey === peerConnectString.split("@")[0]);
|
||||
|
||||
if (peer) {
|
||||
setSelectedPeer(peer.pubkey);
|
||||
} else {
|
||||
showToast(new Error("Peer not found"));
|
||||
}
|
||||
} catch (e) {
|
||||
showToast(eify(e));
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePeerSelect = (
|
||||
e: Event & {
|
||||
currentTarget: HTMLSelectElement;
|
||||
target: HTMLSelectElement;
|
||||
}
|
||||
) => {
|
||||
setSelectedPeer(e.currentTarget.value);
|
||||
};
|
||||
|
||||
const handleSwap = async () => {
|
||||
if (canSwap()) {
|
||||
try {
|
||||
const nodes = await state.mutiny_wallet?.list_nodes();
|
||||
const firstNode = (nodes[0] as string) || "";
|
||||
|
||||
if (useLsp()) {
|
||||
const new_channel = await state.mutiny_wallet?.open_channel(
|
||||
firstNode,
|
||||
undefined,
|
||||
amountSats()
|
||||
);
|
||||
|
||||
setChannelOpenResult({ channel: new_channel });
|
||||
} else {
|
||||
const new_channel = await state.mutiny_wallet?.open_channel(
|
||||
firstNode,
|
||||
selectedPeer(),
|
||||
amountSats()
|
||||
);
|
||||
|
||||
setChannelOpenResult({ channel: new_channel });
|
||||
}
|
||||
} catch (e) {
|
||||
setChannelOpenResult({ failure_reason: eify(e) });
|
||||
// showToast(eify(e))
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const canSwap = () => {
|
||||
const balance = (state.balance?.confirmed || 0n) + (state.balance?.unconfirmed || 0n);
|
||||
return (!!selectedPeer() || !!useLsp()) && amountSats() >= 10000n && amountSats() <= balance;
|
||||
};
|
||||
|
||||
const amountWarning = () => {
|
||||
if (amountSats() < 10000n) {
|
||||
return "It's just silly to make a channel smaller than 10,000 sats";
|
||||
}
|
||||
|
||||
if (amountSats() > (state.balance?.confirmed ?? 0n) || !feeEstimate()) {
|
||||
return "You don't have enough funds to make this channel";
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const network = state.mutiny_wallet?.get_network() as Network;
|
||||
|
||||
return (
|
||||
<MutinyWalletGuard>
|
||||
<SafeArea>
|
||||
<DefaultMain>
|
||||
<BackLink />
|
||||
<LargeHeader>Swap to Lightning</LargeHeader>
|
||||
<FullscreenModal
|
||||
title={channelOpenResult()?.channel ? "Channel Opened" : "Channel Open Failed"}
|
||||
confirmText={channelOpenResult()?.channel ? "Nice" : "Too Bad"}
|
||||
open={!!channelOpenResult()}
|
||||
setOpen={(open: boolean) => {
|
||||
if (!open) setChannelOpenResult(undefined);
|
||||
}}
|
||||
onConfirm={() => {
|
||||
setChannelOpenResult(undefined);
|
||||
navigate("/");
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col items-center gap-8 pb-8">
|
||||
<Switch>
|
||||
<Match when={channelOpenResult()?.failure_reason}>
|
||||
<img src={megaex} alt="fail" class="w-1/2 mx-auto max-w-[30vh] flex-shrink" />
|
||||
|
||||
<p class="text-xl font-light py-2 px-4 rounded-xl bg-white/10">
|
||||
{channelOpenResult()?.failure_reason?.message}
|
||||
</p>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<img
|
||||
src={megacheck}
|
||||
alt="success"
|
||||
class="w-1/2 mx-auto max-w-[30vh] flex-shrink"
|
||||
/>
|
||||
<AmountCard
|
||||
amountSats={channelOpenResult()?.channel?.balance?.toString() || ""}
|
||||
reserve={channelOpenResult()?.channel?.reserve?.toString() || ""}
|
||||
/>
|
||||
<Show when={channelOpenResult()?.channel?.outpoint}>
|
||||
<a
|
||||
class=""
|
||||
href={mempoolTxUrl(
|
||||
channelOpenResult()?.channel?.outpoint?.split(":")[0],
|
||||
"signet"
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Mempool Link
|
||||
</a>
|
||||
</Show>
|
||||
{/* <pre>{JSON.stringify(channelOpenResult()?.channel?.value, null, 2)}</pre> */}
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</FullscreenModal>
|
||||
<VStack biggap>
|
||||
<MethodChooser source={source()} setSource={setSource} both={false} />
|
||||
<VStack>
|
||||
<Show when={hasLsp()}>
|
||||
<Checkbox checked={useLsp()} onChange={setUseLsp} label="Use LSP" />
|
||||
</Show>
|
||||
<Show when={!useLsp()}>
|
||||
<Card>
|
||||
<VStack>
|
||||
<div class="w-full flex flex-col gap-2">
|
||||
<label for="peerselect" class="uppercase font-semibold text-sm">
|
||||
Use existing peer
|
||||
</label>
|
||||
<select
|
||||
name="peerselect"
|
||||
class="bg-black px-4 py-2 rounded truncate w-full"
|
||||
onChange={handlePeerSelect}
|
||||
value={selectedPeer()}
|
||||
>
|
||||
<option value="" class="" selected>
|
||||
Choose a peer
|
||||
</option>
|
||||
<For each={peers()}>
|
||||
{(peer) => (
|
||||
<option value={peer.pubkey}>{peer.alias ?? peer.pubkey}</option>
|
||||
)}
|
||||
</For>
|
||||
</select>
|
||||
</div>
|
||||
<Show when={!selectedPeer()}>
|
||||
<Form onSubmit={onSubmit} class="flex flex-col gap-4">
|
||||
<Field name="peer" validate={[required("")]}>
|
||||
{(field, props) => (
|
||||
<TextField
|
||||
{...props}
|
||||
value={field.value}
|
||||
error={field.error}
|
||||
label="Connect to new peer"
|
||||
placeholder="Peer connect string"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Button layout="small" type="submit" disabled={isConnecting()}>
|
||||
{isConnecting() ? "Connecting..." : "Connect"}
|
||||
</Button>
|
||||
</Form>
|
||||
</Show>
|
||||
</VStack>
|
||||
</Card>
|
||||
</Show>
|
||||
</VStack>
|
||||
<AmountCard
|
||||
amountSats={amountSats().toString()}
|
||||
setAmountSats={setAmountSats}
|
||||
fee={feeEstimate()?.toString()}
|
||||
isAmountEditable={true}
|
||||
/>
|
||||
<Show when={amountWarning() && amountSats() > 0n}>
|
||||
<InfoBox accent={"red"}>{amountWarning()}</InfoBox>
|
||||
</Show>
|
||||
</VStack>
|
||||
<div class="flex-1" />
|
||||
<Button
|
||||
class="w-full flex-grow-0"
|
||||
disabled={!canSwap()}
|
||||
intent="blue"
|
||||
onClick={handleSwap}
|
||||
loading={false}
|
||||
>
|
||||
{"Confirm Swap"}
|
||||
</Button>
|
||||
</DefaultMain>
|
||||
<NavBar activeTab="none" />
|
||||
</SafeArea>
|
||||
</MutinyWalletGuard>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user