lots of tagging works but not sends rn

This commit is contained in:
Paul Miller
2023-05-10 23:42:14 -05:00
parent eab0346dcd
commit 80c5af1489
17 changed files with 222 additions and 300 deletions

View File

@@ -1,16 +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'
@@ -40,14 +37,14 @@ 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, labels: string[] }) {
function OnChainItem(props: { item: OnChainTx, labels: MutinyTagItem[] }) {
const [store, actions] = useMegaStore();
const isReceive = createMemo(() => props.item.received > 0);
@@ -69,26 +66,11 @@ function OnChainItem(props: { item: OnChainTx, labels: string[] }) {
positive={isReceive()}
onClick={() => setOpen(!open())}
/>
{/* <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> */}
</>
)
}
function InvoiceItem(props: { item: MutinyInvoice, labels: string[] }) {
function InvoiceItem(props: { item: MutinyInvoice, labels: MutinyTagItem[] }) {
const [store, actions] = useMegaStore();
const isSend = createMemo(() => props.item.is_send);
@@ -98,21 +80,6 @@ function InvoiceItem(props: { item: MutinyInvoice, labels: string[] }) {
<>
<JsonModal open={open()} data={props.item} title="Lightning Transaction" setOpen={setOpen} />
<ActivityItem kind={"lightning"} labels={props.labels} amount={props.item.amount_sats || 0n} date={props.item.last_updated} positive={!isSend()} onClick={() => setOpen(!open())} />
{/* <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 > */}
</>
)
}
@@ -149,57 +116,49 @@ function Utxo(props: { item: UtxoItem }) {
)
}
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) => {
if (invoice.paid) {
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);
// const addressLabels = createMemo(() => {
// const labels = state.mutiny_wallet?.get_address_labels();
// console.log(labels);
// return labels || [];
// // return labels.filter((label) => label.address === props.item.txid)
// })
// const invoiceLabels = createMemo(() => {
// const labels = state.mutiny_wallet?.get_address_labels();
// console.log(labels);
// if (!labels) return ["abcdefg"];
// return labels;
// // return labels.filter((label) => label.address === props.item.txid)
// })
return (
<Switch>
<Match when={activity.loading}>
@@ -214,11 +173,11 @@ export function CombinedActivity(props: { limit?: number }) {
<Switch>
<Match when={activityItem.type === "onchain"}>
{/* FIXME */}
<OnChainItem item={activityItem.item as OnChainTx} labels={activityItem.item.labels} />
<OnChainItem item={activityItem.item as OnChainTx} labels={activityItem.labels} />
</Match>
<Match when={activityItem.type === "lightning"}>
{/* FIXME */}
<InvoiceItem item={activityItem.item as MutinyInvoice} labels={activityItem.item.labels} />
<InvoiceItem item={activityItem.item as MutinyInvoice} labels={activityItem.labels} />
</Match>
</Switch>
}

View File

@@ -1,9 +1,11 @@
import { ParentComponent, createMemo } from "solid-js";
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(() => {
@@ -35,17 +37,43 @@ export const ActivityAmount: ParentComponent<{ amount: string, price: number, po
)
}
function LabelCircle(props: { name: string }) {
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: "gray" }}
style={{ background: bg() }}
>
{props.name[0] || "?"}
{text()}
</div>
)
}
export function ActivityItem(props: { kind: "lightning" | "onchain", labels: string[], amount: number | bigint, date?: number | bigint, positive?: boolean, onClick?: () => void }) {
// 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()}
@@ -54,14 +82,14 @@ export function ActivityItem(props: { kind: "lightning" | "onchain", labels: str
>
<div class="flex gap-2 md:gap-4 items-center">
<div class="">
{props.kind === "lightning" ? <img src={bolt} alt="lightning" /> : <img src={chain} alt="onchain" />}
{props.kind === "lightning" ? <img class="w-[1rem]" src={bolt} alt="lightning" /> : <img class="w-[1rem]" src={chain} alt="onchain" />}
</div>
<div class="">
<LabelCircle name={props.labels.length ? props.labels[0] : "?"} />
<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": props.labels.length === 0 }}>{props.labels.length ? props.labels[0] : "Unknown"}</span>
<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="">

View File

@@ -1,6 +1,6 @@
import logo from '~/assets/icons/mutiny-logo.svg';
import { DefaultMain, SafeArea, VStack, Card, FullscreenLoader } 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';
@@ -28,7 +28,7 @@ export default function App() {
<Card title="Activity">
<div class="p-1" />
<VStack>
<Show when={!state.wallet_loading} fallback={<FullscreenLoader />}>
<Show when={!state.wallet_loading} fallback={<LoadingShimmer />}>
<CombinedActivity limit={3} />
</Show>
{/* <ButtonLink href="/activity">View All</ButtonLink> */}

View File

@@ -11,7 +11,7 @@ function prettyPrintAmount(n?: number | bigint): string {
return n.toLocaleString()
}
function LoadingShimmer() {
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>

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);
}
@@ -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">
@@ -24,11 +21,6 @@ export function ContactForm(props: { handleSubmit: SubmitHandler<ContactItem>, i
<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>
</VStack>
</div>
<Button type="submit" intent="blue" class="w-full flex-none">

View File

@@ -3,16 +3,23 @@ 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)
const contact = new Contact(c.name, c.npub ?? undefined, undefined, undefined)
props.saveContact(contact)
setIsEditing(false)
}

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,22 @@ 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
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 +73,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

@@ -1,9 +1,11 @@
import { JSX, ParentComponent, Show, Suspense, createSignal } from "solid-js"
import { JSX, ParentComponent, Show, Suspense, createResource, createSignal } from "solid-js"
import Linkify from "./Linkify"
import { Button, ButtonLink } from "./Button"
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,
@@ -122,9 +124,20 @@ export const NiceP: ParentComponent = (props) => {
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>
)

View File

@@ -5,23 +5,40 @@ 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) {
const c = new Contact(contact.name, contact.npub ?? 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 +48,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 +66,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>

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,22 +43,14 @@ 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"
type ReceiveState = "edit" | "show" | "paid"
type PaidState = "lightning_paid" | "onchain_paid";
function tagItemsToLabels(items: TagItem[]) {
const labels = items.map(item => item.kind === "contact" ? item.id : item.name)
console.log("Labels", labels)
return labels;
}
export default function Receive() {
const [state, _] = useMegaStore()
const [state, actions] = useMegaStore()
const navigate = useNavigate();
const [amount, setAmount] = createSignal("")
@@ -68,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>();
@@ -92,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) || []])
});
})
@@ -109,11 +101,8 @@ export default function Receive() {
async function getUnifiedQr(amount: string) {
const bigAmount = BigInt(amount);
console.log(selectedValues());
console.log(tagItemsToLabels(selectedValues()))
try {
// FIXME: actual labels
const raw = await state.mutiny_wallet?.create_bip21(bigAmount, tagItemsToLabels(selectedValues()));
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

@@ -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,20 +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) {
// FIXME: labels
await state.mutiny_wallet?.pay_invoice(firstNode, bolt11, undefined, []);
await state.mutiny_wallet?.pay_invoice(firstNode, bolt11, undefined, tagsToIds(selectedValues()));
sentDetails.amount = invoice()?.amount_sats;
} else {
// FIXME: labels
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 || ""
// FIXME: labels
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) {
@@ -257,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
@@ -323,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,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>();
@@ -33,6 +34,7 @@ export type MegaStore = [{
sync(): Promise<void>;
dismissRestorePrompt(): void;
setHasBackedUp(): void;
listTags(): Promise<MutinyTagItem[]>;
}];
export const Provider: ParentComponent = (props) => {
@@ -119,6 +121,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;

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);
}