mirror of
https://github.com/aljazceru/mutiny-web.git
synced 2026-02-23 07:04:19 +01:00
working activity refactor!
This commit is contained in:
@@ -1,204 +1,133 @@
|
||||
import { LoadingSpinner, NiceP } from "./layout"
|
||||
import {
|
||||
For,
|
||||
Match,
|
||||
Switch,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
createSignal,
|
||||
} from "solid-js"
|
||||
import { useMegaStore } from "~/state/megaStore"
|
||||
import { MutinyInvoice } from "@mutinywallet/mutiny-wasm"
|
||||
import { ActivityItem } from "./ActivityItem"
|
||||
import { MutinyTagItem } from "~/utils/tags"
|
||||
import { Network } from "~/logic/mutinyWalletSetup"
|
||||
import { DetailsModal } from "./DetailsModal"
|
||||
import { For, Match, Show, Switch, createEffect, createResource, createSignal } from "solid-js";
|
||||
import { useMegaStore } from "~/state/megaStore";
|
||||
import { MutinyInvoice, ActivityItem as MutinyActivity } from "@mutinywallet/mutiny-wasm";
|
||||
import { ActivityItem, HackActivityType } from "./ActivityItem";
|
||||
import { MutinyTagItem } from "~/utils/tags";
|
||||
import { DetailsIdModal } from "./DetailsModal";
|
||||
|
||||
export const THREE_COLUMNS =
|
||||
"grid grid-cols-[auto,1fr,auto] gap-4 py-2 px-2 border-b border-neutral-800 last:border-b-0"
|
||||
export const CENTER_COLUMN = "min-w-0 overflow-hidden max-w-full"
|
||||
export const MISSING_LABEL =
|
||||
"py-1 px-2 bg-white/10 rounded inline-block text-sm"
|
||||
export const REDSHIFT_LABEL =
|
||||
"py-1 px-2 bg-white text-m-red rounded inline-block text-sm"
|
||||
export const RIGHT_COLUMN = "flex flex-col items-right text-right max-w-[8rem]"
|
||||
"grid grid-cols-[auto,1fr,auto] gap-4 py-2 px-2 border-b border-neutral-800 last:border-b-0";
|
||||
export const CENTER_COLUMN = "min-w-0 overflow-hidden max-w-full";
|
||||
export const MISSING_LABEL = "py-1 px-2 bg-white/10 rounded inline-block text-sm";
|
||||
export const REDSHIFT_LABEL = "py-1 px-2 bg-white text-m-red rounded inline-block text-sm";
|
||||
export const RIGHT_COLUMN = "flex flex-col items-right text-right max-w-[8rem]";
|
||||
|
||||
export type OnChainTx = {
|
||||
txid: string
|
||||
received: number
|
||||
sent: number
|
||||
fee?: number
|
||||
txid: string;
|
||||
received: number;
|
||||
sent: number;
|
||||
fee?: number;
|
||||
confirmation_time?: {
|
||||
Confirmed?: {
|
||||
height: number
|
||||
time: number
|
||||
}
|
||||
}
|
||||
labels: string[]
|
||||
}
|
||||
height: number;
|
||||
time: number;
|
||||
};
|
||||
};
|
||||
labels: string[];
|
||||
};
|
||||
|
||||
export type UtxoItem = {
|
||||
outpoint: string
|
||||
outpoint: string;
|
||||
txout: {
|
||||
value: number
|
||||
script_pubkey: string
|
||||
}
|
||||
keychain: string
|
||||
is_spent: boolean
|
||||
redshifted?: boolean
|
||||
}
|
||||
value: number;
|
||||
script_pubkey: string;
|
||||
};
|
||||
keychain: string;
|
||||
is_spent: boolean;
|
||||
redshifted?: boolean;
|
||||
};
|
||||
|
||||
function OnChainItem(props: {
|
||||
item: OnChainTx
|
||||
labels: MutinyTagItem[]
|
||||
network: Network
|
||||
function UnifiedActivityItem(props: {
|
||||
item: MutinyActivity;
|
||||
onClick: (id: string, kind: HackActivityType) => void;
|
||||
}) {
|
||||
const isReceive = () => props.item.received > props.item.sent
|
||||
|
||||
const [open, setOpen] = createSignal(false)
|
||||
const click = () => {
|
||||
props.onClick(props.item.id, props.item.kind as unknown as HackActivityType);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DetailsModal open={open()} data={props.item} setOpen={setOpen} />
|
||||
<ActivityItem
|
||||
kind={"onchain"}
|
||||
labels={props.labels}
|
||||
// FIXME: is this something we can put into node logic?
|
||||
amount={
|
||||
isReceive()
|
||||
? props.item.received - props.item.sent
|
||||
: props.item.sent - props.item.received
|
||||
}
|
||||
date={props.item.confirmation_time?.Confirmed?.time}
|
||||
positive={isReceive()}
|
||||
onClick={() => setOpen(o => !o)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function InvoiceItem(props: { item: MutinyInvoice; labels: MutinyTagItem[] }) {
|
||||
const isSend = createMemo(() => !props.item.inbound);
|
||||
|
||||
const [open, setOpen] = createSignal(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<DetailsModal open={open()} data={props.item} setOpen={setOpen} />
|
||||
<ActivityItem
|
||||
kind={"lightning"}
|
||||
labels={props.labels}
|
||||
amount={props.item.amount_sats || 0n}
|
||||
date={props.item.last_updated}
|
||||
positive={!isSend()}
|
||||
onClick={() => setOpen(o => !o)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
<ActivityItem
|
||||
// This is actually the ActivityType enum but wasm is hard
|
||||
kind={props.item.kind as unknown as HackActivityType}
|
||||
labels={props.item.labels}
|
||||
contacts={props.item.contacts}
|
||||
// FIXME: is this something we can put into node logic?
|
||||
amount={props.item.amount_sats || 0}
|
||||
date={props.item.last_updated}
|
||||
positive={props.item.inbound}
|
||||
onClick={click}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type ActivityItem = {
|
||||
type: "onchain" | "lightning"
|
||||
item: OnChainTx | MutinyInvoice
|
||||
time: number
|
||||
labels: MutinyTagItem[]
|
||||
}
|
||||
|
||||
function sortByTime(a: ActivityItem, b: ActivityItem) {
|
||||
return b.time - a.time
|
||||
}
|
||||
type: "onchain" | "lightning";
|
||||
item: OnChainTx | MutinyInvoice;
|
||||
time: number;
|
||||
labels: MutinyTagItem[];
|
||||
};
|
||||
|
||||
export function CombinedActivity(props: { limit?: number }) {
|
||||
const [state, actions] = useMegaStore()
|
||||
const [state, _actions] = useMegaStore();
|
||||
|
||||
const getAllActivity = async () => {
|
||||
console.log("Getting all activity")
|
||||
const txs = (await state.mutiny_wallet?.list_onchain()) as OnChainTx[]
|
||||
const invoices =
|
||||
(await state.mutiny_wallet?.list_invoices()) as MutinyInvoice[]
|
||||
const tags = await actions.listTags()
|
||||
|
||||
let activity: ActivityItem[] = []
|
||||
|
||||
for (let i = 0; i < txs.length; i++) {
|
||||
activity.push({
|
||||
type: "onchain",
|
||||
item: txs[i],
|
||||
time: txs[i].confirmation_time?.Confirmed?.time || Date.now(),
|
||||
labels: [],
|
||||
})
|
||||
}
|
||||
|
||||
for (let i = 0; i < invoices.length; i++) {
|
||||
if (invoices[i].paid) {
|
||||
activity.push({
|
||||
type: "lightning",
|
||||
item: invoices[i],
|
||||
time: Number(invoices[i].last_updated),
|
||||
labels: [],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (props.limit) {
|
||||
activity = activity.sort(sortByTime).slice(0, props.limit)
|
||||
const [activity, { refetch }] = createResource(async () => {
|
||||
console.log("Getting all activity");
|
||||
const allActivity = await state.mutiny_wallet?.get_activity();
|
||||
// return allActivity.reverse().filter((a: MutinyActivity) => a.kind as unknown as HackActivityType === "Lightning" && !a.paid);
|
||||
if (props.limit && allActivity.length > props.limit) {
|
||||
return allActivity.slice(0, props.limit);
|
||||
} else {
|
||||
activity.sort(sortByTime)
|
||||
return allActivity;
|
||||
}
|
||||
|
||||
for (let i = 0; i < activity.length; i++) {
|
||||
// filter the tags to only include the ones that have an id matching one of the labels
|
||||
activity[i].labels = tags.filter((tag) =>
|
||||
activity[i].item.labels.includes(tag.id)
|
||||
)
|
||||
}
|
||||
|
||||
return activity
|
||||
}
|
||||
|
||||
const [activity, { refetch }] = createResource(getAllActivity)
|
||||
|
||||
const network = state.mutiny_wallet?.get_network() as Network
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
// After every sync we should refetch the activity
|
||||
if (!state.is_syncing) {
|
||||
refetch()
|
||||
refetch();
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const [detailsOpen, setDetailsOpen] = createSignal(false);
|
||||
const [detailsKind, setDetailsKind] = createSignal<HackActivityType>();
|
||||
const [detailsId, setDetailsId] = createSignal("");
|
||||
|
||||
function openDetailsModal(id: string, kind: HackActivityType) {
|
||||
console.log("Opening details modal: ", id, kind);
|
||||
|
||||
setDetailsId(id);
|
||||
setDetailsKind(kind);
|
||||
setDetailsOpen(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={activity.loading}>
|
||||
<LoadingSpinner wide />
|
||||
</Match>
|
||||
<Match when={activity.state === "ready" && activity().length === 0}>
|
||||
<div class="w-full text-center">
|
||||
<NiceP>Receive some sats to get started</NiceP>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={activity.state === "ready" && activity().length >= 0}>
|
||||
<For each={activity.latest}>
|
||||
{(activityItem) => (
|
||||
<Switch>
|
||||
<Match when={activityItem.type === "onchain"}>
|
||||
<OnChainItem
|
||||
item={activityItem.item as OnChainTx}
|
||||
labels={activityItem.labels}
|
||||
network={network}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={activityItem.type === "lightning"}>
|
||||
<InvoiceItem
|
||||
item={activityItem.item as MutinyInvoice}
|
||||
labels={activityItem.labels}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
)}
|
||||
</For>
|
||||
</Match>
|
||||
</Switch>
|
||||
<>
|
||||
<Show when={detailsId() && detailsKind()}>
|
||||
<DetailsIdModal
|
||||
open={detailsOpen()}
|
||||
kind={detailsKind()}
|
||||
id={detailsId()}
|
||||
setOpen={setDetailsOpen}
|
||||
/>
|
||||
</Show>
|
||||
<Switch>
|
||||
<Match when={activity.loading}>
|
||||
<LoadingSpinner wide />
|
||||
</Match>
|
||||
<Match when={activity.state === "ready" && activity().length === 0}>
|
||||
<div class="w-full text-center">
|
||||
<NiceP>Receive some sats to get started</NiceP>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={activity.state === "ready" && activity().length >= 0}>
|
||||
<For each={activity.latest}>
|
||||
{(activityItem) => (
|
||||
<UnifiedActivityItem item={activityItem} onClick={openDetailsModal} />
|
||||
)}
|
||||
</For>
|
||||
</Match>
|
||||
</Switch>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,102 +1,138 @@
|
||||
import { ParentComponent, createMemo, createResource } from "solid-js";
|
||||
import { Match, ParentComponent, Switch, createMemo, createResource } from "solid-js";
|
||||
import { satsToUsd } from "~/utils/conversions";
|
||||
import bolt from "~/assets/icons/bolt.svg"
|
||||
import chain from "~/assets/icons/chain.svg"
|
||||
import bolt from "~/assets/icons/bolt.svg";
|
||||
import chain from "~/assets/icons/chain.svg";
|
||||
import shuffle from "~/assets/icons/shuffle.svg";
|
||||
import { timeAgo } from "~/utils/prettyPrintTime";
|
||||
import { MutinyTagItem } from "~/utils/tags";
|
||||
import { generateGradient } from "~/utils/gradientHash";
|
||||
import { useMegaStore } from "~/state/megaStore";
|
||||
import { Contact } from "@mutinywallet/mutiny-wasm";
|
||||
|
||||
export const ActivityAmount: ParentComponent<{ amount: string, price: number, positive?: boolean, center?: boolean }> = (props) => {
|
||||
const amountInUsd = createMemo(() => {
|
||||
const parsed = Number(props.amount);
|
||||
if (isNaN(parsed)) {
|
||||
return props.amount;
|
||||
} else {
|
||||
return satsToUsd(props.price, parsed, true);
|
||||
}
|
||||
})
|
||||
export const ActivityAmount: ParentComponent<{
|
||||
amount: string;
|
||||
price: number;
|
||||
positive?: boolean;
|
||||
center?: boolean;
|
||||
}> = (props) => {
|
||||
const amountInUsd = createMemo(() => {
|
||||
const parsed = Number(props.amount);
|
||||
if (isNaN(parsed)) {
|
||||
return props.amount;
|
||||
} else {
|
||||
return satsToUsd(props.price, parsed, true);
|
||||
}
|
||||
});
|
||||
|
||||
const prettyPrint = createMemo(() => {
|
||||
const parsed = Number(props.amount);
|
||||
if (isNaN(parsed)) {
|
||||
return props.amount;
|
||||
} else {
|
||||
return parsed.toLocaleString();
|
||||
}
|
||||
})
|
||||
const prettyPrint = createMemo(() => {
|
||||
const parsed = Number(props.amount);
|
||||
if (isNaN(parsed)) {
|
||||
return props.amount;
|
||||
} else {
|
||||
return parsed.toLocaleString();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="flex flex-col"
|
||||
classList={{ "items-end": !props.center, "items-center": props.center }}>
|
||||
<div class="text-base"
|
||||
classList={{ "text-m-green": props.positive }}
|
||||
>{props.positive && "+ "}{prettyPrint()} <span class="text-sm">SATS</span>
|
||||
</div>
|
||||
<div class="text-sm text-neutral-500">≈ {amountInUsd()} <span class="text-sm">USD</span></div>
|
||||
return (
|
||||
<div
|
||||
class="flex flex-col"
|
||||
classList={{ "items-end": !props.center, "items-center": props.center }}
|
||||
>
|
||||
<div class="text-base" classList={{ "text-m-green": props.positive }}>
|
||||
{props.positive && "+ "}
|
||||
{prettyPrint()} <span class="text-sm">SATS</span>
|
||||
</div>
|
||||
<div class="text-sm text-neutral-500">
|
||||
≈ {amountInUsd()} <span class="text-sm">USD</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function LabelCircle(props: { name?: string; contact: boolean; label: boolean }) {
|
||||
// TODO: don't need to run this if it's not a contact
|
||||
const [gradient] = createResource(async () => {
|
||||
return generateGradient(props.name || "?");
|
||||
});
|
||||
|
||||
const text = () =>
|
||||
props.contact && props.name && props.name.length ? props.name[0] : props.label ? "≡" : "?";
|
||||
const bg = () => (props.name && props.contact ? gradient() : "gray");
|
||||
|
||||
return (
|
||||
<div
|
||||
class="flex-none h-[3rem] w-[3rem] rounded-full flex items-center justify-center text-3xl uppercase border-t border-b border-t-white/50 border-b-white/10"
|
||||
style={{ background: bg() }}
|
||||
>
|
||||
{text()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type HackActivityType = "Lightning" | "OnChain" | "ChannelOpen";
|
||||
|
||||
export function ActivityItem(props: {
|
||||
// This is actually the ActivityType enum but wasm is hard
|
||||
kind: HackActivityType;
|
||||
contacts: Contact[];
|
||||
labels: string[];
|
||||
amount: number | bigint;
|
||||
date?: number | bigint;
|
||||
positive?: boolean;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
const [state, _actions] = useMegaStore();
|
||||
|
||||
const firstContact = () => (props.contacts?.length ? props.contacts[0] : null);
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => props.onClick && props.onClick()}
|
||||
class="grid grid-cols-[auto_minmax(0,_1fr)_minmax(0,_max-content)] pb-4 gap-4 border-b border-neutral-800 last:border-b-0"
|
||||
classList={{ "cursor-pointer": !!props.onClick }}
|
||||
>
|
||||
<div class="flex gap-2 md:gap-4 items-center">
|
||||
<div class="">
|
||||
<Switch>
|
||||
<Match when={props.kind === "Lightning"}>
|
||||
<img class="w-[1rem]" src={bolt} alt="lightning" />
|
||||
</Match>
|
||||
<Match when={props.kind === "OnChain"}>
|
||||
<img class="w-[1rem]" src={chain} alt="onchain" />
|
||||
</Match>
|
||||
<Match when={props.kind === "ChannelOpen"}>
|
||||
<img class="w-[1rem]" src={shuffle} alt="swap" />
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LabelCircle(props: { name?: string, contact: boolean }) {
|
||||
|
||||
// TODO: don't need to run this if it's not a contact
|
||||
const [gradient] = createResource(async () => {
|
||||
return generateGradient(props.name || "?")
|
||||
})
|
||||
|
||||
const text = () => (props.contact && props.name && props.name.length) ? props.name[0] : (props.name && props.name.length) ? "≡" : "?"
|
||||
const bg = () => (props.name && props.contact) ? gradient() : "gray"
|
||||
|
||||
return (
|
||||
<div class="flex-none h-[3rem] w-[3rem] rounded-full flex items-center justify-center text-3xl uppercase border-t border-b border-t-white/50 border-b-white/10"
|
||||
style={{ background: bg() }}
|
||||
>
|
||||
{text()}
|
||||
<div class="">
|
||||
<LabelCircle
|
||||
name={firstContact()?.name}
|
||||
contact={props.contacts?.length > 0}
|
||||
label={props.labels?.length > 0}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<Switch>
|
||||
<Match when={firstContact()?.name}>
|
||||
<span class="text-base font-semibold truncate">{firstContact()?.name}</span>
|
||||
</Match>
|
||||
<Match when={props.labels.length > 0}>
|
||||
<span class="text-base font-semibold truncate">{props.labels[0]}</span>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<span class="text-base font-semibold text-neutral-500">Unknown</span>
|
||||
</Match>
|
||||
</Switch>
|
||||
<time class="text-sm text-neutral-500">{timeAgo(props.date)}</time>
|
||||
</div>
|
||||
<div class="">
|
||||
<ActivityAmount
|
||||
amount={props.amount.toString()}
|
||||
price={state.price}
|
||||
positive={props.positive}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// function that takes a list of MutinyTagItems and returns bool if one of those items is of kind Contact
|
||||
function includesContact(labels: MutinyTagItem[]) {
|
||||
return labels.some((label) => label.kind === "Contact")
|
||||
}
|
||||
|
||||
// sort the labels so that the contact is always first
|
||||
function sortLabels(labels: MutinyTagItem[]) {
|
||||
const contact = labels.find(label => label.kind === "Contact");
|
||||
return contact ? [contact, ...labels.filter(label => label !== contact)] : labels;
|
||||
}
|
||||
|
||||
// return a string of each label name separated by a comma and a space. if the array is empty return "Unknown"
|
||||
function labelString(labels: MutinyTagItem[]) {
|
||||
return labels.length ? labels.map(label => label.name).join(", ") : "Unknown"
|
||||
}
|
||||
|
||||
export function ActivityItem(props: { kind: "lightning" | "onchain", labels: MutinyTagItem[], amount: number | bigint, date?: number | bigint, positive?: boolean, onClick?: () => void }) {
|
||||
const labels = () => sortLabels(props.labels)
|
||||
const [state, _actions] = useMegaStore();
|
||||
return (
|
||||
<div
|
||||
onClick={() => props.onClick && props.onClick()}
|
||||
class="grid grid-cols-[auto_minmax(0,_1fr)_minmax(0,_max-content)] pb-4 gap-4 border-b border-neutral-800 last:border-b-0"
|
||||
classList={{ "cursor-pointer": !!props.onClick }}
|
||||
>
|
||||
<div class="flex gap-2 md:gap-4 items-center">
|
||||
<div class="">
|
||||
{props.kind === "lightning" ? <img class="w-[1rem]" src={bolt} alt="lightning" /> : <img class="w-[1rem]" src={chain} alt="onchain" />}
|
||||
</div>
|
||||
<div class="">
|
||||
<LabelCircle name={labels().length ? labels()[0].name : ""} contact={includesContact(labels())} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-base font-semibold truncate" classList={{ "text-neutral-500": labels().length === 0 }}>{labelString(labels())}</span>
|
||||
<time class="text-sm text-neutral-500">{timeAgo(props.date)}</time>
|
||||
</div>
|
||||
<div class="">
|
||||
<ActivityAmount amount={props.amount.toString()} price={state.price} positive={props.positive} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,51 +1,53 @@
|
||||
import { Dialog } from "@kobalte/core"
|
||||
import {
|
||||
For,
|
||||
JSX,
|
||||
Match,
|
||||
ParentComponent,
|
||||
Show,
|
||||
Suspense,
|
||||
Switch,
|
||||
createEffect,
|
||||
createMemo,
|
||||
} from "solid-js"
|
||||
import { Hr, ModalCloseButton, TinyButton, VStack } from "~/components/layout"
|
||||
import { MutinyInvoice } from "@mutinywallet/mutiny-wasm"
|
||||
import { OnChainTx } from "./Activity"
|
||||
createResource
|
||||
} from "solid-js";
|
||||
import { Hr, ModalCloseButton, TinyButton, VStack } from "~/components/layout";
|
||||
import { MutinyInvoice } from "@mutinywallet/mutiny-wasm";
|
||||
import { OnChainTx } from "./Activity";
|
||||
|
||||
import bolt from "~/assets/icons/bolt-black.svg"
|
||||
import chain from "~/assets/icons/chain-black.svg"
|
||||
import copyIcon from "~/assets/icons/copy.svg"
|
||||
import bolt from "~/assets/icons/bolt-black.svg";
|
||||
import chain from "~/assets/icons/chain-black.svg";
|
||||
import copyIcon from "~/assets/icons/copy.svg";
|
||||
|
||||
import { ActivityAmount } from "./ActivityItem"
|
||||
import { CopyButton } from "./ShareCard"
|
||||
import { prettyPrintTime } from "~/utils/prettyPrintTime"
|
||||
import { useMegaStore } from "~/state/megaStore"
|
||||
import { tagToMutinyTag } from "~/utils/tags"
|
||||
import { useCopy } from "~/utils/useCopy"
|
||||
import mempoolTxUrl from "~/utils/mempoolTxUrl"
|
||||
import { Network } from "~/logic/mutinyWalletSetup"
|
||||
import { AmountSmall } from "./Amount"
|
||||
import { ActivityAmount, HackActivityType } from "./ActivityItem";
|
||||
import { CopyButton } from "./ShareCard";
|
||||
import { prettyPrintTime } from "~/utils/prettyPrintTime";
|
||||
import { useMegaStore } from "~/state/megaStore";
|
||||
import { tagToMutinyTag } from "~/utils/tags";
|
||||
import { useCopy } from "~/utils/useCopy";
|
||||
import mempoolTxUrl from "~/utils/mempoolTxUrl";
|
||||
import { Network } from "~/logic/mutinyWalletSetup";
|
||||
import { AmountSmall } from "./Amount";
|
||||
|
||||
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 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-neutral-800/80 backdrop-blur-md shadow-xl rounded-xl border border-white/10"
|
||||
"max-w-[500px] w-[90vw] max-h-[100dvh] overflow-y-scroll disable-scrollbars mx-4 p-4 bg-neutral-800/80 backdrop-blur-md shadow-xl rounded-xl border border-white/10";
|
||||
|
||||
function LightningHeader(props: { info: MutinyInvoice }) {
|
||||
const [state, _actions] = useMegaStore()
|
||||
const [state, _actions] = useMegaStore();
|
||||
|
||||
const tags = createMemo(() => {
|
||||
if (props.info.labels.length) {
|
||||
const contact = state.mutiny_wallet?.get_contact(props.info.labels[0])
|
||||
const contact = state.mutiny_wallet?.get_contact(props.info.labels[0]);
|
||||
if (contact) {
|
||||
return [tagToMutinyTag(contact)]
|
||||
return [tagToMutinyTag(contact)];
|
||||
} else {
|
||||
return []
|
||||
return [];
|
||||
}
|
||||
} else {
|
||||
return []
|
||||
return [];
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
@@ -78,58 +80,54 @@ function LightningHeader(props: { info: MutinyInvoice }) {
|
||||
}
|
||||
|
||||
function OnchainHeader(props: { info: OnChainTx }) {
|
||||
const [state, _actions] = useMegaStore()
|
||||
const [state, _actions] = useMegaStore();
|
||||
|
||||
const tags = createMemo(() => {
|
||||
if (props.info.labels.length) {
|
||||
const contact = state.mutiny_wallet?.get_contact(props.info.labels[0])
|
||||
const contact = state.mutiny_wallet?.get_contact(props.info.labels[0]);
|
||||
if (contact) {
|
||||
return [tagToMutinyTag(contact)]
|
||||
return [tagToMutinyTag(contact)];
|
||||
} else {
|
||||
return []
|
||||
return [];
|
||||
}
|
||||
} else {
|
||||
return []
|
||||
return [];
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const isSend = () => {
|
||||
return props.info.sent > props.info.received
|
||||
}
|
||||
return props.info.sent > props.info.received;
|
||||
};
|
||||
|
||||
const amount = () => {
|
||||
if (isSend()) {
|
||||
return (props.info.sent - props.info.received).toString()
|
||||
return (props.info.sent - props.info.received).toString();
|
||||
} else {
|
||||
return (props.info.received - props.info.sent).toString()
|
||||
return (props.info.received - props.info.sent).toString();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="p-4 bg-neutral-100 rounded-full">
|
||||
<img src={chain} alt="blockchain" class="w-8 h-8" />
|
||||
</div>
|
||||
<h1 class="uppercase font-semibold">
|
||||
{isSend() ? "On-chain send" : "On-chain receive"}
|
||||
</h1>
|
||||
<ActivityAmount
|
||||
center
|
||||
amount={amount() ?? "0"}
|
||||
price={state.price}
|
||||
positive={!isSend()}
|
||||
/>
|
||||
<h1 class="uppercase font-semibold">{isSend() ? "On-chain send" : "On-chain receive"}</h1>
|
||||
<ActivityAmount center amount={amount() ?? "0"} price={state.price} positive={!isSend()} />
|
||||
<For each={tags()}>
|
||||
{(tag) => (
|
||||
<TinyButton tag={tag} onClick={() => {
|
||||
// noop
|
||||
}}>
|
||||
<TinyButton
|
||||
tag={tag}
|
||||
onClick={() => {
|
||||
// noop
|
||||
}}
|
||||
>
|
||||
{tag.name}
|
||||
</TinyButton>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const KeyValue: ParentComponent<{ key: string }> = (props) => {
|
||||
@@ -138,11 +136,11 @@ const KeyValue: ParentComponent<{ key: string }> = (props) => {
|
||||
<span class="uppercase font-semibold whitespace-nowrap">{props.key}</span>
|
||||
<span class="font-light">{props.children}</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
function MiniStringShower(props: { text: string }) {
|
||||
const [copy, _copied] = useCopy({ copiedTimeout: 1000 })
|
||||
const [copy, _copied] = useCopy({ copiedTimeout: 1000 });
|
||||
|
||||
return (
|
||||
<div class="w-full grid gap-1 grid-cols-[minmax(0,_1fr)_auto]">
|
||||
@@ -151,7 +149,7 @@ function MiniStringShower(props: { text: string }) {
|
||||
<img src={copyIcon} alt="copy" class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function LightningDetails(props: { info: MutinyInvoice }) {
|
||||
@@ -159,20 +157,14 @@ function LightningDetails(props: { info: MutinyInvoice }) {
|
||||
<VStack>
|
||||
<ul class="flex flex-col gap-4">
|
||||
<KeyValue key="Status">
|
||||
<span class="text-neutral-300">
|
||||
{props.info.paid ? "Paid" : "Unpaid"}
|
||||
</span>
|
||||
<span class="text-neutral-300">{props.info.paid ? "Paid" : "Unpaid"}</span>
|
||||
</KeyValue>
|
||||
<KeyValue key="When">
|
||||
<span class="text-neutral-300">
|
||||
{prettyPrintTime(Number(props.info.last_updated))}
|
||||
</span>
|
||||
<span class="text-neutral-300">{prettyPrintTime(Number(props.info.last_updated))}</span>
|
||||
</KeyValue>
|
||||
<Show when={props.info.description}>
|
||||
<KeyValue key="Description">
|
||||
<span class="text-neutral-300 truncate">
|
||||
{props.info.description}
|
||||
</span>
|
||||
<span class="text-neutral-300 truncate">{props.info.description}</span>
|
||||
</KeyValue>
|
||||
</Show>
|
||||
<KeyValue key="Fees">
|
||||
@@ -191,32 +183,28 @@ function LightningDetails(props: { info: MutinyInvoice }) {
|
||||
</KeyValue>
|
||||
</ul>
|
||||
</VStack>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function OnchainDetails(props: { info: OnChainTx }) {
|
||||
const [state, _actions] = useMegaStore()
|
||||
const [state, _actions] = useMegaStore();
|
||||
|
||||
const confirmationTime = () => {
|
||||
return props.info.confirmation_time?.Confirmed?.time
|
||||
}
|
||||
return props.info.confirmation_time?.Confirmed?.time;
|
||||
};
|
||||
|
||||
const network = state.mutiny_wallet?.get_network() as Network
|
||||
const network = state.mutiny_wallet?.get_network() as Network;
|
||||
|
||||
return (
|
||||
<VStack>
|
||||
<ul class="flex flex-col gap-4">
|
||||
<KeyValue key="Status">
|
||||
<span class="text-neutral-300">
|
||||
{confirmationTime() ? "Confirmed" : "Unconfirmed"}
|
||||
</span>
|
||||
<span class="text-neutral-300">{confirmationTime() ? "Confirmed" : "Unconfirmed"}</span>
|
||||
</KeyValue>
|
||||
<Show when={confirmationTime()}>
|
||||
<KeyValue key="When">
|
||||
<span class="text-neutral-300">
|
||||
{confirmationTime()
|
||||
? prettyPrintTime(Number(confirmationTime()))
|
||||
: "Pending"}
|
||||
{confirmationTime() ? prettyPrintTime(Number(confirmationTime())) : "Pending"}
|
||||
</span>
|
||||
</KeyValue>
|
||||
</Show>
|
||||
@@ -238,63 +226,86 @@ function OnchainDetails(props: { info: OnChainTx }) {
|
||||
Mempool.space
|
||||
</a>
|
||||
</VStack>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function DetailsModal(props: {
|
||||
open: boolean
|
||||
data: MutinyInvoice | OnChainTx
|
||||
setOpen: (open: boolean) => void
|
||||
children?: JSX.Element
|
||||
export function DetailsIdModal(props: {
|
||||
open: boolean;
|
||||
kind?: HackActivityType;
|
||||
id: string;
|
||||
setOpen: (open: boolean) => void;
|
||||
}) {
|
||||
const json = createMemo(() => JSON.stringify(props.data, null, 2))
|
||||
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 () => {
|
||||
if (kind() === "Lightning") {
|
||||
console.log("reading invoice: ", id());
|
||||
const invoice = await state.mutiny_wallet?.get_invoice_by_hash(id());
|
||||
return invoice;
|
||||
} else {
|
||||
console.log("reading tx: ", id());
|
||||
const tx = await state.mutiny_wallet?.get_transaction(id());
|
||||
return tx;
|
||||
}
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (props.id && props.kind && props.open) {
|
||||
refetch();
|
||||
}
|
||||
});
|
||||
|
||||
const json = createMemo(() => JSON.stringify(data() || "", null, 2));
|
||||
|
||||
const isInvoice = () => {
|
||||
return ("bolt11" in props.data) as boolean
|
||||
}
|
||||
return props.kind === "Lightning";
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog.Root
|
||||
open={props.open}
|
||||
onOpenChange={props.setOpen}
|
||||
>
|
||||
<Dialog.Root open={props.open} onOpenChange={props.setOpen}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class={OVERLAY} />
|
||||
<div class={DIALOG_POSITIONER}>
|
||||
<Dialog.Content class={DIALOG_CONTENT}>
|
||||
<div class="flex justify-between mb-2">
|
||||
<div />
|
||||
<Dialog.CloseButton>
|
||||
<ModalCloseButton />
|
||||
</Dialog.CloseButton>
|
||||
</div>
|
||||
<Dialog.Title>
|
||||
<Switch>
|
||||
<Match when={isInvoice()}>
|
||||
<LightningHeader info={props.data as MutinyInvoice} />
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<OnchainHeader info={props.data as OnChainTx} />
|
||||
</Match>
|
||||
</Switch>
|
||||
</Dialog.Title>
|
||||
<Hr />
|
||||
<Dialog.Description class="flex flex-col gap-4">
|
||||
<Switch>
|
||||
<Match when={isInvoice()}>
|
||||
<LightningDetails info={props.data as MutinyInvoice} />
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<OnchainDetails info={props.data as OnChainTx} />
|
||||
</Match>
|
||||
</Switch>
|
||||
<div class="flex justify-center">
|
||||
<CopyButton title="Copy" text={json()} />
|
||||
<Suspense>
|
||||
<div class="flex justify-between mb-2">
|
||||
<div />
|
||||
<Dialog.CloseButton>
|
||||
<ModalCloseButton />
|
||||
</Dialog.CloseButton>
|
||||
</div>
|
||||
</Dialog.Description>
|
||||
<Dialog.Title>
|
||||
<Switch>
|
||||
<Match when={isInvoice()}>
|
||||
<LightningHeader info={data() as MutinyInvoice} />
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<OnchainHeader info={data() as OnChainTx} />
|
||||
</Match>
|
||||
</Switch>
|
||||
</Dialog.Title>
|
||||
<Hr />
|
||||
<Dialog.Description class="flex flex-col gap-4">
|
||||
<Switch>
|
||||
<Match when={isInvoice()}>
|
||||
<LightningDetails info={data() as MutinyInvoice} />
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<OnchainDetails info={data() as OnChainTx} />
|
||||
</Match>
|
||||
</Switch>
|
||||
<div class="flex justify-center">
|
||||
<CopyButton title="Copy" text={json()} />
|
||||
</div>
|
||||
</Dialog.Description>
|
||||
</Suspense>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,66 +1,86 @@
|
||||
import { Show, createSignal, onMount } from "solid-js";
|
||||
import { Show, createSignal } from "solid-js";
|
||||
import { Button, ButtonLink, SmallHeader } from "./layout";
|
||||
import { useMegaStore } from "~/state/megaStore";
|
||||
import { showToast } from "./Toaster";
|
||||
import save from "~/assets/icons/save.svg"
|
||||
import save from "~/assets/icons/save.svg";
|
||||
import close from "~/assets/icons/close.svg";
|
||||
import restore from "~/assets/icons/upload.svg";
|
||||
|
||||
export function OnboardWarning() {
|
||||
const [state, actions] = useMegaStore();
|
||||
const [dismissedBackup, setDismissedBackup] = createSignal(false);
|
||||
const [state, actions] = useMegaStore();
|
||||
const [dismissedBackup, setDismissedBackup] = createSignal(false);
|
||||
|
||||
onMount(() => {
|
||||
actions.sync()
|
||||
})
|
||||
function hasMoney() {
|
||||
return state.balance?.confirmed || state.balance?.lightning || state.balance?.unconfirmed;
|
||||
}
|
||||
|
||||
function hasMoney() {
|
||||
return state.balance?.confirmed || state.balance?.lightning || state.balance?.unconfirmed
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* TODO: show this once we have a restore flow */}
|
||||
<Show when={false}>
|
||||
<div class="grid grid-cols-[auto_minmax(0,_1fr)_auto] rounded-xl p-4 gap-4 bg-neutral-950/50">
|
||||
<div class="self-center">
|
||||
<img src={restore} alt="backup" class="w-8 h-8" />
|
||||
</div>
|
||||
<div class='flex md:flex-row flex-col items-center gap-4'>
|
||||
<div class="flex flex-col">
|
||||
<SmallHeader>Welcome!</SmallHeader>
|
||||
<p class="text-base font-light">
|
||||
If you've used Mutiny before you can restore from a backup. Otherwise you can skip this and enjoy your new wallet!
|
||||
</p>
|
||||
</div>
|
||||
<Button intent="green" layout="xs" class="self-start md:self-auto" onClick={() => { showToast({ title: "Unimplemented", description: "We don't do that yet" }) }}>Restore</Button>
|
||||
</div>
|
||||
<button tabindex="-1" onClick={() => { actions.dismissRestorePrompt() }} class="self-center hover:bg-white/10 rounded-lg active:bg-m-blue w-8">
|
||||
<img src={close} alt="Close" />
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!state.has_backed_up && hasMoney() && !dismissedBackup()}>
|
||||
<div class="grid grid-cols-[auto_minmax(0,_1fr)_auto] rounded-xl p-4 gap-4 bg-neutral-950/50">
|
||||
<div class="self-center">
|
||||
<img src={save} alt="backup" class="w-8 h-8" />
|
||||
</div>
|
||||
<div class='flex flex-row max-md:items-center justify-between gap-4'>
|
||||
<div class="flex flex-col">
|
||||
<SmallHeader>Secure your funds</SmallHeader>
|
||||
<p class="text-base font-light max-md:hidden">
|
||||
You have money stored in this browser. Let's make sure you have a backup.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<ButtonLink intent="blue" layout="xs" class="self-auto" href="/backup">Backup</ButtonLink>
|
||||
</div>
|
||||
</div>
|
||||
<button tabindex="-1" onClick={() => { setDismissedBackup(true) }} class="self-center hover:bg-white/10 rounded-lg active:bg-m-blue w-8">
|
||||
<img src={close} alt="Close" />
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{/* TODO: show this once we have a restore flow */}
|
||||
<Show when={false}>
|
||||
<div class="grid grid-cols-[auto_minmax(0,_1fr)_auto] rounded-xl p-4 gap-4 bg-neutral-950/50">
|
||||
<div class="self-center">
|
||||
<img src={restore} alt="backup" class="w-8 h-8" />
|
||||
</div>
|
||||
<div class="flex md:flex-row flex-col items-center gap-4">
|
||||
<div class="flex flex-col">
|
||||
<SmallHeader>Welcome!</SmallHeader>
|
||||
<p class="text-base font-light">
|
||||
If you've used Mutiny before you can restore from a backup. Otherwise you can skip
|
||||
this and enjoy your new wallet!
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
intent="green"
|
||||
layout="xs"
|
||||
class="self-start md:self-auto"
|
||||
onClick={() => {
|
||||
showToast({ title: "Unimplemented", description: "We don't do that yet" });
|
||||
}}
|
||||
>
|
||||
Restore
|
||||
</Button>
|
||||
</div>
|
||||
<button
|
||||
tabindex="-1"
|
||||
onClick={() => {
|
||||
actions.dismissRestorePrompt();
|
||||
}}
|
||||
class="self-center hover:bg-white/10 rounded-lg active:bg-m-blue w-8"
|
||||
>
|
||||
<img src={close} alt="Close" />
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!state.has_backed_up && hasMoney() && !dismissedBackup()}>
|
||||
<div class="grid grid-cols-[auto_minmax(0,_1fr)_auto] rounded-xl p-4 gap-4 bg-neutral-950/50">
|
||||
<div class="self-center">
|
||||
<img src={save} alt="backup" class="w-8 h-8" />
|
||||
</div>
|
||||
<div class="flex flex-row max-md:items-center justify-between gap-4">
|
||||
<div class="flex flex-col">
|
||||
<SmallHeader>Secure your funds</SmallHeader>
|
||||
<p class="text-base font-light max-md:hidden">
|
||||
You have money stored in this browser. Let's make sure you have a backup.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<ButtonLink intent="blue" layout="xs" class="self-auto" href="/backup">
|
||||
Backup
|
||||
</ButtonLink>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
tabindex="-1"
|
||||
onClick={() => {
|
||||
setDismissedBackup(true);
|
||||
}}
|
||||
class="self-center hover:bg-white/10 rounded-lg active:bg-m-blue w-8"
|
||||
>
|
||||
<img src={close} alt="Close" />
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -389,8 +389,6 @@ export default function Send() {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user