Files
mutiny-web/src/components/ActivityDetailsModal.tsx
2023-11-10 14:54:43 -06:00

609 lines
24 KiB
TypeScript

import { Dialog } from "@kobalte/core";
import {
MutinyChannel,
MutinyInvoice,
TagItem
} from "@mutinywallet/mutiny-wasm";
import {
createEffect,
createMemo,
createResource,
Match,
Show,
Suspense,
Switch
} from "solid-js";
import bolt from "~/assets/icons/bolt.svg";
import chain from "~/assets/icons/chain.svg";
import copyIcon from "~/assets/icons/copy.svg";
import shuffle from "~/assets/icons/shuffle.svg";
import {
ActivityAmount,
AmountFiat,
AmountSats,
FancyCard,
HackActivityType,
Hr,
InfoBox,
KeyValue,
ModalCloseButton,
TinyButton,
TruncateMiddle,
VStack
} from "~/components";
import { useI18n } from "~/i18n/context";
import { Network } from "~/logic/mutinyWalletSetup";
import { BalanceBar } from "~/routes/settings/Channels";
import { useMegaStore } from "~/state/megaStore";
import { mempoolTxUrl, prettyPrintTime, 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-device overflow-y-scroll disable-scrollbars bg-neutral-900/80 backdrop-blur-md shadow-xl rounded-xl border border-white/10";
function LightningHeader(props: { info: MutinyInvoice }) {
const i18n = useI18n();
return (
<div class="flex flex-col items-center gap-4">
<div class="flex flex-row items-center justify-center gap-[4px] font-normal">
{props.info.inbound
? i18n.t("activity.transaction_details.lightning_receive")
: i18n.t("activity.transaction_details.lightning_send")}
<img src={bolt} alt="lightning bolt" class="h-4 w-4" />
</div>
<div class="flex flex-col items-center">
<div
class="text-2xl"
classList={{ "text-m-green": props.info.inbound }}
>
<AmountSats
amountSats={props.info.amount_sats}
icon={props.info.inbound ? "plus" : undefined}
denominationSize="lg"
/>
</div>
<div class="text-lg text-white/70">
<AmountFiat
amountSats={props.info.amount_sats}
denominationSize="sm"
/>
</div>
</div>
</div>
);
}
function OnchainHeader(props: { info: OnChainTx; kind?: HackActivityType }) {
const i18n = useI18n();
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="flex flex-row items-center justify-center gap-[4px] font-normal">
{props.kind === "ChannelOpen"
? i18n.t("activity.transaction_details.channel_open")
: props.kind === "ChannelClose"
? i18n.t("activity.transaction_details.channel_close")
: isSend()
? i18n.t("activity.transaction_details.onchain_send")
: i18n.t("activity.transaction_details.onchain_receive")}
<Switch>
<Match
when={
props.kind === "ChannelOpen" ||
props.kind === "ChannelClose"
}
>
<img src={shuffle} alt="swap" class="h-4 w-4" />
</Match>
<Match when={true}>
<img src={chain} alt="blockchain" class="h-4 w-4" />
</Match>
</Switch>
</div>
<Show when={props.kind !== "ChannelClose"}>
<div class="flex flex-col items-center">
<div
class="text-2xl"
classList={{ "text-m-green": !isSend() }}
>
<AmountSats
amountSats={Number(amount())}
icon={!isSend() ? "plus" : undefined}
denominationSize="lg"
/>
</div>
<div class="text-lg text-white/70">
<AmountFiat
amountSats={Number(amount())}
denominationSize="sm"
/>
</div>
</div>
</Show>
</div>
);
}
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} />
<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>
);
}
export function FormatPrettyPrint(props: { ts: number }) {
return (
<div>
{prettyPrintTime(props.ts).split(",", 2).join(",")}
<div class="text-right text-sm text-white/70">
{prettyPrintTime(props.ts).split(", ")[2]}
</div>
</div>
);
}
function LightningDetails(props: { info: MutinyInvoice; tags?: TagItem }) {
const i18n = useI18n();
const [state, _actions] = useMegaStore();
return (
<VStack>
<ul class="flex flex-col gap-4">
<KeyValue key={i18n.t("activity.transaction_details.fee")}>
<ActivityAmount
amount={props.info.fees_paid?.toString() || "0"}
price={state.price}
/>
</KeyValue>
<Show when={props.tags || props.info.labels[0]}>
<KeyValue
key={i18n.t("activity.transaction_details.tagged_to")}
>
<TinyButton
tag={props.tags?.value ?? undefined}
onClick={() => {
// noop
}}
>
{props.tags?.name || props.info.labels[0]}
</TinyButton>
</KeyValue>
</Show>
<KeyValue key={i18n.t("activity.transaction_details.status")}>
{props.info.paid
? i18n.t("activity.transaction_details.paid")
: i18n.t("activity.transaction_details.unpaid")}
</KeyValue>
<KeyValue key={i18n.t("activity.transaction_details.date")}>
<FormatPrettyPrint ts={Number(props.info.last_updated)} />
</KeyValue>
<Show when={props.info.description}>
<KeyValue
key={i18n.t("activity.transaction_details.description")}
>
<span class="pl-6">{props.info.description}</span>
</KeyValue>
</Show>
<KeyValue key={i18n.t("activity.transaction_details.invoice")}>
<MiniStringShower text={props.info.bolt11 ?? ""} />
</KeyValue>
<KeyValue
key={i18n.t("activity.transaction_details.payment_hash")}
>
<MiniStringShower text={props.info.payment_hash ?? ""} />
</KeyValue>
<Show when={props.info.paid}>
<KeyValue
key={i18n.t(
"activity.transaction_details.payment_preimage"
)}
>
<MiniStringShower text={props.info.preimage ?? ""} />
</KeyValue>
</Show>
</ul>
</VStack>
);
}
function OnchainDetails(props: {
info: OnChainTx;
kind?: HackActivityType;
tags?: TagItem;
}) {
const i18n = useI18n();
const [state, _actions] = useMegaStore();
const [copy, copied] = useCopy({ copiedTimeout: 1000 });
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">
<Switch>
<Match when={props.kind === "ChannelOpen" && channelInfo()}>
<BalanceBar
inbound={
Number(channelInfo()?.size) -
(Number(channelInfo()?.balance) +
Number(channelInfo()?.reserve)) || 0
}
reserve={Number(channelInfo()?.reserve) || 0}
outbound={Number(channelInfo()?.balance) || 0}
/>
<KeyValue
key={i18n.t("activity.transaction_details.total")}
>
<ActivityAmount
amount={channelInfo()!.size.toString()}
price={state.price}
/>
</KeyValue>
<KeyValue
key={i18n.t(
"activity.transaction_details.onchain_fee"
)}
>
<ActivityAmount
amount={props.info.fee!.toString()}
price={state.price}
/>
</KeyValue>
</Match>
<Match when={props.kind === "ChannelOpen"}>
<InfoBox accent="blue">
{i18n.t("activity.transaction_details.no_details")}
</InfoBox>
</Match>
</Switch>
<Show
when={
props.kind !== "ChannelOpen" &&
props.info.fee &&
props.info.fee > 0
}
>
<KeyValue
key={i18n.t("activity.transaction_details.onchain_fee")}
>
<ActivityAmount
amount={props.info.fee!.toString()}
price={state.price}
/>
</KeyValue>
</Show>
<Show when={props.tags && props.kind === "OnChain"}>
<KeyValue
key={i18n.t("activity.transaction_details.tagged_to")}
>
<TinyButton
tag={props.tags?.value ?? undefined}
onClick={() => {
// noop
}}
>
{props.tags?.name || props.info.labels[0]}
</TinyButton>
</KeyValue>
</Show>
<KeyValue key={i18n.t("activity.transaction_details.status")}>
{confirmationTime()
? i18n.t("activity.transaction_details.confirmed")
: i18n.t("activity.transaction_details.unconfirmed")}
</KeyValue>
<KeyValue key={i18n.t("activity.transaction_details.date")}>
{confirmationTime() ? (
<FormatPrettyPrint ts={Number(confirmationTime())} />
) : (
"Pending"
)}
</KeyValue>
<Show when={props.kind === "ChannelOpen" && channelInfo()}>
<KeyValue key={i18n.t("activity.transaction_details.peer")}>
<MiniStringShower text={channelInfo()?.peer ?? ""} />
</KeyValue>
</Show>
<KeyValue key={i18n.t("activity.transaction_details.txid")}>
<div class="flex gap-1">
{/* Have to do all these shenanigans because css / html is hard */}
<div class="grid w-full grid-cols-[minmax(0,_1fr)_auto] gap-1">
<a
target="_blank"
rel="noopener noreferrer"
href={mempoolTxUrl(props.info.txid, network)}
>
<div class="flex flex-nowrap items-center font-mono text-white">
<span class="truncate">
{props.info.txid}
</span>
<span>
{props.info.txid.length > 32
? props.info.txid.slice(-8)
: ""}
</span>
<svg
class="inline-block w-[16px] overflow-visible pl-0.5 text-white"
width="16"
height="16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.00002 3.33337v1.33334H10.39L2.66669 12.39l.94333.9434 7.72338-7.72336V10h1.3333V3.33337H6.00002Z"
fill="currentColor"
/>
</svg>
</div>
</a>
</div>
<button
class="min-w-[1.5rem] p-1"
classList={{ "bg-m-green rounded": copied() }}
onClick={() => copy(props.info.txid)}
>
<img src={copyIcon} alt="copy" class="h-4 w-4" />
</button>
</div>
</KeyValue>
</ul>
</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">
<InfoBox accent="blue">
<p>{i18n.t("activity.transaction_details.sweep_delay")}</p>
</InfoBox>
<KeyValue
key={i18n.t("activity.transaction_details.channel_id")}
>
<MiniStringShower text={props.info.channel_id ?? ""} />
</KeyValue>
<Show when={props.info.timestamp}>
<KeyValue key={i18n.t("activity.transaction_details.date")}>
{props.info.timestamp ? (
<FormatPrettyPrint
ts={Number(props.info.timestamp)}
/>
) : (
i18n.t("common.pending")
)}
</KeyValue>
</Show>
<KeyValue key={i18n.t("activity.transaction_details.reason")}>
<p class="whitespace-normal text-right text-neutral-300">
{props.info.reason ?? ""}
</p>
</KeyValue>
</ul>
</VStack>
);
}
export function ActivityDetailsModal(props: {
open: boolean;
kind?: HackActivityType;
id: string;
setOpen: (open: boolean) => void;
}) {
const [state, _actions] = useMegaStore();
const id = () => props.id;
const kind = () => props.kind;
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 !== undefined &&
typeof data()?.labels[0] === "string"
) {
try {
// find if there's just one for now
const contacts = state.mutiny_wallet?.get_contact(
data().labels[0]
);
if (contacts) {
return contacts;
} else {
return;
}
} catch (e) {
console.error(e);
}
} else {
return;
}
});
createEffect(() => {
if (props.id && props.kind && props.open) {
refetch();
}
});
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="p-4">
<div class="flex justify-between">
<div />
<Dialog.CloseButton>
<ModalCloseButton />
</Dialog.CloseButton>
</div>
<Dialog.Title>
<FancyCard>
<Show when={data.latest}>
<Switch>
<Match
when={
kind() === "Lightning"
}
>
<LightningHeader
info={
data() as MutinyInvoice
}
/>
</Match>
<Match
when={
kind() === "OnChain" ||
kind() ===
"ChannelOpen" ||
kind() ===
"ChannelClose"
}
>
<OnchainHeader
info={
data() as OnChainTx
}
kind={kind()}
/>
</Match>
</Switch>
</Show>
</FancyCard>
</Dialog.Title>
<Hr />
<Switch>
<Show when={data.latest}>
<Match when={kind() === "Lightning"}>
<LightningDetails
info={data() as MutinyInvoice}
tags={tags()}
/>
</Match>
<Match
when={
kind() === "OnChain" ||
kind() === "ChannelOpen"
}
>
<OnchainDetails
info={data() as OnChainTx}
kind={kind()}
tags={tags()}
/>
</Match>
<Match when={kind() === "ChannelClose"}>
<ChannelCloseDetails
info={data() as ChannelClosure}
/>
</Match>
</Show>
</Switch>
</div>
</Suspense>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog.Root>
);
}