From 5671c04df567c5b525b4f3e00d82f2495376acda Mon Sep 17 00:00:00 2001 From: Paul Miller Date: Thu, 1 Jun 2023 11:36:00 -0500 Subject: [PATCH] working activity refactor! --- src/components/Activity.tsx | 275 +++++++++++------------------- src/components/ActivityItem.tsx | 220 ++++++++++++++---------- src/components/DetailsModal.tsx | 245 +++++++++++++------------- src/components/OnboardWarning.tsx | 134 ++++++++------- src/routes/Send.tsx | 2 - 5 files changed, 435 insertions(+), 441 deletions(-) diff --git a/src/components/Activity.tsx b/src/components/Activity.tsx index b1a6bea..1ccb91a 100644 --- a/src/components/Activity.tsx +++ b/src/components/Activity.tsx @@ -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 ( - <> - - setOpen(o => !o)} - /> - - ) -} - -function InvoiceItem(props: { item: MutinyInvoice; labels: MutinyTagItem[] }) { - const isSend = createMemo(() => !props.item.inbound); - - const [open, setOpen] = createSignal(false) - - return ( - <> - - setOpen(o => !o)} - /> - - ) + + ); } 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(); + const [detailsId, setDetailsId] = createSignal(""); + + function openDetailsModal(id: string, kind: HackActivityType) { + console.log("Opening details modal: ", id, kind); + + setDetailsId(id); + setDetailsKind(kind); + setDetailsOpen(true); + } return ( - - - - - -
- Receive some sats to get started -
-
- = 0}> - - {(activityItem) => ( - - - - - - - - - )} - - -
+ <> + + + + + + + + +
+ Receive some sats to get started +
+
+ = 0}> + + {(activityItem) => ( + + )} + + +
+ ); } diff --git a/src/components/ActivityItem.tsx b/src/components/ActivityItem.tsx index 4c922cf..1bd8b07 100644 --- a/src/components/ActivityItem.tsx +++ b/src/components/ActivityItem.tsx @@ -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 ( -
-
{props.positive && "+ "}{prettyPrint()} SATS -
-
≈ {amountInUsd()} USD
+ return ( +
+
+ {props.positive && "+ "} + {prettyPrint()} SATS +
+
+ ≈ {amountInUsd()} USD +
+
+ ); +}; + +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 ( +
+ {text()} +
+ ); +} + +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 ( +
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 }} + > +
+
+ + + lightning + + + onchain + + + swap + +
- ) -} - -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 ( -
- {text()} +
+ 0} + label={props.labels?.length > 0} + />
- ) +
+
+ + + {firstContact()?.name} + + 0}> + {props.labels[0]} + + + Unknown + + + +
+
+ +
+
+ ); } - -// 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 ( -
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 }} - > -
-
- {props.kind === "lightning" ? lightning : onchain} -
-
- -
-
-
- {labelString(labels())} - -
-
- -
-
- ) -} \ No newline at end of file diff --git a/src/components/DetailsModal.tsx b/src/components/DetailsModal.tsx index b08962e..26d242f 100644 --- a/src/components/DetailsModal.tsx +++ b/src/components/DetailsModal.tsx @@ -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 (
@@ -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 (
blockchain
-

- {isSend() ? "On-chain send" : "On-chain receive"} -

- +

{isSend() ? "On-chain send" : "On-chain receive"}

+ {(tag) => ( - { - // noop - }}> + { + // noop + }} + > {tag.name} )}
- ) + ); } const KeyValue: ParentComponent<{ key: string }> = (props) => { @@ -138,11 +136,11 @@ const KeyValue: ParentComponent<{ key: string }> = (props) => { {props.key} {props.children} - ) -} + ); +}; function MiniStringShower(props: { text: string }) { - const [copy, _copied] = useCopy({ copiedTimeout: 1000 }) + const [copy, _copied] = useCopy({ copiedTimeout: 1000 }); return (
@@ -151,7 +149,7 @@ function MiniStringShower(props: { text: string }) { copy
- ) + ); } function LightningDetails(props: { info: MutinyInvoice }) { @@ -159,20 +157,14 @@ function LightningDetails(props: { info: MutinyInvoice }) {
    - - {props.info.paid ? "Paid" : "Unpaid"} - + {props.info.paid ? "Paid" : "Unpaid"} - - {prettyPrintTime(Number(props.info.last_updated))} - + {prettyPrintTime(Number(props.info.last_updated))} - - {props.info.description} - + {props.info.description} @@ -191,32 +183,28 @@ function LightningDetails(props: { info: MutinyInvoice }) {
- ) + ); } 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 (
    - - {confirmationTime() ? "Confirmed" : "Unconfirmed"} - + {confirmationTime() ? "Confirmed" : "Unconfirmed"} - {confirmationTime() - ? prettyPrintTime(Number(confirmationTime())) - : "Pending"} + {confirmationTime() ? prettyPrintTime(Number(confirmationTime())) : "Pending"} @@ -238,63 +226,86 @@ function OnchainDetails(props: { info: OnChainTx }) { Mempool.space - ) + ); } -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 ( - +
    -
    -
    - - - -
    - - - - - - - - - - -
    - - - - - - - - - -
    - + +
    +
    + + +
    - + + + + + + + + + + +
    + + + + + + + + + +
    + +
    +
    +
    - ) + ); } diff --git a/src/components/OnboardWarning.tsx b/src/components/OnboardWarning.tsx index 504ee77..d01c887 100644 --- a/src/components/OnboardWarning.tsx +++ b/src/components/OnboardWarning.tsx @@ -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 */} - -
    -
    - backup -
    -
    -
    - Welcome! -

    - If you've used Mutiny before you can restore from a backup. Otherwise you can skip this and enjoy your new wallet! -

    -
    - -
    - -
    -
    - -
    -
    - backup -
    -
    -
    - Secure your funds -

    - You have money stored in this browser. Let's make sure you have a backup. -

    -
    -
    - Backup -
    -
    - -
    -
    - - ) -} \ No newline at end of file + return ( + <> + {/* TODO: show this once we have a restore flow */} + +
    +
    + backup +
    +
    +
    + Welcome! +

    + If you've used Mutiny before you can restore from a backup. Otherwise you can skip + this and enjoy your new wallet! +

    +
    + +
    + +
    +
    + +
    +
    + backup +
    +
    +
    + Secure your funds +

    + You have money stored in this browser. Let's make sure you have a backup. +

    +
    +
    + + Backup + +
    +
    + +
    +
    + + ); +} diff --git a/src/routes/Send.tsx b/src/routes/Send.tsx index b4c8f3f..0780d0b 100644 --- a/src/routes/Send.tsx +++ b/src/routes/Send.tsx @@ -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);