mirror of
https://github.com/aljazceru/mutiny-web.git
synced 2026-01-06 07:44:27 +01:00
533 lines
19 KiB
TypeScript
533 lines
19 KiB
TypeScript
import { Dialog } from "@kobalte/core";
|
|
import { MutinyChannel, MutinyInvoice } from "@mutinywallet/mutiny-wasm";
|
|
import {
|
|
createEffect,
|
|
createMemo,
|
|
createResource,
|
|
For,
|
|
Match,
|
|
ParentComponent,
|
|
Show,
|
|
Suspense,
|
|
Switch
|
|
} from "solid-js";
|
|
|
|
import bolt from "~/assets/icons/bolt-black.svg";
|
|
import chain from "~/assets/icons/chain-black.svg";
|
|
import copyIcon from "~/assets/icons/copy.svg";
|
|
import shuffle from "~/assets/icons/shuffle-black.svg";
|
|
import {
|
|
ActivityAmount,
|
|
AmountSmall,
|
|
CopyButton,
|
|
ExternalLink,
|
|
HackActivityType,
|
|
Hr,
|
|
InfoBox,
|
|
ModalCloseButton,
|
|
TinyButton,
|
|
TruncateMiddle,
|
|
VStack
|
|
} from "~/components";
|
|
import { useI18n } from "~/i18n/context";
|
|
import { Network } from "~/logic/mutinyWalletSetup";
|
|
import { useMegaStore } from "~/state/megaStore";
|
|
import {
|
|
mempoolTxUrl,
|
|
MutinyTagItem,
|
|
prettyPrintTime,
|
|
tagToMutinyTag,
|
|
useCopy
|
|
} from "~/utils";
|
|
|
|
interface ChannelClosure {
|
|
channel_id: string;
|
|
node_id: string;
|
|
reason: string;
|
|
timestamp: number;
|
|
}
|
|
|
|
interface OnChainTx {
|
|
txid: string;
|
|
received: number;
|
|
sent: number;
|
|
fee?: number;
|
|
confirmation_time?: {
|
|
Confirmed?: {
|
|
height: number;
|
|
time: number;
|
|
};
|
|
};
|
|
labels: string[];
|
|
}
|
|
|
|
export const OVERLAY = "fixed inset-0 z-50 bg-black/50 backdrop-blur-sm";
|
|
export const DIALOG_POSITIONER =
|
|
"fixed inset-0 z-50 flex items-center justify-center";
|
|
export const DIALOG_CONTENT =
|
|
"max-w-[500px] w-[90vw] max-h-[100dvh] overflow-y-scroll disable-scrollbars mx-4 p-4 bg-m-grey-800/75 backdrop-blur-md shadow-xl rounded-xl border border-white/10";
|
|
|
|
function LightningHeader(props: {
|
|
info: MutinyInvoice;
|
|
tags: MutinyTagItem[];
|
|
}) {
|
|
const i18n = useI18n();
|
|
const [state, _actions] = useMegaStore();
|
|
|
|
return (
|
|
<div class="flex flex-col items-center gap-4">
|
|
<div class="rounded-full bg-neutral-100 p-4">
|
|
<img src={bolt} alt="lightning bolt" class="h-8 w-8" />
|
|
</div>
|
|
<h1 class="font-semibold uppercase">
|
|
{props.info.inbound
|
|
? i18n.t("modals.transaction_details.lightning_receive")
|
|
: i18n.t("modals.transaction_details.lightning_send")}
|
|
</h1>
|
|
<ActivityAmount
|
|
center
|
|
amount={props.info.amount_sats?.toString() ?? "0"}
|
|
price={state.price}
|
|
positive={props.info.inbound}
|
|
/>
|
|
<For each={props.tags}>
|
|
{(tag) => (
|
|
<TinyButton
|
|
tag={tag}
|
|
onClick={() => {
|
|
// noop
|
|
}}
|
|
>
|
|
{tag.name}
|
|
</TinyButton>
|
|
)}
|
|
</For>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function OnchainHeader(props: {
|
|
info: OnChainTx;
|
|
tags: MutinyTagItem[];
|
|
kind?: HackActivityType;
|
|
}) {
|
|
const i18n = useI18n();
|
|
const [state, _actions] = useMegaStore();
|
|
|
|
const isSend = () => {
|
|
return props.info.sent > props.info.received;
|
|
};
|
|
|
|
const amount = () => {
|
|
if (isSend()) {
|
|
return (props.info.sent - props.info.received).toString();
|
|
} else {
|
|
return (props.info.received - props.info.sent).toString();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div class="flex flex-col items-center gap-4">
|
|
<div class="rounded-full bg-neutral-100 p-4">
|
|
<Switch>
|
|
<Match
|
|
when={
|
|
props.kind === "ChannelOpen" ||
|
|
props.kind === "ChannelClose"
|
|
}
|
|
>
|
|
<img src={shuffle} alt="swap" class="h-8 w-8" />
|
|
</Match>
|
|
<Match when={true}>
|
|
<img src={chain} alt="blockchain" class="h-8 w-8" />
|
|
</Match>
|
|
</Switch>
|
|
</div>
|
|
<h1 class="font-semibold uppercase">
|
|
{props.kind === "ChannelOpen"
|
|
? i18n.t("modals.transaction_details.channel_open")
|
|
: props.kind === "ChannelClose"
|
|
? i18n.t("modals.transaction_details.channel_close")
|
|
: isSend()
|
|
? i18n.t("modals.transaction_details.onchain_send")
|
|
: i18n.t("modals.transaction_details.onchain_receive")}
|
|
</h1>
|
|
<Show when={props.kind !== "ChannelClose"}>
|
|
<ActivityAmount
|
|
center
|
|
amount={amount() ?? "0"}
|
|
price={state.price}
|
|
positive={!isSend()}
|
|
/>
|
|
</Show>
|
|
<For each={props.tags}>
|
|
{(tag) => (
|
|
<TinyButton
|
|
tag={tag}
|
|
onClick={() => {
|
|
// noop
|
|
}}
|
|
>
|
|
{tag.name}
|
|
</TinyButton>
|
|
)}
|
|
</For>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export const KeyValue: ParentComponent<{ key: string }> = (props) => {
|
|
return (
|
|
<li class="flex items-center justify-between gap-4">
|
|
<span class="whitespace-nowrap text-sm font-semibold uppercase">
|
|
{props.key}
|
|
</span>
|
|
<span class="font-light">{props.children}</span>
|
|
</li>
|
|
);
|
|
};
|
|
|
|
export function MiniStringShower(props: { text: string }) {
|
|
const [copy, copied] = useCopy({ copiedTimeout: 1000 });
|
|
|
|
return (
|
|
<div class="grid w-full grid-cols-[minmax(0,_1fr)_auto] gap-1">
|
|
<TruncateMiddle text={props.text} />
|
|
{/* <pre class="truncate text-neutral-300 font-light">{props.text}</pre> */}
|
|
<button
|
|
class="w-[1.5rem] p-1"
|
|
classList={{ "bg-m-green rounded": copied() }}
|
|
onClick={() => copy(props.text)}
|
|
>
|
|
<img src={copyIcon} alt="copy" class="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function LightningDetails(props: { info: MutinyInvoice }) {
|
|
const i18n = useI18n();
|
|
return (
|
|
<VStack>
|
|
<ul class="flex flex-col gap-4">
|
|
<KeyValue key={i18n.t("modals.transaction_details.status")}>
|
|
<span class="text-neutral-300">
|
|
{props.info.paid
|
|
? i18n.t("modals.transaction_details.paid")
|
|
: i18n.t("modals.transaction_details.unpaid")}
|
|
</span>
|
|
</KeyValue>
|
|
<KeyValue key={i18n.t("modals.transaction_details.when")}>
|
|
<span class="text-neutral-300">
|
|
{prettyPrintTime(Number(props.info.last_updated))}
|
|
</span>
|
|
</KeyValue>
|
|
<Show when={props.info.description}>
|
|
<KeyValue
|
|
key={i18n.t("modals.transaction_details.description")}
|
|
>
|
|
<span class="truncate text-neutral-300">
|
|
{props.info.description}
|
|
</span>
|
|
</KeyValue>
|
|
</Show>
|
|
<KeyValue key={i18n.t("modals.transaction_details.fees")}>
|
|
<span class="text-neutral-300">
|
|
<AmountSmall amountSats={props.info.fees_paid} />
|
|
</span>
|
|
</KeyValue>
|
|
<KeyValue key={i18n.t("modals.transaction_details.bolt11")}>
|
|
<MiniStringShower text={props.info.bolt11 ?? ""} />
|
|
</KeyValue>
|
|
<KeyValue
|
|
key={i18n.t("modals.transaction_details.payment_hash")}
|
|
>
|
|
<MiniStringShower text={props.info.payment_hash ?? ""} />
|
|
</KeyValue>
|
|
<KeyValue key={i18n.t("modals.transaction_details.preimage")}>
|
|
<MiniStringShower text={props.info.preimage ?? ""} />
|
|
</KeyValue>
|
|
</ul>
|
|
</VStack>
|
|
);
|
|
}
|
|
|
|
function OnchainDetails(props: { info: OnChainTx; kind?: HackActivityType }) {
|
|
const i18n = useI18n();
|
|
const [state, _actions] = useMegaStore();
|
|
|
|
const confirmationTime = () => {
|
|
return props.info.confirmation_time?.Confirmed?.time;
|
|
};
|
|
|
|
const network = state.mutiny_wallet?.get_network() as Network;
|
|
|
|
// Can return nothing if the channel is already closed
|
|
const [channelInfo] = createResource(async () => {
|
|
if (props.kind === "ChannelOpen") {
|
|
try {
|
|
const channels =
|
|
await (state.mutiny_wallet?.list_channels() as Promise<
|
|
MutinyChannel[]
|
|
>);
|
|
const channel = channels.find(
|
|
(channel) => channel.outpoint?.startsWith(props.info.txid)
|
|
);
|
|
return channel;
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
} else {
|
|
return undefined;
|
|
}
|
|
});
|
|
|
|
return (
|
|
<VStack>
|
|
{/* <pre>{JSON.stringify(channelInfo() || "", null, 2)}</pre> */}
|
|
<ul class="flex flex-col gap-4">
|
|
<KeyValue key={i18n.t("modals.transaction_details.status")}>
|
|
<span class="text-neutral-300">
|
|
{confirmationTime()
|
|
? i18n.t("modals.transaction_details.confirmed")
|
|
: i18n.t("modals.transaction_details.unconfirmed")}
|
|
</span>
|
|
</KeyValue>
|
|
<Show when={confirmationTime()}>
|
|
<KeyValue key={i18n.t("modals.transaction_details.when")}>
|
|
<span class="text-neutral-300">
|
|
{confirmationTime()
|
|
? prettyPrintTime(Number(confirmationTime()))
|
|
: "Pending"}
|
|
</span>
|
|
</KeyValue>
|
|
</Show>
|
|
<Show when={props.info.fee && props.info.fee > 0}>
|
|
<KeyValue key={i18n.t("modals.transaction_details.fee")}>
|
|
<span class="text-neutral-300">
|
|
<AmountSmall amountSats={props.info.fee} />
|
|
</span>
|
|
</KeyValue>
|
|
</Show>
|
|
<KeyValue key={i18n.t("modals.transaction_details.txid")}>
|
|
<MiniStringShower text={props.info.txid ?? ""} />
|
|
</KeyValue>
|
|
<Switch>
|
|
<Match when={props.kind === "ChannelOpen" && channelInfo()}>
|
|
<KeyValue
|
|
key={i18n.t("modals.transaction_details.balance")}
|
|
>
|
|
<span class="text-neutral-300">
|
|
<AmountSmall
|
|
amountSats={channelInfo()?.balance}
|
|
/>
|
|
</span>
|
|
</KeyValue>
|
|
<KeyValue
|
|
key={i18n.t("modals.transaction_details.reserve")}
|
|
>
|
|
<span class="text-neutral-300">
|
|
<AmountSmall
|
|
amountSats={channelInfo()?.reserve}
|
|
/>
|
|
</span>
|
|
</KeyValue>
|
|
<KeyValue
|
|
key={i18n.t("modals.transaction_details.peer")}
|
|
>
|
|
<span class="text-neutral-300">
|
|
<MiniStringShower
|
|
text={channelInfo()?.peer ?? ""}
|
|
/>
|
|
</span>
|
|
</KeyValue>
|
|
</Match>
|
|
<Match when={props.kind === "ChannelOpen"}>
|
|
<InfoBox accent="blue">
|
|
{i18n.t("modals.transaction_details.no_details")}
|
|
</InfoBox>
|
|
</Match>
|
|
</Switch>
|
|
</ul>
|
|
<div class="text-center">
|
|
<ExternalLink href={mempoolTxUrl(props.info.txid, network)}>
|
|
{i18n.t("common.view_transaction")}
|
|
</ExternalLink>
|
|
</div>
|
|
</VStack>
|
|
);
|
|
}
|
|
|
|
function ChannelCloseDetails(props: { info: ChannelClosure }) {
|
|
const i18n = useI18n();
|
|
return (
|
|
<VStack>
|
|
{/* <pre>{JSON.stringify(props.info.value, null, 2)}</pre> */}
|
|
<ul class="flex flex-col gap-4">
|
|
<KeyValue key={i18n.t("modals.transaction_details.channel_id")}>
|
|
<MiniStringShower text={props.info.channel_id ?? ""} />
|
|
</KeyValue>
|
|
<Show when={props.info.timestamp}>
|
|
<KeyValue key={i18n.t("modals.transaction_details.when")}>
|
|
<span class="text-neutral-300">
|
|
{props.info.timestamp
|
|
? prettyPrintTime(Number(props.info.timestamp))
|
|
: i18n.t("common.pending")}
|
|
</span>
|
|
</KeyValue>
|
|
</Show>
|
|
<KeyValue key={i18n.t("modals.transaction_details.reason")}>
|
|
<p class="text-right text-neutral-300">
|
|
{props.info.reason ?? ""}
|
|
</p>
|
|
</KeyValue>
|
|
</ul>
|
|
</VStack>
|
|
);
|
|
}
|
|
|
|
export function DetailsIdModal(props: {
|
|
open: boolean;
|
|
kind?: HackActivityType;
|
|
id: string;
|
|
setOpen: (open: boolean) => void;
|
|
}) {
|
|
const i18n = useI18n();
|
|
const [state, _actions] = useMegaStore();
|
|
|
|
const id = () => props.id;
|
|
const kind = () => props.kind;
|
|
|
|
// TODO: is there a cleaner way to do refetch when id changes?
|
|
const [data, { refetch }] = createResource(async () => {
|
|
try {
|
|
if (kind() === "Lightning") {
|
|
console.debug("reading invoice: ", id());
|
|
const invoice = await state.mutiny_wallet?.get_invoice_by_hash(
|
|
id()
|
|
);
|
|
return invoice;
|
|
} else if (kind() === "ChannelClose") {
|
|
console.debug("reading channel close: ", id());
|
|
const closeItem =
|
|
await state.mutiny_wallet?.get_channel_closure(id());
|
|
|
|
return closeItem;
|
|
} else {
|
|
console.debug("reading tx: ", id());
|
|
const tx = await state.mutiny_wallet?.get_transaction(id());
|
|
|
|
return tx;
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
return undefined;
|
|
}
|
|
});
|
|
|
|
const tags = createMemo(() => {
|
|
if (data() && data().labels && data().labels.length > 0) {
|
|
try {
|
|
const contact = state.mutiny_wallet?.get_contact(
|
|
data().labels[0]
|
|
);
|
|
if (contact) {
|
|
return [tagToMutinyTag(contact)];
|
|
} else {
|
|
return [];
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
return [];
|
|
}
|
|
} else {
|
|
return [];
|
|
}
|
|
});
|
|
|
|
createEffect(() => {
|
|
if (props.id && props.kind && props.open) {
|
|
refetch();
|
|
}
|
|
});
|
|
|
|
const json = createMemo(() => JSON.stringify(data() || "", null, 2));
|
|
|
|
return (
|
|
<Dialog.Root open={props.open} onOpenChange={props.setOpen}>
|
|
<Dialog.Portal>
|
|
<Dialog.Overlay class={OVERLAY} />
|
|
<div class={DIALOG_POSITIONER}>
|
|
<Dialog.Content class={DIALOG_CONTENT}>
|
|
<Suspense>
|
|
<div class="mb-2 flex justify-between">
|
|
<div />
|
|
<Dialog.CloseButton>
|
|
<ModalCloseButton />
|
|
</Dialog.CloseButton>
|
|
</div>
|
|
<Dialog.Title>
|
|
<Switch>
|
|
<Match when={props.kind === "Lightning"}>
|
|
<LightningHeader
|
|
info={data() as MutinyInvoice}
|
|
tags={tags()}
|
|
/>
|
|
</Match>
|
|
<Match
|
|
when={
|
|
props.kind === "OnChain" ||
|
|
props.kind === "ChannelOpen" ||
|
|
props.kind === "ChannelClose"
|
|
}
|
|
>
|
|
<OnchainHeader
|
|
info={data() as OnChainTx}
|
|
tags={tags()}
|
|
kind={props.kind}
|
|
/>
|
|
</Match>
|
|
</Switch>
|
|
</Dialog.Title>
|
|
<Hr />
|
|
<Dialog.Description class="flex flex-col gap-4">
|
|
<Switch>
|
|
<Match when={props.kind === "Lightning"}>
|
|
<LightningDetails
|
|
info={data() as MutinyInvoice}
|
|
/>
|
|
</Match>
|
|
<Match
|
|
when={
|
|
props.kind === "OnChain" ||
|
|
props.kind === "ChannelOpen"
|
|
}
|
|
>
|
|
<OnchainDetails
|
|
info={data() as OnChainTx}
|
|
kind={props.kind}
|
|
/>
|
|
</Match>
|
|
<Match when={props.kind === "ChannelClose"}>
|
|
<ChannelCloseDetails
|
|
info={data() as ChannelClosure}
|
|
/>
|
|
</Match>
|
|
</Switch>
|
|
<Show when={props.kind !== "ChannelClose"}>
|
|
<div class="flex justify-center">
|
|
<CopyButton
|
|
title={i18n.t("common.copy")}
|
|
text={json()}
|
|
/>
|
|
</div>
|
|
</Show>
|
|
</Dialog.Description>
|
|
</Suspense>
|
|
</Dialog.Content>
|
|
</div>
|
|
</Dialog.Portal>
|
|
</Dialog.Root>
|
|
);
|
|
}
|