Merge pull request #90 from MutinyWallet/activity-design-update

Activity design update
This commit is contained in:
Paul Miller
2023-05-11 17:57:15 -05:00
committed by GitHub
42 changed files with 706 additions and 552 deletions

View File

@@ -0,0 +1,3 @@
<svg width="14" height="16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.0778 6.68331 4.44176 15.5633c-.24.246-.638-.039-.482-.345l3.074-6.06599c.02328-.04578.03442-.09677.03235-.14809-.00207-.05132-.01728-.10125-.04418-.14501-.02689-.04375-.06457-.07987-.10943-.10489-.04485-.02502-.09538-.03811-.14674-.03801H.299757c-.059058-.00005-.116788-.01752-.165955-.05024-.0491673-.03272-.087584-.07922-.1104352-.13368-.02285107-.05446-.02912021-.11445-.01802154-.17246.01109864-.058.03907164-.11144.08041244-.15362L8.09576.0913129c.232-.2349999.618.0230001.489.3280001l-2.297 5.414997c-.01945.04591-.02715.09594-.02241.14557.00475.04963.02179.0973.04958.13869.02779.04139.06546.07521.10961.09838.04414.02318.09336.03499.14322.03436l6.29104-.078c.0593-.00095.1176.01573.1675.04794.0499.03221.0891.0785.1127.133.0235.0545.0304.11477.0197.17317-.0108.0584-.0386.11231-.0799.15489l-.001.001Z" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 921 B

View File

@@ -0,0 +1,4 @@
<svg width="17" height="17" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="m3.2916 7.53561-2.12 2.121C.421438 10.4068 0 11.4242 0 12.4851s.421438 2.0783 1.1716 2.8285c.75017.7502 1.76761 1.1716 2.8285 1.1716 1.0609 0 2.07834-.4214 2.8285-1.1716l2.828-2.828c.3715-.3714.6661-.8124.8671-1.2977.2011-.4853.3045-1.0055.3045-1.53079 0-.5253-.1034-1.04546-.3045-1.53078-.201-.48531-.4956-.92628-.8671-1.29772l-1.06 1.06c.23222.23216.41643.50778.54211.81114.12567.30336.19036.6285.19036.95686 0 .32836-.06469.65349-.19036.95689-.12568.3033-.30989.579-.54211.8111l-2.831 2.828c-.4715.4554-1.10301.7074-1.7585.7017-.65549-.0057-1.28252-.2686-1.74604-.7321-.46352-.4636-.72645-1.0906-.73214-1.7461-.0057-.6555.24629-1.287.70168-1.7585l2.12-2.12099-1.06-1.061h.001Z" fill="#fff"/>
<path d="m12.1304 7.8886 2.121-2.12c.4655-.46947.7261-1.10423.7248-1.76538-.0014-.66114-.2646-1.29483-.732-1.7624-.4674-.46756-1.1011-.73093-1.7622-.73247-.6612-.00154-1.296.25887-1.7656.72425l-2.82899 2.828c-.23222.23216-.41643.50779-.5421.81114-.12568.30336-.19037.6285-.19037.95686 0 .32836.06469.65351.19037.95686.12567.30336.30988.57899.5421.81114l-1.06 1.06c-.37146-.37143-.66612-.8124-.86715-1.29772-.20103-.48531-.3045-1.00547-.3045-1.53078 0-.5253.10347-1.04546.3045-1.53078.20103-.48531.49569-.92628.86715-1.29772l2.828-2.828C10.4056.421438 11.423-1e-8 12.4839 0c1.0609 1e-8 2.0783.421438 2.8285 1.1716.7502.75017 1.1716 1.76761 1.1716 2.8285 0 1.0609-.4214 2.07834-1.1716 2.8285l-2.121 2.121-1.061-1.06v-.001Z" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="m9.9998 13.5998 5.9-5.9c.1833-.18333.4167-.275.7-.275.2833 0 .5167.09167.7.275.1833.18334.275.41667.275.7 0 .28334-.0917.51667-.275.7l-6.6 6.6c-.2.2-.4333.3-.7.3-.26666 0-.5-.1-.7-.3l-2.6-2.6c-.18333-.1833-.275-.4167-.275-.7 0-.2833.09167-.5167.275-.7.18334-.1833.41667-.275.7-.275.28334 0 .51667.0917.7.275l1.9 1.9Z" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 425 B

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 3c-.53043 0-1.03914.21071-1.41421.58579C3.21071 3.96086 3 4.46957 3 5v14c0 .5304.21071 1.0391.58579 1.4142C3.96086 20.7893 4.46957 21 5 21h14c.5304 0 1.0391-.2107 1.4142-.5858S21 19.5304 21 19V5.5L18.5 3H17v6c0 .26522-.1054.51957-.2929.70711C16.5196 9.89464 16.2652 10 16 10H8c-.26522 0-.51957-.10536-.70711-.29289C7.10536 9.51957 7 9.26522 7 9V3H5Zm7 1v5h3V4h-3Zm-5 8h10c.2652 0 .5196.1054.7071.2929S18 12.7348 18 13v6H6v-6c0-.2652.10536-.5196.29289-.7071C6.48043 12.1054 6.73478 12 7 12Z" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 601 B

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 20c-.55 0-1.021-.196-1.413-.588C4.195 19.02 3.99934 18.5493 4 18v-3h2v3h12v-3h2v3c0 .55-.196 1.021-.588 1.413-.392.392-.8627.5877-1.412.587H6Zm5-4V7.85l-2.6 2.6L7 9l5-5 5 5-1.4 1.45-2.6-2.6V16h-2Z" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 308 B

View File

