onchain, lightning, and utxo activity cards

This commit is contained in:
Paul Miller
2023-04-18 19:41:58 -05:00
parent 18e3ad8a41
commit 4be030749a
14 changed files with 330 additions and 30 deletions

View File

@@ -0,0 +1,3 @@
<svg width="16" height="17" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.333 13.008a.667.667 0 0 1-.666.667h-6A.667.667 0 0 1 3 13.008v-6a.667.667 0 1 1 1.333 0v4.39l7.348-7.346a.667.667 0 1 1 .942.942l-7.347 7.348h4.39c.369 0 .667.298.667.666Z" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 325 B

204
src/components/Activity.tsx Normal file
View File

@@ -0,0 +1,204 @@
import send from '~/assets/icons/send.svg';
import receive from '~/assets/icons/receive.svg';
import { Card, Hr, LoadingSpinner, SmallAmount, SmallHeader, VStack } from './layout';
import { For, JSX, Match, Show, Suspense, Switch, createMemo, createResource, createSignal } from 'solid-js';
import { useMegaStore } from '~/state/megaStore';
import { MutinyInvoice } from '@mutinywallet/mutiny-wasm';
import { prettyPrintTime } from '~/utils/prettyPrintTime';
import { JsonModal } from './JsonModal';
import mempoolTxUrl from '~/utils/mempoolTxUrl';
const THREE_COLUMNS = 'grid grid-cols-[auto,1fr,auto] gap-4 py-2 px-2 border-b border-neutral-800 last:border-b-0'
const CENTER_COLUMN = 'min-w-0 overflow-hidden max-w-full'
const MISSING_LABEL = 'py-1 px-2 bg-m-red rounded inline-block text-sm'
const RIGHT_COLUMN = 'flex flex-col items-right text-right'
type OnChainTx = {
txid: string
received: number
sent: number
fee?: number
confirmation_time?: {
height: number
timestamp: number
}
}
type Utxo = {
outpoint: string
txout: {
value: number
script_pubkey: string
}
keychain: string
is_spent: boolean
}
function SubtleText(props: { children: any }) {
return <h3 class='text-xs text-gray-500 uppercase'>{props.children}</h3>
}
function OnChainItem(props: { item: OnChainTx }) {
const isReceive = createMemo(() => props.item.received > 0);
const [open, setOpen] = createSignal(false)
return (
<>
<JsonModal open={open()} data={props.item} title="On-Chain Transaction" setOpen={setOpen}>
<a href={mempoolTxUrl(props.item.txid, "signet")} target="_blank" rel="noreferrer">
Mempool Link
</a>
</JsonModal>
<div class={THREE_COLUMNS} onclick={() => setOpen(!open())}>
{isReceive() ? <img src={receive} alt="receive arrow" /> : <img src={send} alt="send arrow" />}
<div class={CENTER_COLUMN}>
<h2 class={MISSING_LABEL}>Label Missing</h2>
{isReceive() ? <SmallAmount amount={props.item.received} /> : <SmallAmount amount={props.item.sent} />}
{/* <h2 class="truncate">Txid: {props.item.txid}</h2> */}
</div>
<div class={RIGHT_COLUMN}>
<SmallHeader class={isReceive() ? "text-m-green" : "text-m-red"}>
{isReceive() ? "RECEIVE" : "SEND"}
</SmallHeader>
<SubtleText>{props.item.confirmation_time ? prettyPrintTime(props.item.confirmation_time.timestamp) : "Unconfirmed"}</SubtleText>
</div>
</div>
</>
)
}
function InvoiceItem(props: { item: MutinyInvoice }) {
const isSend = createMemo(() => props.item.is_send);
const [open, setOpen] = createSignal(false)
return (
<>
<JsonModal open={open()} data={props.item} title="Lightning Transaction" setOpen={setOpen} />
<div class={THREE_COLUMNS} onclick={() => setOpen(!open())}>
{isSend() ? <img src={send} alt="send arrow" /> : <img src={receive} alt="receive arrow" />}
<div class={CENTER_COLUMN}>
<h2 class={MISSING_LABEL}>Label Missing</h2>
<SmallAmount amount={props.item.amount_sats || 0} />
</div>
<div class={RIGHT_COLUMN}>
<SmallHeader class={isSend() ? "text-m-red" : "text-m-green"}>
{isSend() ? "SEND" : "RECEIVE"}
</SmallHeader>
<SubtleText>{prettyPrintTime(Number(props.item.expire))}</SubtleText>
</div>
</div >
</>
)
}
function Utxo(props: { item: Utxo }) {
const spent = createMemo(() => props.item.is_spent);
const [open, setOpen] = createSignal(false)
return (
<>
<JsonModal open={open()} data={props.item} title="Unspent Transaction Output" setOpen={setOpen} />
<div class={THREE_COLUMNS} onclick={() => setOpen(!open())}>
<img src={receive} alt="receive arrow" />
<div class={CENTER_COLUMN}>
<h2 class={MISSING_LABEL}>Label Missing</h2>
<SmallAmount amount={props.item.txout.value} />
</div>
<div class={RIGHT_COLUMN}>
<SmallHeader class={spent() ? "text-m-red" : "text-m-green"}>
{spent() ? "SPENT" : "UNSPENT"}
</SmallHeader>
</div>
</div>
</>
)
}
export function Activity() {
const [state, _] = useMegaStore();
const getTransactions = async () => {
console.log("Getting onchain txs");
const txs = await state.node_manager?.list_onchain() as OnChainTx[];
return txs.reverse();
}
const getInvoices = async () => {
console.log("Getting invoices");
const invoices = await state.node_manager?.list_invoices() as MutinyInvoice[];
return invoices.filter((inv) => inv.paid).reverse();
}
const getUtXos = async () => {
console.log("Getting utxos");
const utxos = await state.node_manager?.list_utxos() as Utxo[];
return utxos;
}
const [transactions, { refetch: refetchTransactions }] = createResource(getTransactions);
const [invoices, { refetch: refetchInvoices }] = createResource(getInvoices);
const [utxos, { refetch: refetchUtxos }] = createResource(getUtXos);
return (
<VStack>
<Suspense>
<Card title="On-chain">
<Switch>
<Match when={transactions.loading}>
<LoadingSpinner big />
</Match>
<Match when={transactions.state === "ready" && transactions().length === 0}>
<code>No transactions (empty state)</code>
</Match>
<Match when={transactions.state === "ready" && transactions().length >= 0}>
<For each={transactions()}>
{(tx) =>
<OnChainItem item={tx} />
}
</For>
</Match>
</Switch>
</Card>
<Card title="Lightning">
<Switch>
<Match when={invoices.loading}>
<LoadingSpinner big />
</Match>
<Match when={invoices.state === "ready" && invoices().length === 0}>
<code>No invoices (empty state)</code>
</Match>
<Match when={invoices.state === "ready" && invoices().length >= 0}>
<For each={invoices()}>
{(invoice) =>
<InvoiceItem item={invoice} />
}
</For>
</Match>
</Switch>
</Card>
<Card title="UTXOs">
<Switch>
<Match when={utxos.loading}>
<LoadingSpinner big />
</Match>
<Match when={utxos.state === "ready" && utxos().length === 0}>
<code>No utxos (empty state)</code>
</Match>
<Match when={utxos.state === "ready" && utxos().length >= 0}>
<For each={utxos()}>
{(utxo) =>
<Utxo item={utxo} />
}
</For>
</Match>
</Switch>
</Card>
</Suspense>
</VStack>
)
}

