Files
mutiny-web/src/components/KitchenSink.tsx
2023-09-06 20:29:30 -07:00

477 lines
16 KiB
TypeScript

import { Collapsible, TextField } from "@kobalte/core";
import { MutinyChannel, MutinyPeer } from "@mutinywallet/mutiny-wasm";
import {
createResource,
createSignal,
For,
Match,
Show,
Suspense,
Switch
} from "solid-js";
import {
Button,
ConfirmDialog,
ExternalLink,
Hr,
InnerCard,
MiniStringShower,
ResetRouter,
Restart,
ResyncOnchain,
showToast,
VStack
} from "~/components";
import { useI18n } from "~/i18n/context";
import { Network } from "~/logic/mutinyWalletSetup";
import { useMegaStore } from "~/state/megaStore";
import { eify, mempoolTxUrl } from "~/utils";
// TODO: hopefully I don't have to maintain this type forever but I don't know how to pass it around otherwise
type RefetchPeersType = (
info?: unknown
) => MutinyPeer[] | Promise<MutinyPeer[] | undefined> | null | undefined;
function PeerItem(props: { peer: MutinyPeer }) {
const i18n = useI18n();
const [state, _] = useMegaStore();
const handleDisconnectPeer = async () => {
const nodes = await state.mutiny_wallet?.list_nodes();
const firstNode = (nodes[0] as string) || "";
if (props.peer.is_connected) {
await state.mutiny_wallet?.disconnect_peer(
firstNode,
props.peer.pubkey
);
} else {
await state.mutiny_wallet?.delete_peer(
firstNode,
props.peer.pubkey
);
}
};
return (
<Collapsible.Root>
<Collapsible.Trigger class="w-full">
<h2 class="truncate rounded bg-neutral-200 px-4 py-2 text-start font-mono text-lg text-black">
{">"}{" "}
{props.peer.alias ? props.peer.alias : props.peer.pubkey}
</h2>
</Collapsible.Trigger>
<Collapsible.Content>
<VStack>
<pre class="overflow-x-auto whitespace-pre-wrap break-all">
{JSON.stringify(props.peer, null, 2)}
</pre>
<Button
intent="red"
layout="xs"
onClick={handleDisconnectPeer}
>
{i18n.t("settings.admin.kitchen_sink.disconnect")}
</Button>
</VStack>
</Collapsible.Content>
</Collapsible.Root>
);
}
function PeersList() {
const i18n = useI18n();
const [state, _] = useMegaStore();
const getPeers = async () => {
return (await state.mutiny_wallet?.list_peers()) as Promise<
MutinyPeer[]
>;
};
const [peers, { refetch }] = createResource(getPeers);
return (
<>
<InnerCard title={i18n.t("settings.admin.kitchen_sink.peers")}>
{/* By wrapping this in a suspense I don't cause the page to jump to the top */}
<Suspense>
<VStack>
<For
each={peers.latest}
fallback={
<code>
{i18n.t(
"settings.admin.kitchen_sink.no_peers"
)}
</code>
}
>
{(peer) => <PeerItem peer={peer} />}
</For>
</VStack>
</Suspense>
<Button layout="small" onClick={refetch}>
{i18n.t("settings.admin.kitchen_sink.refresh_peers")}
</Button>
</InnerCard>
<ConnectPeer refetchPeers={refetch} />
</>
);
}
function ConnectPeer(props: { refetchPeers: RefetchPeersType }) {
const i18n = useI18n();
const [state, _] = useMegaStore();
const [value, setValue] = createSignal("");
const onSubmit = async (e: SubmitEvent) => {
e.preventDefault();
const peerConnectString = value().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 props.refetchPeers();
setValue("");
};
return (
<InnerCard>
<form class="flex flex-col gap-4" onSubmit={onSubmit}>
<TextField.Root
value={value()}
onChange={setValue}
validationState={value() == "" ? "valid" : "invalid"}
class="flex flex-col gap-4"
>
<TextField.Label class="text-sm font-semibold uppercase">
{i18n.t("settings.admin.kitchen_sink.connect_peer")}
</TextField.Label>
<TextField.Input
class="w-full rounded-lg p-2 text-black"
placeholder="028241..."
/>
<TextField.ErrorMessage class="text-red-500">
{i18n.t("settings.admin.kitchen_sink.expect_a_value")}
</TextField.ErrorMessage>
</TextField.Root>
<Button layout="small" type="submit">
{i18n.t("settings.admin.kitchen_sink.connect")}
</Button>
</form>
</InnerCard>
);
}
type RefetchChannelsListType = (
info?: unknown
) => MutinyChannel[] | Promise<MutinyChannel[] | undefined> | null | undefined;
type PendingChannelAction = "close" | "force_close" | "abandon";
function ChannelItem(props: { channel: MutinyChannel; network?: Network }) {
const i18n = useI18n();
const [state, _] = useMegaStore();
const [pendingChannelAction, setPendingChannelAction] =
createSignal<PendingChannelAction>();
const [confirmLoading, setConfirmLoading] = createSignal(false);
async function confirmChannelAction() {
const action = pendingChannelAction();
if (!action) return;
setConfirmLoading(true);
try {
await state.mutiny_wallet?.close_channel(
props.channel.outpoint as string,
action === "force_close",
action === "abandon"
);
} catch (e) {
console.error(e);
showToast(eify(e));
}
setConfirmLoading(false);
setPendingChannelAction(undefined);
}
return (
<Collapsible.Root>
<Collapsible.Trigger class="w-full">
<h2 class="truncate rounded bg-neutral-200 px-4 py-2 text-start font-mono text-lg text-black">
{">"} {props.channel.peer}
</h2>
</Collapsible.Trigger>
<Collapsible.Content>
<VStack>
<pre class="overflow-x-auto whitespace-pre-wrap break-all">
{JSON.stringify(props.channel, null, 2)}
</pre>
<ExternalLink
href={mempoolTxUrl(
props.channel.outpoint?.split(":")[0],
props.network
)}
>
{i18n.t("common.view_transaction")}
</ExternalLink>
<Button
intent="red"
layout="xs"
onClick={() => setPendingChannelAction("close")}
>
{i18n.t("settings.admin.kitchen_sink.close_channel")}
</Button>
<Button
intent="red"
layout="xs"
onClick={() => setPendingChannelAction("force_close")}
>
{i18n.t("settings.admin.kitchen_sink.force_close")}
</Button>
<Button
intent="red"
layout="xs"
onClick={() => setPendingChannelAction("abandon")}
>
{i18n.t("settings.admin.kitchen_sink.abandon_channel")}
</Button>
</VStack>
<ConfirmDialog
open={!!pendingChannelAction()}
onConfirm={confirmChannelAction}
onCancel={() => setPendingChannelAction(undefined)}
loading={confirmLoading()}
>
<Switch>
<Match when={pendingChannelAction() === "close"}>
<p>
{i18n.t(
"settings.admin.kitchen_sink.confirm_close_channel"
)}
</p>
</Match>
<Match when={pendingChannelAction() === "force_close"}>
<p>
{i18n.t(
"settings.admin.kitchen_sink.confirm_force_close"
)}
</p>
</Match>
<Match when={pendingChannelAction() === "abandon"}>
<p>
{i18n.t(
"settings.admin.kitchen_sink.confirm_abandon_channel"
)}
</p>
</Match>
</Switch>
</ConfirmDialog>
</Collapsible.Content>
</Collapsible.Root>
);
}
function ChannelsList() {
const i18n = useI18n();
const [state, _] = useMegaStore();
const getChannels = async () => {
return (await state.mutiny_wallet?.list_channels()) as Promise<
MutinyChannel[]
>;
};
const [channels, { refetch }] = createResource(getChannels);
const network = state.mutiny_wallet?.get_network() as Network;
return (
<>
<InnerCard title={i18n.t("settings.admin.kitchen_sink.channels")}>
{/* By wrapping this in a suspense I don't cause the page to jump to the top */}
<Suspense>
<For
each={channels()}
fallback={
<code>
{i18n.t(
"settings.admin.kitchen_sink.no_channels"
)}
</code>
}
>
{(channel) => (
<ChannelItem channel={channel} network={network} />
)}
</For>
</Suspense>
<Button
type="button"
layout="small"
onClick={(e) => {
e.preventDefault();
refetch();
}}
>
{i18n.t("settings.admin.kitchen_sink.refresh_channels")}
</Button>
</InnerCard>
<OpenChannel refetchChannels={refetch} />
</>
);
}
function OpenChannel(props: { refetchChannels: RefetchChannelsListType }) {
const i18n = useI18n();
const [state, _] = useMegaStore();
const [creationError, setCreationError] = createSignal<Error>();
const [amount, setAmount] = createSignal("");
const [peerPubkey, setPeerPubkey] = createSignal("");
const [newChannel, setNewChannel] = createSignal<MutinyChannel>();
const onSubmit = async (e: SubmitEvent) => {
e.preventDefault();
// TODO: figure out why this doesn't catch the rust error
// src/logging.rs:29
// ERROR: Could not create a signed transaction to open channel with: The invoice or address is on a different network.
try {
const pubkey = peerPubkey().trim();
const bigAmount = BigInt(amount());
const nodes = await state.mutiny_wallet?.list_nodes();
const firstNode = (nodes[0] as string) || "";
const new_channel = await state.mutiny_wallet?.open_channel(
firstNode,
pubkey,
bigAmount
);
setNewChannel(new_channel);
await props.refetchChannels();
setAmount("");
setPeerPubkey("");
} catch (e) {
setCreationError(eify(e));
}
};
const network = state.mutiny_wallet?.get_network() as Network;
return (
<>
<InnerCard>
<form class="flex flex-col gap-4" onSubmit={onSubmit}>
<TextField.Root
value={peerPubkey()}
onChange={setPeerPubkey}
class="flex flex-col gap-2"
>
<TextField.Label class="text-sm font-semibold uppercase">
{i18n.t("settings.admin.kitchen_sink.pubkey")}
</TextField.Label>
<TextField.Input class="w-full rounded-lg p-2 text-black" />
</TextField.Root>
<TextField.Root
value={amount()}
onChange={setAmount}
class="flex flex-col gap-2"
>
<TextField.Label class="text-sm font-semibold uppercase">
{i18n.t("settings.admin.kitchen_sink.amount")}
</TextField.Label>
<TextField.Input
type="number"
class="w-full rounded-lg p-2 text-black"
/>
</TextField.Root>
<Button layout="small" type="submit">
{i18n.t("settings.admin.kitchen_sink.open_channel")}
</Button>
</form>
</InnerCard>
<Show when={newChannel()}>
<pre class="overflow-x-auto whitespace-pre-wrap break-all">
{JSON.stringify(newChannel()?.outpoint, null, 2)}
</pre>
<pre>{newChannel()?.outpoint}</pre>
<ExternalLink
href={mempoolTxUrl(
newChannel()?.outpoint?.split(":")[0],
network
)}
>
{i18n.t("common.view_transaction")}
</ExternalLink>
</Show>
<Show when={creationError()}>
<pre>{creationError()?.message}</pre>
</Show>
</>
);
}
function ListNodes() {
const i18n = useI18n();
const [state, _] = useMegaStore();
const getNodeIds = async () => {
const nodes = await state.mutiny_wallet?.list_nodes();
return nodes as string[];
};
const [nodeIds] = createResource(getNodeIds);
return (
<InnerCard title={i18n.t("settings.admin.kitchen_sink.nodes")}>
<Suspense>
<For
each={nodeIds()}
fallback={
<code>
{i18n.t("settings.admin.kitchen_sink.no_nodes")}
</code>
}
>
{(nodeId) => <MiniStringShower text={nodeId} />}
</For>
</Suspense>
</InnerCard>
);
}
export function KitchenSink() {
return (
<>
<ListNodes />
<Hr />
<PeersList />
<Hr />
<ChannelsList />
<Hr />
<ResyncOnchain />
<Hr />
<ResetRouter />
<Hr />
<Restart />
<Hr />
</>
);
}