@@ -1,15 +1,13 @@
import send from '~/assets/icons/send.svg';
import receive from '~/assets/icons/receive.svg';
import { ButtonLink, Card, LoadingSpinner, NiceP, SmallAmount, SmallHeader, VStack } from './layout';
import { For, Match, ParentComponent, Show, Suspense, Switch, createMemo, createResource, createSignal } from 'solid-js';
import { LoadingSpinner, NiceP, SmallAmount, SmallHeader } from './layout';
import { For, Match, ParentComponent, Show, 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 '~/components/JsonModal';
import mempoolTxUrl from '~/utils/mempoolTxUrl';
import wave from "~/assets/wave.gif"
import utxoIcon from '~/assets/icons/coin.svg';
import { getRedshifted } from '~/utils/fakeLabels';
import { ActivityItem } from './ActivityItem';
import { MutinyTagItem } from '~/utils/tags';
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'
@@ -27,7 +25,8 @@ export type OnChainTx = {
height: number
time: number
}
}
},
labels: string[]
}
export type UtxoItem = {
@@ -38,15 +37,16 @@ export type UtxoItem = {
}
keychain: string
is_spent: boolean,
redshifted?: boolean
redshifted?: boolean,
}
const SubtleText: ParentComponent = (props) => {
return <h3 class='text-xs text-gray-500 uppercase'>{props.children}</h3>
}
function OnChainItem(props: { item: OnChainTx }) {
const isReceive = createMemo(() => props.item.received > 0);
function OnChainItem(props: { item: OnChainTx, labels: MutinyTagItem[] }) {
const [store, actions] = useMegaStore();
const isReceive = () => props.item.received > props.item.sent
const [open, setOpen] = createSignal(false)
@@ -57,26 +57,21 @@ function OnChainItem(props: { item: OnChainTx }) {
Mempool Link
</a>
</JsonModal>
<div class={THREE_COLUMNS} onClick={() => setOpen(!open())}>
<div class="flex items-center">
{isReceive() ? <img src={receive} alt="receive arrow" /> : <img src={send} alt="send arrow" />}
</div>
<div class={CENTER_COLUMN}>
<h2 class={MISSING_LABEL}>Unknown</h2>
{isReceive() ? <SmallAmount amount={props.item.received} /> : <SmallAmount amount={props.item.sent} />}
</div>
<div class={RIGHT_COLUMN}>
<SmallHeader>
<span class="text-neutral-500">On-chain</span>&nbsp;{isReceive() ? <span class="text-m-green">Receive</span> : <span class="text-m-red">Send</span>}
</SmallHeader>
<SubtleText>{props.item.confirmation_time?.Confirmed ? prettyPrintTime(props.item.confirmation_time?.Confirmed?.time) : "Unconfirmed"}</SubtleText>
</div>
</div>
{/* {JSON.stringify(props.labels)} */}
<ActivityItem
kind={"onchain"}
labels={props.labels}
amount={isReceive() ? props.item.received : props.item.sent}
date={props.item.confirmation_time?.Confirmed?.time}
positive={isReceive()}
onClick={() => setOpen(!open())}
/>
</>
)
}
function InvoiceItem(props: { item: MutinyInvoice }) {
function InvoiceItem(props: { item: MutinyInvoice, labels: MutinyTagItem[] }) {
const [store, actions] = useMegaStore();
const isSend = createMemo(() => props.item.is_send);
const [open, setOpen] = createSignal(false)
@@ -84,21 +79,7 @@ function InvoiceItem(props: { item: MutinyInvoice }) {
return (
<>
<JsonModal open={open()} data={props.item} title="Lightning Transaction" setOpen={setOpen} />
<div class={THREE_COLUMNS} onClick={() => setOpen(!open())}>
<div class="flex items-center">
{isSend() ? <img src={send} alt="send arrow" /> : <img src={receive} alt="receive arrow" />}
</div>
<div class={CENTER_COLUMN}>
<h2 class={MISSING_LABEL}>Unknown</h2>
<SmallAmount amount={props.item.amount_sats || 0} />
</div>
<div class={RIGHT_COLUMN}>
<SmallHeader>
<span class="text-neutral-500">Lightning</span>&nbsp;{!isSend() ? <span class="text-m-green">Receive</span> : <span class="text-m-red">Send</span>}
</SmallHeader>
<SubtleText>{prettyPrintTime(Number(props.item.expire))}</SubtleText>
</div>
</div >
<ActivityItem kind={"lightning"} labels={props.labels} amount={props.item.amount_sats || 0n} date={props.item.last_updated} positive={!isSend()} onClick={() => setOpen(!open())} />
</>
)
}
@@ -135,122 +116,45 @@ function Utxo(props: { item: UtxoItem }) {
)
}
export function Activity() {
const [state, _] = useMegaStore();
const getTransactions = async () => {
console.log("Getting onchain txs");
const txs = await state.mutiny_wallet?.list_onchain() as OnChainTx[];
return txs.reverse();
}
const getInvoices = async () => {
console.log("Getting invoices");
const invoices = await state.mutiny_wallet?.list_invoices() as MutinyInvoice[];
return invoices.filter((inv) => inv.paid).reverse();
}
const getUtXos = async () => {
console.log("Getting utxos");
const utxos = await state.mutiny_wallet?.list_utxos() as UtxoItem[];
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 wide />
</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 wide />
</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 wide />
</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>
<ButtonLink href="/redshift" layout="small" class="flex items-center gap-2 self-center hover:text-m-red">Redshift <img src={wave} class="h-4" alt="redshift"></img></ButtonLink>
</Card>
</Suspense>
</VStack>
)
}
type ActivityItem = { type: "onchain" | "lightning", item: OnChainTx | MutinyInvoice, time: number }
type ActivityItem = { type: "onchain" | "lightning", item: OnChainTx | MutinyInvoice, time: number, labels: MutinyTagItem[] }
function sortByTime(a: ActivityItem, b: ActivityItem) {
return b.time - a.time;
}
export function CombinedActivity(props: { limit?: number }) {
const [state, _] = 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();
const activity: ActivityItem[] = [];
let activity: ActivityItem[] = [];
txs.forEach((tx) => {
activity.push({ type: "onchain", item: tx, time: tx.confirmation_time?.Confirmed?.time || Date.now() })
})
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: [] })
}
invoices.forEach((invoice) => {
activity.push({ type: "lightning", item: invoice, time: Number(invoice.expire) })
})
for (let i = 0; i < invoices.length; i++) {
if (invoices[i].paid) {
activity.push({ type: "lightning", item: invoices[i], time: Number(invoices[i].expire), labels: [] })
}
}
if (props.limit) {
return activity.sort(sortByTime).slice(0, props.limit);
activity = activity.sort(sortByTime).slice(0, props.limit);
} else {
return activity.sort(sortByTime);
activity.sort(sortByTime);
}
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] = createResource(getAllActivity);
@@ -268,10 +172,12 @@ export function CombinedActivity(props: { limit?: number }) {
{(activityItem) =>
<Switch>
<Match when={activityItem.type === "onchain"}>
<OnChainItem item={activityItem.item as OnChainTx} />
{/* FIXME */}
<OnChainItem item={activityItem.item as OnChainTx} labels={activityItem.labels} />
</Match>
<Match when={activityItem.type === "lightning"}>
<InvoiceItem item={activityItem.item as MutinyInvoice} />
{/* FIXME */}
<InvoiceItem item={activityItem.item as MutinyInvoice} labels={activityItem.labels} />
</Match>
</Switch>
}

View File

@@ -0,0 +1,100 @@
import { ParentComponent, createMemo, createResource } from "solid-js";
import { InlineAmount } from "./AmountCard";
import { satsToUsd } from "~/utils/conversions";
import bolt from "~/assets/icons/bolt.svg"
import chain from "~/assets/icons/chain.svg"
import { timeAgo } from "~/utils/prettyPrintTime";
import { MutinyTagItem } from "~/utils/tags";
import { generateGradient } from "~/utils/gradientHash";
export const ActivityAmount: ParentComponent<{ amount: string, price: number, positive?: 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();
}
})
return (
<div class="flex flex-col items-end">
<div class="text-base"
classList={{ "text-m-green": props.positive }}
>{props.positive && "+ "}{prettyPrint()}&nbsp;<span class="text-sm">SATS</span>
</div>
<div class="text-sm text-neutral-500">&#8776;&nbsp;{amountInUsd()}&nbsp;<span class="text-sm">USD</span></div>
</div>
)
}
function LabelCircle(props: { name?: string, contact: boolean }) {
// TODO: don't need to run this if it's not a contact
const [gradient] = createResource(props.name, async (name: string) => {
return generateGradient(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>
)
}
// 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)
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={30000} positive={props.positive} />
</div>
</div>
)
}

View File

