mirror of
https://github.com/aljazceru/mutiny-web.git
synced 2025-12-27 19:04:24 +01:00
477 lines
16 KiB
TypeScript
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 />
|
|
</>
|
|
);
|
|
}
|