Merge pull request #123 from MutinyWallet/tag-editor-fixes

simplified tagging
This commit is contained in:
Paul Miller
2023-05-13 18:33:24 -05:00
committed by GitHub
11 changed files with 151 additions and 113 deletions

View File

@@ -6,6 +6,7 @@ import { Dialog } from '@kobalte/core';
import close from "~/assets/icons/close.svg";
import pencil from "~/assets/icons/pencil.svg";
import { InlineAmount } from './AmountCard';
import { DIALOG_CONTENT, DIALOG_POSITIONER } from '~/styles/dialogs';
const CHARACTERS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", ".", "0", "DEL"];
@@ -131,9 +132,6 @@ export const AmountEditable: ParentComponent<{ initialAmountSats: string, initia
setIsOpen(false);
}
const DIALOG_POSITIONER = "fixed inset-0 safe-top safe-bottom z-50"
const DIALOG_CONTENT = "h-full safe-bottom flex flex-col justify-between p-4 backdrop-blur-xl bg-neutral-800/70"
return (
<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">

View File

@@ -5,6 +5,7 @@ import close from "~/assets/icons/close.svg";
import { SubmitHandler } from '@modular-forms/solid';
import { ContactForm } from './ContactForm';
import { ContactFormValues } from './ContactViewer';
import { DIALOG_CONTENT, DIALOG_POSITIONER } from '~/styles/dialogs';
export function ContactEditor(props: { createContact: (contact: ContactFormValues) => void, list?: boolean }) {
const [isOpen, setIsOpen] = createSignal(false);
@@ -15,9 +16,6 @@ export function ContactEditor(props: { createContact: (contact: ContactFormValue
setIsOpen(false);
}
const DIALOG_POSITIONER = "fixed inset-0 safe-top safe-bottom z-50"
const DIALOG_CONTENT = "h-full safe-bottom flex flex-col justify-between p-4 backdrop-blur-xl bg-neutral-800/70"
return (
<Dialog.Root open={isOpen()}>
<Switch>

View File

@@ -6,6 +6,7 @@ import { SubmitHandler } from '@modular-forms/solid';
import { ContactForm } from './ContactForm';
import { showToast } from './Toaster';
import { Contact } from '@mutinywallet/mutiny-wasm';
import { DIALOG_CONTENT, DIALOG_POSITIONER } from '~/styles/dialogs';
export type ContactFormValues = {
name: string,
@@ -24,9 +25,6 @@ export function ContactViewer(props: { contact: Contact, gradient: string, saveC
setIsEditing(false)
}
const DIALOG_POSITIONER = "fixed inset-0 safe-top safe-bottom z-50"
const DIALOG_CONTENT = "h-full safe-bottom flex flex-col justify-between p-4 backdrop-blur-xl bg-neutral-800/70"
return (
<Dialog.Root open={isOpen()}>
<button onClick={() => setIsOpen(true)} class="flex flex-col items-center gap-2 w-16 flex-shrink-0 overflow-x-hidden">

View File

@@ -1,31 +1,37 @@
import { Select, createOptions } from "@thisbeyond/solid-select";
import "~/styles/solid-select.css"
import { For } from "solid-js";
import { ContactEditor } from "./ContactEditor";
import { For, Show, createMemo, createSignal, onMount } from "solid-js";
import { TinyButton } from "./layout";
import { ContactFormValues } from "./ContactViewer";
import { MutinyTagItem } from "~/utils/tags";
import { Contact } from "@mutinywallet/mutiny-wasm";
import { MutinyTagItem, sortByLastUsed } from "~/utils/tags";
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[]) {
const set = new Set(b);
return a.filter(x => !set.has(x));
}
const createLabelValue = (label: string): Partial<MutinyTagItem> => {
return { id: label, name: label, kind: "Label" };
return { name: label, kind: "Contact" };
};
export function TagEditor(props: {
values: MutinyTagItem[],
setValues: (values: MutinyTagItem[]) => void,
selectedValues: MutinyTagItem[],
setSelectedValues: (values: MutinyTagItem[]) => void,
selectedValues: Partial<MutinyTagItem>[],
setSelectedValues: (value: Partial<MutinyTagItem>[]) => void,
placeholder: string
}) {
const [state, actions] = useMegaStore();
const [_state, actions] = useMegaStore();
const [availableTags, setAvailableTags] = createSignal<MutinyTagItem[]>([]);
onMount(async () => {
const tags = await actions.listTags()
if (tags) {
setAvailableTags(tags.filter((tag) => tag.kind === "Contact").sort(sortByLastUsed))
}
})
const selectProps = createMemo(() => {
return createOptions(availableTags() || [], {
key: "name",
filterable: true, // Default
createable: createLabelValue,
});
})
const onChange = (selected: MutinyTagItem[]) => {
props.setSelectedValues(selected);
@@ -33,54 +39,30 @@ export function TagEditor(props: {
console.log(selected)
const lastValue = selected[selected.length - 1];
if (lastValue && !props.values.includes(lastValue)) {
props.setValues([...props.values, lastValue]);
if (lastValue && availableTags() && !availableTags()!.includes(lastValue)) {
setAvailableTags([...availableTags(), lastValue]);
}
};
const selectProps = createOptions(props.values, {
key: "name",
disable: (value) => props.selectedValues.includes(value),
filterable: true, // Default
createable: createLabelValue,
});
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 (
<div class="flex flex-col gap-2 flex-grow flex-shrink flex-1" >
{/* FIXME this is causing overflow scroll for now good reason */}
<div class="flex flex-col gap-2 flex-shrink flex-1" >
<Select
multiple
initialValue={props.selectedValues}
onChange={onChange}
placeholder={props.placeholder}
{...selectProps}
onChange={onChange}
{...selectProps()}
/>
<div class="flex gap-2 flex-wrap">
<For each={subtract(props.values, props.selectedValues).slice(0, 3)}>
{(tag) => (
<TinyButton tag={tag} onClick={() => onChange([...props.selectedValues, tag])}
>
{tag.name}
</TinyButton>
)}
</For>
<ContactEditor createContact={createContact} />
<Show when={availableTags() && availableTags()!.length > 0}>
<For each={availableTags()!.slice(0, 3)}>
{(tag) => (
<TinyButton tag={tag} onClick={() => props.setSelectedValues([...props.selectedValues!, tag])}>
{tag.name}
</TinyButton>
)}
</For>
</Show>
</div>
</div >
)

View File

@@ -3,9 +3,7 @@ import { Dialog } from "@kobalte/core";
import { JSX } from "solid-js";
import { Button, LargeHeader } from "~/components/layout";
import close from "~/assets/icons/close.svg";
const DIALOG_POSITIONER = "fixed inset-0 safe-top safe-bottom z-50"
const DIALOG_CONTENT = "h-full flex flex-col justify-between p-4 backdrop-blur-md bg-neutral-900/50"
import { DIALOG_CONTENT, DIALOG_POSITIONER } from "~/styles/dialogs";
type FullscreenModalProps = {
title: string,

View File

@@ -19,7 +19,7 @@ export const SmallHeader: ParentComponent<{ class?: string }> = (props) => {
export const Card: ParentComponent<{ title?: string, titleElement?: JSX.Element }> = (props) => {
return (
<div class='rounded-xl p-4 flex flex-col gap-2 bg-neutral-950/50 overflow-x-hidden w-full'>
<div class='rounded-xl p-4 flex flex-col gap-2 bg-neutral-950/50 w-full'>
{props.title && <SmallHeader>{props.title}</SmallHeader>}
{props.titleElement && props.titleElement}
{props.children}
@@ -50,7 +50,7 @@ export const FancyCard: ParentComponent<{ title?: string, tag?: JSX.Element }> =
export const SafeArea: ParentComponent = (props) => {
return (
<div class="safe-top safe-left safe-right safe-bottom">
<div class="h-[100dvh] safe-left safe-right">
{/* <div class="flex-1 disable-scrollbars overflow-y-scroll md:pl-[8rem] md:pr-[6rem]"> */}
{props.children}
{/* </div> */}
@@ -60,15 +60,17 @@ export const SafeArea: ParentComponent = (props) => {
export const DefaultMain: ParentComponent = (props) => {
return (
<main class="w-full max-w-[600px] flex flex-col gap-4 mx-auto p-4">
<main class="w-full max-w-[600px] flex flex-col gap-4 mx-auto p-4 h-full">
{props.children}
{/* CSS is hard sometimes */}
<div class="py-4" />
</main>
)
}
export const FullscreenLoader = () => {
return (
<div class="w-screen h-screen flex justify-center items-center">
<div class="w-full h-[100dvh] flex justify-center items-center">
<LoadingSpinner />
</div>
);
@@ -132,8 +134,6 @@ export const TinyButton: ParentComponent<{ onClick: () => void, tag?: MutinyTagI
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()}
style={{ background: bg() }}

View File

@@ -14,7 +14,7 @@ import { Contact } from "@mutinywallet/mutiny-wasm";
import { showToast } from "~/components/Toaster";
function ContactRow() {
const [state, actions] = useMegaStore();
const [state, _actions] = useMegaStore();
const [contacts, { refetch }] = createResource(async () => {
const contacts = state.mutiny_wallet?.get_contacts();
console.log(contacts)
@@ -44,14 +44,16 @@ function ContactRow() {
}
return (
<div class="w-full overflow-x-scroll flex gap-4 disable-scrollbars">
<div class="flex gap-4">
<ContactEditor list createContact={createContact} />
<Show when={contacts() && gradients()}>
<For each={contacts()}>
{(contact) => (
<ContactViewer contact={contact} gradient={gradients()?.get(contact.name)} saveContact={saveContact} />
)}
</For>
<Show when={contacts()}>
<div class="flex gap-4 flex-1 overflow-x-scroll disable-scrollbars">
<For each={contacts()}>
{(contact) => (
<ContactViewer contact={contact} gradient={gradients()?.get(contact.name)} saveContact={saveContact} />
)}
</For>
</div>
</Show>
</div>
)
@@ -67,7 +69,6 @@ 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

@@ -1,7 +1,7 @@
import { MutinyBip21RawMaterials, MutinyInvoice } from "@mutinywallet/mutiny-wasm";
import { Contact, MutinyBip21RawMaterials, MutinyInvoice } from "@mutinywallet/mutiny-wasm";
import { createEffect, createMemo, createResource, createSignal, Match, onCleanup, onMount, Show, Switch } from "solid-js";
import { QRCodeSVG } from "solid-qr-code";
import { Button, Card, Indicator, LargeHeader, MutinyWalletGuard, SafeArea } from "~/components/layout";
import { Button, Card, DefaultMain, Indicator, LargeHeader, MutinyWalletGuard, SafeArea } from "~/components/layout";
import NavBar from "~/components/NavBar";
import { useMegaStore } from "~/state/megaStore";
import { objectToSearchParams } from "~/utils/objectToSearchParams";
@@ -17,7 +17,7 @@ import megacheck from "~/assets/icons/megacheck.png";
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";
import { MutinyTagItem } from "~/utils/tags";
type OnChainTx = {
transaction: {
@@ -61,7 +61,6 @@ export default function Receive() {
// Tagging stuff
const [selectedValues, setSelectedValues] = createSignal<MutinyTagItem[]>([]);
const [values, setValues] = createSignal<MutinyTagItem[]>([UNKNOWN_TAG]);
// The data we get after a payment
const [paymentTx, setPaymentTx] = createSignal<OnChainTx>();
@@ -83,12 +82,6 @@ export default function Receive() {
}
})
onMount(() => {
actions.listTags().then((tags) => {
setValues(prev => [...prev, ...tags.sort(sortByLastUsed) || []])
});
})
function clearAll() {
setAmount("")
setReceiveState("edit")
@@ -99,10 +92,43 @@ export default function Receive() {
setSelectedValues([])
}
async function processContacts(contacts: Partial<MutinyTagItem>[]): Promise<string[]> {
console.log("Processing contacts", contacts)
if (contacts.length) {
const first = contacts![0];
if (!first.name) {
console.error("Something went wrong with contact creation, proceeding anyway")
return []
}
if (!first.id && first.name) {
console.error("Creating new contact", first.name)
const c = new Contact(first.name, undefined, undefined, undefined);
const newContactId = await state.mutiny_wallet?.create_new_contact(c);
if (newContactId) {
return [newContactId];
}
}
if (first.id) {
console.error("Using existing contact", first.name, first.id)
return [first.id];
}
}
console.error("Something went wrong with contact creation, proceeding anyway")
return []
}
async function getUnifiedQr(amount: string) {
const bigAmount = BigInt(amount);
try {
const raw = await state.mutiny_wallet?.create_bip21(bigAmount, tagsToIds(selectedValues()));
const tags = await processContacts(selectedValues());
const raw = await state.mutiny_wallet?.create_bip21(bigAmount, tags);
// Save the raw info so we can watch the address and invoice
setBip21Raw(raw);
@@ -167,7 +193,7 @@ export default function Receive() {
return (
<MutinyWalletGuard>
<SafeArea>
<main class="max-w-[600px] flex flex-col flex-1 gap-4 mx-auto p-4 overflow-y-auto">
<DefaultMain>
<Show when={receiveState() === "show"} fallback={<BackLink />}>
<BackButton onClick={() => setReceiveState("edit")} title="Edit" />
</Show>
@@ -177,12 +203,12 @@ export default function Receive() {
<div class="flex flex-col flex-1 gap-8">
<AmountCard initialOpen={shouldShowAmountEditor()} amountSats={amount() || "0"} setAmountSats={setAmount} isAmountEditable />
<Card title="Tag the origin">
<TagEditor values={values()} setValues={setValues} selectedValues={selectedValues()} setSelectedValues={setSelectedValues} placeholder="Where's it coming from?" />
<Card title="Private tags">
<TagEditor selectedValues={selectedValues()} setSelectedValues={setSelectedValues} placeholder="Add the sender for your records" />
</Card>
<div class="flex-1" />
<Button class="w-full flex-grow-0 mb-4" disabled={!amount() || !selectedValues().length} intent="green" onClick={onSubmit}>Create Request</Button>
<Button class="w-full flex-grow-0" disabled={!amount()} intent="green" onClick={onSubmit}>Create Request</Button>
</div>
</Match>
<Match when={unified() && receiveState() === "show"}>
@@ -223,7 +249,7 @@ export default function Receive() {
</FullscreenModal>
</Match>
</Switch>
</main>
</DefaultMain>
<NavBar activeTab="receive" />
</SafeArea >
</MutinyWalletGuard>

View File

@@ -5,7 +5,7 @@ import { Button, ButtonLink, Card, DefaultMain, HStack, LargeHeader, MutinyWalle
import { Paste } from "~/assets/svg/Paste";
import { Scan } from "~/assets/svg/Scan";
import { useMegaStore } from "~/state/megaStore";
import { MutinyInvoice } from "@mutinywallet/mutiny-wasm";
import { Contact, MutinyInvoice } from "@mutinywallet/mutiny-wasm";
import { StyledRadioGroup } from "~/components/layout/Radio";
import { ParsedParams, toParsedParams } from "./Scanner";
import { showToast } from "~/components/Toaster";
@@ -121,8 +121,7 @@ export default function Send() {
const [sentDetails, setSentDetails] = createSignal<SentDetails>();
// Tagging stuff
const [selectedValues, setSelectedValues] = createSignal<MutinyTagItem[]>([]);
const [values, setValues] = createSignal<MutinyTagItem[]>([UNKNOWN_TAG]);
const [selectedContacts, setSelectedContacts] = createSignal<Partial<MutinyTagItem>[]>([]);
function clearAll() {
setDestination(undefined);
@@ -146,10 +145,6 @@ 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
@@ -216,27 +211,62 @@ export default function Send() {
});
}
async function processContacts(contacts: Partial<MutinyTagItem>[]): Promise<string[]> {
console.log("Processing contacts", contacts)
if (contacts.length) {
const first = contacts![0];
if (!first.name) {
console.error("Something went wrong with contact creation, proceeding anyway")
return []
}
if (!first.id && first.name) {
console.error("Creating new contact", first.name)
const c = new Contact(first.name, undefined, undefined, undefined);
const newContactId = await state.mutiny_wallet?.create_new_contact(c);
if (newContactId) {
return [newContactId];
}
}
if (first.id) {
console.error("Using existing contact", first.name, first.id)
return [first.id];
}
}
console.error("Something went wrong with contact creation, proceeding anyway")
return []
}
async function handleSend() {
try {
setSending(true);
const bolt11 = invoice()?.bolt11;
const sentDetails: Partial<SentDetails> = {};
const tags = await processContacts(selectedContacts());
if (source() === "lightning" && invoice() && bolt11) {
const nodes = await state.mutiny_wallet?.list_nodes();
const firstNode = nodes[0] as string || ""
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, undefined, tagsToIds(selectedValues()));
await state.mutiny_wallet?.pay_invoice(firstNode, bolt11, undefined, tags);
sentDetails.amount = invoice()?.amount_sats;
} else {
await state.mutiny_wallet?.pay_invoice(firstNode, bolt11, amountSats(), tagsToIds(selectedValues()));
await state.mutiny_wallet?.pay_invoice(firstNode, bolt11, amountSats(), tags);
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(), tagsToIds(selectedValues()));
const payment = await state.mutiny_wallet?.keysend(firstNode, nodePubkey()!, amountSats(), tags);
// TODO: handle timeouts
if (!payment?.paid) {
@@ -246,7 +276,7 @@ export default function Send() {
}
} else if (source() === "onchain" && address()) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const txid = await state.mutiny_wallet?.send_to_address(address()!, amountSats(), tagsToIds(selectedValues()));
const txid = await state.mutiny_wallet?.send_to_address(address()!, amountSats(), tags);
sentDetails.amount = amountSats();
sentDetails.destination = address();
// TODO: figure out if this is necessary, it takes forever
@@ -312,9 +342,11 @@ export default function Send() {
<Card>
<VStack>
<DestinationShower source={source()} description={description()} invoice={invoice()} address={address()} nodePubkey={nodePubkey()} clearAll={clearAll} />
<TagEditor values={values()} setValues={setValues} selectedValues={selectedValues()} setSelectedValues={setSelectedValues} placeholder="Where's it going to?" />
<SmallHeader>Private tags</SmallHeader>
<TagEditor selectedValues={selectedContacts()} setSelectedValues={setSelectedContacts} placeholder="Add the receiver for your records" />
</VStack>
</Card>
<AmountCard amountSats={amountSats().toString()} setAmountSats={setAmountSats} fee={fakeFee().toString()} isAmountEditable={!(invoice()?.amount_sats)} />
</Match>
<Match when={true}>
@@ -322,7 +354,7 @@ export default function Send() {
</Match>
</Switch>
<Show when={destination()}>
<Button disabled={sendButtonDisabled()} intent="blue" onClick={handleSend} loading={sending()}>{sending() ? "Sending..." : "Confirm Send"}</Button>
<Button class="w-full flex-grow-0" disabled={sendButtonDisabled()} intent="blue" onClick={handleSend} loading={sending()}>{sending() ? "Sending..." : "Confirm Send"}</Button>
</Show>
</VStack>
</DefaultMain>

2
src/styles/dialogs.ts Normal file
View File

@@ -0,0 +1,2 @@
export const DIALOG_POSITIONER = "fixed inset-0 h-[100dvh] z-50"
export const DIALOG_CONTENT = "h-[100dvh] flex flex-col justify-between px-4 pt-4 pb-8 bg-neutral-800/80 backdrop-blur-xl"

View File

@@ -1,4 +1,4 @@
import { TagItem } from "@mutinywallet/mutiny-wasm"
import { Contact, TagItem } from "@mutinywallet/mutiny-wasm"
export type MutinyTagItem = {
id: string,
@@ -12,7 +12,10 @@ export type MutinyTagItem = {
export const UNKNOWN_TAG: MutinyTagItem = { id: "Unknown", kind: "Label", name: "Unknown", last_used_time: 0n }
export function tagsToIds(tags: MutinyTagItem[]): string[] {
export function tagsToIds(tags?: MutinyTagItem[]): string[] {
if (!tags) {
return []
}
return tags.filter((tag) => tag.id !== "Unknown").map((tag) => tag.id)
}