@@ -135,7 +135,7 @@ export const AmountEditable: ParentComponent<{ initialAmountSats: string, initia
const DIALOG_CONTENT = "h-full safe-bottom flex flex-col justify-between p-4 backdrop-blur-xl bg-neutral-800/70"
return (
<Dialog.Root isOpen={isOpen()}>
<Dialog.Root open={isOpen()}>
<button onClick={() => setIsOpen(true)} class="px-4 py-2 rounded-xl border-2 border-m-blue flex gap-2 items-center">
{/* <Amount amountSats={Number(displayAmount())} showFiat /><span>&#x270F;&#xFE0F;</span> */}
<Show when={displayAmount() !== "0"} fallback={<div class="inline-block font-semibold">Set amount</div>}>

View File

@@ -1,35 +1,42 @@
import logo from '~/assets/icons/mutiny-logo.svg';
import { DefaultMain, MutinyWalletGuard, SafeArea, VStack, Card } from "~/components/layout";
import BalanceBox from "~/components/BalanceBox";
import { DefaultMain, SafeArea, VStack, Card, LoadingSpinner } from "~/components/layout";
import BalanceBox, { LoadingShimmer } from "~/components/BalanceBox";
import NavBar from "~/components/NavBar";
import ReloadPrompt from "~/components/Reload";
import { A } from 'solid-start';
import { OnboardWarning } from '~/components/OnboardWarning';
import { CombinedActivity } from './Activity';
import userClock from '~/assets/icons/user-clock.svg';
import { useMegaStore } from '~/state/megaStore';
import { Show } from 'solid-js';
export default function App() {
const [state, _actions] = useMegaStore();
return (
<MutinyWalletGuard>
<SafeArea>
<DefaultMain>
<header class="w-full flex justify-between items-center mt-4 mb-2">
<img src={logo} class="h-10" alt="logo" />
<A class="md:hidden p-2 hover:bg-white/5 rounded-lg active:bg-m-blue" href="/activity"><img src={userClock} alt="Activity" /></A>
</header>
<SafeArea>
<DefaultMain>
<header class="w-full flex justify-between items-center mt-4 mb-2">
<img src={logo} class="h-10" alt="logo" />
<A class="md:hidden p-2 hover:bg-white/5 rounded-lg active:bg-m-blue" href="/activity"><img src={userClock} alt="Activity" /></A>
</header>
<Show when={!state.wallet_loading}>
<OnboardWarning />
<ReloadPrompt />
<BalanceBox />
<Card title="Activity">
<VStack>
</Show>
<BalanceBox loading={state.wallet_loading} />
<Card title="Activity">
<div class="p-1" />
<VStack>
<Show when={!state.wallet_loading} fallback={<LoadingShimmer />}>
<CombinedActivity limit={3} />
{/* <ButtonLink href="/activity">View All</ButtonLink> */}
<A href="/activity" class="text-m-red active:text-m-red/80 text-xl font-semibold no-underline self-center">View All</A>
</VStack>
</Card>
</DefaultMain>
<NavBar activeTab="home" />
</SafeArea>
</MutinyWalletGuard>
</Show>
{/* <ButtonLink href="/activity">View All</ButtonLink> */}
</VStack>
<A href="/activity" class="text-m-red active:text-m-red/80 text-xl font-semibold no-underline self-center">View All</A>
</Card>
</DefaultMain>
<NavBar activeTab="home" />
</SafeArea>
);
}

View File

@@ -1,7 +1,8 @@
import { Show, Suspense } from "solid-js";
import { ButtonLink, FancyCard, Indicator } from "~/components/layout";
import { Button, ButtonLink, FancyCard, Indicator } from "~/components/layout";
import { useMegaStore } from "~/state/megaStore";
import { Amount } from "./Amount";
import { useNavigate } from "solid-start";
function prettyPrintAmount(n?: number | bigint): string {
if (!n || n.valueOf() === 0) {
@@ -10,19 +11,38 @@ function prettyPrintAmount(n?: number | bigint): string {
return n.toLocaleString()
}
export default function BalanceBox() {
export function LoadingShimmer() {
return (<div class="flex flex-col gap-2 animate-pulse">
<h1 class="text-4xl font-light">
<div class="w-[12rem] rounded bg-neutral-700 h-[2.5rem]"></div>
</h1>
<h2 class="text-xl font-light text-white/70" >
<div class="w-[8rem] rounded bg-neutral-700 h-[1.75rem]"></div>
</h2>
</div>)
}
export default function BalanceBox(props: { loading?: boolean }) {
const [state, actions] = useMegaStore();
const emptyBalance = () => (state.balance?.confirmed || 0n) === 0n && (state.balance?.lightning || 0n) === 0n
const navigate = useNavigate()
return (
<>
<FancyCard title="Lightning">
<Amount amountSats={state.balance?.lightning || 0} showFiat />
<Show when={!props.loading} fallback={<LoadingShimmer />}>
<Amount amountSats={state.balance?.lightning || 0} showFiat />
</Show>
</FancyCard>
<FancyCard title="On-Chain" tag={state.is_syncing && <Indicator>Syncing</Indicator>}>
<div onClick={actions.sync}>
<Amount amountSats={state.balance?.confirmed} showFiat />
</div>
<Show when={!props.loading} fallback={<LoadingShimmer />}>
<div onClick={actions.sync}>
<Amount amountSats={state.balance?.confirmed} showFiat />
</div>
</Show>
<Suspense>
<Show when={state.balance?.unconfirmed}>
<div class="flex flex-col gap-2">
@@ -37,8 +57,8 @@ export default function BalanceBox() {
</Suspense>
</FancyCard>
<div class="flex gap-2 py-4">
<ButtonLink href="/send" intent="green">Send</ButtonLink>
<ButtonLink href="/receive" intent="blue">Receive</ButtonLink>
<Button onClick={() => navigate("/send")} disabled={emptyBalance() || props.loading} intent="green">Send</Button>
<Button onClick={() => navigate("/receive")} disabled={props.loading} intent="blue">Receive</Button>
</div>
</>
)

View File

@@ -1,59 +0,0 @@
import { RadioGroup as Kobalte } from '@kobalte/core';
import { type JSX, Show, splitProps, For } from 'solid-js';
type RadioGroupProps = {
name: string;
label?: string | undefined;
options: { label: string; value: string }[];
value: string | undefined;
error: string;
required?: boolean | undefined;
disabled?: boolean | undefined;
ref: (element: HTMLInputElement | HTMLTextAreaElement) => void;
onInput: JSX.EventHandler<HTMLInputElement | HTMLTextAreaElement, InputEvent>;
onChange: JSX.EventHandler<HTMLInputElement | HTMLTextAreaElement, Event>;
onBlur: JSX.EventHandler<HTMLInputElement | HTMLTextAreaElement, FocusEvent>;
};
type Color = "blue" | "green" | "red" | "gray"
export const colorVariants = {
blue: "bg-m-blue",
green: "bg-m-green",
red: "bg-m-red",
gray: "bg-[#898989]",
}
export function ColorRadioGroup(props: RadioGroupProps) {
const [rootProps, inputProps] = splitProps(
props,
['name', 'value', 'required', 'disabled'],
['ref', 'onInput', 'onChange', 'onBlur']
);
return (
<Kobalte.Root
{...rootProps}
validationState={props.error ? 'invalid' : 'valid'}
class="flex flex-col gap-2"
>
<Show when={props.label}>
<Kobalte.Label class="text-sm uppercase font-semibold">
{props.label}
</Kobalte.Label>
</Show>
<div class="flex gap-2">
<For each={props.options}>
{(option) => (
<Kobalte.Item value={option.value} class="ui-checked:bg-neutral-950 rounded outline outline-black/50 ui-checked:outline-white ui-checked:outline-2">
<Kobalte.ItemInput {...inputProps} />
<Kobalte.ItemControl class={`${colorVariants[option.value as Color]} w-8 h-8 rounded`}>
<Kobalte.ItemIndicator />
</Kobalte.ItemControl>
{/* <Kobalte.ItemLabel>{option.label}</Kobalte.ItemLabel> */}
</Kobalte.Item>
)}
</For>
</div>
<Kobalte.ErrorMessage>{props.error}</Kobalte.ErrorMessage>
</Kobalte.Root>
);
}

View File

@@ -1,24 +1,17 @@
import { Match, Switch, createSignal, createUniqueId } from 'solid-js';
import { Match, Switch, createSignal } from 'solid-js';
import { SmallHeader, TinyButton } from '~/components/layout';
import { Dialog } from '@kobalte/core';
import close from "~/assets/icons/close.svg";
import { SubmitHandler } from '@modular-forms/solid';
import { ContactItem } from '~/state/contacts';
import { ContactForm } from './ContactForm';
import { ContactFormValues } from './ContactViewer';
const INITIAL: ContactItem = { id: createUniqueId(), kind: "contact", name: "", color: "gray" }
export function ContactEditor(props: { createContact: (contact: ContactItem) => void, list?: boolean }) {
export function ContactEditor(props: { createContact: (contact: ContactFormValues) => void, list?: boolean }) {
const [isOpen, setIsOpen] = createSignal(false);
// What we're all here for in the first place: returning a value
const handleSubmit: SubmitHandler<ContactItem> = (c: ContactItem) => {
// TODO: why do the id and color disappear?
const odd = { id: createUniqueId(), kind: "contact" }
props.createContact({ ...odd, ...c })
const handleSubmit: SubmitHandler<ContactFormValues> = (c: ContactFormValues) => {
props.createContact(c)
setIsOpen(false);
}
@@ -26,7 +19,7 @@ export function ContactEditor(props: { createContact: (contact: ContactItem) =>
const DIALOG_CONTENT = "h-full safe-bottom flex flex-col justify-between p-4 backdrop-blur-xl bg-neutral-800/70"
return (
<Dialog.Root isOpen={isOpen()}>
<Dialog.Root open={isOpen()}>
<Switch>
<Match when={props.list}>
<button onClick={() => setIsOpen(true)} class="flex flex-col items-center gap-2">
@@ -50,7 +43,7 @@ export function ContactEditor(props: { createContact: (contact: ContactItem) =>
<img src={close} alt="Close" />
</button>
</div>
<ContactForm title="New contact" cta="Create contact" handleSubmit={handleSubmit} initialValues={INITIAL} />
<ContactForm title="New contact" cta="Create contact" handleSubmit={handleSubmit} />
</Dialog.Content>
</div>
</Dialog.Portal>

View File

@@ -1,13 +1,10 @@
import { SubmitHandler, createForm, required } from "@modular-forms/solid";
import { ContactItem } from "~/state/contacts";
import { Button, LargeHeader, VStack } from "~/components/layout";
import { TextField } from "~/components/layout/TextField";
import { ColorRadioGroup } from "~/components/ColorRadioGroup";
import { ContactFormValues } from "./ContactViewer";
const colorOptions = [{ label: "blue", value: "blue" }, { label: "green", value: "green" }, { label: "red", value: "red" }, { label: "gray", value: "gray" }]
export function ContactForm(props: { handleSubmit: SubmitHandler<ContactItem>, initialValues?: ContactItem, title: string, cta: string }) {
const [_contactForm, { Form, Field }] = createForm<ContactItem>({ initialValues: props.initialValues });
export function ContactForm(props: { handleSubmit: SubmitHandler<ContactFormValues>, initialValues?: ContactFormValues, title: string, cta: string }) {
const [_contactForm, { Form, Field }] = createForm<ContactFormValues>({ initialValues: props.initialValues });
return (
<Form onSubmit={props.handleSubmit} class="flex flex-col flex-1 justify-around gap-4 max-w-[400px] mx-auto w-full">
@@ -19,16 +16,11 @@ export function ContactForm(props: { handleSubmit: SubmitHandler<ContactItem>, i
<TextField {...props} placeholder='Satoshi' value={field.value} error={field.error} label="Name" />
)}
</Field>
<Field name="npub" validate={[]}>
{/* <Field name="npub" validate={[]}>
{(field, props) => (
<TextField {...props} placeholder='npub...' value={field.value} error={field.error} label="Nostr npub or NIP-05 (optional)" />
)}
</Field>
<Field name="color">
{(field, props) => (
<ColorRadioGroup options={colorOptions} {...props} value={field.value} error={field.error} label="Color" />
)}
</Field>
</Field> */}
</VStack>
</div>
<Button type="submit" intent="blue" class="w-full flex-none">

View File

@@ -3,16 +3,24 @@ import { Button, Card, NiceP, SmallHeader } from '~/components/layout';
import { Dialog } from '@kobalte/core';
import close from "~/assets/icons/close.svg";
import { SubmitHandler } from '@modular-forms/solid';
import { ContactItem } from '~/state/contacts';
import { ContactForm } from './ContactForm';
import { showToast } from './Toaster';
import { Contact } from '@mutinywallet/mutiny-wasm';
export function ContactViewer(props: { contact: ContactItem, gradient: string, saveContact: (contact: ContactItem) => void }) {
export type ContactFormValues = {
name: string,
npub?: string,
}
export function ContactViewer(props: { contact: Contact, gradient: string, saveContact: (contact: Contact) => void }) {
const [isOpen, setIsOpen] = createSignal(false);
const [isEditing, setIsEditing] = createSignal(false);
const handleSubmit: SubmitHandler<ContactItem> = (c: ContactItem) => {
props.saveContact({ ...props.contact, ...c })
const handleSubmit: SubmitHandler<ContactFormValues> = (c: ContactFormValues) => {
// FIXME: merge with existing contact if saving (need edit contact method)
// FIXME: npub not valid? other undefineds
const contact = new Contact(c.name, undefined, undefined, undefined)
props.saveContact(contact)
setIsEditing(false)
}
@@ -20,7 +28,7 @@ export function ContactViewer(props: { contact: ContactItem, gradient: string, s
const DIALOG_CONTENT = "h-full safe-bottom flex flex-col justify-between p-4 backdrop-blur-xl bg-neutral-800/70"
return (
<Dialog.Root isOpen={isOpen()}>
<Dialog.Root open={isOpen()}>
<button onClick={() => setIsOpen(true)} class="flex flex-col items-center gap-2 w-16 flex-shrink-0 overflow-x-hidden">
<div class="flex-none h-16 w-16 rounded-full flex items-center justify-center text-4xl uppercase border-t border-b border-t-white/50 border-b-white/10"
style={{ background: props.gradient }}

View File

@@ -53,7 +53,7 @@ export function DeleteEverything() {
return (
<>
<Button onClick={confirmReset}>Delete Everything</Button>
<ConfirmDialog loading={confirmLoading()} isOpen={confirmOpen()} onConfirm={resetNode} onCancel={() => setConfirmOpen(false)}>
<ConfirmDialog loading={confirmLoading()} open={confirmOpen()} onConfirm={resetNode} onCancel={() => setConfirmOpen(false)}>
This will delete your node's state. This can't be undone!
</ConfirmDialog>
</>

View File

@@ -7,9 +7,9 @@ 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"
// TODO: implement this like toast so it's just one global confirm and I can call it with `confirm({ title: "Are you sure?", description: "This will delete your node" })`
export const ConfirmDialog: ParentComponent<{ isOpen: boolean; loading: boolean; onCancel: () => void, onConfirm: () => void }> = (props) => {
export const ConfirmDialog: ParentComponent<{ open: boolean; loading: boolean; onCancel: () => void, onConfirm: () => void }> = (props) => {
return (
<Dialog.Root isOpen={props.isOpen} onOpenChange={props.onCancel}>
<Dialog.Root open={props.open} onOpenChange={props.onCancel}>
<Dialog.Portal>
<Dialog.Overlay class={OVERLAY} />
<div class={DIALOG_POSITIONER}>

View File

@@ -67,7 +67,7 @@ export function ImportExport() {
<Button onClick={uploadFile}>Upload Saved State</Button>
</VStack>
</InnerCard>
<ConfirmDialog loading={confirmLoading()} isOpen={confirmOpen()} onConfirm={importJson} onCancel={() => setConfirmOpen(false)}>
<ConfirmDialog loading={confirmLoading()} open={confirmOpen()} onConfirm={importJson} onCancel={() => setConfirmOpen(false)}>
Do you want to replace your state with {files()[0].name}?
</ConfirmDialog>
</>

View File

@@ -13,7 +13,7 @@ export function JsonModal(props: { title: string, open: boolean, data?: unknown,
const [copy, copied] = useCopy({ copiedTimeout: 1000 });
return (
<Dialog.Root isOpen={props.open} onOpenChange={(isOpen) => props.setOpen(isOpen)}>
<Dialog.Root open={props.open} onOpenChange={(isOpen) => props.setOpen(isOpen)}>
<Dialog.Portal>
<Dialog.Overlay class={OVERLAY} />
<div class={DIALOG_POSITIONER}>

View File

@@ -110,7 +110,7 @@ function ConnectPeer(props: { refetchPeers: RefetchPeersType }) {
<form class="flex flex-col gap-4" onSubmit={onSubmit} >
<TextField.Root
value={value()}
onValueChange={setValue}
onChange={setValue}
validationState={(value() == "" || value().startsWith("mutiny:")) ? "valid" : "invalid"}
class="flex flex-col gap-4"
>
@@ -167,7 +167,7 @@ function ChannelItem(props: { channel: MutinyChannel, network?: string }) {
<Button intent="glowy" layout="xs" onClick={handleCloseChannel}>Close Channel</Button>
</VStack>
<ConfirmDialog isOpen={confirmOpen()} onConfirm={confirmCloseChannel} onCancel={() => setConfirmOpen(false)} loading={confirmLoading()}>
<ConfirmDialog open={confirmOpen()} onConfirm={confirmCloseChannel} onCancel={() => setConfirmOpen(false)} loading={confirmLoading()}>
<p>Are you sure you want to close this channel?</p>
</ConfirmDialog>
</Collapsible.Content>
@@ -259,7 +259,7 @@ function OpenChannel(props: { refetchChannels: RefetchChannelsListType }) {
<form class="flex flex-col gap-4" onSubmit={onSubmit} >
<TextField.Root
value={peerPubkey()}
onValueChange={setPeerPubkey}
onChange={setPeerPubkey}
class="flex flex-col gap-2"
>
<TextField.Label class="text-sm font-semibold uppercase" >Pubkey</TextField.Label>
@@ -267,7 +267,7 @@ function OpenChannel(props: { refetchChannels: RefetchChannelsListType }) {
</TextField.Root>
<TextField.Root
value={amount()}
onValueChange={setAmount}
onChange={setAmount}
class="flex flex-col gap-2"
>
<TextField.Label class="text-sm font-semibold uppercase" >Amount</TextField.Label>
@@ -313,7 +313,7 @@ function LnUrlAuth() {
<form class="flex flex-col gap-4" onSubmit={onSubmit} >
<TextField.Root
value={value()}
onValueChange={setValue}
onChange={setValue}
validationState={(value() == "" || value().toLowerCase().startsWith("lnurl")) ? "valid" : "invalid"}
class="flex flex-col gap-4"
>
@@ -327,6 +327,32 @@ function LnUrlAuth() {
)
}
function ListTags() {
const [_state, actions] = useMegaStore()
const [tags] = createResource(actions.listTags)
return (
<Collapsible.Root>
<Collapsible.Trigger class="w-full">
<h2 class="truncate text-start text-lg font-mono bg-neutral-200 text-black rounded px-4 py-2">
{">"} Tags
</h2>
</Collapsible.Trigger>
<Collapsible.Content>
<VStack>
<pre class="overflow-x-auto whitespace-pre-wrap break-all">
{JSON.stringify(tags(), null, 2)}
</pre>
</VStack>
</Collapsible.Content>
</Collapsible.Root>
)
}
export default function KitchenSink() {
@@ -339,6 +365,9 @@ export default function KitchenSink() {
<ChannelsList />
<Hr />
<LnUrlAuth />
<Hr />
<ListTags />
<Hr />
<ImportExport />
</Card>

View File

@@ -7,7 +7,7 @@ export function Logs() {
async function handleSave() {
const logs = await state.mutiny_wallet?.get_logs()
downloadTextFile(logs.join() || "", "mutiny-logs.txt", "text/plain")
downloadTextFile(logs.join("") || "", "mutiny-logs.txt", "text/plain")
}
return (

View File

@@ -1,7 +1,10 @@
import { Show, createSignal, onMount } from "solid-js";
import { Button, ButtonLink, SmallHeader, VStack } from "./layout";
import { Button, ButtonLink, SmallHeader } from "./layout";
import { useMegaStore } from "~/state/megaStore";
import { showToast } from "./Toaster";
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();
@@ -18,30 +21,42 @@ export function OnboardWarning() {
return (
<>
{/* TODO: show this once we have a restore flow */}
<Show when={!state.dismissed_restore_prompt && false}>
<div class='rounded-xl p-4 flex flex-col gap-2 bg-neutral-950 overflow-x-hidden'>
<SmallHeader>Welcome!</SmallHeader>
<VStack>
<p class="text-2xl font-light">
Do you want to restore an existing Mutiny Wallet?
</p>
<div class="w-full flex gap-2">
<Button intent="green" onClick={() => { showToast({ title: "Unimplemented", description: "We don't do that yet" }) }}>Restore</Button>
<Button onClick={actions.dismissRestorePrompt}>Nope</Button>
<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>
</VStack>
<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='rounded-xl p-4 flex flex-col gap-2 bg-neutral-950 overflow-x-hidden'>
<SmallHeader>Secure your funds</SmallHeader>
<p class="text-2xl font-light">
You have money stored in this browser. Let's make sure you have a backup.
</p>
<div class="w-full flex gap-2">
<ButtonLink intent="blue" href="/backup">Backup</ButtonLink>
<Button onClick={() => { setDismissedBackup(true) }}>Nope</Button>
<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 md:flex-row flex-col items-center gap-4'>
<div class="flex flex-col">
<SmallHeader>Secure your funds</SmallHeader>
<p class="text-base font-light">
You have money stored in this browser. Let's make sure you have a backup.
</p>
</div>
<ButtonLink intent="blue" layout="xs" class="self-start md:self-auto" href="/backup">Backup</ButtonLink>
</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>
</>

View File

@@ -25,7 +25,7 @@ const ReloadPrompt: Component = () => {
return (
<Show when={offlineReady() || needRefresh()}>
<Card title="PWA settings">
{/* <Card title="PWA settings">
<div>
<Show
fallback={<span>New content available, click on reload button to update.</span>}
@@ -38,7 +38,7 @@ const ReloadPrompt: Component = () => {
<Button onClick={() => updateServiceWorker(true)}>Reload</Button>
</Show>
<Button onClick={() => close()}>Close</Button>
</Card>
</Card> */}
</Show>
)
}

View File

@@ -35,13 +35,12 @@ export function StringShower(props: { text: string }) {
return (
<>
<JsonModal open={open()} data={props.text} title="Details" setOpen={setOpen} />
<div class="flex gap-2">
<div class="w-full grid grid-cols-[minmax(0,_1fr)_auto]">
<pre class="truncate text-neutral-400">{props.text}</pre>
<button class="w-[16rem]" onClick={() => setOpen(true)}>
<button class="w-[2rem]" onClick={() => setOpen(true)}>
<img src={eyeIcon} alt="eye" />
</button>
</div>
</>
)
}

View File

@@ -1,9 +1,12 @@
import { Select, createOptions } from "@thisbeyond/solid-select";
import "~/styles/solid-select.css"
import { For, createUniqueId } from "solid-js";
import { For } from "solid-js";
import { ContactEditor } from "./ContactEditor";
import { ContactItem, TagItem, TextItem, addContact } from "~/state/contacts";
import { TinyButton } from "./layout";
import { ContactFormValues } from "./ContactViewer";
import { MutinyTagItem } from "~/utils/tags";
import { Contact } from "@mutinywallet/mutiny-wasm";
import { useMegaStore } from "~/state/megaStore";
// take two arrays, subtract the second from the first, then return the first
function subtract<T>(a: T[], b: T[]) {
@@ -11,12 +14,20 @@ function subtract<T>(a: T[], b: T[]) {
return a.filter(x => !set.has(x));
}
const createValue = (name: string): TextItem => {
return { id: createUniqueId(), name, kind: "text" };
const createLabelValue = (label: string): Partial<MutinyTagItem> => {
return { id: label, name: label, kind: "Label" };
};
export function TagEditor(props: { values: TagItem[], setValues: (values: TagItem[]) => void, selectedValues: TagItem[], setSelectedValues: (values: TagItem[]) => void, placeholder: string }) {
const onChange = (selected: TagItem[]) => {
export function TagEditor(props: {
values: MutinyTagItem[],
setValues: (values: MutinyTagItem[]) => void,
selectedValues: MutinyTagItem[],
setSelectedValues: (values: MutinyTagItem[]) => void,
placeholder: string
}) {
const [state, actions] = useMegaStore();
const onChange = (selected: MutinyTagItem[]) => {
props.setSelectedValues(selected);
console.log(selected)
@@ -31,12 +42,23 @@ export function TagEditor(props: { values: TagItem[], setValues: (values: TagIte
key: "name",
disable: (value) => props.selectedValues.includes(value),
filterable: true, // Default
createable: createValue,
createable: createLabelValue,
});
const newContact = async (contact: ContactItem) => {
await addContact(contact)
onChange([...props.selectedValues, contact])
async function createContact(contact: ContactFormValues) {
// FIXME: undefineds
// FIXME: npub not valid? other undefineds
const c = new Contact(contact.name, undefined, undefined, undefined);
const newContactId = await state.mutiny_wallet?.create_new_contact(c);
const contactItem = await state.mutiny_wallet?.get_contact(newContactId ?? "");
const mutinyContactItem: MutinyTagItem = { id: contactItem?.id || "", name: contactItem?.name || "", kind: "Contact", last_used_time: 0n };
if (contactItem) {
// @ts-ignore
// FIXME: make typescript less mad about this
onChange([...props.selectedValues, mutinyContactItem])
} else {
console.error("Failed to create contact")
}
}
return (
@@ -52,13 +74,13 @@ export function TagEditor(props: { values: TagItem[], setValues: (values: TagIte
<div class="flex gap-2 flex-wrap">
<For each={subtract(props.values, props.selectedValues).slice(0, 3)}>
{(tag) => (
<TinyButton onClick={() => onChange([...props.selectedValues, tag])}
<TinyButton tag={tag} onClick={() => onChange([...props.selectedValues, tag])}
>
{tag.name}
</TinyButton>
)}
</For>
<ContactEditor createContact={newContact} />
<ContactEditor createContact={createContact} />
</div>
</div >
)

View File

@@ -43,13 +43,10 @@ export function ToastItem(props: { toastId: number, title: string, description:
</p>
</Toast.Description>
</div>
<Toast.CloseButton class="hover:bg-white/10 rounded-lg active:bg-m-blue w-[5rem] flex-0">
<Toast.CloseButton class="hover:bg-white/10 rounded-lg active:bg-m-blue flex-0">
<img src={close} alt="Close" />
</Toast.CloseButton>
</div>
{/* <Toast.ProgressTrack class="toast__progress-track">
<Toast.ProgressFill class="toast__progress-fill" />
</Toast.ProgressTrack> */}
</Toast.Root>
)
}

View File

@@ -4,7 +4,7 @@ import { Dynamic } from "solid-js/web";
import { A } from "solid-start";
import { LoadingSpinner } from ".";
const button = cva("p-3 rounded-xl text-xl font-semibold disabled:opacity-50 disabled:grayscale transition", {
const button = cva("p-3 rounded-xl font-semibold disabled:opacity-50 disabled:grayscale transition", {
variants: {
// TODO: button hover has to work different than buttonlinks (like disabled state)
intent: {
@@ -16,10 +16,10 @@ const button = cva("p-3 rounded-xl text-xl font-semibold disabled:opacity-50 dis
green: "bg-m-green text-white shadow-inner-button hover:bg-m-green-dark text-shadow-button",
},
layout: {
flex: "flex-1",
pad: "px-8",
flex: "flex-1 text-xl",
pad: "px-8 text-xl",
small: "px-4 py-2 w-auto text-lg",
xs: "px-2 py-1 w-auto rounded-lg font-normal text-base"
xs: "px-4 py-2 w-auto rounded-lg text-base"
},
},
defaultVariants: {
@@ -32,7 +32,8 @@ const button = cva("p-3 rounded-xl text-xl font-semibold disabled:opacity-50 dis
type StyleProps = VariantProps<typeof button>
interface ButtonProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement>, StyleProps {
loading?: boolean
loading?: boolean,
disabled?: boolean,
}
export const Button: ParentComponent<ButtonProps> = props => {
@@ -42,6 +43,7 @@ export const Button: ParentComponent<ButtonProps> = props => {
return (
<button
{...attrs}
disabled={props.disabled || props.loading}
class={button({
class: local.class || "",
intent: local.intent,

View File

@@ -18,7 +18,7 @@ type FullscreenModalProps = {
export function FullscreenModal(props: FullscreenModalProps) {
return (
<Dialog.Root isOpen={props.open} onOpenChange={(isOpen) => props.setOpen(isOpen)}>
<Dialog.Root open={props.open} onOpenChange={(isOpen) => props.setOpen(isOpen)}>
<Dialog.Portal>
<div class={DIALOG_POSITIONER}>
<Dialog.Content class={DIALOG_CONTENT}>

View File

@@ -7,7 +7,7 @@ type Choices = { value: string, label: string, caption: string }[]
export function StyledRadioGroup(props: { value: string, choices: Choices, onValueChange: (value: string) => void, small?: boolean, accent?: "red" | "white" }) {
return (
// TODO: rewrite this with CVA, props are bad for tailwind
<RadioGroup.Root value={props.value} onValueChange={(e) => props.onValueChange(e)}
<RadioGroup.Root value={props.value} onChange={(e) => props.onValueChange(e)}
class={"grid w-full gap-4"}
classList={{ "grid-cols-2": props.choices.length === 2, "grid-cols-3": props.choices.length === 3, "gap-2": props.small }}
>

View File

@@ -1,8 +1,11 @@
import { JSX, ParentComponent, Show, Suspense } from "solid-js"
import { JSX, ParentComponent, Show, Suspense, createResource, createSignal } from "solid-js"
import Linkify from "./Linkify"
import { Button, ButtonLink } from "./Button"
import { Separator } from "@kobalte/core"
import { Checkbox as KCheckbox, Separator } from "@kobalte/core"
import { useMegaStore } from "~/state/megaStore"
import check from "~/assets/icons/check.svg"
import { MutinyTagItem } from "~/utils/tags"
import { generateGradient } from "~/utils/gradientHash"
export {
Button,
@@ -118,12 +121,23 @@ export const SmallAmount: ParentComponent<{ amount: number | bigint, sign?: stri
}
export const NiceP: ParentComponent = (props) => {
return (<p class="text-2xl font-light">{props.children}</p>)
return (<p class="text-xl font-light">{props.children}</p>)
}
export const TinyButton: ParentComponent<{ onClick: () => void }> = (props) => {
export const TinyButton: ParentComponent<{ onClick: () => void, tag?: MutinyTagItem }> = (props) => {
// TODO: don't need to run this if it's not a contact
const [gradient] = createResource(props.tag?.name, async (name: string) => {
return generateGradient(name || "?")
})
const bg = () => (props.tag?.name && props.tag?.kind === "Contact") ? gradient() : "rgb(255 255 255 / 0.1)"
console.log("tiny tag", props.tag?.name, gradient())
return (
<button class="py-1 px-2 rounded-lg bg-white/10" onClick={() => props.onClick()}>
<button class="py-1 px-2 rounded-lg bg-white/10" onClick={() => props.onClick()}
style={{ background: bg() }}
>
{props.children}
</button>
)
@@ -134,3 +148,17 @@ export const Indicator: ParentComponent = (props) => {
<div class="box-border animate-pulse px-2 py-1 -my-1 bg-white/70 rounded text-xs uppercase text-black">{props.children}</div>
)
}
export function Checkbox(props: { label: string, checked: boolean, onChange: (checked: boolean) => void }) {
return (
<KCheckbox.Root class="inline-flex items-center gap-2" checked={props.checked} onChange={props.onChange}>
<KCheckbox.Input class="" />
<KCheckbox.Control class="flex-0 w-8 h-8 rounded-lg border-2 border-white bg-neutral-800 ui-checked:bg-m-red">
<KCheckbox.Indicator>
<img src={check} class="w-8 h-8" alt="check" />
</KCheckbox.Indicator>
</KCheckbox.Control>
<KCheckbox.Label class="flex-1 text-xl font-light">{props.label}</KCheckbox.Label>
</KCheckbox.Root>
)
}

View File

@@ -5,23 +5,41 @@ import { BackLink } from "~/components/layout/BackLink";
import { CombinedActivity } from "~/components/Activity";
import { A } from "solid-start";
import settings from '~/assets/icons/settings.svg';
import { ContactItem, addContact, editContact, listContacts } from "~/state/contacts";
import { Tabs } from "@kobalte/core";
import { gradientsPerContact } from "~/utils/gradientHash";
import { ContactEditor } from "~/components/ContactEditor";
import { ContactViewer } from "~/components/ContactViewer";
import { ContactFormValues, ContactViewer } from "~/components/ContactViewer";
import { useMegaStore } from "~/state/megaStore";
import { Contact } from "@mutinywallet/mutiny-wasm";
import { showToast } from "~/components/Toaster";
function ContactRow() {
const [contacts, { refetch }] = createResource(listContacts)
const [state, actions] = useMegaStore();
const [contacts, { refetch }] = createResource(async () => {
const contacts = state.mutiny_wallet?.get_contacts();
console.log(contacts)
let c: Contact[] = []
if (contacts) {
for (let contact in contacts) {
c.push(contacts[contact])
}
}
return c || []
})
const [gradients] = createResource(contacts, gradientsPerContact);
async function createContact(contact: ContactItem) {
await addContact(contact)
async function createContact(contact: ContactFormValues) {
// FIXME: npub not valid? other undefineds
const c = new Contact(contact.name, undefined, undefined, undefined);
await state.mutiny_wallet?.create_new_contact(c)
refetch();
}
async function saveContact(contact: ContactItem) {
await editContact(contact)
//
async function saveContact(contact: ContactFormValues) {
showToast(new Error("Unimplemented"))
// await editContact(contact)
refetch();
}
@@ -31,7 +49,7 @@ function ContactRow() {
<Show when={contacts() && gradients()}>
<For each={contacts()}>
{(contact) => (
<ContactViewer contact={contact} gradient={gradients()?.get(contact.id)} saveContact={saveContact} />
<ContactViewer contact={contact} gradient={gradients()?.get(contact.name)} saveContact={saveContact} />
)}
</For>
</Show>
@@ -49,6 +67,7 @@ export default function Activity() {
<BackLink />
<LargeHeader action={<A class="md:hidden p-2 hover:bg-white/5 rounded-lg active:bg-m-blue" href="/settings"><img src={settings} alt="Settings" /></A>}>Activity</LargeHeader>
<ContactRow />
<Tabs.Root defaultValue="mutiny">
<Tabs.List class="relative flex justify-around mt-4 mb-8 gap-1 bg-neutral-950 p-1 rounded-xl">
<Tabs.Trigger value="mutiny" class={TAB}>Mutiny</Tabs.Trigger>
@@ -58,7 +77,10 @@ export default function Activity() {
<Tabs.Content value="mutiny">
{/* <MutinyActivity /> */}
<Card title="Activity">
<CombinedActivity />
<div class="p-1" />
<VStack>
<CombinedActivity />
</VStack>
</Card>
</Tabs.Content>
<Tabs.Content value="nostr">

View File

@@ -1,16 +1,39 @@
import { Button, DefaultMain, LargeHeader, NiceP, MutinyWalletGuard, SafeArea, VStack } from "~/components/layout";
import { Button, DefaultMain, LargeHeader, NiceP, MutinyWalletGuard, SafeArea, VStack, Checkbox } from "~/components/layout";
import NavBar from "~/components/NavBar";
import { useNavigate } from 'solid-start';
import { SeedWords } from '~/components/SeedWords';
import { useMegaStore } from '~/state/megaStore';
import { Show, createSignal } from 'solid-js';
import { Show, createEffect, createSignal } from 'solid-js';
import { BackLink } from "~/components/layout/BackLink";
function Quiz(props: { setHasCheckedAll: (hasChecked: boolean) => void }) {
const [one, setOne] = createSignal(false);
const [two, setTwo] = createSignal(false);
const [three, setThree] = createSignal(false);
createEffect(() => {
if (one() && two() && three()) {
props.setHasCheckedAll(true)
} else {
props.setHasCheckedAll(false)
}
})
return (
<VStack>
<Checkbox checked={one()} onChange={setOne} label="I wrote down the words" />
<Checkbox checked={two()} onChange={setTwo} label="I understand that my funds are my responsibility" />
<Checkbox checked={three()} onChange={setThree} label="I'm not lying just to get this over with" />
</VStack>
)
}
export default function App() {
const [store, actions] = useMegaStore();
const navigate = useNavigate();
const [hasSeenBackup, setHasSeenBackup] = createSignal(false);
const [hasCheckedAll, setHasCheckedAll] = createSignal(false);
function wroteDownTheWords() {
actions.setHasBackedUp()
@@ -23,6 +46,7 @@ export default function App() {
<DefaultMain>
<BackLink />
<LargeHeader>Backup</LargeHeader>
<VStack>
<NiceP>Let's get these funds secured.</NiceP>
<NiceP>We'll show you 12 words. You write down the 12 words.</NiceP>
@@ -32,9 +56,9 @@ export default function App() {
<NiceP>Mutiny is self-custodial. It's all up to you...</NiceP>
<SeedWords words={store.mutiny_wallet?.show_seed() || ""} setHasSeen={setHasSeenBackup} />
<Show when={hasSeenBackup()}>
<NiceP>You are responsible for your funds!</NiceP>
<Quiz setHasCheckedAll={setHasCheckedAll} />
</Show>
<Button disabled={!hasSeenBackup()} intent="blue" onClick={wroteDownTheWords}>I wrote down the words</Button>
<Button disabled={!hasSeenBackup() || !hasCheckedAll()} intent="blue" onClick={wroteDownTheWords}>I wrote down the words</Button>
</VStack>
</DefaultMain>
<NavBar activeTab="none" />

View File

@@ -14,10 +14,10 @@ import { StyledRadioGroup } from "~/components/layout/Radio";
import { showToast } from "~/components/Toaster";
import { useNavigate } from "solid-start";
import megacheck from "~/assets/icons/megacheck.png";
import { TagItem, listTags } from "~/state/contacts";
import { AmountCard } from "~/components/AmountCard";
import { ShareCard } from "~/components/ShareCard";
import { BackButton } from "~/components/layout/BackButton";
import { MutinyTagItem, UNKNOWN_TAG, sortByLastUsed, tagsToIds } from "~/utils/tags";
type OnChainTx = {
transaction: {
@@ -43,8 +43,6 @@ type OnChainTx = {
}
}
const createUniqueId = () => Math.random().toString(36).substr(2, 9);
const RECEIVE_FLAVORS = [{ value: "unified", label: "Unified", caption: "Sender decides" }, { value: "lightning", label: "Lightning", caption: "Fast and cool" }, { value: "onchain", label: "On-chain", caption: "Just like Satoshi did it" }]
type ReceiveFlavor = "unified" | "lightning" | "onchain"
@@ -52,7 +50,7 @@ type ReceiveState = "edit" | "show" | "paid"
type PaidState = "lightning_paid" | "onchain_paid";
export default function Receive() {
const [state, _] = useMegaStore()
const [state, actions] = useMegaStore()
const navigate = useNavigate();
const [amount, setAmount] = createSignal("")
@@ -62,8 +60,8 @@ export default function Receive() {
const [shouldShowAmountEditor, setShouldShowAmountEditor] = createSignal(true)
// Tagging stuff
const [selectedValues, setSelectedValues] = createSignal<TagItem[]>([]);
const [values, setValues] = createSignal<TagItem[]>([{ id: createUniqueId(), name: "Unknown", kind: "text" }]);
const [selectedValues, setSelectedValues] = createSignal<MutinyTagItem[]>([]);
const [values, setValues] = createSignal<MutinyTagItem[]>([UNKNOWN_TAG]);
// The data we get after a payment
const [paymentTx, setPaymentTx] = createSignal<OnChainTx>();
@@ -86,8 +84,8 @@ export default function Receive() {
})
onMount(() => {
listTags().then((tags) => {
setValues(prev => [...prev, ...tags || []])
actions.listTags().then((tags) => {
setValues(prev => [...prev, ...tags.sort(sortByLastUsed) || []])
});
})
@@ -104,8 +102,7 @@ export default function Receive() {
async function getUnifiedQr(amount: string) {
const bigAmount = BigInt(amount);
try {
// FIXME: actual labels
const raw = await state.mutiny_wallet?.create_bip21(bigAmount, []);
const raw = await state.mutiny_wallet?.create_bip21(bigAmount, tagsToIds(selectedValues()));
// Save the raw info so we can watch the address and invoice
setBip21Raw(raw);

View File

@@ -341,7 +341,7 @@ export default function Redshift() {
<Match when={shiftStage() === "choose"}>
<VStack>
<NiceP>Where is this going?</NiceP>
<StyledRadioGroup red value={shiftType()} onValueChange={(newValue) => setShiftType(newValue as ShiftOption)} choices={SHIFT_OPTIONS} />
<StyledRadioGroup accent="red" value={shiftType()} onValueChange={(newValue) => setShiftType(newValue as ShiftOption)} choices={SHIFT_OPTIONS} />
</VStack>
<VStack>
<NiceP>Choose your <span class="inline-block"><img class="h-4" src={wave} alt="sine wave" /></span> UTXO to begin</NiceP>

View File

@@ -17,9 +17,9 @@ import mempoolTxUrl from "~/utils/mempoolTxUrl";
import { BackLink } from "~/components/layout/BackLink";
import { useNavigate } from "solid-start";
import { TagEditor } from "~/components/TagEditor";
import { TagItem, createUniqueId, listTags } from "~/state/contacts";
import { StringShower } from "~/components/ShareCard";
import { AmountCard } from "~/components/AmountCard";
import { MutinyTagItem, UNKNOWN_TAG, sortByLastUsed, tagsToIds } from "~/utils/tags";
type SendSource = "lightning" | "onchain";
@@ -97,23 +97,6 @@ function DestinationShower(props: {
)
}
function SendTags() {
// Tagging stuff
const [selectedValues, setSelectedValues] = createSignal<TagItem[]>([]);
const [values, setValues] = createSignal<TagItem[]>([{ id: createUniqueId(), name: "Unknown", kind: "text" }]);
onMount(() => {
listTags().then((tags) => {
setValues(prev => [...prev, ...tags || []])
});
})
return (
<TagEditor values={values()} setValues={setValues} selectedValues={selectedValues()} setSelectedValues={setSelectedValues} placeholder="Where's it going to?" />
)
}
export default function Send() {
const [state, actions] = useMegaStore();
const navigate = useNavigate()
@@ -136,6 +119,10 @@ export default function Send() {
const [sending, setSending] = createSignal(false);
const [sentDetails, setSentDetails] = createSignal<SentDetails>();
// Tagging stuff
const [selectedValues, setSelectedValues] = createSignal<MutinyTagItem[]>([]);
const [values, setValues] = createSignal<MutinyTagItem[]>([UNKNOWN_TAG]);
function clearAll() {
setDestination(undefined);
setAmountSats(0n);
@@ -158,6 +145,10 @@ export default function Send() {
setDestination(state.scan_result);
actions.setScanResult(undefined);
}
actions.listTags().then((tags) => {
setValues(prev => [...prev, ...tags.sort(sortByLastUsed) || []])
});
})
// Rerun every time the destination changes
@@ -235,17 +226,16 @@ export default function Send() {
sentDetails.destination = bolt11;
// If the invoice has sats use that, otherwise we pass the user-defined amount
if (invoice()?.amount_sats) {
await state.mutiny_wallet?.pay_invoice(firstNode, bolt11);
await state.mutiny_wallet?.pay_invoice(firstNode, bolt11, undefined, tagsToIds(selectedValues()));
sentDetails.amount = invoice()?.amount_sats;
} else {
await state.mutiny_wallet?.pay_invoice(firstNode, bolt11, amountSats());
await state.mutiny_wallet?.pay_invoice(firstNode, bolt11, amountSats(), tagsToIds(selectedValues()));
sentDetails.amount = amountSats();
}
} else if (source() === "lightning" && nodePubkey()) {
const nodes = await state.mutiny_wallet?.list_nodes();
const firstNode = nodes[0] as string || ""
const payment = await state.mutiny_wallet?.keysend(firstNode, nodePubkey()!, amountSats());
console.log(payment?.value)
const payment = await state.mutiny_wallet?.keysend(firstNode, nodePubkey()!, amountSats(), tagsToIds(selectedValues()));
// TODO: handle timeouts
if (!payment?.paid) {
@@ -254,9 +244,8 @@ export default function Send() {
sentDetails.amount = amountSats();
}
} else if (source() === "onchain" && address()) {
// FIXME: actual labels
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const txid = await state.mutiny_wallet?.send_to_address(address()!, amountSats(), []);
const txid = await state.mutiny_wallet?.send_to_address(address()!, amountSats(), tagsToIds(selectedValues()));
sentDetails.amount = amountSats();
sentDetails.destination = address();
// TODO: figure out if this is necessary, it takes forever
@@ -320,7 +309,7 @@ export default function Send() {
<Card>
<VStack>
<DestinationShower source={source()} description={description()} invoice={invoice()} address={address()} nodePubkey={nodePubkey()} clearAll={clearAll} />
<SendTags />
<TagEditor values={values()} setValues={setValues} selectedValues={selectedValues()} setSelectedValues={setSelectedValues} placeholder="Where's it going to?" />
</VStack>
</Card>
<AmountCard amountSats={amountSats().toString()} setAmountSats={setAmountSats} fee={fakeFee().toString()} isAmountEditable={!(invoice()?.amount_sats)} />

View File

@@ -1,7 +1,9 @@
import { ActivityItem } from "~/components/ActivityItem";
import { AmountCard } from "~/components/AmountCard";
import NavBar from "~/components/NavBar";
import { OnboardWarning } from "~/components/OnboardWarning";
import { ShareCard } from "~/components/ShareCard";
import { DefaultMain, LargeHeader, SafeArea, VStack } from "~/components/layout";
import { Card, DefaultMain, LargeHeader, SafeArea, VStack } from "~/components/layout";
const SAMPLE = "bitcoin:tb1prqm8xtlgme0vmw5s30lgf0a4f5g4mkgsqundwmpu6thrg8zr6uvq2qrhzq?amount=0.001&lightning=lntbs1m1pj9n9xjsp5xgdrmvprtm67p7nq4neparalexlhlmtxx87zx6xeqthsplu842zspp546d6zd2seyaxpapaxx62m88yz3xueqtjmn9v6wj8y56np8weqsxqdqqnp4qdn2hj8tfknpuvdg6tz9yrf3e27ltrx9y58c24jh89lnm43yjwfc5xqrpwjcqpj9qrsgq5sdgh0m3ur5mu5hrmmag4mx9yvy86f83pd0x9ww80kgck6tac3thuzkj0mrtltaxwnlfea95h2re7tj4qsnwzxlvrdmyq2h9mgapnycpppz6k6"
export default function Admin() {
@@ -9,12 +11,16 @@ export default function Admin() {
<SafeArea>
<DefaultMain>
<LargeHeader>Storybook</LargeHeader>
<OnboardWarning />
<VStack>
<AmountCard amountSats={"100000"} fee={"69"} />
<AmountCard amountSats={"100000"} />
<AmountCard amountSats={"100000"} isAmountEditable />
<AmountCard amountSats={"0"} isAmountEditable />
<ShareCard text={SAMPLE} />
<Card title="Activity">
<ActivityItem kind="lightning" labels={["benthecarman"]} amount={100000} date={1683664966} />
<ActivityItem kind="onchain" labels={["tony"]} amount={42000000} positive date={1683664966} />
<ActivityItem kind="onchain" labels={["a fake name thati is too long"]} amount={42000000} date={1683664966} />
<ActivityItem kind="onchain" labels={["a fake name thati is too long"]} amount={42000000} date={1683664966} />
</Card>
</VStack>
</DefaultMain>
<NavBar activeTab="none" />

View File

@@ -1,58 +0,0 @@
export type TagItem = TextItem | ContactItem;
export type TextItem = {
id: string;
kind: "text";
name: string;
}
export type ContactItem = {
id: string;
kind: "contact";
name: string;
npub?: string;
color: Color;
}
export type Color = "blue" | "green" | "red" | "gray"
export const createUniqueId = () => Math.random().toString(36).substr(2, 9);
export async function listContacts(): Promise<ContactItem[]> {
// get contacts from localstorage
const contacts: ContactItem[] = JSON.parse(localStorage.getItem("contacts") || "[]");
return contacts;
}
export async function listTexts(): Promise<TextItem[]> {
// get texts from localstorage
const texts: TextItem[] = JSON.parse(localStorage.getItem("texts") || "[]");
return texts;
}
export async function listTags(): Promise<TagItem[]> {
const contacts = await listContacts();
const texts = await listTexts();
return [...contacts, ...texts];
}
export async function addContact(contact: ContactItem): Promise<void> {
const contacts = await listContacts();
contacts.push(contact);
localStorage.setItem("contacts", JSON.stringify(contacts));
}
export async function editContact(contact: ContactItem): Promise<void> {
const contacts = await listContacts();
const index = contacts.findIndex(c => c.id === contact.id);
contacts[index] = contact;
localStorage.setItem("contacts", JSON.stringify(contacts));
}
export async function addTextTag(text: TextItem): Promise<void> {
const texts = await listTexts();
texts.push(text);
localStorage.setItem("texts", JSON.stringify(texts));
}

View File

@@ -6,6 +6,7 @@ import { createStore } from "solid-js/store";
import { MutinyWalletSettingStrings, setupMutinyWallet } from "~/logic/mutinyWalletSetup";
import { MutinyBalance, MutinyWallet } from "@mutinywallet/mutiny-wasm";
import { ParsedParams } from "~/routes/Scanner";
import { MutinyTagItem } from "~/utils/tags";
const MegaStoreContext = createContext<MegaStore>();
@@ -23,7 +24,8 @@ export type MegaStore = [{
last_sync?: number;
price: number
has_backed_up: boolean,
dismissed_restore_prompt: boolean
dismissed_restore_prompt: boolean,
wallet_loading: boolean
}, {
fetchUserStatus(): Promise<UserStatus>;
setupMutinyWallet(settings?: MutinyWalletSettingStrings): Promise<void>;
@@ -33,6 +35,7 @@ export type MegaStore = [{
sync(): Promise<void>;
dismissRestorePrompt(): void;
setHasBackedUp(): void;
listTags(): Promise<MutinyTagItem[]>;
}];
export const Provider: ParentComponent = (props) => {
@@ -49,7 +52,8 @@ export const Provider: ParentComponent = (props) => {
balance: undefined as MutinyBalance | undefined,
last_sync: undefined as number | undefined,
is_syncing: false,
dismissed_restore_prompt: localStorage.getItem("dismissed_restore_prompt") === "true"
dismissed_restore_prompt: localStorage.getItem("dismissed_restore_prompt") === "true",
wallet_loading: true
});
const actions = {
@@ -81,8 +85,9 @@ export const Provider: ParentComponent = (props) => {
},
async setupMutinyWallet(settings?: MutinyWalletSettingStrings): Promise<void> {
try {
setState({ wallet_loading: true })
const mutinyWallet = await setupMutinyWallet(settings)
setState({ mutiny_wallet: mutinyWallet })
setState({ mutiny_wallet: mutinyWallet, wallet_loading: false })
} catch (e) {
console.error(e)
}
@@ -126,6 +131,9 @@ export const Provider: ParentComponent = (props) => {
dismissRestorePrompt() {
localStorage.setItem("dismissed_restore_prompt", "true")
setState({ dismissed_restore_prompt: true })
},
async listTags(): Promise<MutinyTagItem[]> {
return state.mutiny_wallet?.get_tag_items() as MutinyTagItem[]
}
};

View File

@@ -1,6 +1,6 @@
import { ContactItem } from "~/state/contacts";
import { Contact } from "@mutinywallet/mutiny-wasm";
async function generateGradientFromHashedString(str: string) {
export async function generateGradient(str: string) {
const encoder = new TextEncoder();
const data = encoder.encode(str);
const digestBuffer = await crypto.subtle.digest('SHA-256', data);
@@ -13,11 +13,12 @@ async function generateGradientFromHashedString(str: string) {
return gradient;
}
export async function gradientsPerContact(contacts: ContactItem[]) {
export async function gradientsPerContact(contacts: Contact[]) {
console.log(contacts);
const gradients = new Map();
for (const contact of contacts) {
const gradient = await generateGradientFromHashedString(contact.name);
gradients.set(contact.id, gradient);
const gradient = await generateGradient(contact.name);
gradients.set(contact.name, gradient);
}
return gradients;

View File

@@ -9,4 +9,31 @@ export function prettyPrintTime(ts: number) {
};
return new Date(ts * 1000).toLocaleString('en-US', options);
}
}
export function timeAgo(ts?: number | bigint): string {
if (!ts || ts === 0) return "Pending";
const timestamp = Number(ts) * 1000;
const now = Date.now();
const elapsedMilliseconds = now - timestamp;
const elapsedSeconds = Math.floor(elapsedMilliseconds / 1000);
const elapsedMinutes = Math.floor(elapsedSeconds / 60);
const elapsedHours = Math.floor(elapsedMinutes / 60);
const elapsedDays = Math.floor(elapsedHours / 24);
if (elapsedSeconds < 60) {
return "Just now";
} else if (elapsedMinutes < 60) {
return `${elapsedMinutes} minute${elapsedMinutes > 1 ? 's' : ''} ago`;
} else if (elapsedHours < 24) {
return `${elapsedHours} hour${elapsedHours > 1 ? 's' : ''} ago`;
} else if (elapsedDays < 7) {
return `${elapsedDays} day${elapsedDays > 1 ? 's' : ''} ago`;
} else {
const date = new Date(timestamp);
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const year = date.getFullYear();
return `${month}/${day}/${year}`;
}
}

27
src/utils/tags.ts Normal file
View File

@@ -0,0 +1,27 @@
import { TagItem } from "@mutinywallet/mutiny-wasm"
export type MutinyTagItem = {
id: string,
kind: "Label" | "Contact"
name: string,
last_used_time: bigint,
npub?: string,
ln_address?: string,
lnurl?: string,
}
export const UNKNOWN_TAG: MutinyTagItem = { id: "Unknown", kind: "Label", name: "Unknown", last_used_time: 0n }
export function tagsToIds(tags: MutinyTagItem[]): string[] {
return tags.filter((tag) => tag.id !== "Unknown").map((tag) => tag.id)
}
export function tagToMutinyTag(tag: TagItem): MutinyTagItem {
// @ts-ignore
// FIXME: make typescript less mad about this
return tag as MutinyTagItem
}
export function sortByLastUsed(a: MutinyTagItem, b: MutinyTagItem) {
return Number(b.last_used_time - a.last_used_time);
}