View File

@@ -12,12 +12,7 @@ function prettyPrintAmount(n?: number | bigint): string {
export function Amount(props: { amountSats: bigint | number | undefined, showFiat?: boolean, loading?: boolean }) { export function Amount(props: { amountSats: bigint | number | undefined, showFiat?: boolean, loading?: boolean }) {
const [state, _] = useMegaStore() const [state, _] = useMegaStore()
async function getPrice() { const amountInUsd = () => satsToUsd(state.price, Number(props.amountSats) || 0, true)
return await state.node_manager?.get_bitcoin_price()
}
const [price] = createResource(getPrice)
const amountInUsd = () => satsToUsd(price(), Number(props.amountSats) || 0, true)
return ( return (
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">

View File

@@ -98,12 +98,7 @@ export function AmountEditable(props: { initialAmountSats: string, setAmountSats
// Fiat conversion // Fiat conversion
const [state, _] = useMegaStore() const [state, _] = useMegaStore()
async function getPrice() { const amountInUsd = () => satsToUsd(state.price, Number(displayAmount()) || 0, true)
return await state.node_manager?.get_bitcoin_price()
}
const [price] = createResource(getPrice)
const amountInUsd = () => satsToUsd(price(), Number(displayAmount()) || 0, true)
// What we're all here for in the first place: returning a value // What we're all here for in the first place: returning a value
function handleSubmit() { function handleSubmit() {

View File

@@ -5,6 +5,7 @@ import NavBar from "~/components/NavBar";
import ReloadPrompt from "~/components/Reload"; import ReloadPrompt from "~/components/Reload";
import { Scan } from '~/assets/svg/Scan'; import { Scan } from '~/assets/svg/Scan';
import { A } from 'solid-start'; import { A } from 'solid-start';
import { Activity } from './Activity';
export default function App() { export default function App() {
return ( return (
@@ -15,8 +16,9 @@ export default function App() {
<img src={logo} class="h-10" alt="logo" /> <img src={logo} class="h-10" alt="logo" />
<A class="p-2 hover:bg-white/5 rounded-lg active:bg-m-blue" href="scanner"><Scan /></A> <A class="p-2 hover:bg-white/5 rounded-lg active:bg-m-blue" href="scanner"><Scan /></A>
</header> </header>
<BalanceBox />
<ReloadPrompt /> <ReloadPrompt />
<BalanceBox />
<Activity />
</DefaultMain> </DefaultMain>
<NavBar activeTab="home" /> <NavBar activeTab="home" />
</SafeArea> </SafeArea>

View File

@@ -0,0 +1,44 @@
import { Dialog } from "@kobalte/core";
import { JSX, createMemo } from "solid-js";
import { Button, ButtonLink, SmallHeader } from "~/components/layout";
import { useCopy } from "~/utils/useCopy";
const OVERLAY = "fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
const DIALOG_POSITIONER = "fixed inset-0 z-50 flex items-center justify-center"
const DIALOG_CONTENT = "max-w-[600px] max-h-screen-safe p-4 bg-gray/50 backdrop-blur-md shadow-xl rounded-xl border border-white/10 overflow-y-scroll disable-scrollbars"
export function JsonModal(props: { title: string, open: boolean, data?: unknown, setOpen: (open: boolean) => void, children?: JSX.Element }) {
const json = createMemo(() => JSON.stringify(props.data, null, 2));
const [copy, copied] = useCopy({ copiedTimeout: 1000 });
return (
<Dialog.Root isOpen={props.open} onOpenChange={(isOpen) => props.setOpen(isOpen)}>
<Dialog.Portal>
<Dialog.Overlay class={OVERLAY} />
<div class={DIALOG_POSITIONER}>
<Dialog.Content class={DIALOG_CONTENT}>
<div class="flex justify-between mb-2">
<Dialog.Title>
<SmallHeader>
{props.title}
</SmallHeader>
</Dialog.Title>
<Dialog.CloseButton>
<code>X</code>
</Dialog.CloseButton>
</div>
<Dialog.Description class="flex flex-col gap-4">
<pre class="whitespace-pre-wrap break-all">
{json()}
</pre>
{props.children}
<Button onClick={(_) => copy(json() ?? "")}>{copied() ? "Copied" : "Copy"}</Button>
<Button onClick={(_) => props.setOpen(false)}>Close</Button>
</Dialog.Description>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog.Root >
)
}

View File

@@ -28,7 +28,7 @@ function PeersList() {
<Suspense> <Suspense>
<For each={peers()} fallback={<code>No peers</code>}> <For each={peers()} fallback={<code>No peers</code>}>
{(peer) => ( {(peer) => (
<pre class="overflow-x-auto whitespace-pre-line break-all"> <pre class="overflow-x-auto whitespace-pre-wrap break-all">
{JSON.stringify(peer, null, 2)} {JSON.stringify(peer, null, 2)}
</pre> </pre>
)} )}
@@ -102,7 +102,7 @@ function ChannelsList() {
<For each={channels()} fallback={<code>No channels</code>}> <For each={channels()} fallback={<code>No channels</code>}>
{(channel) => ( {(channel) => (
<> <>
<pre class="overflow-x-auto whitespace-pre-line break-all"> <pre class="overflow-x-auto whitespace-pre-wrap break-all">
{JSON.stringify(channel, null, 2)} {JSON.stringify(channel, null, 2)}
</pre> </pre>
<a class="text-sm font-light opacity-50 mt-2" href={mempoolTxUrl(channel.outpoint?.split(":")[0], network)} target="_blank" rel="noreferrer"> <a class="text-sm font-light opacity-50 mt-2" href={mempoolTxUrl(channel.outpoint?.split(":")[0], network)} target="_blank" rel="noreferrer">
@@ -182,7 +182,7 @@ function OpenChannel(props: { refetchChannels: RefetchChannelsListType }) {
</form > </form >
</InnerCard> </InnerCard>
<Show when={newChannel()}> <Show when={newChannel()}>
<pre class="overflow-x-auto whitespace-pre-line break-all"> <pre class="overflow-x-auto whitespace-pre-wrap break-all">
{JSON.stringify(newChannel()?.outpoint, null, 2)} {JSON.stringify(newChannel()?.outpoint, null, 2)}
</pre> </pre>
<pre>{newChannel()?.outpoint}</pre> <pre>{newChannel()?.outpoint}</pre>

View File

@@ -6,7 +6,6 @@ const DIALOG_POSITIONER = "fixed inset-0 z-50 flex items-center justify-center"
const DIALOG_CONTENT = "w-[80vw] max-w-[400px] p-4 bg-gray/50 backdrop-blur-md shadow-xl rounded-xl border border-white/10" const DIALOG_CONTENT = "w-[80vw] max-w-[400px] p-4 bg-gray/50 backdrop-blur-md shadow-xl rounded-xl border border-white/10"
export function SentModal(props: { details?: { nice: string } }) { export function SentModal(props: { details?: { nice: string } }) {
return ( return (
<Dialog.Root isOpen={!!props.details}> <Dialog.Root isOpen={!!props.details}>
<Dialog.Portal> <Dialog.Portal>

View File

@@ -4,11 +4,13 @@ import { Button, ButtonLink } from "./Button"
import { Separator } from "@kobalte/core" import { Separator } from "@kobalte/core"
import { useMegaStore } from "~/state/megaStore" import { useMegaStore } from "~/state/megaStore"
const SmallHeader: ParentComponent = (props) => <header class='text-sm font-semibold uppercase'>{props.children}</header> const SmallHeader: ParentComponent<{ class?: string }> = (props) => {
return <header class={`text-sm font-semibold uppercase ${props.class}`}>{props.children}</header>
}
const Card: ParentComponent<{ title?: string }> = (props) => { const Card: ParentComponent<{ title?: string }> = (props) => {
return ( return (
<div class='rounded-xl p-4 flex flex-col gap-2 bg-neutral-800'> <div class='rounded-xl p-4 flex flex-col gap-2 bg-neutral-950'>
{props.title && <SmallHeader>{props.title}</SmallHeader>} {props.title && <SmallHeader>{props.title}</SmallHeader>}
{props.children} {props.children}
</div> </div>
@@ -74,8 +76,8 @@ const NodeManagerGuard: ParentComponent = (props) => {
) )
} }
const LoadingSpinner = () => { const LoadingSpinner = (props: { big?: boolean }) => {
return (<div role="status" class="w-full h-full grid" > return (<div role="status" class={props.big ? "w-full h-full grid" : ""} >
<svg aria-hidden="true" class="w-8 h-8 mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-m-red place-self-center" viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg aria-hidden="true" class="w-8 h-8 mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-m-red place-self-center" viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" fill="currentColor" /> <path d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" fill="currentColor" />
<path d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z" fill="currentFill" /> <path d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z" fill="currentFill" />
@@ -90,4 +92,29 @@ const LargeHeader: ParentComponent = (props) => {
return (<h1 class="text-4xl font-semibold uppercase border-b-2 border-b-white my-4">{props.children}</h1>) return (<h1 class="text-4xl font-semibold uppercase border-b-2 border-b-white my-4">{props.children}</h1>)
} }
export { SmallHeader, Card, SafeArea, LoadingSpinner, Button, ButtonLink, Linkify, Hr, NodeManagerGuard, FullscreenLoader, InnerCard, FancyCard, DefaultMain, LargeHeader } const VStack: ParentComponent = (props) => {
return (<div class="flex flex-col gap-4">{props.children}</div>)
}
const SmallAmount: ParentComponent<{ amount: number | bigint }> = (props) => {
return (<h2 class="font-light text-lg">{props.amount.toLocaleString()} <span class="text-sm">SATS</span></h2>)
}
export {
SmallHeader,
Card,
SafeArea,
LoadingSpinner,
Button,
ButtonLink,
Linkify,
Hr,
NodeManagerGuard,
FullscreenLoader,
InnerCard,
FancyCard,
DefaultMain,
LargeHeader,
VStack,
SmallAmount
}

20
src/routes/Admin.tsx Normal file
View File

@@ -0,0 +1,20 @@
import { Activity } from "~/components/Activity";
import KitchenSink from "~/components/KitchenSink";
import NavBar from "~/components/NavBar";
import { Card, DefaultMain, LargeHeader, SafeArea, VStack } from "~/components/layout";
export default function Admin() {
return (
<SafeArea>
<DefaultMain>
<LargeHeader>Admin</LargeHeader>
<VStack>
<Card><p>If you know what you're doing you're in the right place!</p></Card>
<Activity />
<KitchenSink />
</VStack>
</DefaultMain>
<NavBar activeTab="none" />
</SafeArea>
)
}

View File

@@ -82,14 +82,7 @@ export default function Receive() {
setReceiveState("show") setReceiveState("show")
} }
async function getPrice() { const amountInUsd = createMemo(() => satsToUsd(state.price, parseInt(amount()) || 0, true))
// return await state.node_manager?.get_bitcoin_price()
return 30000
}
const [price] = createResource(getPrice)
const amountInUsd = createMemo(() => satsToUsd(price(), parseInt(amount()) || 0, true))
function handleAmountSave() { function handleAmountSave() {
console.error("focusing label input...") console.error("focusing label input...")

View File

@@ -16,6 +16,7 @@ export type MegaStore = [{
scan_result?: string; scan_result?: string;
balance?: MutinyBalance; balance?: MutinyBalance;
last_sync?: number; last_sync?: number;
price: number
}, { }, {
fetchUserStatus(): Promise<UserStatus>; fetchUserStatus(): Promise<UserStatus>;
setupNodeManager(): Promise<void>; setupNodeManager(): Promise<void>;
@@ -27,6 +28,8 @@ export const Provider: ParentComponent = (props) => {
waitlist_id: localStorage.getItem("waitlist_id"), waitlist_id: localStorage.getItem("waitlist_id"),
node_manager: undefined as NodeManager | undefined, node_manager: undefined as NodeManager | undefined,
user_status: undefined as UserStatus, user_status: undefined as UserStatus,
// TODO: wire this up to real price once we have caching
price: 30000
}); });
const actions = { const actions = {

View File

@@ -0,0 +1,12 @@
export function prettyPrintTime(ts: number) {
const options = {
weekday: 'long',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: 'numeric'
};
return new Date(ts * 1000).toLocaleString('en-US', options as any);
}

View File

@@ -70,6 +70,9 @@ module.exports = {
'.min-h-screen-safe': { '.min-h-screen-safe': {
minHeight: 'calc(100vh - (env(safe-area-inset-top) + env(safe-area-inset-bottom)))' minHeight: 'calc(100vh - (env(safe-area-inset-top) + env(safe-area-inset-bottom)))'
}, },
'.max-h-screen-safe': {
maxHeight: 'calc(100vh - (env(safe-area-inset-top) + env(safe-area-inset-bottom)))'
},
'.disable-scrollbars': { '.disable-scrollbars': {
scrollbarWidth: 'none', scrollbarWidth: 'none',
'-ms-overflow-style': 'none', '-ms-overflow-style': 'none',