contact search and new amount editor

This commit is contained in:
Paul Miller
2023-11-16 23:37:29 -06:00
committed by Tony Giorgio
parent 077ccb2a63
commit bc2fbf9b2f
65 changed files with 2045 additions and 2483 deletions

View File

@@ -1,67 +1,69 @@
import { test, expect } from "@playwright/test"; import { expect, test } from "@playwright/test";
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.goto("http://localhost:3420/"); await page.goto("http://localhost:3420/");
}); });
test("rountrip receive and send", async ({ page }) => { test("rountrip receive and send", async ({ page }) => {
// Click the receive button // Click the receive button
await page.click("text=Receive"); await page.click("text=Receive");
// Expect the url to conain receive // Expect the url to conain receive
await expect(page).toHaveURL(/.*receive/); await expect(page).toHaveURL(/.*receive/);
// At least one h1 should show "0 sats" // At least one h1 should show "0 sats"
await expect(page.locator("h1")).toContainText(["0 SATS"]); await expect(page.locator("h1")).toContainText(["0 SATS"]);
// At least one h2 should show "0 USD" // At least one h2 should show "0 USD"
await expect(page.locator("h2")).toContainText(["$0 USD"]); await expect(page.locator("h2")).toContainText(["$0 USD"]);
// Click the 100k button // Type 100000 into the input
await page.click("text=100k"); await page.locator("#sats-input").pressSequentially("100000");
// Now the h1 should show "10,000 sats" // Now the h1 should show "100,000 sats"
await expect(page.locator("h1")).toContainText(["100,000 SATS"]); await expect(page.locator("h1")).toContainText(["100,000 SATS"]);
// Click the "Set Amount" button
await page.click("text=Set Amount");
// There should be a button with the text "Continue" and it should not be disabled // There should be a button with the text "Continue" and it should not be disabled
const continueButton = await page.locator("button", { hasText: "Continue" }); const continueButton = await page.locator("button", {
hasText: "Continue"
});
await expect(continueButton).not.toBeDisabled(); await expect(continueButton).not.toBeDisabled();
// Wait one second // Wait one second
// TODO: figure out how to not get an error without waiting // TODO: figure out how to not get an error without waiting
await page.waitForTimeout(1000); await page.waitForTimeout(1000);
continueButton.click(); continueButton.click();
await expect( await expect(
page.getByText("Keep Mutiny open to complete the payment.") page.getByText("Keep Mutiny open to complete the payment.")
).toBeVisible(); ).toBeVisible();
// Locate an SVG inside a div with id "qr" // Locate an SVG inside a div with id "qr"
const qrCode = await page.locator("#qr > svg"); const qrCode = await page.locator("#qr > svg");
await expect(qrCode).toBeVisible(); await expect(qrCode).toBeVisible();
const value = await qrCode.getAttribute("value"); const value = await qrCode.getAttribute("value");
// The SVG's value property includes "bitcoin:t" // The SVG's value property includes "bitcoin:t"
expect(value).toContain("bitcoin:t"); expect(value).toContain("bitcoin:t");
const lightningInvoice = value?.split("lightning=")[1]; const lightningInvoice = value?.split("lightning=")[1];
// Post the lightning invoice to the server // Post the lightning invoice to the server
const _response = await fetch("https://faucet.mutinynet.com/api/lightning", { const _response = await fetch(
method: "POST", "https://faucet.mutinynet.com/api/lightning",
headers: { {
"Content-Type": "application/json" method: "POST",
}, headers: {
body: JSON.stringify({ "Content-Type": "application/json"
bolt11: lightningInvoice },
}) body: JSON.stringify({
}); bolt11: lightningInvoice
})
}
);
// Wait for an h1 to appear in the dom that says "Payment Received" // Wait for an h1 to appear in the dom that says "Payment Received"
await page.waitForSelector("text=Payment Received", { timeout: 30000 }); await page.waitForSelector("text=Payment Received", { timeout: 30000 });
@@ -73,20 +75,29 @@ test("rountrip receive and send", async ({ page }) => {
await page.click("text=Send"); await page.click("text=Send");
// In the textarea with the placeholder "bitcoin:..." type refund@lnurl-staging.mutinywallet.com // In the textarea with the placeholder "bitcoin:..." type refund@lnurl-staging.mutinywallet.com
const sendInput = await page.locator("textarea"); const sendInput = await page.locator("input");
await sendInput.fill("refund@lnurl-staging.mutinywallet.com"); await sendInput.fill("refund@lnurl-staging.mutinywallet.com");
await page.click("text=Continue"); await page.click("text=Continue");
await page.click("text=Set Amount"); // Wait two seconds (the destination doesn't show up immediately)
// TODO: figure out how to not get an error without waiting
await page.waitForTimeout(2000);
await page.click("text=10k"); // Type 10000 into the input
await page.locator("#sats-input").fill("10000");
await page.click("text=Set Amount"); // Now the h1 should show "100,000 sats"
await expect(page.locator("h1")).toContainText(["10,000 SATS"]);
await page.click("text=Confirm Send"); // There should be a button with the text "Confirm Send" and it should not be disabled
const confirmButton = await page.locator("button", {
hasText: "Confirm Send"
});
await expect(confirmButton).not.toBeDisabled();
// Wait for an h1 to appear in the dom that says "Payment Received" confirmButton.click();
// Wait for an h1 to appear in the dom that says "Payment Sent"
await page.waitForSelector("text=Payment Sent", { timeout: 30000 }); await page.waitForSelector("text=Payment Sent", { timeout: 30000 });
}); });

View File

@@ -183,7 +183,9 @@ test("visit each route", async ({ page }) => {
// Swap // Swap
await page.goto("http://localhost:3420/swap"); await page.goto("http://localhost:3420/swap");
await expect(page.locator("h1")).toHaveText("Swap to Lightning"); await expect(
page.getByRole("heading", { name: "Swap to Lightning" })
).toBeVisible();
checklist.set("/swap", true); checklist.set("/swap", true);
// Gift // Gift

View File

@@ -58,16 +58,13 @@
"@mutinywallet/mutiny-wasm": "0.5.2", "@mutinywallet/mutiny-wasm": "0.5.2",
"@mutinywallet/waila-wasm": "^0.2.6", "@mutinywallet/waila-wasm": "^0.2.6",
"@solid-primitives/upload": "^0.0.111", "@solid-primitives/upload": "^0.0.111",
"@solid-primitives/websocket": "^1.2.0",
"@solidjs/meta": "^0.29.1", "@solidjs/meta": "^0.29.1",
"@solidjs/router": "^0.9.0", "@solidjs/router": "^0.9.0",
"@thisbeyond/solid-select": "^0.14.0",
"i18next": "^22.5.1", "i18next": "^22.5.1",
"i18next-browser-languagedetector": "^7.1.0", "i18next-browser-languagedetector": "^7.1.0",
"qr-scanner": "^1.4.2", "qr-scanner": "^1.4.2",
"solid-js": "^1.8.5", "solid-js": "^1.8.5",
"solid-qr-code": "^0.0.8", "solid-qr-code": "^0.0.8"
"undici": "^5.27.1"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"

37
pnpm-lock.yaml generated
View File

@@ -62,18 +62,12 @@ importers:
'@solid-primitives/upload': '@solid-primitives/upload':
specifier: ^0.0.111 specifier: ^0.0.111
version: 0.0.111(solid-js@1.8.5) version: 0.0.111(solid-js@1.8.5)
'@solid-primitives/websocket':
specifier: ^1.2.0
version: 1.2.0(solid-js@1.8.5)
'@solidjs/meta': '@solidjs/meta':
specifier: ^0.29.1 specifier: ^0.29.1
version: 0.29.1(solid-js@1.8.5) version: 0.29.1(solid-js@1.8.5)
'@solidjs/router': '@solidjs/router':
specifier: ^0.9.0 specifier: ^0.9.0
version: 0.9.0(solid-js@1.8.5) version: 0.9.0(solid-js@1.8.5)
'@thisbeyond/solid-select':
specifier: ^0.14.0
version: 0.14.0(solid-js@1.8.5)
i18next: i18next:
specifier: ^22.5.1 specifier: ^22.5.1
version: 22.5.1 version: 22.5.1
@@ -89,9 +83,6 @@ importers:
solid-qr-code: solid-qr-code:
specifier: ^0.0.8 specifier: ^0.0.8
version: 0.0.8(qr.js@0.0.0)(solid-js@1.8.5) version: 0.0.8(qr.js@0.0.0)(solid-js@1.8.5)
undici:
specifier: ^5.27.1
version: 5.27.1
devDependencies: devDependencies:
'@capacitor/assets': '@capacitor/assets':
specifier: ^2.0.4 specifier: ^2.0.4
@@ -2130,11 +2121,6 @@ packages:
resolution: {integrity: sha512-cEee/Z+I12mZcFJshKcCqC8tuX5hG3s+d+9nZ3LabqKF1vKdF41B92pJVCBggjAGORAeOzyyDDKrZwIkLffeOQ==} resolution: {integrity: sha512-cEee/Z+I12mZcFJshKcCqC8tuX5hG3s+d+9nZ3LabqKF1vKdF41B92pJVCBggjAGORAeOzyyDDKrZwIkLffeOQ==}
dev: true dev: true
/@fastify/busboy@2.0.0:
resolution: {integrity: sha512-JUFJad5lv7jxj926GPgymrWQxxjPYuJNiNjNMzqT+HiuP6Vl3dk5xzG+8sTX96np0ZAluvaMzPsjhHZ5rNuNQQ==}
engines: {node: '>=14'}
dev: false
/@floating-ui/core@1.4.1: /@floating-ui/core@1.4.1:
resolution: {integrity: sha512-jk3WqquEJRlcyu7997NtR5PibI+y5bi+LS3hPmguVClypenMsCY3CBa3LAQnozRCtCrYWSEtAdiskpamuJRFOQ==} resolution: {integrity: sha512-jk3WqquEJRlcyu7997NtR5PibI+y5bi+LS3hPmguVClypenMsCY3CBa3LAQnozRCtCrYWSEtAdiskpamuJRFOQ==}
dependencies: dependencies:
@@ -3492,14 +3478,6 @@ packages:
solid-js: 1.8.5 solid-js: 1.8.5
dev: false dev: false
/@solid-primitives/websocket@1.2.0(solid-js@1.8.5):
resolution: {integrity: sha512-Ft74wlLD/zrOSDUq4zMDDEs4Bf7ywQO52zOMQqijLYZ9ndvepje5Eb1xiFGnfZ2kcbKaLUfnqIQxVZVB7FGnPQ==}
peerDependencies:
solid-js: ^1.6.12
dependencies:
solid-js: 1.8.5
dev: false
/@solidjs/meta@0.28.6(solid-js@1.7.9): /@solidjs/meta@0.28.6(solid-js@1.7.9):
resolution: {integrity: sha512-mplUfmp7tjGgDTiVbEAqkWDLpr0ZNyR1+OOETNyJt759MqPzh979X3oJUk8SZisGII0BNycmHDIGc0Shqx7bIg==} resolution: {integrity: sha512-mplUfmp7tjGgDTiVbEAqkWDLpr0ZNyR1+OOETNyJt759MqPzh979X3oJUk8SZisGII0BNycmHDIGc0Shqx7bIg==}
peerDependencies: peerDependencies:
@@ -4743,14 +4721,6 @@ packages:
'@testing-library/dom': 9.3.1 '@testing-library/dom': 9.3.1
dev: true dev: true
/@thisbeyond/solid-select@0.14.0(solid-js@1.8.5):
resolution: {integrity: sha512-ecq4U3Vnc/nJbU84ARuPg2scNuYt994ljF5AmBlzuZW87x43mWiGJ5hEWufIJJMpDT6CcnCIx/xbrdDkaDEHQw==}
peerDependencies:
solid-js: ^1.5
dependencies:
solid-js: 1.8.5
dev: false
/@trapezedev/gradle-parse@5.0.10: /@trapezedev/gradle-parse@5.0.10:
resolution: {integrity: sha512-yriBEyOkJ8K4mHCgoyUKQCyVI8tP4S513Wp6/9SCx6Ub8ZvSQUonqU3/OZB2G8FRfL4aijpFfMWtiVFJbX6V/w==} resolution: {integrity: sha512-yriBEyOkJ8K4mHCgoyUKQCyVI8tP4S513Wp6/9SCx6Ub8ZvSQUonqU3/OZB2G8FRfL4aijpFfMWtiVFJbX6V/w==}
dev: true dev: true
@@ -13060,13 +13030,6 @@ packages:
dependencies: dependencies:
busboy: 1.6.0 busboy: 1.6.0
/undici@5.27.1:
resolution: {integrity: sha512-h0P6HVTlbcvF6wiX88+aoouMOuiKJ2TEGfcr+tEbr96OIizJaM4BhXJs/wxdDN/RD0F2yZCV/ylCVJy9PjQJIg==}
engines: {node: '>=14.0'}
dependencies:
'@fastify/busboy': 2.0.0
dev: false
/unicode-canonical-property-names-ecmascript@2.0.0: /unicode-canonical-property-names-ecmascript@2.0.0:
resolution: {integrity: sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==} resolution: {integrity: sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==}
engines: {node: '>=4'} engines: {node: '>=4'}

View File

@@ -21,14 +21,7 @@ import { useI18n } from "~/i18n/context";
import { useMegaStore } from "~/state/megaStore"; import { useMegaStore } from "~/state/megaStore";
import { createDeepSignal } from "~/utils"; import { createDeepSignal } from "~/utils";
export const THREE_COLUMNS = interface IActivityItem {
"grid grid-cols-[auto,1fr,auto] gap-4 py-2 px-2 border-b border-neutral-800 last:border-b-0";
export const CENTER_COLUMN = "min-w-0 overflow-hidden max-w-full";
export const MISSING_LABEL =
"py-1 px-2 bg-white/10 rounded inline-block text-sm";
export const RIGHT_COLUMN = "flex flex-col items-right text-right max-w-[8rem]";
export interface IActivityItem {
kind: HackActivityType; kind: HackActivityType;
id: string; id: string;
amount_sats: number; amount_sats: number;

View File

@@ -178,7 +178,7 @@ export function MiniStringShower(props: { text: string }) {
); );
} }
export function FormatPrettyPrint(props: { ts: number }) { function FormatPrettyPrint(props: { ts: number }) {
return ( return (
<div> <div>
{prettyPrintTime(props.ts).split(",", 2).join(",")} {prettyPrintTime(props.ts).split(",", 2).join(",")}

View File

@@ -1,15 +1,13 @@
import { TagItem } from "@mutinywallet/mutiny-wasm"; import { TagItem } from "@mutinywallet/mutiny-wasm";
import { createResource, Match, ParentComponent, Switch } from "solid-js"; import { Match, ParentComponent, Switch } from "solid-js";
import bolt from "~/assets/icons/bolt.svg"; import bolt from "~/assets/icons/bolt.svg";
import chain from "~/assets/icons/chain.svg"; import chain from "~/assets/icons/chain.svg";
import off from "~/assets/icons/download-channel.svg";
import shuffle from "~/assets/icons/shuffle.svg"; import shuffle from "~/assets/icons/shuffle.svg";
import on from "~/assets/icons/upload-channel.svg"; import { AmountFiat, AmountSats, LabelCircle } from "~/components";
import { AmountFiat, AmountSats } from "~/components";
import { useI18n } from "~/i18n/context"; import { useI18n } from "~/i18n/context";
import { useMegaStore } from "~/state/megaStore"; import { useMegaStore } from "~/state/megaStore";
import { generateGradient, timeAgo } from "~/utils"; import { timeAgo } from "~/utils";
export const ActivityAmount: ParentComponent<{ export const ActivityAmount: ParentComponent<{
amount: string; amount: string;
@@ -44,50 +42,6 @@ export const ActivityAmount: ParentComponent<{
); );
}; };
function LabelCircle(props: {
name?: string;
image_url?: string;
contact: boolean;
label: boolean;
channel?: HackActivityType;
}) {
const [gradient] = createResource(async () => {
if (props.name && props.contact) {
return generateGradient(props.name || "?");
} else {
return undefined;
}
});
const text = () =>
props.contact && props.name && props.name.length
? props.name[0]
: props.label
? "≡"
: "?";
const bg = () => (props.name && props.contact ? gradient() : "");
return (
<div
class="flex h-[3rem] w-[3rem] flex-none items-center justify-center overflow-clip rounded-full border-b border-t border-b-white/10 border-t-white/50 bg-neutral-700 text-3xl uppercase"
style={{ background: bg() }}
>
<Switch>
<Match when={props.image_url}>
<img src={props.image_url} alt={"image"} />
</Match>
<Match when={props.channel === "ChannelOpen"}>
<img src={on} alt="channel open" />
</Match>
<Match when={props.channel === "ChannelClose"}>
<img src={off} alt="channel close" />
</Match>
<Match when={true}>{text()}</Match>
</Switch>
</div>
);
}
export type HackActivityType = export type HackActivityType =
| "Lightning" | "Lightning"
| "OnChain" | "OnChain"

View File

@@ -1,210 +0,0 @@
import { createMemo, Match, ParentComponent, Show, Switch } from "solid-js";
import { AmountEditable, Card, VStack } from "~/components";
import { useI18n } from "~/i18n/context";
import { useMegaStore } from "~/state/megaStore";
import { satsToFormattedFiat } from "~/utils";
const noop = () => {
// do nothing
};
const AmountKeyValue: ParentComponent<{ key: string; gray?: boolean }> = (
props
) => {
return (
<div
class="flex items-center justify-between"
classList={{ "text-neutral-400": props.gray }}
>
<div class="font-semibold uppercase">{props.key}</div>
<div class="font-light">{props.children}</div>
</div>
);
};
export const InlineAmount: ParentComponent<{
amount: string;
sign?: string;
}> = (props) => {
const i18n = useI18n();
const prettyPrint = createMemo(() => {
const parsed = Number(props.amount);
if (isNaN(parsed)) {
return props.amount;
} else {
return parsed.toLocaleString(navigator.languages[0]);
}
});
return (
<div class="inline-block text-lg">
{props.sign ? `${props.sign} ` : ""}
{prettyPrint()} <span class="text-sm">{i18n.t("common.sats")}</span>
</div>
);
};
function USDShower(props: { amountSats: string; fee?: string }) {
const [state, _] = useMegaStore();
const amountInFiat = () =>
(state.fiat.value === "BTC" ? "" : "~") +
satsToFormattedFiat(
state.price,
add(props.amountSats, props.fee),
state.fiat
);
return (
<Show when={!(props.amountSats === "0")}>
<AmountKeyValue gray key="">
<div class="self-end whitespace-nowrap">
{`${amountInFiat()} `}
<span class="text-sm">{state.fiat.value}</span>
</div>
</AmountKeyValue>
</Show>
);
}
function add(a: string, b?: string) {
return Number(a || 0) + Number(b || 0);
}
export function AmountCard(props: {
amountSats: string;
fee?: string;
reserve?: string;
initialOpen?: boolean;
isAmountEditable?: boolean;
setAmountSats?: (amount: bigint) => void;
showWarnings?: boolean;
exitRoute?: string;
maxAmountSats?: bigint;
}) {
const i18n = useI18n();
// Normally we want to add the fee to the amount, but for max amount we just show the max
const totalOrTotalLessFee = () => {
if (
props.fee &&
props.maxAmountSats &&
props.amountSats === props.maxAmountSats?.toString()
) {
return props.maxAmountSats.toLocaleString();
} else {
return add(props.amountSats, props.fee).toString();
}
};
return (
<Card>
<VStack>
<Switch>
<Match when={props.fee}>
<div class="flex flex-col gap-1">
<AmountKeyValue key={i18n.t("receive.amount")}>
<Show
when={props.isAmountEditable}
fallback={
<InlineAmount
amount={props.amountSats}
/>
}
>
<AmountEditable
initialOpen={props.initialOpen ?? false}
initialAmountSats={props.amountSats.toString()}
setAmountSats={
props.setAmountSats
? props.setAmountSats
: noop
}
showWarnings={
props.showWarnings ?? false
}
exitRoute={props.exitRoute}
maxAmountSats={props.maxAmountSats}
fee={props.fee}
/>
</Show>
</AmountKeyValue>
<AmountKeyValue gray key={i18n.t("receive.fee")}>
<InlineAmount amount={props.fee || "0"} />
</AmountKeyValue>
</div>
<hr class="border-white/20" />
<div class="flex flex-col gap-1">
<AmountKeyValue key={i18n.t("receive.total")}>
<InlineAmount amount={totalOrTotalLessFee()} />
</AmountKeyValue>
<USDShower
amountSats={props.amountSats}
fee={props.fee}
/>
</div>
</Match>
<Match when={props.reserve}>
<div class="flex flex-col gap-1">
<AmountKeyValue
key={i18n.t("receive.channel_size")}
>
<InlineAmount
amount={add(
props.amountSats,
props.reserve
).toString()}
/>
</AmountKeyValue>
<AmountKeyValue
gray
key={i18n.t("receive.channel_reserve")}
>
<InlineAmount amount={props.reserve || "0"} />
</AmountKeyValue>
</div>
<hr class="border-white/20" />
<div class="flex flex-col gap-1">
<AmountKeyValue key={i18n.t("receive.spendable")}>
<InlineAmount amount={props.amountSats} />
</AmountKeyValue>
<USDShower
amountSats={props.amountSats}
fee={props.reserve}
/>
</div>
</Match>
<Match when={!props.fee && !props.reserve}>
<div class="flex flex-col gap-1">
<AmountKeyValue key={i18n.t("receive.amount")}>
<Show
when={props.isAmountEditable}
fallback={
<InlineAmount
amount={props.amountSats}
/>
}
>
<AmountEditable
initialOpen={props.initialOpen ?? false}
initialAmountSats={props.amountSats.toString()}
setAmountSats={
props.setAmountSats
? props.setAmountSats
: noop
}
showWarnings={
props.showWarnings ?? false
}
exitRoute={props.exitRoute}
maxAmountSats={props.maxAmountSats}
fee={props.fee}
/>
</Show>
</AmountKeyValue>
<USDShower amountSats={props.amountSats} />
</div>
</Match>
</Switch>
</VStack>
</Card>
);
}

View File

@@ -1,384 +1,46 @@
import { Capacitor } from "@capacitor/core";
import { Dialog } from "@kobalte/core";
import { useNavigate } from "@solidjs/router";
import { import {
createEffect, createEffect,
createResource,
createSignal, createSignal,
For,
Match,
onCleanup, onCleanup,
onMount, onMount,
ParentComponent, ParentComponent,
Show, Show
Switch
} from "solid-js"; } from "solid-js";
import close from "~/assets/icons/close.svg"; import { AmountSmall, BigMoney } from "~/components";
import currencySwap from "~/assets/icons/currency-swap.svg";
import pencil from "~/assets/icons/pencil.svg";
import { Button, FeesModal, InfoBox, InlineAmount, VStack } from "~/components";
import { useI18n } from "~/i18n/context"; import { useI18n } from "~/i18n/context";
import { useMegaStore } from "~/state/megaStore"; import { useMegaStore } from "~/state/megaStore";
import { DIALOG_CONTENT, DIALOG_POSITIONER } from "~/styles/dialogs"; import {
import { Currency, fiatToSats, satsToFiat } from "~/utils"; btcFloatRounding,
fiatInputSanitizer,
// Checks the users locale to determine if decimals should be a "." or a "," fiatToSats,
const decimalDigitDivider = Number(1.0) satsInputSanitizer,
.toLocaleString(navigator.languages[0], { minimumFractionDigits: 1 }) satsToFiat,
.substring(1, 2); toDisplayHandleNaN
} from "~/utils";
function btcFloatRounding(localValue: string): string {
return (
(parseFloat(localValue) -
parseFloat(localValue.charAt(localValue.length - 1)) / 100000000) /
10
).toFixed(8);
}
function fiatInputSanitizer(input: string, maxDecimals: number): string {
// Make sure only numbers and a single decimal point are allowed if decimals are allowed
let allowDecimalRegex;
if (maxDecimals !== 0) {
allowDecimalRegex = new RegExp("[^0-9.]", "g");
} else {
allowDecimalRegex = new RegExp("[^0-9]", "g");
}
const numeric = input
.replace(allowDecimalRegex, "")
.replace(/(\..*)\./g, "$1");
// Remove leading zeros if not a decimal, add 0 if starts with a decimal
const cleaned = numeric.replace(/^0([^.]|$)/g, "$1").replace(/^\./g, "0.");
// If there are more characters after the decimal than allowed, shift the decimal
const shiftRegex = new RegExp(
"(\\.[0-9]{" + (maxDecimals + 1) + "}).*",
"g"
);
const shifted = cleaned.match(shiftRegex)
? (parseFloat(cleaned) * 10).toFixed(maxDecimals)
: cleaned;
// Truncate any numbers past the maxDecimal for the currency
const decimalRegex = new RegExp("(\\.[0-9]{" + maxDecimals + "}).*", "g");
const decimals = shifted.replace(decimalRegex, "$1");
return decimals;
}
function satsInputSanitizer(input: string): string {
// Make sure only numbers are allowed
const numeric = input.replace(/[^0-9]/g, "");
// If it starts with a 0, remove the 0
const noLeadingZero = numeric.replace(/^0([^.]|$)/g, "$1");
return noLeadingZero;
}
function SingleDigitButton(props: {
character: string;
onClick: (c: string) => void;
onClear: () => void;
fiat?: Currency;
}) {
const i18n = useI18n();
let holdTimer: ReturnType<typeof setTimeout> | undefined;
const holdThreshold = 500;
function onHold() {
if (
props.character === "DEL" ||
props.character === i18n.t("receive.amount_editable.del")
) {
holdTimer = setTimeout(() => {
props.onClear();
}, holdThreshold);
}
}
function endHold() {
clearTimeout(holdTimer);
}
function onClick() {
props.onClick(props.character);
clearTimeout(holdTimer);
}
onCleanup(() => {
clearTimeout(holdTimer);
});
return (
// Skip the "." if it's sats or a fiat with no decimal option
<Show
when={
(props.fiat &&
props.fiat?.maxFractionalDigits !== 0 &&
props.fiat?.value !== "BTC") ||
!(props.character === "." || props.character === ",")
}
fallback={<div />}
>
<button
class="font-semi font-inter flex items-center justify-center rounded-lg p-2 text-4xl text-white active:bg-m-blue disabled:opacity-50 md:hover:bg-white/10"
onPointerDown={onHold}
onPointerUp={endHold}
onClick={onClick}
>
{props.character}
</button>
</Show>
);
}
function BigScalingText(props: {
text: string;
fiat?: Currency;
mode: "fiat" | "sats";
loading: boolean;
}) {
const chars = () => props.text.length;
const i18n = useI18n();
return (
<h1
class="whitespace-nowrap px-2 text-center text-4xl font-light transition-transform duration-300 ease-out"
classList={{
"scale-90": chars() >= 11,
"scale-95": chars() === 10,
"scale-100": chars() === 9,
"scale-105": chars() === 7,
"scale-110": chars() === 6,
"scale-125": chars() === 5,
"scale-150": chars() <= 4
}}
>
<Show when={!props.loading || props.mode === "sats"} fallback="…">
{!props.loading && props.mode === "sats"}
{props.mode === "fiat" &&
//adds only the symbol
props.fiat?.hasSymbol}
{`${props.text} `}
<span class="text-xl">
{props.fiat ? props.fiat.value : i18n.t("common.sats")}
</span>
</Show>
</h1>
);
}
function SmallSubtleAmount(props: {
text: string;
fiat?: Currency;
mode: "fiat" | "sats";
loading: boolean;
}) {
const i18n = useI18n();
return (
<h2 class="flex flex-row items-end whitespace-nowrap text-xl font-light text-neutral-400">
<Show when={!props.loading || props.mode === "fiat"} fallback="…">
{props.fiat?.value !== "BTC" && props.mode === "sats" && "~"}
{props.mode === "sats" &&
//adds only the symbol
props.fiat?.hasSymbol}
{`${props.text} `}
{/* IDK why a space doesn't work here */}
<span class="flex-0 w-1">{""}</span>
<span class="text-base">
{props.fiat ? props.fiat.value : i18n.t("common.sats")}
</span>
<img
class={"pb-[4px] pl-[4px] hover:cursor-pointer"}
src={currencySwap}
height={24}
width={24}
alt="Swap currencies"
/>
</Show>
</h2>
);
}
function toDisplayHandleNaN(input: string, fiat?: Currency): string {
const parsed = Number(input);
//handle decimals so the user can always see the accurate amount
if (isNaN(parsed)) {
return "0";
} else if (parsed === Math.trunc(parsed) && input.endsWith(".")) {
return (
parsed.toLocaleString(navigator.languages[0]) + decimalDigitDivider
);
/* To avoid having logic to handle every number up to 8 decimals
any custom currency pair that has more than 3 decimals will always show all decimals*/
} else if (fiat?.maxFractionalDigits && fiat.maxFractionalDigits > 3) {
return parsed.toLocaleString(navigator.languages[0], {
minimumFractionDigits: parsed === 0 ? 0 : fiat.maxFractionalDigits,
maximumFractionDigits: fiat.maxFractionalDigits
});
} else if (parsed === Math.trunc(parsed) && input.endsWith(".0")) {
return parsed.toLocaleString(navigator.languages[0], {
minimumFractionDigits: 1
});
} else if (parsed === Math.trunc(parsed) && input.endsWith(".00")) {
return parsed.toLocaleString(navigator.languages[0], {
minimumFractionDigits: 2
});
} else if (parsed === Math.trunc(parsed) && input.endsWith(".000")) {
return parsed.toLocaleString(navigator.languages[0], {
minimumFractionDigits: 3
});
} else if (
parsed !== Math.trunc(parsed) &&
// matches strings that have 3 total digits after the decimal and ends with 0
input.match(/\.\d{2}0$/) &&
input.includes(".", input.length - 4)
) {
return parsed.toLocaleString(navigator.languages[0], {
minimumFractionDigits: 3
});
} else if (
parsed !== Math.trunc(parsed) &&
// matches strings that have 2 total digits after the decimal and ends with 0
input.match(/\.\d{1}0$/) &&
input.includes(".", input.length - 3)
) {
return parsed.toLocaleString(navigator.languages[0], {
minimumFractionDigits: 2
});
} else {
return parsed.toLocaleString(navigator.languages[0], {
maximumFractionDigits: 3
});
}
}
export const AmountEditable: ParentComponent<{ export const AmountEditable: ParentComponent<{
initialAmountSats: string; initialAmountSats: string | bigint;
initialOpen: boolean;
setAmountSats: (s: bigint) => void; setAmountSats: (s: bigint) => void;
showWarnings: boolean;
exitRoute?: string;
maxAmountSats?: bigint; maxAmountSats?: bigint;
fee?: string; fee?: string;
frozenAmount?: boolean;
onSubmit?: () => void;
}> = (props) => { }> = (props) => {
const i18n = useI18n();
const navigate = useNavigate();
const [isOpen, setIsOpen] = createSignal(props.initialOpen);
const [state, _actions] = useMegaStore(); const [state, _actions] = useMegaStore();
const [mode, setMode] = createSignal<"fiat" | "sats">("sats"); const [mode, setMode] = createSignal<"fiat" | "sats">("sats");
const i18n = useI18n();
const [localSats, setLocalSats] = createSignal( const [localSats, setLocalSats] = createSignal(
props.initialAmountSats || "0" props.initialAmountSats.toString() || "0"
); );
const [localFiat, setLocalFiat] = createSignal( const [localFiat, setLocalFiat] = createSignal(
satsToFiat( satsToFiat(
state.price, state.price,
parseInt(props.initialAmountSats || "0") || 0, parseInt(props.initialAmountSats.toString() || "0") || 0,
state.fiat state.fiat
) )
); );
const setSecondaryAmount = () =>
mode() === "fiat"
? setLocalSats(
fiatToSats(
state.price,
parseFloat(localFiat() || "0") || 0,
false
)
)
: setLocalFiat(
satsToFiat(state.price, Number(localSats()) || 0, state.fiat)
);
/** FixedAmounts allows for the user to choose 3 amount options approximately equal to ~$1, ~$10, ~$100
* This is done by fetching the price and reducing it such that the amounts all end up around the same value
*
* price = ~261,508.89
* roundedPrice = "261508"
* priceLength = 6
*
* input - {@link multipler}: 1, 10, 100
* fixedAmount - (10 ** (6 - 5)) * {@link multiplier}
* result - 10, 100, 1000
*/
const fixedAmount = (multiplier: number, label: boolean) => {
const roundedPrice = Math.round(state.price);
const priceLength = roundedPrice.toString().length;
//This returns a stringified number based on the price range of the chosen currency as compared to BTC
if (!label) {
return Number(10 ** (priceLength - 5) * multiplier).toString();
// Handle labels with a currency identifier inserted in front/back
} else {
return `${state.fiat?.hasSymbol ?? ""}${Number(
10 ** (priceLength - 5) * multiplier
).toLocaleString(navigator.languages[0], {
maximumFractionDigits: state.fiat.maxFractionalDigits
})} ${!state.fiat?.hasSymbol ? state.fiat?.value : ""}`;
}
};
const FIXED_AMOUNTS_SATS = [
{
label: i18n.t("receive.amount_editable.fix_amounts.ten_k"),
amount: "10000"
},
{
label: i18n.t("receive.amount_editable.fix_amounts.one_hundred_k"),
amount: "100000"
},
{
label: i18n.t("receive.amount_editable.fix_amounts.one_million"),
amount: "1000000"
}
];
// Wait to set fiat amounts until we have a price when loading the page
let FIXED_AMOUNTS_FIAT;
createEffect(() => {
if (state.price !== 0) {
// set FIXED_AMOUNTS_FIAT once we have a price
FIXED_AMOUNTS_FIAT = [
{
label: fixedAmount(1, true),
amount: fixedAmount(1, false)
},
{
label: fixedAmount(10, true),
amount: fixedAmount(10, false)
},
{
label: fixedAmount(100, true),
amount: fixedAmount(100, false)
}
];
// Update secondary amount when price changes
setSecondaryAmount();
}
});
const CHARACTERS = [
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
decimalDigitDivider,
"0",
i18n.t("receive.amount_editable.del")
];
const displaySats = () => toDisplayHandleNaN(localSats()); const displaySats = () => toDisplayHandleNaN(localSats());
const displayFiat = () => const displayFiat = () =>
state.price !== 0 ? toDisplayHandleNaN(localFiat(), state.fiat) : "…"; state.price !== 0 ? toDisplayHandleNaN(localFiat(), state.fiat) : "…";
@@ -386,207 +48,12 @@ export const AmountEditable: ParentComponent<{
let satsInputRef!: HTMLInputElement; let satsInputRef!: HTMLInputElement;
let fiatInputRef!: HTMLInputElement; let fiatInputRef!: HTMLInputElement;
const [inboundCapacity] = createResource(async () => { createEffect(() => {
try { if (focusState() === "focused") {
const channels = await state.mutiny_wallet?.list_channels(); props.setAmountSats(BigInt(localSats()));
let inbound = 0;
for (const channel of channels) {
inbound += channel.size - (channel.balance + channel.reserve);
}
return inbound;
} catch (e) {
console.error(e);
return 0;
} }
}); });
const warningText = () => {
if (state.federations?.length !== 0) {
return undefined;
}
if ((state.balance?.lightning || 0n) === 0n) {
return i18n.t("receive.amount_editable.receive_too_small", {
amount: "100,000"
});
}
const parsed = Number(localSats());
if (isNaN(parsed)) {
return undefined;
}
if (parsed > (inboundCapacity() || 0)) {
return i18n.t("receive.amount_editable.setup_fee_lightning");
}
return undefined;
};
const betaWarning = () => {
const parsed = Number(localSats());
if (isNaN(parsed)) {
return undefined;
}
if (parsed >= 2099999997690000) {
// If over 21 million bitcoin, warn that too much
return i18n.t("receive.amount_editable.more_than_21m");
} else if (parsed >= 4000000) {
// If over 4 million sats, warn that it's a beta bro
return i18n.t("receive.amount_editable.too_big_for_beta");
}
};
/** Handling character inputs gives our virtual keyboard full functionality to add and remove digits in a UX friendly way
* When the input is dealing with sats there is no allowed decimals
*
* Special logic is required for BTC as we want to start from the 8th decimal
* if state.fiat.value === "BTC"
* input - 000123
* result - 0.00000123
*
* input - 11"DEL"11
* result - 0.00000111
*
* for other currencies the inputSanitizer seeks to limit the maximum decimal digits
*
* if state.fiat.value === "KWD"
* input - 123.456666
* result - 123456.666
*/
function handleCharacterInput(characterInput: string) {
const isFiatMode = mode() === "fiat";
const character = characterInput === "," ? "." : characterInput;
let inputSanitizer;
if (isFiatMode) {
inputSanitizer = fiatInputSanitizer;
} else {
inputSanitizer = satsInputSanitizer;
}
const localValue = isFiatMode ? localFiat : localSats;
let sane;
if (
character === "DEL" ||
character === i18n.t("receive.amount_editable.del")
) {
if (
localValue().length === 1 ||
(state.fiat.maxFractionalDigits === 0 &&
localValue().startsWith("0"))
) {
sane = "0";
} else if (
state.fiat.value === "BTC" &&
isFiatMode &&
localValue() !== "0"
) {
// This allows us to handle the backspace key and fight float rounding
sane = inputSanitizer(
btcFloatRounding(localValue()),
state.fiat.maxFractionalDigits
);
} else {
sane = inputSanitizer(
localValue().slice(0, -1),
state.fiat.maxFractionalDigits
);
}
} else {
if (localValue() === "0" && state.fiat.value !== "BTC") {
sane = inputSanitizer(
character,
state.fiat.maxFractionalDigits
);
} else if (state.fiat.value === "BTC" && isFiatMode) {
sane = inputSanitizer(
Number(localValue()).toFixed(8) + character,
state.fiat.maxFractionalDigits
);
} else {
sane = inputSanitizer(
localValue() + character,
state.fiat.maxFractionalDigits
);
}
}
if (isFiatMode) {
setLocalFiat(sane);
setLocalSats(
fiatToSats(state.price, parseFloat(sane || "0") || 0, false)
);
} else {
setLocalSats(sane);
setLocalFiat(
satsToFiat(state.price, Number(sane) || 0, state.fiat)
);
}
// After a button press make sure we re-focus the input
focus();
}
function handleClear() {
const isFiatMode = mode() === "fiat";
if (isFiatMode) {
setLocalFiat("0");
setLocalSats(fiatToSats(state.price, parseFloat("0") || 0, false));
} else {
setLocalSats("0");
setLocalFiat(satsToFiat(state.price, Number("0") || 0, state.fiat));
}
// After a button press make sure we re-focus the input
focus();
}
function setFixedAmount(amount: string) {
if (mode() === "fiat") {
setLocalFiat(amount);
setLocalSats(
fiatToSats(state.price, parseFloat(amount || "0") || 0, false)
);
} else {
setLocalSats(amount);
setLocalFiat(
satsToFiat(state.price, Number(amount) || 0, state.fiat)
);
}
}
function handleClose(e: SubmitEvent | MouseEvent | KeyboardEvent) {
e.preventDefault();
props.setAmountSats(BigInt(props.initialAmountSats));
setIsOpen(false);
setLocalSats(props.initialAmountSats);
setLocalFiat(
satsToFiat(
state.price,
parseInt(props.initialAmountSats || "0") || 0,
state.fiat
)
);
props.exitRoute && navigate(props.exitRoute);
return false;
}
// What we're all here for in the first place: returning a value
function handleSubmit(e: SubmitEvent | MouseEvent) {
e.preventDefault();
props.setAmountSats(BigInt(localSats()));
setLocalFiat(
satsToFiat(state.price, Number(localSats()) || 0, state.fiat)
);
setIsOpen(false);
return false;
}
function handleSatsInput(e: InputEvent) { function handleSatsInput(e: InputEvent) {
const { value } = e.target as HTMLInputElement; const { value } = e.target as HTMLInputElement;
const sane = satsInputSanitizer(value); const sane = satsInputSanitizer(value);
@@ -625,6 +92,7 @@ export const AmountEditable: ParentComponent<{
); );
} }
} else { } else {
console.log("we're in the fiat branch");
sane = fiatInputSanitizer( sane = fiatInputSanitizer(
value.replace(",", "."), value.replace(",", "."),
state.fiat.maxFractionalDigits state.fiat.maxFractionalDigits
@@ -640,7 +108,6 @@ export const AmountEditable: ParentComponent<{
if (!disabled) { if (!disabled) {
setMode((m) => (m === "sats" ? "fiat" : "sats")); setMode((m) => (m === "sats" ? "fiat" : "sats"));
} }
focus();
} }
onMount(() => { onMount(() => {
@@ -649,7 +116,7 @@ export const AmountEditable: ParentComponent<{
function focus() { function focus() {
// Make sure we actually have the inputs mounted before we try to focus them // Make sure we actually have the inputs mounted before we try to focus them
if (isOpen() && satsInputRef && fiatInputRef) { if (satsInputRef && fiatInputRef && !props.frozenAmount) {
if (mode() === "sats") { if (mode() === "sats") {
satsInputRef.focus(); satsInputRef.focus();
} else { } else {
@@ -658,202 +125,116 @@ export const AmountEditable: ParentComponent<{
} }
} }
// If the user is trying to send the max amount we want to show max minus fee let divRef: HTMLDivElement;
// Otherwise we just the actual amount they've entered
const maxOrLocalSats = () => { const [focusState, setFocusState] = createSignal<"focused" | "unfocused">(
if ( "focused"
props.maxAmountSats && );
props.fee &&
props.maxAmountSats === BigInt(localSats()) const handleMouseDown = (e: MouseEvent) => {
) { e.preventDefault();
return ( // If it was already active, we'll need to toggle
Number(props.maxAmountSats) - Number(props.fee) if (focusState() === "unfocused") {
).toLocaleString(navigator.languages[0]); focus();
setFocusState("focused");
} else { } else {
return localSats(); toggle(state.price === 0);
focus();
} }
}; };
const handleClickOutside = (e: MouseEvent) => {
if (e.target instanceof Element && !divRef.contains(e.target)) {
setFocusState("unfocused");
}
};
// When the keyboard on mobile is shown / hidden we should update our "focus" state
// TODO: find a way so this doesn't fire on devices without a virtual keyboard
function handleResize(e: Event) {
const VIEWPORT_VS_CLIENT_HEIGHT_RATIO = 0.75;
const target = e.target as VisualViewport;
if (
(target.height * target.scale) / window.screen.height <
VIEWPORT_VS_CLIENT_HEIGHT_RATIO
) {
console.log("keyboard is shown");
setFocusState("focused");
} else {
console.log("keyboard is hidden");
setFocusState("unfocused");
}
}
onMount(() => {
document.body.addEventListener("click", handleClickOutside);
if ("visualViewport" in window) {
window?.visualViewport?.addEventListener("resize", handleResize);
}
});
onCleanup(() => {
document.body.removeEventListener("click", handleClickOutside);
if ("visualViewport" in window) {
window?.visualViewport?.removeEventListener("resize", handleResize);
}
});
return ( return (
<Dialog.Root open={isOpen()}> <div class="mx-auto flex w-full max-w-[400px] flex-col items-center">
<button <div ref={(el) => (divRef = el)} onMouseDown={handleMouseDown}>
type="button" <form
onClick={() => setIsOpen(true)} class="absolute -z-10 opacity-0"
class="flex items-center gap-2 rounded-xl border-2 border-m-blue px-4 py-2" onSubmit={(e) => {
> e.preventDefault();
<Show props.onSubmit
when={localSats() !== "0"} ? props.onSubmit()
fallback={ : setFocusState("unfocused");
<div class="inline-block font-semibold"> }}
{i18n.t("receive.amount_editable.set_amount")}
</div>
}
> >
<InlineAmount amount={maxOrLocalSats()} /> <input type="submit" style={{ display: "none" }} />
</Show> <input
<img src={pencil} alt="Edit" /> id="sats-input"
</button> ref={(el) => (satsInputRef = el)}
<Dialog.Portal> disabled={mode() === "fiat" || props.frozenAmount}
<div class={DIALOG_POSITIONER}> autofocus={mode() === "sats"}
<Dialog.Content type="text"
class={DIALOG_CONTENT} value={localSats()}
// Should always be on top, even when nested in other dialogs onInput={handleSatsInput}
classList={{ inputMode={"decimal"}
"z-50": true, autocomplete="off"
// h-device works for android, h-[100dvh] works for ios />
"h-device": Capacitor.getPlatform() === "android" <input
}} id="fiat-input"
onEscapeKeyDown={handleClose} ref={(el) => (fiatInputRef = el)}
> disabled={mode() === "sats" || props.frozenAmount}
<div class="py-2" /> autofocus={mode() === "fiat"}
type="text"
<div class="flex w-full justify-end"> value={localFiat()}
<button onInput={handleFiatInput}
onClick={handleClose} inputMode={"decimal"}
type="button" autocomplete="off"
class="h-8 w-8 rounded-lg hover:bg-white/10 active:bg-m-blue" />
> </form>
<img src={close} alt="Close" /> <BigMoney
</button> mode={mode()}
</div> displayFiat={displayFiat()}
<form displaySats={displaySats()}
onSubmit={handleSubmit} onToggle={() => toggle(state.price === 0)}
class="absolute -z-10 opacity-0" inputFocused={
> focusState() === "focused" && !props.frozenAmount
<input }
ref={(el) => (satsInputRef = el)} onFocus={() => focus()}
disabled={mode() === "fiat"} />
type="text" </div>
value={localSats()} <Show when={props.maxAmountSats}>
onInput={handleSatsInput} <p class="flex gap-2 px-4 py-2 text-sm font-light text-m-grey-400 md:text-base">
inputMode="none" {`${i18n.t("receive.amount_editable.balance")} `}
/> <AmountSmall amountSats={props.maxAmountSats!} />
<input </p>
ref={(el) => (fiatInputRef = el)} </Show>
disabled={mode() === "sats"} </div>
type="text"
value={localFiat()}
onInput={handleFiatInput}
inputMode="none"
/>
</form>
<div class="mx-auto flex w-full max-w-[400px] flex-1 flex-col justify-around gap-2">
<div class="flex justify-center">
<div
class="flex w-max flex-col items-center justify-center gap-4 p-4"
onClick={() => toggle(state.price === 0)}
>
<BigScalingText
text={
mode() === "fiat"
? displayFiat()
: displaySats()
}
fiat={
mode() === "fiat"
? state.fiat
: undefined
}
mode={mode()}
loading={state.price === 0}
/>
<SmallSubtleAmount
text={
mode() !== "fiat"
? displayFiat()
: displaySats()
}
fiat={
mode() !== "fiat"
? state.fiat
: undefined
}
mode={mode()}
loading={state.price === 0}
/>
</div>
</div>
<Switch>
<Match when={betaWarning()}>
<InfoBox accent="red">
{betaWarning()}
</InfoBox>
</Match>
<Match
when={warningText() && props.showWarnings}
>
<InfoBox accent="blue">
{warningText()} <FeesModal />
</InfoBox>
</Match>
</Switch>
<div class="my-2 flex justify-center gap-4">
<For
each={
mode() === "fiat"
? FIXED_AMOUNTS_FIAT
: FIXED_AMOUNTS_SATS
}
>
{(amount) => (
<button
onClick={() => {
setFixedAmount(amount.amount);
focus();
}}
class="rounded-lg bg-white/10 px-4 py-2"
>
{amount.label}
</button>
)}
</For>
<Show
when={
mode() === "sats" && props.maxAmountSats
}
>
<button
onClick={() => {
setFixedAmount(
props.maxAmountSats!.toString()
);
focus();
}}
class="rounded-lg bg-white/10 px-4 py-2"
>
{i18n.t("receive.amount_editable.max")}
</button>
</Show>
</div>
<div class="grid w-full flex-none grid-cols-3">
<For each={CHARACTERS}>
{(character) => (
<SingleDigitButton
fiat={
mode() === "fiat"
? state.fiat
: undefined
}
character={character}
onClick={handleCharacterInput}
onClear={handleClear}
/>
)}
</For>
</div>
<VStack>
<Button intent="green" onClick={handleSubmit}>
{i18n.t(
"receive.amount_editable.set_amount"
)}
</Button>
</VStack>
</div>
<div class="py-2" />
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog.Root>
); );
}; };

View File

@@ -155,7 +155,7 @@ export function BalanceBox(props: { loading?: boolean }) {
</FancyCard> </FancyCard>
<div class="flex gap-2 py-4"> <div class="flex gap-2 py-4">
<Button <Button
onClick={() => navigate("/send")} onClick={() => navigate("/search")}
disabled={emptyBalance() || props.loading} disabled={emptyBalance() || props.loading}
intent="green" intent="green"
> >

View File

@@ -35,7 +35,7 @@ export function BetaWarningModal() {
); );
} }
export const WarningModal: ParentComponent<{ const WarningModal: ParentComponent<{
linkText: string; linkText: string;
title: string; title: string;
}> = (props) => { }> = (props) => {

123
src/components/BigMoney.tsx Normal file
View File

@@ -0,0 +1,123 @@
import { Show } from "solid-js";
import currencySwap from "~/assets/icons/currency-swap.svg";
import { useI18n } from "~/i18n/context";
import { useMegaStore } from "~/state/megaStore";
import { Currency } from "~/utils";
function BigScalingText(props: {
text: string;
fiat?: Currency;
mode: "fiat" | "sats";
loading: boolean;
}) {
const chars = () => props.text.length;
const i18n = useI18n();
return (
<h1
class="whitespace-nowrap px-2 text-center text-4xl font-light transition-transform duration-300 ease-out"
classList={{
"scale-90": chars() >= 11,
"scale-95": chars() === 10,
"scale-100": chars() === 9,
"scale-105": chars() === 7,
"scale-110": chars() === 6,
"scale-125": chars() === 5,
"scale-150": chars() <= 4
}}
>
<Show when={!props.loading || props.mode === "sats"} fallback="…">
{!props.loading && props.mode === "sats"}
{props.mode === "fiat" &&
//adds only the symbol
props.fiat?.hasSymbol}
{`${props.text} `}
<span class="text-xl">
{props.fiat ? props.fiat.value : i18n.t("common.sats")}
</span>
</Show>
</h1>
);
}
function SmallSubtleAmount(props: {
text: string;
fiat?: Currency;
mode: "fiat" | "sats";
loading: boolean;
}) {
const i18n = useI18n();
return (
<h2
class="flex flex-row items-end whitespace-nowrap text-xl font-light text-neutral-400"
tabIndex={0}
>
<Show when={!props.loading || props.mode === "fiat"} fallback="…">
{props.fiat?.value !== "BTC" && props.mode === "sats" && "~"}
{props.mode === "sats" &&
//adds only the symbol
props.fiat?.hasSymbol}
{`${props.text} `}
{/* IDK why a space doesn't work here */}
<span class="flex-0 w-1">{""}</span>
<span class="text-base">
{props.fiat ? props.fiat.value : i18n.t("common.sats")}
</span>
<img
class={"pb-[4px] pl-[4px] hover:cursor-pointer"}
src={currencySwap}
height={24}
width={24}
alt="Swap currencies"
/>
</Show>
</h2>
);
}
export function BigMoney(props: {
mode: "fiat" | "sats";
displaySats: string;
displayFiat: string;
onToggle: () => void;
inputFocused: boolean;
onFocus: () => void;
}) {
const [state, _actions] = useMegaStore();
return (
<div class="flex justify-center">
<div class="flex w-max flex-col items-center justify-center p-4">
<BigScalingText
text={
props.mode === "fiat"
? props.displayFiat
: props.displaySats
}
fiat={props.mode === "fiat" ? state.fiat : undefined}
mode={props.mode}
loading={state.price === 0}
/>
<div
class="mb-2 mt-4 h-[2px] w-full rounded-full"
classList={{
"bg-m-blue animate-pulse": props.inputFocused,
"bg-m-blue/0": !props.inputFocused
}}
/>
<SmallSubtleAmount
text={
props.mode !== "fiat"
? props.displayFiat
: props.displaySats
}
fiat={props.mode !== "fiat" ? state.fiat : undefined}
mode={props.mode}
loading={state.price === 0}
/>
</div>
</div>
);
}

View File

@@ -1,16 +1,14 @@
import { Dialog } from "@kobalte/core";
import { SubmitHandler } from "@modular-forms/solid"; import { SubmitHandler } from "@modular-forms/solid";
import { A } from "@solidjs/router";
import { createSignal, Match, Switch } from "solid-js"; import { createSignal, Match, Switch } from "solid-js";
import close from "~/assets/icons/close.svg";
import { import {
ContactForm, ContactForm,
ContactFormValues, ContactFormValues,
SmallHeader, SimpleDialog,
TinyButton SmallHeader
} from "~/components"; } from "~/components";
import { useI18n } from "~/i18n/context"; import { useI18n } from "~/i18n/context";
import { DIALOG_CONTENT, DIALOG_POSITIONER } from "~/styles/dialogs";
export function ContactEditor(props: { export function ContactEditor(props: {
createContact: (contact: ContactFormValues) => void; createContact: (contact: ContactFormValues) => void;
@@ -28,7 +26,7 @@ export function ContactEditor(props: {
}; };
return ( return (
<Dialog.Root open={isOpen()}> <>
<Switch> <Switch>
<Match when={props.list}> <Match when={props.list}>
<button <button
@@ -44,34 +42,35 @@ export function ContactEditor(props: {
</button> </button>
</Match> </Match>
<Match when={!props.list}> <Match when={!props.list}>
<TinyButton onClick={() => setIsOpen(true)}> <button
+ {i18n.t("contacts.add_contact")} onClick={() => setIsOpen(true)}
</TinyButton> class="flex w-full items-center gap-2 rounded-lg bg-neutral-700 p-2"
>
<h2 class="overflow-hidden overflow-ellipsis text-base font-semibold">
+ {i18n.t("contacts.add_contact")}
</h2>
</button>
</Match> </Match>
</Switch> </Switch>
<Dialog.Portal> <SimpleDialog
<div class={DIALOG_POSITIONER}> open={isOpen()}
<Dialog.Content setOpen={setIsOpen}
class={DIALOG_CONTENT} title={i18n.t("contacts.new_contact")}
onEscapeKeyDown={() => setIsOpen(false)} >
> <ContactForm
<div class="flex w-full justify-end"> cta={i18n.t("contacts.create_contact")}
<button handleSubmit={handleSubmit}
tabindex="-1" />
onClick={() => setIsOpen(false)} <A
class="rounded-lg hover:bg-white/10 active:bg-m-blue" href="/settings/syncnostrcontacts"
> class="self-center font-semibold text-m-red no-underline active:text-m-red/80"
<img src={close} alt="Close" /> state={{
</button> previous: location.pathname
</div> }}
<ContactForm >
title={i18n.t("contacts.new_contact")} Import Nostr Contacts
cta={i18n.t("contacts.create_contact")} </A>
handleSubmit={handleSubmit} </SimpleDialog>
/> </>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog.Root>
); );
} }

View File

@@ -1,23 +1,33 @@
import { import {
createForm, createForm,
custom,
email, email,
required, required,
SubmitHandler SubmitHandler
} from "@modular-forms/solid"; } from "@modular-forms/solid";
import { import { Button, ContactFormValues, TextField, VStack } from "~/components";
Button,
ContactFormValues,
LargeHeader,
TextField,
VStack
} from "~/components";
import { useI18n } from "~/i18n/context"; import { useI18n } from "~/i18n/context";
import { hexpubFromNpub } from "~/utils";
const validateNpub = async (value?: string) => {
if (!value) {
return false;
}
try {
const hexpub = await hexpubFromNpub(value);
if (!hexpub) {
return false;
}
return true;
} catch (e) {
return false;
}
};
export function ContactForm(props: { export function ContactForm(props: {
handleSubmit: SubmitHandler<ContactFormValues>; handleSubmit: SubmitHandler<ContactFormValues>;
initialValues?: ContactFormValues; initialValues?: ContactFormValues;
title: string;
cta: string; cta: string;
}) { }) {
const i18n = useI18n(); const i18n = useI18n();
@@ -31,7 +41,6 @@ export function ContactForm(props: {
class="mx-auto flex w-full max-w-[400px] flex-1 flex-col justify-around gap-4" class="mx-auto flex w-full max-w-[400px] flex-1 flex-col justify-around gap-4"
> >
<div> <div>
<LargeHeader>{props.title}</LargeHeader>
<VStack> <VStack>
<Field <Field
name="name" name="name"
@@ -49,7 +58,12 @@ export function ContactForm(props: {
</Field> </Field>
<Field <Field
name="ln_address" name="ln_address"
validate={[email(i18n.t("contacts.email_error"))]} validate={[
required(
i18n.t("contacts.error_ln_address_missing")
),
email(i18n.t("contacts.email_error"))
]}
> >
{(field, props) => ( {(field, props) => (
<TextField <TextField
@@ -61,6 +75,22 @@ export function ContactForm(props: {
/> />
)} )}
</Field> </Field>
<Field
name="npub"
validate={[
custom(validateNpub, i18n.t("contacts.npub_error"))
]}
>
{(field, props) => (
<TextField
{...props}
placeholder="npub1..."
value={field.value}
error={field.error}
label={i18n.t("contacts.npub")}
/>
)}
</Field>
</VStack> </VStack>
</div> </div>
<VStack> <VStack>

View File

@@ -1,23 +1,21 @@
import { Dialog } from "@kobalte/core";
import { SubmitHandler } from "@modular-forms/solid"; import { SubmitHandler } from "@modular-forms/solid";
import { TagItem } from "@mutinywallet/mutiny-wasm"; import { TagItem } from "@mutinywallet/mutiny-wasm";
import { useNavigate } from "@solidjs/router"; import { useNavigate } from "@solidjs/router";
import { createSignal, Match, Show, Switch } from "solid-js"; import { createSignal, Match, Show, Switch } from "solid-js";
import close from "~/assets/icons/close.svg";
import { import {
Button, Button,
ContactForm, ContactForm,
KeyValue, KeyValue,
MiniStringShower, MiniStringShower,
showToast, showToast,
SimpleDialog,
SmallHeader, SmallHeader,
VStack VStack
} from "~/components"; } from "~/components";
import { useI18n } from "~/i18n/context"; import { useI18n } from "~/i18n/context";
import { toParsedParams } from "~/logic/waila"; import { toParsedParams } from "~/logic/waila";
import { useMegaStore } from "~/state/megaStore"; import { useMegaStore } from "~/state/megaStore";
import { DIALOG_CONTENT, DIALOG_POSITIONER } from "~/styles/dialogs";
export type ContactFormValues = { export type ContactFormValues = {
name: string; name: string;
@@ -71,7 +69,7 @@ export function ContactViewer(props: {
}; };
return ( return (
<Dialog.Root open={isOpen()}> <>
<button <button
onClick={() => setIsOpen(true)} onClick={() => setIsOpen(true)}
class="flex w-16 flex-shrink-0 flex-col items-center gap-2 overflow-x-hidden" class="flex w-16 flex-shrink-0 flex-col items-center gap-2 overflow-x-hidden"
@@ -91,141 +89,93 @@ export function ContactViewer(props: {
{props.contact.name} {props.contact.name}
</SmallHeader> </SmallHeader>
</button> </button>
<Dialog.Portal> <SimpleDialog
<div class={DIALOG_POSITIONER}> open={isOpen()}
<Dialog.Content setOpen={setIsOpen}
class={DIALOG_CONTENT} title={isEditing() ? i18n.t("contacts.edit_contact") : ""}
onEscapeKeyDown={() => { >
setIsOpen(false); <Switch>
setIsEditing(false); <Match when={isEditing()}>
}} <ContactForm
> cta={i18n.t("contacts.save_contact")}
<div class="flex w-full justify-end"> handleSubmit={handleSubmit}
<button initialValues={props.contact}
tabindex="-1" />
onClick={() => { </Match>
setIsOpen(false); <Match when={!isEditing()}>
setIsEditing(false); <div class="mx-auto flex w-full max-w-[400px] flex-1 flex-col items-center justify-around gap-4">
}} <div class="flex w-full flex-col items-center">
class="rounded-lg hover:bg-white/10 active:bg-m-blue" <div
> class="flex h-32 w-32 flex-none items-center justify-center overflow-clip rounded-full border-b border-t border-b-white/10 border-t-white/50 text-8xl uppercase"
<img src={close} alt="Close" /> style={{
</button> background: props.gradient
</div> }}
<Switch> >
<Match when={isEditing()}> <Switch>
<ContactForm <Match when={props.contact.image_url}>
title={i18n.t("contacts.edit_contact")} <img
cta={i18n.t("contacts.save_contact")} src={props.contact.image_url}
handleSubmit={handleSubmit} />
initialValues={props.contact} </Match>
/> <Match when={true}>
</Match> {props.contact.name[0]}
<Match when={!isEditing()}> </Match>
<div class="mx-auto flex w-full max-w-[400px] flex-1 flex-col items-center justify-around gap-4"> </Switch>
<div class="flex w-full flex-col items-center">
<div
class="flex h-32 w-32 flex-none items-center justify-center overflow-clip rounded-full border-b border-t border-b-white/10 border-t-white/50 text-8xl uppercase"
style={{
background: props.gradient
}}
>
<Switch>
<Match
when={
props.contact.image_url
}
>
<img
src={
props.contact
.image_url
}
/>
</Match>
<Match when={true}>
{props.contact.name[0]}
</Match>
</Switch>
</div>
<h1 class="mb-4 mt-2 text-2xl font-semibold uppercase">
{props.contact.name}
</h1>
<div class="flex flex-1 flex-col justify-center">
<VStack>
<Show when={props.contact.npub}>
<KeyValue key={"Npub"}>
<MiniStringShower
text={
props.contact
.npub!
}
/>
</KeyValue>
</Show>
<Show
when={
props.contact.ln_address
}
>
<KeyValue
key={i18n.t(
"contacts.lightning_address"
)}
>
<MiniStringShower
text={
props.contact
.ln_address!
}
/>
</KeyValue>
</Show>
</VStack>
</div>
{/* TODO: show payment history for a contact */}
{/* <Card
title={i18n.t(
"contacts.payment_history"
)}
>
<NiceP>
{i18n.t("contacts.no_payments")}{" "}
<span class="font-semibold">
{props.contact.name}
</span>
</NiceP>
</Card> */}
</div>
{/* TODO: implement contact editing */}
<div class="flex w-full gap-2">
<Button
layout="flex"
intent="green"
onClick={() => setIsEditing(true)}
>
{i18n.t("contacts.edit")}
</Button>
<Button
intent="blue"
disabled={
!props.contact.lnurl &&
!props.contact.ln_address
}
onClick={handlePay}
>
{i18n.t("contacts.pay")}
</Button>
</div>
</div> </div>
</Match>
</Switch> <h1 class="mb-4 mt-2 text-2xl font-semibold uppercase">
</Dialog.Content> {props.contact.name}
</div> </h1>
</Dialog.Portal>
</Dialog.Root> <div class="flex flex-1 flex-col justify-center">
<VStack>
<Show when={props.contact.npub}>
<KeyValue key={"Npub"}>
<MiniStringShower
text={props.contact.npub!}
/>
</KeyValue>
</Show>
<Show when={props.contact.ln_address}>
<KeyValue
key={i18n.t(
"contacts.lightning_address"
)}
>
<MiniStringShower
text={
props.contact
.ln_address!
}
/>
</KeyValue>
</Show>
</VStack>
</div>
</div>
<div class="flex w-full gap-2">
<Button
layout="flex"
intent="green"
onClick={() => setIsEditing(true)}
>
{i18n.t("contacts.edit")}
</Button>
<Button
intent="blue"
disabled={
!props.contact.lnurl &&
!props.contact.ln_address
}
onClick={handlePay}
>
{i18n.t("contacts.pay")}
</Button>
</div>
</div>
</Match>
</Switch>
</SimpleDialog>
</>
); );
} }

View File

@@ -1,5 +1,6 @@
import { Title } from "@solidjs/meta"; import { Title } from "@solidjs/meta";
import { A } from "@solidjs/router"; import { A } from "@solidjs/router";
import { onMount } from "solid-js";
import { ExternalLink } from "~/components"; import { ExternalLink } from "~/components";
import { import {
@@ -23,6 +24,9 @@ export function SimpleErrorDisplay(props: { error: Error }) {
export function ErrorDisplay(props: { error: Error }) { export function ErrorDisplay(props: { error: Error }) {
const i18n = useI18n(); const i18n = useI18n();
onMount(() => {
console.error(props.error);
});
return ( return (
<SafeArea> <SafeArea>
<Title>{i18n.t("error.general.oh_no")}</Title> <Title>{i18n.t("error.general.oh_no")}</Title>

View File

@@ -0,0 +1,106 @@
import { createMemo, ParentComponent, Show } from "solid-js";
import { VStack } from "~/components";
import { useI18n } from "~/i18n/context";
import { useMegaStore } from "~/state/megaStore";
import { satsToFormattedFiat } from "~/utils";
const AmountKeyValue: ParentComponent<{ key: string; gray?: boolean }> = (
props
) => {
return (
<div
class="flex items-center justify-between"
classList={{ "text-neutral-400": props.gray }}
>
<div class="font-semibold uppercase">{props.key}</div>
<div class="font-light">{props.children}</div>
</div>
);
};
function USDShower(props: { amountSats: string; fee?: string }) {
const [state, _] = useMegaStore();
const amountInFiat = () =>
(state.fiat.value === "BTC" ? "" : "~") +
satsToFormattedFiat(
state.price,
add(props.amountSats, props.fee),
state.fiat
);
return (
<Show when={!(props.amountSats === "0")}>
<AmountKeyValue gray key="">
<div class="self-end whitespace-nowrap">
{`${amountInFiat()} `}
<span class="text-sm">{state.fiat.value}</span>
</div>
</AmountKeyValue>
</Show>
);
}
const InlineAmount: ParentComponent<{
amount: string;
sign?: string;
}> = (props) => {
const i18n = useI18n();
const prettyPrint = createMemo(() => {
const parsed = Number(props.amount);
if (isNaN(parsed)) {
return props.amount;
} else {
return parsed.toLocaleString(navigator.languages[0]);
}
});
return (
<div class="inline-block text-lg">
{props.sign ? `${props.sign} ` : ""}
{prettyPrint()} <span class="text-sm">{i18n.t("common.sats")}</span>
</div>
);
};
function add(a: string, b?: string) {
return Number(a || 0) + Number(b || 0);
}
export function FeeDisplay(props: {
amountSats: string;
fee: string;
maxAmountSats?: bigint;
}) {
const i18n = useI18n();
// Normally we want to add the fee to the amount, but for max amount we just show the max
const totalOrTotalLessFee = () => {
if (
props.fee &&
props.maxAmountSats &&
props.amountSats === props.maxAmountSats?.toString()
) {
return props.maxAmountSats.toLocaleString();
} else {
return add(props.amountSats, props.fee).toString();
}
};
return (
<div class="w-[20rem] self-center">
<VStack>
<div class="flex flex-col gap-1">
<AmountKeyValue gray key={i18n.t("receive.fee")}>
<InlineAmount amount={props.fee || "0"} />
</AmountKeyValue>
</div>
<hr class="border-white/20" />
<div class="flex flex-col gap-1">
<AmountKeyValue key={i18n.t("receive.total")}>
<InlineAmount amount={totalOrTotalLessFee()} />
</AmountKeyValue>
<USDShower amountSats={props.amountSats} fee={props.fee} />
</div>
</VStack>
</div>
);
}

View File

@@ -1,14 +1,6 @@
import { Dialog } from "@kobalte/core";
import { createMemo, JSX } from "solid-js"; import { createMemo, JSX } from "solid-js";
import { import { CopyButton, SimpleDialog } from "~/components";
CopyButton,
DIALOG_CONTENT,
DIALOG_POSITIONER,
ModalCloseButton,
OVERLAY,
SmallHeader
} from "~/components";
import { useI18n } from "~/i18n/context"; import { useI18n } from "~/i18n/context";
export function JsonModal(props: { export function JsonModal(props: {
@@ -25,34 +17,18 @@ export function JsonModal(props: {
); );
return ( return (
<Dialog.Root open={props.open} onOpenChange={props.setOpen}> <SimpleDialog
<Dialog.Portal> title={props.title}
<Dialog.Overlay class={OVERLAY} /> open={props.open}
<div class={DIALOG_POSITIONER}> setOpen={props.setOpen}
<Dialog.Content class={DIALOG_CONTENT}> >
<div class="mb-2 flex items-center justify-between"> <div class="max-h-[50vh] overflow-y-scroll rounded-xl bg-white/5 p-4 disable-scrollbars">
<Dialog.Title> <pre class="whitespace-pre-wrap break-all">{json()}</pre>
<SmallHeader>{props.title}</SmallHeader> </div>
</Dialog.Title> {props.children}
<Dialog.CloseButton> <div class="self-center">
<ModalCloseButton /> <CopyButton title={i18n.t("common.copy")} text={json()} />
</Dialog.CloseButton> </div>
</div> </SimpleDialog>
<Dialog.Description class="flex flex-col items-center gap-4">
<div class="max-h-[50vh] overflow-y-scroll rounded-xl bg-white/5 p-4 disable-scrollbars">
<pre class="whitespace-pre-wrap break-all">
{json()}
</pre>
</div>
{props.children}
<CopyButton
title={i18n.t("common.copy")}
text={json()}
/>
</Dialog.Description>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog.Root>
); );
} }

View File

@@ -0,0 +1,63 @@
import { createResource, createSignal, Match, Switch } from "solid-js";
import off from "~/assets/icons/download-channel.svg";
import on from "~/assets/icons/upload-channel.svg";
import { HackActivityType } from "~/components";
import { generateGradient } from "~/utils";
export function LabelCircle(props: {
name?: string;
image_url?: string;
contact: boolean;
label: boolean;
channel?: HackActivityType;
onError?: () => void;
}) {
const [gradient] = createResource(async () => {
if (props.name && props.contact) {
return generateGradient(props.name || "?");
} else {
return undefined;
}
});
const text = () =>
props.contact && props.name && props.name.length
? props.name[0]
: props.label
? "≡"
: "?";
const bg = () => (props.name && props.contact ? gradient() : "");
const [errored, setErrored] = createSignal(false);
return (
<div
class="flex h-[3rem] w-[3rem] flex-none items-center justify-center overflow-clip rounded-full border-b border-t border-b-white/10 border-t-white/50 bg-neutral-700 text-3xl uppercase"
style={{
background: props.image_url && !errored() ? "none" : bg()
}}
>
<Switch>
<Match when={errored()}>{text()}</Match>
<Match when={props.image_url}>
<img
src={props.image_url}
alt={"image"}
onError={() => {
props.onError && props.onError();
setErrored(true);
}}
/>
</Match>
<Match when={props.channel === "ChannelOpen"}>
<img src={on} alt="channel open" />
</Match>
<Match when={props.channel === "ChannelClose"}>
<img src={off} alt="channel close" />
</Match>
<Match when={true}>{text()}</Match>
</Switch>
</div>
);
}

View File

@@ -4,7 +4,7 @@ import { Show } from "solid-js";
import { useI18n } from "~/i18n/context"; import { useI18n } from "~/i18n/context";
import { useMegaStore } from "~/state/megaStore"; import { useMegaStore } from "~/state/megaStore";
export function LoadingBar(props: { value: number; max: number }) { function LoadingBar(props: { value: number; max: number }) {
const i18n = useI18n(); const i18n = useI18n();
function valueToStage(value: number) { function valueToStage(value: number) {
switch (value) { switch (value) {

View File

@@ -0,0 +1,71 @@
import { createMemo, Match, Switch } from "solid-js";
import { StyledRadioGroup } from "~/components";
import { useMegaStore } from "~/state/megaStore";
type SendSource = "lightning" | "onchain";
export function MethodChooser(props: {
source: SendSource;
setSource: (source: string) => void;
both?: boolean;
}) {
const [store, _actions] = useMegaStore();
const methods = createMemo(() => {
const lnBalance =
(store.balance?.lightning || 0n) +
(store.balance?.federation || 0n);
const onchainBalance =
(store.balance?.confirmed || 0n) +
(store.balance?.unconfirmed || 0n);
return [
{
value: "lightning",
label: "Lightning Balance",
caption:
lnBalance > 0n
? `${lnBalance.toLocaleString()} SATS`
: "No balance",
disabled: lnBalance === 0n
},
{
value: "onchain",
label: "On-chain Balance",
caption:
onchainBalance > 0n
? `${onchainBalance.toLocaleString()} SATS`
: "No balance",
disabled: onchainBalance === 0n
}
];
});
return (
<Switch>
<Match when={props.both}>
<StyledRadioGroup
accent="white"
initialValue={props.source}
onValueChange={props.setSource}
choices={methods()}
/>
</Match>
<Match when={props.source === "lightning"}>
<StyledRadioGroup
accent="white"
initialValue={props.source}
onValueChange={props.setSource}
choices={[methods()[0]]}
/>
</Match>
<Match when={props.source === "onchain"}>
<StyledRadioGroup
accent="white"
initialValue={props.source}
onValueChange={props.setSource}
choices={[methods()[1]]}
/>
</Match>
</Switch>
);
}

View File

@@ -1,15 +1,7 @@
import { Dialog } from "@kobalte/core";
import { createSignal, JSXElement, ParentComponent } from "solid-js"; import { createSignal, JSXElement, ParentComponent } from "solid-js";
import help from "~/assets/icons/help.svg"; import help from "~/assets/icons/help.svg";
import { import { ExternalLink, SimpleDialog } from "~/components";
DIALOG_CONTENT,
DIALOG_POSITIONER,
ExternalLink,
ModalCloseButton,
OVERLAY,
SmallHeader
} from "~/components";
import { useI18n } from "~/i18n/context"; import { useI18n } from "~/i18n/context";
export function FeesModal(props: { icon?: boolean }) { export function FeesModal(props: { icon?: boolean }) {
@@ -36,35 +28,24 @@ export function FeesModal(props: { icon?: boolean }) {
); );
} }
export const MoreInfoModal: ParentComponent<{ const MoreInfoModal: ParentComponent<{
linkText: string | JSXElement; linkText: string | JSXElement;
title: string; title: string;
}> = (props) => { }> = (props) => {
const [open, setOpen] = createSignal(false); const [open, setOpen] = createSignal(false);
return ( return (
<Dialog.Root open={open()} onOpenChange={setOpen}> <>
<Dialog.Trigger> <button
<button class="font-semibold underline decoration-light-text hover:decoration-white"> tabIndex={-1}
{props.linkText} onClick={() => setOpen(true)}
</button> class="font-semibold underline decoration-light-text hover:decoration-white"
</Dialog.Trigger> >
<Dialog.Portal> {props.linkText}
<Dialog.Overlay class={OVERLAY} /> </button>
<div class={DIALOG_POSITIONER}> <SimpleDialog open={open()} setOpen={setOpen} title={props.title}>
<Dialog.Content class={DIALOG_CONTENT}> {props.children}
<Dialog.Title class="mb-2 flex items-center justify-between"> </SimpleDialog>
<SmallHeader>{props.title}</SmallHeader> </>
<Dialog.CloseButton>
<ModalCloseButton />
</Dialog.CloseButton>
</Dialog.Title>
<Dialog.Description class="flex flex-col gap-4">
<div>{props.children}</div>
</Dialog.Description>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog.Root>
); );
}; };

View File

@@ -5,7 +5,7 @@ import forward from "~/assets/icons/forward.svg";
import { useI18n } from "~/i18n/context"; import { useI18n } from "~/i18n/context";
import { useMegaStore } from "~/state/megaStore"; import { useMegaStore } from "~/state/megaStore";
export const CtaCard: ParentComponent = (props) => { const CtaCard: ParentComponent = (props) => {
return ( return (
<div class="w-full"> <div class="w-full">
<div class="relative"> <div class="relative">

View File

@@ -34,7 +34,7 @@ import { useMegaStore } from "~/state/megaStore";
import { fetchNostrProfile } from "~/utils"; import { fetchNostrProfile } from "~/utils";
type BudgetInterval = "Day" | "Week" | "Month" | "Year"; type BudgetInterval = "Day" | "Week" | "Month" | "Year";
export type BudgetForm = { type BudgetForm = {
connection_name: string; connection_name: string;
auto_approve: boolean; auto_approve: boolean;
budget_amount: string; // modular forms doesn't like bigint budget_amount: string; // modular forms doesn't like bigint
@@ -406,44 +406,39 @@ function NWCEditorForm(props: {
<TinyText> <TinyText>
{i18n.t("settings.connections.careful")} {i18n.t("settings.connections.careful")}
</TinyText> </TinyText>
<KeyValue key={i18n.t("settings.connections.budget")}>
<Field name="budget_amount"> <Field name="budget_amount">
{(field, _fieldProps) => ( {(field, _fieldProps) => (
<div class="flex flex-col items-end gap-2"> <div class="flex flex-col items-end gap-2">
<Show <Show
when={ when={props.budgetMode === "editable"}
props.budgetMode === "editable" fallback={
} <AmountSats
fallback={ amountSats={
<AmountSats Number(field.value) || 0
amountSats={
Number(field.value) || 0
}
/>
}
>
<AmountEditable
initialOpen={false}
initialAmountSats={
field.value || "0"
} }
showWarnings={false}
setAmountSats={(a) => {
setValue(
budgetForm,
"budget_amount",
a.toString()
);
}}
/> />
</Show> }
<p class="text-sm text-m-red"> >
{field.error} <AmountEditable
</p> initialAmountSats={
</div> field.value || "0"
)} }
</Field> setAmountSats={(a) => {
</KeyValue> setValue(
budgetForm,
"budget_amount",
a.toString()
);
}}
/>
</Show>
<p class="text-sm text-m-red">
{field.error}
</p>
</div>
)}
</Field>
<KeyValue <KeyValue
key={i18n.t("settings.connections.resets_every")} key={i18n.t("settings.connections.resets_every")}
> >

View File

@@ -49,7 +49,7 @@ export function NavBar(props: { activeTab: ActiveTab }) {
alt="home" alt="home"
/> />
<NavBarItem <NavBarItem
href="/send" href="/search"
icon={airplane} icon={airplane}
active={props.activeTab === "send"} active={props.activeTab === "send"}
alt="send" alt="send"

View File

@@ -0,0 +1,77 @@
import { createResource, Match, Switch } from "solid-js";
import { InfoBox } from "~/components/InfoBox";
import { FeesModal } from "~/components/MoreInfoModal";
import { useI18n } from "~/i18n/context";
import { useMegaStore } from "~/state/megaStore";
export function ReceiveWarnings(props: { amountSats: string | bigint }) {
const i18n = useI18n();
const [state, _actions] = useMegaStore();
const [inboundCapacity] = createResource(async () => {
try {
const channels = await state.mutiny_wallet?.list_channels();
let inbound = 0;
for (const channel of channels) {
inbound += channel.size - (channel.balance + channel.reserve);
}
return inbound;
} catch (e) {
console.error(e);
return 0;
}
});
const warningText = () => {
if (state.federations?.length !== 0) {
return undefined;
}
if ((state.balance?.lightning || 0n) === 0n) {
return i18n.t("receive.amount_editable.receive_too_small", {
amount: "100,000"
});
}
const parsed = Number(props.amountSats);
if (isNaN(parsed)) {
return undefined;
}
if (parsed > (inboundCapacity() || 0)) {
return i18n.t("receive.amount_editable.setup_fee_lightning");
}
return undefined;
};
const betaWarning = () => {
const parsed = Number(props.amountSats);
if (isNaN(parsed)) {
return undefined;
}
if (parsed >= 2099999997690000) {
// If over 21 million bitcoin, warn that too much
return i18n.t("receive.amount_editable.more_than_21m");
} else if (parsed >= 4000000) {
// If over 4 million sats, warn that it's a beta bro
return i18n.t("receive.amount_editable.too_big_for_beta");
}
};
return (
<Switch>
<Match when={betaWarning()}>
<InfoBox accent="red">{betaWarning()}</InfoBox>
</Match>
<Match when={warningText()}>
<InfoBox accent="blue">
{warningText()} <FeesModal />
</InfoBox>
</Match>
</Switch>
);
}

View File

@@ -12,10 +12,7 @@ import { useCopy } from "~/utils";
const STYLE = const STYLE =
"px-4 py-2 rounded-xl border-2 border-white flex gap-2 items-center font-semibold hover:text-m-blue transition-colors"; "px-4 py-2 rounded-xl border-2 border-white flex gap-2 items-center font-semibold hover:text-m-blue transition-colors";
export function ShareButton(props: { function ShareButton(props: { receiveString: string; whiteBg?: boolean }) {
receiveString: string;
whiteBg?: boolean;
}) {
const i18n = useI18n(); const i18n = useI18n();
async function share(receiveString: string) { async function share(receiveString: string) {
// If the browser doesn't support share we can just copy the address // If the browser doesn't support share we can just copy the address
@@ -68,7 +65,7 @@ export function StringShower(props: { text: string }) {
title={i18n.t("modals.details")} title={i18n.t("modals.details")}
setOpen={setOpen} setOpen={setOpen}
/> />
<div class="grid w-full grid-cols-[minmax(0,_1fr)_auto]"> <div class="grid w-full grid-cols-[minmax(0,_1fr)_auto] items-center">
<TruncateMiddle text={props.text} /> <TruncateMiddle text={props.text} />
<button class="w-[2rem]" onClick={() => setOpen(true)}> <button class="w-[2rem]" onClick={() => setOpen(true)}>
<img src={eyeIcon} alt="eye" /> <img src={eyeIcon} alt="eye" />

View File

@@ -0,0 +1,24 @@
import { JSX } from "solid-js";
type SimpleInputProps = {
type?: "text" | "email" | "tel" | "password" | "url" | "date";
placeholder?: string;
value: string | undefined;
disabled?: boolean;
onInput: JSX.EventHandler<
HTMLInputElement | HTMLTextAreaElement,
InputEvent
>;
};
export function SimpleInput(props: SimpleInputProps) {
return (
<input
class="w-full rounded-lg bg-m-grey-750 p-2 placeholder-m-grey-400 disabled:text-m-grey-400"
type="text"
value={props.value}
onInput={(e) => props.onInput(e)}
placeholder={props.placeholder}
/>
);
}

View File

@@ -8,7 +8,7 @@ import { useI18n } from "~/i18n/context";
import { useMegaStore } from "~/state/megaStore"; import { useMegaStore } from "~/state/megaStore";
import { eify } from "~/utils"; import { eify } from "~/utils";
export type NostrContactsForm = { type NostrContactsForm = {
npub: string; npub: string;
}; };

View File

@@ -1,79 +0,0 @@
import { createOptions, Select } from "@thisbeyond/solid-select";
import "~/styles/solid-select.css";
import { TagItem, TagKind } from "@mutinywallet/mutiny-wasm";
import { createMemo, createSignal, onMount } from "solid-js";
import { useMegaStore } from "~/state/megaStore";
import { sortByLastUsed } from "~/utils";
const createLabelValue = (label: string): Partial<TagItem> => {
return { name: label, kind: TagKind.Contact };
};
export function TagEditor(props: {
selectedValues: Partial<TagItem>[];
setSelectedValues: (value: Partial<TagItem>[]) => void;
placeholder: string;
autoFillTag?: string | undefined;
}) {
const [_state, actions] = useMegaStore();
const [availableTags, setAvailableTags] = createSignal<TagItem[]>([]);
onMount(async () => {
const tags = await actions.listTags();
if (tags) {
setAvailableTags(
tags
.filter((tag) => tag.kind === TagKind.Contact)
.sort(sortByLastUsed)
);
if (props.autoFillTag && availableTags()) {
const tagToAutoSelect = availableTags().find(
(tag) => tag.name === props.autoFillTag
);
if (tagToAutoSelect) {
props.setSelectedValues([
...props.selectedValues,
tagToAutoSelect
]);
}
}
}
});
const selectProps = createMemo(() => {
return createOptions(availableTags() || [], {
key: "name",
disable: (value) => props.selectedValues.includes(value),
filterable: true, // Default
createable: createLabelValue
});
});
const onChange = (selected: TagItem[]) => {
props.setSelectedValues(selected);
const lastValue = selected[selected.length - 1];
if (
lastValue &&
availableTags() &&
!availableTags()!.includes(lastValue)
) {
setAvailableTags([...availableTags(), lastValue]);
}
};
return (
<>
<Select
multiple
initialValue={props.selectedValues}
placeholder={props.placeholder}
onChange={onChange}
{...selectProps()}
/>
</>
);
}

View File

@@ -37,7 +37,7 @@ export function showToast(arg: ToastArg) {
} }
} }
export function ToastItem(props: { function ToastItem(props: {
toastId: number; toastId: number;
title: string; title: string;
description: string; description: string;

View File

@@ -5,7 +5,6 @@ export * from "./Activity";
export * from "./ActivityDetailsModal"; export * from "./ActivityDetailsModal";
export * from "./ActivityItem"; export * from "./ActivityItem";
export * from "./Amount"; export * from "./Amount";
export * from "./AmountCard";
export * from "./AmountEditable"; export * from "./AmountEditable";
export * from "./BalanceBox"; export * from "./BalanceBox";
export * from "./BetaWarningModal"; export * from "./BetaWarningModal";
@@ -38,7 +37,6 @@ export * from "./ResyncOnchain";
export * from "./SeedWords"; export * from "./SeedWords";
export * from "./SetupErrorDisplay"; export * from "./SetupErrorDisplay";
export * from "./ShareCard"; export * from "./ShareCard";
export * from "./TagEditor";
export * from "./Toaster"; export * from "./Toaster";
export * from "./NostrActivity"; export * from "./NostrActivity";
export * from "./SyncContactsForm"; export * from "./SyncContactsForm";
@@ -47,3 +45,9 @@ export * from "./MutinyPlusCta";
export * from "./ToggleHodl"; export * from "./ToggleHodl";
export * from "./IOSbanner"; export * from "./IOSbanner";
export * from "./HomePrompt"; export * from "./HomePrompt";
export * from "./BigMoney";
export * from "./FeeDisplay";
export * from "./ReceiveWarnings";
export * from "./SimpleInput";
export * from "./MethodChooser";
export * from "./LabelCircle";

View File

@@ -1,4 +1,5 @@
import { useLocation, useNavigate } from "@solidjs/router"; import { useLocation, useNavigate } from "@solidjs/router";
import { JSXElement } from "solid-js";
import { BackButton } from "~/components"; import { BackButton } from "~/components";
import { useI18n } from "~/i18n/context"; import { useI18n } from "~/i18n/context";
@@ -31,3 +32,32 @@ export function BackPop() {
/> />
); );
} }
export function UnstyledBackPop(props: { children: JSXElement }) {
const i18n = useI18n();
const navigate = useNavigate();
const location = useLocation();
const state = location.state as StateWithPrevious;
// If there's no previous state want to just go back one level, basically ../
const newBackPath = location.pathname.split("/").slice(0, -1).join("/");
const backPath = () => (state?.previous ? state?.previous : newBackPath);
return (
<button
title={
backPath() === "/"
? i18n.t("common.home")
: i18n.t("common.back")
}
onClick={() => {
console.log("backPath", backPath());
navigate(backPath());
}}
>
{props.children}
</button>
);
}

View File

@@ -6,7 +6,7 @@ import { LoadingSpinner } from "~/components";
// Help from https://github.com/arpadgabor/credee/blob/main/packages/www/src/components/ui/button.tsx // Help from https://github.com/arpadgabor/credee/blob/main/packages/www/src/components/ui/button.tsx
export type CommonButtonStyleProps = { type CommonButtonStyleProps = {
intent?: "active" | "inactive" | "blue" | "red" | "green" | "text"; intent?: "active" | "inactive" | "blue" | "red" | "green" | "text";
layout?: "flex" | "pad" | "small" | "xs" | "full"; layout?: "flex" | "pad" | "small" | "xs" | "full";
}; };

View File

@@ -1,44 +0,0 @@
import { JSX } from "solid-js";
interface LinkifyProps {
initialText: string;
}
export function Linkify(props: LinkifyProps): JSX.Element {
// By naming this "initialText" we can prove to eslint that the props won't change
const text = props.initialText;
const links: (string | JSX.Element)[] = [];
const pattern = /((https?:\/\/|www\.)\S+)/gi;
let lastIndex = 0;
let match;
while ((match = pattern.exec(text)) !== null) {
const link = match[1];
const href = link.startsWith("http") ? link : `https://${link}`;
const beforeLink = text.slice(lastIndex, match.index);
lastIndex = pattern.lastIndex;
if (beforeLink) {
links.push(beforeLink);
}
links.push(
<a
href={href}
class="break-all"
target="_blank"
rel="noopener noreferrer"
>
{link}
</a>
);
}
const remainingText = text.slice(lastIndex);
if (remainingText) {
links.push(remainingText);
}
return <>{links}</>;
}

View File

@@ -129,17 +129,23 @@ export const SafeArea: ParentComponent = (props) => {
); );
}; };
export const DefaultMain: ParentComponent = (props) => { export const DefaultMain = (props: {
children?: JSX.Element;
zeroBottomPadding?: boolean;
}) => {
return ( return (
<main class="mx-auto flex h-full w-full max-w-[600px] flex-col gap-4 p-4"> <main
class="mx-auto flex w-full max-w-[600px] flex-1 flex-col gap-4 p-4"
classList={{ "pb-0": props.zeroBottomPadding }}
>
{props.children} {props.children}
{/* CSS is hard sometimes */} {/* CSS is hard sometimes */}
<div class="py-1" /> {/* <div class="py-1" /> */}
</main> </main>
); );
}; };
export const FullscreenLoader = () => { const FullscreenLoader = () => {
const i18n = useI18n(); const i18n = useI18n();
const [waitedTooLong, setWaitedTooLong] = createSignal(false); const [waitedTooLong, setWaitedTooLong] = createSignal(false);
@@ -239,26 +245,6 @@ export const VStack: ParentComponent<{
); );
}; };
export const HStack: ParentComponent<{ biggap?: boolean }> = (props) => {
return (
<div class={`flex gap-${props.biggap ? "8" : "4"}`}>
{props.children}
</div>
);
};
export const SmallAmount: ParentComponent<{
amount: number | bigint;
sign?: string;
}> = (props) => {
return (
<h2 class="text-lg font-light">
{props.sign ? `${props.sign} ` : ""}
{props.amount.toLocaleString()} <span class="text-sm">SATS</span>
</h2>
);
};
export const NiceP: ParentComponent = (props) => { export const NiceP: ParentComponent = (props) => {
return <p class="text-xl font-light text-neutral-200">{props.children}</p>; return <p class="text-xl font-light text-neutral-200">{props.children}</p>;
}; };
@@ -340,10 +326,10 @@ export function ModalCloseButton() {
); );
} }
export const SIMPLE_OVERLAY = "fixed inset-0 z-50 bg-black/50 backdrop-blur-lg"; const SIMPLE_OVERLAY = "fixed inset-0 z-50 bg-black/50 backdrop-blur-lg";
export const SIMPLE_DIALOG_POSITIONER = const SIMPLE_DIALOG_POSITIONER =
"fixed inset-0 z-50 flex items-center justify-center"; "fixed inset-0 z-50 flex items-center justify-center";
export const SIMPLE_DIALOG_CONTENT = const SIMPLE_DIALOG_CONTENT =
"max-w-[500px] w-[90vw] max-h-device overflow-y-scroll disable-scrollbars mx-4 p-4 bg-neutral-800/90 rounded-xl border border-white/10"; "max-w-[500px] w-[90vw] max-h-device overflow-y-scroll disable-scrollbars mx-4 p-4 bg-neutral-800/90 rounded-xl border border-white/10";
export const SimpleDialog: ParentComponent<{ export const SimpleDialog: ParentComponent<{
@@ -355,11 +341,12 @@ export const SimpleDialog: ParentComponent<{
<Dialog.Root <Dialog.Root
open={props.open} open={props.open}
onOpenChange={props.setOpen && props.setOpen} onOpenChange={props.setOpen && props.setOpen}
modal={true}
> >
<Dialog.Portal> <Dialog.Portal>
<Dialog.Overlay class={SIMPLE_OVERLAY} /> <Dialog.Overlay class={SIMPLE_OVERLAY} />
<div class={SIMPLE_DIALOG_POSITIONER}> <div class={SIMPLE_DIALOG_POSITIONER}>
<Dialog.Content class={SIMPLE_DIALOG_CONTENT}> <Dialog.Content class={SIMPLE_DIALOG_CONTENT} tabIndex={0}>
<div class="mb-2 flex items-center justify-between"> <div class="mb-2 flex items-center justify-between">
<Dialog.Title> <Dialog.Title>
<SmallHeader>{props.title}</SmallHeader> <SmallHeader>{props.title}</SmallHeader>

View File

@@ -1,52 +0,0 @@
import { Progress } from "@kobalte/core";
import { SmallHeader } from "~/components";
import { useI18n } from "~/i18n/context";
export function formatNumber(num: number) {
const map = [
{ suffix: "T", threshold: 1e12 },
{ suffix: "B", threshold: 1e9 },
{ suffix: "M", threshold: 1e6 },
{ suffix: "K", threshold: 1e3 },
{ suffix: "", threshold: 1 }
];
const found = map.find((x) => Math.abs(num) >= x.threshold);
if (found) {
const formatted =
(num / found.threshold).toLocaleString() + found.suffix;
return formatted;
}
return num.toLocaleString();
}
export function ProgressBar(props: { value: number; max: number }) {
const i18n = useI18n();
return (
<Progress.Root
value={props.value}
minValue={0}
maxValue={props.max}
getValueLabel={({ value, max }) =>
`${formatNumber(value)} ${i18n.t(
"send.progress_bar.of"
)} ${formatNumber(max)} ${i18n.t(
"send.progress_bar.sats_sent"
)}`
}
class="flex w-full flex-col gap-2"
>
<div class="flex justify-between">
<Progress.Label>
<SmallHeader>{i18n.t("send.sending")}</SmallHeader>
</Progress.Label>
<Progress.ValueLabel class="text-sm font-semibold uppercase" />
</div>
<Progress.Track class="h-6 rounded bg-white/10">
<Progress.Fill class="h-full w-[var(--kb-progress-fill-width)] rounded bg-m-red transition-[width]" />
</Progress.Track>
</Progress.Root>
);
}

View File

@@ -2,9 +2,7 @@ export * from "./BackButton";
export * from "./BackLink"; export * from "./BackLink";
export * from "./BackPop"; export * from "./BackPop";
export * from "./Button"; export * from "./Button";
export * from "./Linkify";
export * from "./Misc"; export * from "./Misc";
export * from "./ProgressBar";
export * from "./Radio"; export * from "./Radio";
export * from "./TextField"; export * from "./TextField";
export * from "./ExternalLink"; export * from "./ExternalLink";

View File

@@ -42,7 +42,10 @@ export default {
unimplemented: "Unimplemented", unimplemented: "Unimplemented",
not_available: "We don't do that yet", not_available: "We don't do that yet",
error_name: "We at least need a name", error_name: "We at least need a name",
email_error: "That doesn't look like a lightning address" email_error: "That doesn't look like a lightning address",
npub_error: "That doesn't look like a nostr npub",
error_ln_address_missing: "New contacts need a lightning address",
npub: "Nostr Npub"
}, },
receive: { receive: {
receive_bitcoin: "Receive Bitcoin", receive_bitcoin: "Receive Bitcoin",
@@ -81,7 +84,7 @@ export default {
"Something went wrong when creating the on-chain address", "Something went wrong when creating the on-chain address",
amount_editable: { amount_editable: {
receive_too_small: receive_too_small:
"Your first lightning receive needs to be {{amount}} SATS or greater. A setup fee will be deducted from the requested amount.", "A setup fee will be deducted from the requested amount.",
setup_fee_lightning: setup_fee_lightning:
"A lightning setup fee will be charged if paid over lightning.", "A lightning setup fee will be charged if paid over lightning.",
too_big_for_beta: too_big_for_beta:
@@ -94,7 +97,8 @@ export default {
one_hundred_k: "100k", one_hundred_k: "100k",
one_million: "1m" one_million: "1m"
}, },
del: "DEL" del: "DEL",
balance: "Balance"
}, },
integrated_qr: { integrated_qr: {
onchain: "On-chain", onchain: "On-chain",
@@ -102,7 +106,8 @@ export default {
unified: "Unified", unified: "Unified",
gift: "Lightning Gift" gift: "Lightning Gift"
}, },
remember_choice: "Remember my choice next time" remember_choice: "Remember my choice next time",
what_for: "What's this for?"
}, },
send: { send: {
sending: "Sending...", sending: "Sending...",
@@ -119,11 +124,13 @@ export default {
of: "of", of: "of",
sats_sent: "sats sent" sats_sent: "sats sent"
}, },
what_for: "What's this for?",
error_low_balance: error_low_balance:
"We do not have enough balance to pay the given amount.", "We do not have enough balance to pay the given amount.",
error_invoice_match: error_invoice_match:
"Amount requested, {{amount}} SATS, does not equal amount set.", "Amount requested, {{amount}} SATS, does not equal amount set.",
error_channel_reserves: "Not enough available funds.", error_channel_reserves: "Not enough available funds.",
error_address: "Invalid Lightning Address",
error_channel_reserves_explained: error_channel_reserves_explained:
"A portion of your channel balance is reserved for fees. Try sending a smaller amount or adding funds.", "A portion of your channel balance is reserved for fees. Try sending a smaller amount or adding funds.",
error_clipboard: "Clipboard not supported", error_clipboard: "Clipboard not supported",

View File

@@ -18,6 +18,7 @@ export type ParsedParams = {
nostr_wallet_auth?: string; nostr_wallet_auth?: string;
fedimint_invite?: string; fedimint_invite?: string;
is_lnurl_auth?: boolean; is_lnurl_auth?: boolean;
contact_id?: string;
}; };
export function toParsedParams( export function toParsedParams(

View File

@@ -4,7 +4,7 @@
body { body {
@apply text-white; @apply text-white;
@apply min-h-[100dvh] overflow-y-scroll overscroll-none safe-top safe-bottom disable-scrollbars; @apply flex min-h-[100dvh] flex-col overflow-y-scroll overscroll-none safe-top safe-bottom disable-scrollbars;
} }
html { html {
@@ -12,6 +12,10 @@ html {
@apply bg-neutral-900; @apply bg-neutral-900;
} }
#root {
@apply flex flex-1 flex-col;
}
@media (prefers-color-scheme: light) { @media (prefers-color-scheme: light) {
/* we don't support this but I want the browser to know I care */ /* we don't support this but I want the browser to know I care */
} }

View File

@@ -13,6 +13,7 @@ import {
NotFound, NotFound,
Receive, Receive,
Scanner, Scanner,
Search,
Send, Send,
Swap Swap
} from "~/routes"; } from "~/routes";
@@ -100,6 +101,7 @@ export function Router() {
<Route path="/scanner" component={Scanner} /> <Route path="/scanner" component={Scanner} />
<Route path="/send" component={Send} /> <Route path="/send" component={Send} />
<Route path="/swap" component={Swap} /> <Route path="/swap" component={Swap} />
<Route path="/search" component={Search} />
<Route path="/settings"> <Route path="/settings">
<Route path="/" component={Settings} /> <Route path="/" component={Settings} />
<Route path="/admin" component={Admin} /> <Route path="/admin" component={Admin} />

View File

@@ -33,7 +33,7 @@ import {
} from "~/components"; } from "~/components";
import { useI18n } from "~/i18n/context"; import { useI18n } from "~/i18n/context";
import { useMegaStore } from "~/state/megaStore"; import { useMegaStore } from "~/state/megaStore";
import { eify, gradientsPerContact } from "~/utils"; import { eify, gradientsPerContact, hexpubFromNpub } from "~/utils";
function ContactRow() { function ContactRow() {
const [state, _actions] = useMegaStore(); const [state, _actions] = useMegaStore();
@@ -73,6 +73,8 @@ function ContactRow() {
// //
async function saveContact(id: string, contact: ContactFormValues) { async function saveContact(id: string, contact: ContactFormValues) {
console.log("saving contact", id, contact);
const hexpub = await hexpubFromNpub(contact.npub?.trim());
try { try {
const existing = state.mutiny_wallet?.get_tag_item(id); const existing = state.mutiny_wallet?.get_tag_item(id);
// This shouldn't happen // This shouldn't happen
@@ -80,7 +82,7 @@ function ContactRow() {
await state.mutiny_wallet?.edit_contact( await state.mutiny_wallet?.edit_contact(
id, id,
contact.name, contact.name,
contact.npub ? contact.npub.trim() : undefined, hexpub ? hexpub : undefined,
contact.ln_address ? contact.ln_address.trim() : undefined, contact.ln_address ? contact.ln_address.trim() : undefined,
existing.lnurl, existing.lnurl,
existing.image_url existing.image_url

View File

@@ -2,8 +2,7 @@
import { import {
MutinyBip21RawMaterials, MutinyBip21RawMaterials,
MutinyInvoice, MutinyInvoice
TagItem
} from "@mutinywallet/mutiny-wasm"; } from "@mutinywallet/mutiny-wasm";
import { useNavigate } from "@solidjs/router"; import { useNavigate } from "@solidjs/router";
import { import {
@@ -20,13 +19,12 @@ import {
import side2side from "~/assets/icons/side-to-side.svg"; import side2side from "~/assets/icons/side-to-side.svg";
import { import {
ActivityDetailsModal, ActivityDetailsModal,
AmountCard, AmountEditable,
AmountFiat, AmountFiat,
AmountSats, AmountSats,
BackButton, BackButton,
BackLink, BackLink,
Button, Button,
Card,
Checkbox, Checkbox,
DefaultMain, DefaultMain,
Fee, Fee,
@@ -39,12 +37,12 @@ import {
MegaCheck, MegaCheck,
MutinyWalletGuard, MutinyWalletGuard,
NavBar, NavBar,
SafeArea, ReceiveWarnings,
showToast, showToast,
SimpleDialog, SimpleDialog,
SimpleInput,
StyledRadioGroup, StyledRadioGroup,
SuccessModal, SuccessModal,
TagEditor,
VStack VStack
} from "~/components"; } from "~/components";
import { useI18n } from "~/i18n/context"; import { useI18n } from "~/i18n/context";
@@ -111,18 +109,15 @@ export function Receive() {
const navigate = useNavigate(); const navigate = useNavigate();
const i18n = useI18n(); const i18n = useI18n();
const [amount, setAmount] = createSignal(""); const [amount, setAmount] = createSignal<bigint>(0n);
const [whatForInput, setWhatForInput] = createSignal("");
const [receiveState, setReceiveState] = createSignal<ReceiveState>("edit"); const [receiveState, setReceiveState] = createSignal<ReceiveState>("edit");
const [bip21Raw, setBip21Raw] = createSignal<MutinyBip21RawMaterials>(); const [bip21Raw, setBip21Raw] = createSignal<MutinyBip21RawMaterials>();
const [unified, setUnified] = createSignal(""); const [unified, setUnified] = createSignal("");
const [shouldShowAmountEditor, setShouldShowAmountEditor] =
createSignal(true);
const [lspFee, setLspFee] = createSignal(0n); const [lspFee, setLspFee] = createSignal(0n);
// Tagging stuff
const [selectedValues, setSelectedValues] = createSignal<TagItem[]>([]);
// The data we get after a payment // The data we get after a payment
const [paymentTx, setPaymentTx] = createSignal<OnChainTx>(); const [paymentTx, setPaymentTx] = createSignal<OnChainTx>();
const [paymentInvoice, setPaymentInvoice] = createSignal<MutinyInvoice>(); const [paymentInvoice, setPaymentInvoice] = createSignal<MutinyInvoice>();
@@ -174,13 +169,12 @@ export function Receive() {
}); });
function clearAll() { function clearAll() {
setAmount(""); setAmount(0n);
setReceiveState("edit"); setReceiveState("edit");
setBip21Raw(undefined); setBip21Raw(undefined);
setUnified(""); setUnified("");
setPaymentTx(undefined); setPaymentTx(undefined);
setPaymentInvoice(undefined); setPaymentInvoice(undefined);
setSelectedValues([]);
} }
function openDetailsModal() { function openDetailsModal() {
@@ -207,39 +201,8 @@ export function Receive() {
setDetailsOpen(true); setDetailsOpen(true);
} }
async function processContacts( async function getUnifiedQr(amount: bigint) {
contacts: Partial<TagItem>[] console.log("get unified amount", amount);
): Promise<string[]> {
if (contacts.length) {
const first = contacts![0];
if (!first.name) {
return [];
}
if (!first.id && first.name) {
try {
const newContactId =
await state.mutiny_wallet?.create_new_contact(
first.name
);
if (newContactId) {
return [newContactId];
}
} catch (e) {
console.error(e);
}
}
if (first.id) {
return [first.id];
}
}
return [];
}
async function getUnifiedQr(amount: string) {
const bigAmount = BigInt(amount); const bigAmount = BigInt(amount);
setLoading(true); setLoading(true);
@@ -247,7 +210,7 @@ export function Receive() {
let tags; let tags;
try { try {
tags = await processContacts(selectedValues()); tags = whatForInput() ? [whatForInput().trim()] : [];
} catch (e) { } catch (e) {
showToast(eify(e)); showToast(eify(e));
console.error(e); console.error(e);
@@ -258,6 +221,7 @@ export function Receive() {
// Happy path // Happy path
// First we try to get both an invoice and an address // First we try to get both an invoice and an address
try { try {
console.log("big amount", bigAmount);
const raw = await state.mutiny_wallet?.create_bip21( const raw = await state.mutiny_wallet?.create_bip21(
bigAmount, bigAmount,
tags tags
@@ -265,6 +229,8 @@ export function Receive() {
// Save the raw info so we can watch the address and invoice // Save the raw info so we can watch the address and invoice
setBip21Raw(raw); setBip21Raw(raw);
console.log("raw", raw);
const params = objectToSearchParams({ const params = objectToSearchParams({
amount: raw?.btc_amount, amount: raw?.btc_amount,
lightning: raw?.invoice lightning: raw?.invoice
@@ -305,11 +271,16 @@ export function Receive() {
async function onSubmit(e: Event) { async function onSubmit(e: Event) {
e.preventDefault(); e.preventDefault();
const unifiedQr = await getUnifiedQr(amount()); await getQr();
}
setUnified(unifiedQr || ""); async function getQr() {
setReceiveState("show"); if (amount()) {
setShouldShowAmountEditor(false); const unifiedQr = await getUnifiedQr(amount());
setUnified(unifiedQr || "");
setReceiveState("show");
}
} }
async function checkIfPaid( async function checkIfPaid(
@@ -378,193 +349,174 @@ export function Receive() {
return ( return (
<MutinyWalletGuard> <MutinyWalletGuard>
<SafeArea> <DefaultMain>
<DefaultMain> <Show when={receiveState() === "show"} fallback={<BackLink />}>
<Show <BackButton
when={receiveState() === "show"} onClick={() => setReceiveState("edit")}
fallback={<BackLink />} title={i18n.t("receive.edit")}
> showOnDesktop
<BackButton />
onClick={() => setReceiveState("edit")} </Show>
title={i18n.t("receive.edit")} <LargeHeader
showOnDesktop action={
/> receiveState() === "show" && (
</Show> <Indicator>{i18n.t("receive.checking")}</Indicator>
<LargeHeader )
action={ }
receiveState() === "show" && ( >
<Indicator> {i18n.t("receive.receive_bitcoin")}
{i18n.t("receive.checking")} </LargeHeader>
</Indicator> <Switch>
) <Match when={!unified() || receiveState() === "edit"}>
} <div class="flex-1" />
> <VStack>
{i18n.t("receive.receive_bitcoin")} <AmountEditable
</LargeHeader> initialAmountSats={amount() || "0"}
<Switch> setAmountSats={setAmount}
<Match when={!unified() || receiveState() === "edit"}> onSubmit={getQr}
<div class="flex flex-1 flex-col gap-8">
<AmountCard
initialOpen={shouldShowAmountEditor()}
amountSats={amount() || "0"}
setAmountSats={setAmount}
isAmountEditable
exitRoute={amount() ? "/receive" : "/"}
showWarnings
/>
<Card title={i18n.t("common.private_tags")}>
<TagEditor
selectedValues={selectedValues()}
setSelectedValues={setSelectedValues}
placeholder={i18n.t(
"receive.receive_add_the_sender"
)}
/>
</Card>
<div class="flex-1" />
<VStack>
<Button
disabled={!amount()}
intent="green"
onClick={onSubmit}
loading={loading()}
>
{i18n.t("common.continue")}
</Button>
</VStack>
</div>
</Match>
<Match when={unified() && receiveState() === "show"}>
<FeeWarning fee={lspFee()} flavor={flavor()} />
<Show when={error()}>
<InfoBox accent="red">
<p>{error()}</p>
</InfoBox>
</Show>
<IntegratedQr
value={receiveString() ?? ""}
amountSats={amount() || "0"}
kind={flavor()}
/> />
<p class="text-center text-neutral-400"> <ReceiveWarnings amountSats={amount() || "0"} />
{i18n.t("receive.keep_mutiny_open")} </VStack>
</p> <div class="flex-1" />
{/* Only show method chooser when we have an invoice */} <VStack>
<Show when={bip21Raw()?.invoice}> <form onSubmit={onSubmit}>
<button <SimpleInput
class="mx-auto flex items-center gap-2 pb-8 font-bold text-m-grey-400" type="text"
onClick={() => setMethodChooserOpen(true)} value={whatForInput()}
> placeholder={i18n.t("receive.what_for")}
<span> onInput={(e) =>
{i18n.t("receive.choose_format")} setWhatForInput(e.currentTarget.value)
</span>
<img class="h-4 w-4" src={side2side} />
</button>
<SimpleDialog
title={i18n.t(
"receive.choose_payment_format"
)}
open={methodChooserOpen()}
setOpen={(open) =>
setMethodChooserOpen(open)
} }
> />
<StyledRadioGroup </form>
initialValue={flavor()} <Button
onValueChange={selectFlavor} disabled={!amount()}
choices={RECEIVE_FLAVORS} intent="green"
accent="white" onClick={onSubmit}
vertical loading={loading()}
delayOnChange
/>
<Checkbox
label={i18n.t(
"receive.remember_choice"
)}
checked={rememberChoice()}
onChange={setRememberChoice}
/>
</SimpleDialog>
</Show>
</Match>
<Match when={receiveState() === "paid"}>
<SuccessModal
open={!!paidState()}
setOpen={(open: boolean) => {
if (!open) clearAll();
}}
onConfirm={() => {
clearAll();
navigate("/");
}}
> >
<Show when={detailsId() && detailsKind()}> {i18n.t("common.continue")}
<ActivityDetailsModal </Button>
open={detailsOpen()} </VStack>
kind={detailsKind()} </Match>
id={detailsId()} <Match when={unified() && receiveState() === "show"}>
setOpen={setDetailsOpen} <FeeWarning fee={lspFee()} flavor={flavor()} />
<Show when={error()}>
<InfoBox accent="red">
<p>{error()}</p>
</InfoBox>
</Show>
<IntegratedQr
value={receiveString() ?? ""}
amountSats={amount() ? amount().toString() : "0"}
kind={flavor()}
/>
<p class="text-center text-neutral-400">
{i18n.t("receive.keep_mutiny_open")}
</p>
{/* Only show method chooser when we have an invoice */}
<Show when={bip21Raw()?.invoice}>
<button
class="mx-auto flex items-center gap-2 pb-8 font-bold text-m-grey-400"
onClick={() => setMethodChooserOpen(true)}
>
<span>{i18n.t("receive.choose_format")}</span>
<img class="h-4 w-4" src={side2side} />
</button>
<SimpleDialog
title={i18n.t("receive.choose_payment_format")}
open={methodChooserOpen()}
setOpen={(open) => setMethodChooserOpen(open)}
>
<StyledRadioGroup
initialValue={flavor()}
onValueChange={selectFlavor}
choices={RECEIVE_FLAVORS}
accent="white"
vertical
delayOnChange
/>
<Checkbox
label={i18n.t("receive.remember_choice")}
checked={rememberChoice()}
onChange={setRememberChoice}
/>
</SimpleDialog>
</Show>
</Match>
<Match when={receiveState() === "paid"}>
<SuccessModal
open={!!paidState()}
setOpen={(open: boolean) => {
if (!open) clearAll();
}}
onConfirm={() => {
clearAll();
navigate("/");
}}
>
<Show when={detailsId() && detailsKind()}>
<ActivityDetailsModal
open={detailsOpen()}
kind={detailsKind()}
id={detailsId()}
setOpen={setDetailsOpen}
/>
</Show>
<MegaCheck />
<h1 class="mb-2 mt-4 w-full text-center text-2xl font-semibold md:text-3xl">
{receiveState() === "paid" &&
paidState() === "lightning_paid"
? i18n.t("receive.payment_received")
: i18n.t("receive.payment_initiated")}
</h1>
<div class="flex flex-col items-center gap-1">
<div class="text-xl">
<AmountSats
amountSats={
receiveState() === "paid" &&
paidState() === "lightning_paid"
? paymentInvoice()?.amount_sats
: paymentTx()?.received
}
icon="plus"
/> />
</Show>
<MegaCheck />
<h1 class="mb-2 mt-4 w-full text-center text-2xl font-semibold md:text-3xl">
{receiveState() === "paid" &&
paidState() === "lightning_paid"
? i18n.t("receive.payment_received")
: i18n.t("receive.payment_initiated")}
</h1>
<div class="flex flex-col items-center gap-1">
<div class="text-xl">
<AmountSats
amountSats={
receiveState() === "paid" &&
paidState() === "lightning_paid"
? paymentInvoice()
?.amount_sats
: paymentTx()?.received
}
icon="plus"
/>
</div>
<div class="text-white/70">
<AmountFiat
amountSats={
receiveState() === "paid" &&
paidState() === "lightning_paid"
? paymentInvoice()
?.amount_sats
: paymentTx()?.received
}
denominationSize="sm"
/>
</div>
</div> </div>
<hr class="w-16 bg-m-grey-400" /> <div class="text-white/70">
<Show <AmountFiat
when={ amountSats={
receiveState() === "paid" && receiveState() === "paid" &&
paidState() === "lightning_paid" paidState() === "lightning_paid"
} ? paymentInvoice()?.amount_sats
: paymentTx()?.received
}
denominationSize="sm"
/>
</div>
</div>
<hr class="w-16 bg-m-grey-400" />
<Show
when={
receiveState() === "paid" &&
paidState() === "lightning_paid"
}
>
<Fee amountSats={lspFee()} />
</Show>
{/*TODO: Confirmation time estimate still not possible needs to be implemented in mutiny-node first*/}
<Show when={receiveState() === "paid"}>
<p
class="cursor-pointer underline"
onClick={openDetailsModal}
> >
<Fee amountSats={lspFee()} /> {i18n.t("common.view_payment_details")}
</Show> </p>
{/*TODO: Confirmation time estimate still not possible needs to be implemented in mutiny-node first*/} </Show>
<Show when={receiveState() === "paid"}> </SuccessModal>
<p </Match>
class="cursor-pointer underline" </Switch>
onClick={openDetailsModal} </DefaultMain>
> <NavBar activeTab="receive" />
{i18n.t("common.view_payment_details")}
</p>
</Show>
</SuccessModal>
</Match>
</Switch>
</DefaultMain>
<NavBar activeTab="receive" />
</SafeArea>
</MutinyWalletGuard> </MutinyWalletGuard>
); );
} }

297
src/routes/Search.tsx Normal file
View File

@@ -0,0 +1,297 @@
import { Clipboard } from "@capacitor/clipboard";
import { Capacitor } from "@capacitor/core";
import { TagItem } from "@mutinywallet/mutiny-wasm";
import { A, useNavigate } from "@solidjs/router";
import {
createMemo,
createResource,
createSignal,
For,
onMount,
Show,
Suspense
} from "solid-js";
import close from "~/assets/icons/close.svg";
import paste from "~/assets/icons/paste.svg";
import scan from "~/assets/icons/scan.svg";
import {
ContactEditor,
ContactFormValues,
LabelCircle,
NavBar,
showToast
} from "~/components";
import {
BackLink,
Button,
DefaultMain,
MutinyWalletGuard,
SafeArea
} from "~/components/layout";
import { useI18n } from "~/i18n/context";
import { useMegaStore } from "~/state/megaStore";
export function Search() {
return (
<MutinyWalletGuard>
<SafeArea>
<DefaultMain zeroBottomPadding={true}>
<div class="flex items-center justify-between">
<BackLink />
<A
class="rounded-lg p-2 hover:bg-white/5 active:bg-m-blue md:hidden"
href="/scanner"
>
<img src={scan} alt="Scan" class="h-6 w-6" />
</A>{" "}
</div>
{/* Need to put the search view in a supsense so it loads list on first nav */}
<Suspense>
<ActualSearch />
</Suspense>
</DefaultMain>
<NavBar activeTab="send" />
</SafeArea>
</MutinyWalletGuard>
);
}
function ActualSearch() {
const [searchValue, setSearchValue] = createSignal("");
const [state, actions] = useMegaStore();
const navigate = useNavigate();
const i18n = useI18n();
async function contactsFetcher() {
try {
console.log("getting contacts");
const contacts: TagItem[] =
state.mutiny_wallet?.get_contacts_sorted();
return contacts || [];
} catch (e) {
console.error(e);
return [];
}
}
const [contacts] = createResource(contactsFetcher);
const filteredContacts = createMemo(() => {
return (
contacts()?.filter((c) => {
const s = searchValue().toLowerCase();
return (
//
c.ln_address &&
(c.name.toLowerCase().includes(s) ||
c.ln_address?.toLowerCase().includes(s) ||
c.npub?.includes(s))
);
}) || []
);
});
const showSendButton = createMemo(() => {
if (searchValue() === "") {
return false;
} else {
const text = searchValue().trim();
// Only want to check for something parseable if it's of reasonable length
if (text.length < 6) {
return false;
}
let success = false;
actions.handleIncomingString(
text,
(error) => {
// showToast(error);
console.log("error", error);
},
(result) => {
console.log("result", result);
success = true;
}
);
return success;
}
});
function handleContinue() {
actions.handleIncomingString(
searchValue().trim(),
(error) => {
showToast(error);
},
(result) => {
if (result) {
actions.setScanResult(result);
navigate("/send", { state: { previous: "/search" } });
} else {
showToast(new Error(i18n.t("send.error_address")));
}
}
);
}
function sendToContact(contact: TagItem) {
const address = contact.ln_address || contact.lnurl;
if (address) {
actions.handleIncomingString(
(address || "").trim(),
(error) => {
showToast(error);
},
(result) => {
actions.setScanResult({
...result,
contact_id: contact.id
});
navigate("/send", { state: { previous: "/search" } });
}
);
} else {
console.error("no ln_address or lnurl");
}
}
async function createContact(contact: ContactFormValues) {
try {
const contactId = await state.mutiny_wallet?.create_new_contact(
contact.name,
contact.npub ? contact.npub.trim() : undefined,
contact.ln_address ? contact.ln_address.trim() : undefined,
undefined,
undefined
);
if (!contactId) {
throw new Error("no contact id returned");
}
const tagItem = await state.mutiny_wallet?.get_tag_item(contactId);
if (!tagItem) {
throw new Error("no contact returned");
}
sendToContact(tagItem);
} catch (e) {
console.error(e);
}
}
// Search input stuff
async function handlePaste() {
try {
let text;
if (Capacitor.isNativePlatform()) {
const { value } = await Clipboard.read();
text = value;
} else {
if (!navigator.clipboard.readText) {
return showToast(new Error(i18n.t("send.error_clipboard")));
}
text = await navigator.clipboard.readText();
}
const trimText = text.trim();
setSearchValue(trimText);
parsePaste(trimText);
} catch (e) {
console.error(e);
}
}
function parsePaste(text: string) {
actions.handleIncomingString(
text,
(error) => {
showToast(error);
},
(result) => {
actions.setScanResult(result);
navigate("/send", { state: { previous: "/search" } });
}
);
}
let searchInputRef!: HTMLInputElement;
onMount(() => {
searchInputRef.focus();
});
return (
<>
<div class="relative">
<input
class="w-full rounded-lg bg-m-grey-750 p-2 placeholder-m-grey-400 disabled:text-m-grey-400"
type="text"
value={searchValue()}
onInput={(e) => setSearchValue(e.currentTarget.value)}
placeholder="Name, address, invoice..."
autofocus
ref={(el) => (searchInputRef = el)}
/>
<Show when={!searchValue()}>
<button
class="bg-m-grey- absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-1 py-1 pr-4"
onClick={handlePaste}
>
<img src={paste} alt="Paste" class="h-4 w-4" />
Paste
</button>
</Show>
<Show when={!!searchValue()}>
<button
class="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-1 rounded-full bg-m-grey-800 px-1 py-1"
onClick={() => setSearchValue("")}
>
<img src={close} alt="Clear" class="h-4 w-4" />
</button>
</Show>
</div>
<Show when={showSendButton()}>
<Button intent="green" onClick={handleContinue}>
Continue
</Button>
</Show>
<div class="flex h-full flex-col gap-3 overflow-y-scroll">
<div class="sticky top-0 z-50 bg-m-grey-900/90 py-2 backdrop-blur-sm">
<h2 class="text-xl font-semibold">Contacts</h2>
</div>
<Show when={contacts.latest && contacts?.latest.length > 0}>
<For each={filteredContacts()}>
{(contact) => (
<button
onClick={() => sendToContact(contact)}
class="flex items-center gap-2"
>
<LabelCircle
name={contact.name}
image_url={contact.image_url}
contact
label={false}
// Annoyingly the search input loses focus when the image load errors
onError={() => searchInputRef.focus()}
/>
<div class="flex flex-col items-start">
<h2 class="overflow-hidden overflow-ellipsis text-base font-semibold">
{contact.name}
</h2>
<h3 class="overflow-hidden overflow-ellipsis text-sm font-normal text-neutral-500">
{contact.ln_address}
</h3>
</div>
</button>
)}
</For>
</Show>
<ContactEditor createContact={createContact} />
<div class="h-4" />
</div>
</>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -13,17 +13,19 @@ import {
import { import {
ActivityDetailsModal, ActivityDetailsModal,
AmountCard, AmountEditable,
AmountFiat, AmountFiat,
BackLink, BackLink,
Button, Button,
Card, Card,
DefaultMain, DefaultMain,
FeeDisplay,
HackActivityType, HackActivityType,
InfoBox, InfoBox,
LargeHeader, LargeHeader,
MegaCheck, MegaCheck,
MegaEx, MegaEx,
MethodChooser,
MutinyWalletGuard, MutinyWalletGuard,
NavBar, NavBar,
SafeArea, SafeArea,
@@ -34,7 +36,7 @@ import {
} from "~/components"; } from "~/components";
import { useI18n } from "~/i18n/context"; import { useI18n } from "~/i18n/context";
import { Network } from "~/logic/mutinyWalletSetup"; import { Network } from "~/logic/mutinyWalletSetup";
import { MethodChooser, SendSource } from "~/routes/Send"; import { SendSource } from "~/routes/Send";
import { useMegaStore } from "~/state/megaStore"; import { useMegaStore } from "~/state/megaStore";
import { eify, vibrateSuccess } from "~/utils"; import { eify, vibrateSuccess } from "~/utils";
@@ -441,13 +443,19 @@ export function Swap() {
</Card> </Card>
</Show> </Show>
</VStack> </VStack>
<AmountCard <AmountEditable
amountSats={amountSats().toString()} initialAmountSats={amountSats()}
setAmountSats={setAmountSats} setAmountSats={setAmountSats}
fee={feeEstimate()?.toString()} fee={feeEstimate()?.toString()}
isAmountEditable={true}
maxAmountSats={maxOnchain()} maxAmountSats={maxOnchain()}
/> />
<Show when={feeEstimate() && amountSats() > 0n}>
<FeeDisplay
amountSats={amountSats().toString()}
fee={feeEstimate()!.toString()}
maxAmountSats={maxOnchain()}
/>
</Show>
<Show when={amountWarning() && amountSats() > 0n}> <Show when={amountWarning() && amountSats() > 0n}>
<InfoBox accent={"red"}>{amountWarning()}</InfoBox> <InfoBox accent={"red"}>{amountWarning()}</InfoBox>
</Show> </Show>

View File

@@ -7,3 +7,4 @@ export * from "./Receive";
export * from "./Scanner"; export * from "./Scanner";
export * from "./Send"; export * from "./Send";
export * from "./Swap"; export * from "./Swap";
export * from "./Search";

View File

@@ -169,7 +169,7 @@ function SingleChannelItem(props: { channel: MutinyChannel }) {
); );
} }
export function LiquidityMonitor() { function LiquidityMonitor() {
const i18n = useI18n(); const i18n = useI18n();
const [state, _actions] = useMegaStore(); const [state, _actions] = useMegaStore();

View File

@@ -19,7 +19,7 @@ import {
} from "solid-js"; } from "solid-js";
import { import {
AmountCard, AmountEditable,
BackPop, BackPop,
Button, Button,
Collapser, Collapser,
@@ -49,10 +49,7 @@ type CreateGiftForm = {
amount: string; amount: string;
}; };
export function SingleGift(props: { function SingleGift(props: { profile: NwcProfile; onDelete?: () => void }) {
profile: NwcProfile;
onDelete?: () => void;
}) {
const i18n = useI18n(); const i18n = useI18n();
const [state, _actions] = useMegaStore(); const [state, _actions] = useMegaStore();
@@ -268,6 +265,22 @@ export function Gift() {
<NiceP> <NiceP>
{i18n.t("settings.gift.send_explainer")} {i18n.t("settings.gift.send_explainer")}
</NiceP> </NiceP>
<Field name="amount">
{(field) => (
<AmountEditable
initialAmountSats={
field.value || "0"
}
setAmountSats={(newAmount) =>
setValue(
giftForm,
"amount",
newAmount.toString()
)
}
/>
)}
</Field>
<Field <Field
name="name" name="name"
validate={[ validate={[
@@ -290,23 +303,7 @@ export function Gift() {
/> />
)} )}
</Field> </Field>
<Field name="amount">
{(field) => (
<>
<AmountCard
amountSats={field.value || "0"}
isAmountEditable
setAmountSats={(newAmount) =>
setValue(
giftForm,
"amount",
newAmount.toString()
)
}
/>
</>
)}
</Field>
<Show when={lessThanMinChannelSize()}> <Show when={lessThanMinChannelSize()}>
<InfoBox accent="green"> <InfoBox accent="green">
{i18n.t( {i18n.t(

View File

@@ -45,7 +45,7 @@ function validateWord(word?: string): boolean {
return WORDS_EN.includes(word?.trim() ?? ""); return WORDS_EN.includes(word?.trim() ?? "");
} }
export function SeedTextField(props: TextFieldProps) { function SeedTextField(props: TextFieldProps) {
const [fieldProps] = splitProps(props, [ const [fieldProps] = splitProps(props, [
"placeholder", "placeholder",
"ref", "ref",

View File

@@ -24,7 +24,7 @@ import {
} from "~/logic/mutinyWalletSetup"; } from "~/logic/mutinyWalletSetup";
import { eify } from "~/utils"; import { eify } from "~/utils";
export function SettingsStringsEditor(props: { function SettingsStringsEditor(props: {
initialSettings: MutinyWalletSettingStrings; initialSettings: MutinyWalletSettingStrings;
}) { }) {
const i18n = useI18n(); const i18n = useI18n();

View File

@@ -2,7 +2,7 @@ import { createForm, required, SubmitHandler } from "@modular-forms/solid";
import { createSignal, Match, Show, Switch } from "solid-js"; import { createSignal, Match, Show, Switch } from "solid-js";
import { import {
BackLink, BackPop,
Button, Button,
DefaultMain, DefaultMain,
FancyCard, FancyCard,
@@ -26,7 +26,7 @@ type NostrContactsForm = {
const PRIMAL_API = import.meta.env.VITE_PRIMAL; const PRIMAL_API = import.meta.env.VITE_PRIMAL;
export function SyncContactsForm() { function SyncContactsForm() {
const i18n = useI18n(); const i18n = useI18n();
const [state, actions] = useMegaStore(); const [state, actions] = useMegaStore();
const [error, setError] = createSignal<Error>(); const [error, setError] = createSignal<Error>();
@@ -121,7 +121,7 @@ export function SyncNostrContacts() {
<MutinyWalletGuard> <MutinyWalletGuard>
<SafeArea> <SafeArea>
<DefaultMain> <DefaultMain>
<BackLink href="/settings" title="Settings" /> <BackPop />
<LargeHeader>Sync Nostr Contacts</LargeHeader> <LargeHeader>Sync Nostr Contacts</LargeHeader>
<Switch> <Switch>
<Match when={state.npub}> <Match when={state.npub}>

View File

@@ -36,14 +36,14 @@ import {
const MegaStoreContext = createContext<MegaStore>(); const MegaStoreContext = createContext<MegaStore>();
export type LoadStage = type LoadStage =
| "fresh" | "fresh"
| "checking_double_init" | "checking_double_init"
| "downloading" | "downloading"
| "setup" | "setup"
| "done"; | "done";
export type MegaStore = [ type MegaStore = [
{ {
mutiny_wallet?: MutinyWallet; mutiny_wallet?: MutinyWallet;
deleting: boolean; deleting: boolean;

View File

@@ -1,67 +0,0 @@
.solid-select-container[data-disabled="true"] {
@apply pointer-events-none;
}
.solid-select-container {
@apply relative;
}
.solid-select-control[data-disabled="true"] {
}
.solid-select-control {
@apply w-full rounded-lg bg-white/10 p-2 placeholder-neutral-400;
@apply grid leading-6;
grid-template-columns: repeat(1, minmax(0, 1fr));
}
.solid-select-control[data-multiple="true"][data-has-value="true"] {
@apply flex flex-wrap items-stretch gap-1;
}
.solid-select-placeholder {
@apply text-neutral-400;
@apply col-start-1 row-start-1;
}
.solid-select-single-value {
@apply col-start-1 row-start-1;
}
.solid-select-multi-value {
@apply flex items-center rounded bg-white/20 px-1;
}
.solid-select-multi-value-remove {
/* TODO: there's gotta be a better way to vertically center this */
@apply -mt-2 pl-2 pr-1 text-2xl leading-3;
}
.solid-select-input {
@apply flex-shrink flex-grow bg-transparent caret-transparent;
outline: 2px solid transparent;
@apply col-start-1 row-start-1;
}
.solid-select-input:read-only {
@apply cursor-default;
}
.solid-select-input[data-multiple="true"] {
@apply caret-current;
}
.solid-select-input[data-is-active="true"] {
@apply caret-current;
}
.solid-select-list {
@apply absolute z-10 max-h-[50vh] min-w-full overflow-y-auto whitespace-nowrap rounded-lg bg-neutral-950 p-2;
}
.solid-select-option[data-focused="true"] {
}
.solid-select-option > mark {
@apply bg-white/10 text-white underline;
}
.solid-select-option {
@apply cursor-default select-none rounded p-1 hover:bg-neutral-800;
}
.solid-select-option[data-disabled="true"] {
@apply pointer-events-none opacity-50;
}
.solid-select-list-placeholder {
@apply cursor-default select-none;
}

View File

@@ -221,12 +221,11 @@ export function bech32WordsToUrl(words: number[]) {
} }
export const bech32 = getLibraryFromEncoding("bech32"); export const bech32 = getLibraryFromEncoding("bech32");
export const bech32m = getLibraryFromEncoding("bech32m"); interface Decoded {
export interface Decoded {
prefix: string; prefix: string;
words: number[]; words: number[];
} }
export interface BechLib { interface BechLib {
decodeUnsafe: ( decodeUnsafe: (
str: string, str: string,
LIMIT?: number | undefined LIMIT?: number | undefined

View File

@@ -6,7 +6,7 @@ import { ResourceFetcher } from "solid-js";
import { useMegaStore } from "~/state/megaStore"; import { useMegaStore } from "~/state/megaStore";
import { hexpubFromNpub, NostrKind, NostrTag } from "~/utils/nostr"; import { hexpubFromNpub, NostrKind, NostrTag } from "~/utils/nostr";
export type NostrEvent = { type NostrEvent = {
created_at: number; created_at: number;
content: string; content: string;
tags: NostrTag[]; tags: NostrTag[];
@@ -16,7 +16,7 @@ export type NostrEvent = {
sig?: string; sig?: string;
}; };
export type SimpleZapItem = { type SimpleZapItem = {
kind: "public" | "private" | "anonymous"; kind: "public" | "private" | "anonymous";
from_hexpub: string; from_hexpub: string;
to_hexpub: string; to_hexpub: string;
@@ -28,7 +28,7 @@ export type SimpleZapItem = {
content?: string; content?: string;
}; };
export type NostrProfile = { type NostrProfile = {
id: string; id: string;
pubkey: string; pubkey: string;
created_at: number; created_at: number;
@@ -116,7 +116,7 @@ async function simpleZapFromEvent(
} }
} }
export const PRIMAL_API = import.meta.env.VITE_PRIMAL; const PRIMAL_API = import.meta.env.VITE_PRIMAL;
async function fetchFollows(npub: string): Promise<string[]> { async function fetchFollows(npub: string): Promise<string[]> {
let pubkey = undefined; let pubkey = undefined;

View File

@@ -1,13 +0,0 @@
export function getHostname(url: string): string {
// Check if the URL begins with "ws://" or "wss://"
if (url.startsWith("ws://")) {
// If it does, remove "ws://" from the URL
url = url.slice(5);
} else if (url.startsWith("wss://")) {
// If it begins with "wss://", remove "wss://" from the URL
url = url.slice(6);
}
// Return the resulting URL
return url;
}

View File

@@ -2,13 +2,11 @@ export * from "./conversions";
export * from "./deepSignal"; export * from "./deepSignal";
export * from "./download"; export * from "./download";
export * from "./eify"; export * from "./eify";
export * from "./getHostname";
export * from "./gradientHash"; export * from "./gradientHash";
export * from "./mempoolTxUrl"; export * from "./mempoolTxUrl";
export * from "./objectToSearchParams"; export * from "./objectToSearchParams";
export * from "./prettyPrintTime"; export * from "./prettyPrintTime";
export * from "./subscriptions"; export * from "./subscriptions";
export * from "./tags";
export * from "./timeout"; export * from "./timeout";
export * from "./typescript"; export * from "./typescript";
export * from "./useCopy"; export * from "./useCopy";
@@ -19,3 +17,4 @@ export * from "./openLinkProgrammatically";
export * from "./nostr"; export * from "./nostr";
export * from "./currencies"; export * from "./currencies";
export * from "./bech32"; export * from "./bech32";
export * from "./keypad";

121
src/utils/keypad.ts Normal file
View File

@@ -0,0 +1,121 @@
import { Currency } from "./currencies";
// Checks the users locale to determine if decimals should be a "." or a ","
const decimalDigitDivider = Number(1.0)
.toLocaleString(navigator.languages[0], { minimumFractionDigits: 1 })
.substring(1, 2);
export function toDisplayHandleNaN(
input?: string | bigint,
fiat?: Currency
): string {
if (!input) {
return "0";
}
if (typeof input === "bigint") {
console.error("toDisplayHandleNaN: input is a bigint", input);
}
const inputStr = input.toString();
const parsed = Number(input);
//handle decimals so the user can always see the accurate amount
if (isNaN(parsed)) {
return "0";
} else if (parsed === Math.trunc(parsed) && inputStr.endsWith(".")) {
return (
parsed.toLocaleString(navigator.languages[0]) + decimalDigitDivider
);
/* To avoid having logic to handle every number up to 8 decimals
any custom currency pair that has more than 3 decimals will always show all decimals*/
} else if (fiat?.maxFractionalDigits && fiat.maxFractionalDigits > 3) {
return parsed.toLocaleString(navigator.languages[0], {
minimumFractionDigits: parsed === 0 ? 0 : fiat.maxFractionalDigits,
maximumFractionDigits: fiat.maxFractionalDigits
});
} else if (parsed === Math.trunc(parsed) && inputStr.endsWith(".0")) {
return parsed.toLocaleString(navigator.languages[0], {
minimumFractionDigits: 1
});
} else if (parsed === Math.trunc(parsed) && inputStr.endsWith(".00")) {
return parsed.toLocaleString(navigator.languages[0], {
minimumFractionDigits: 2
});
} else if (parsed === Math.trunc(parsed) && inputStr.endsWith(".000")) {
return parsed.toLocaleString(navigator.languages[0], {
minimumFractionDigits: 3
});
} else if (
parsed !== Math.trunc(parsed) &&
// matches strings that have 3 total digits after the decimal and ends with 0
inputStr.match(/\.\d{2}0$/) &&
inputStr.includes(".", inputStr.length - 4)
) {
return parsed.toLocaleString(navigator.languages[0], {
minimumFractionDigits: 3
});
} else if (
parsed !== Math.trunc(parsed) &&
// matches strings that have 2 total digits after the decimal and ends with 0
inputStr.match(/\.\d{1}0$/) &&
inputStr.includes(".", inputStr.length - 3)
) {
return parsed.toLocaleString(navigator.languages[0], {
minimumFractionDigits: 2
});
} else {
return parsed.toLocaleString(navigator.languages[0], {
maximumFractionDigits: 3
});
}
}
export function fiatInputSanitizer(input: string, maxDecimals: number): string {
// Make sure only numbers and a single decimal point are allowed if decimals are allowed
let allowDecimalRegex;
if (maxDecimals !== 0) {
allowDecimalRegex = new RegExp("[^0-9.]", "g");
} else {
allowDecimalRegex = new RegExp("[^0-9]", "g");
}
const numeric = input
.replace(allowDecimalRegex, "")
.replace(/(\..*)\./g, "$1");
// Remove leading zeros if not a decimal, add 0 if starts with a decimal
const cleaned = numeric.replace(/^0([^.]|$)/g, "$1").replace(/^\./g, "0.");
// If there are more characters after the decimal than allowed, shift the decimal
const shiftRegex = new RegExp(
"(\\.[0-9]{" + (maxDecimals + 1) + "}).*",
"g"
);
const shifted = cleaned.match(shiftRegex)
? (parseFloat(cleaned) * 10).toFixed(maxDecimals)
: cleaned;
// Truncate any numbers past the maxDecimal for the currency
const decimalRegex = new RegExp("(\\.[0-9]{" + maxDecimals + "}).*", "g");
const decimals = shifted.replace(decimalRegex, "$1");
return decimals;
}
export function satsInputSanitizer(input: string): string {
// Make sure only numbers are allowed
const numeric = input.replace(/[^0-9]/g, "");
// If it starts with a 0, remove the 0
const noLeadingZero = numeric.replace(/^0([^.]|$)/g, "$1");
return noLeadingZero;
}
export function btcFloatRounding(localValue: string): string {
return (
(parseFloat(localValue) -
parseFloat(localValue.charAt(localValue.length - 1)) / 100000000) /
10
).toFixed(8);
}

View File

@@ -45,8 +45,11 @@ export declare enum NostrKind {
} }
export async function hexpubFromNpub( export async function hexpubFromNpub(
npub: string npub?: string
): Promise<string | undefined> { ): Promise<string | undefined> {
if (!npub) {
return undefined;
}
if (!npub.toLowerCase().startsWith("npub")) { if (!npub.toLowerCase().startsWith("npub")) {
return undefined; return undefined;
} }

View File

@@ -1,12 +0,0 @@
import { TagItem } from "@mutinywallet/mutiny-wasm";
export function tagsToIds(tags?: TagItem[]): string[] {
if (!tags) {
return [];
}
return tags.filter((tag) => tag.id !== "Unknown").map((tag) => tag.id);
}
export function sortByLastUsed(a: TagItem, b: TagItem) {
return Number(b.last_used_time - a.last_used_time);
}

View File

@@ -4,7 +4,7 @@ import { Capacitor } from "@capacitor/core";
import type { Accessor } from "solid-js"; import type { Accessor } from "solid-js";
import { createSignal } from "solid-js"; import { createSignal } from "solid-js";
export type UseCopyProps = { type UseCopyProps = {
copiedTimeout?: number; copiedTimeout?: number;
}; };
type CopyFn = (text: string) => Promise<void>; type CopyFn = (text: string) => Promise<void>;

View File

@@ -1,14 +1,6 @@
import { Haptics } from "@capacitor/haptics"; import { Haptics } from "@capacitor/haptics";
import { NotificationType } from "@capacitor/haptics/dist/esm/definitions"; import { NotificationType } from "@capacitor/haptics/dist/esm/definitions";
export const vibrate = async (millis = 250) => {
try {
await Haptics.vibrate({ duration: millis });
} catch (error) {
console.warn(error);
}
};
export const vibrateSuccess = async () => { export const vibrateSuccess = async () => {
try { try {
await Haptics.notification({ type: NotificationType.Success }); await Haptics.notification({ type: NotificationType.Success });

View File

@@ -37,7 +37,7 @@ module.exports = {
"m-grey-750": "hsla(0, 0%, 17%, 1)", "m-grey-750": "hsla(0, 0%, 17%, 1)",
"m-grey-800": "hsla(0, 0%, 12%, 1)", "m-grey-800": "hsla(0, 0%, 12%, 1)",
"m-grey-900": "hsla(0, 0%, 9%, 1)", "m-grey-900": "hsla(0, 0%, 9%, 1)",
"m-grey-950": "hsla(0, 0%, 8%, 1)", "m-grey-950": "hsla(0, 0%, 8%, 1)"
}, },
backgroundImage: { backgroundImage: {
"fade-to-blue": "fade-to-blue":
@@ -58,7 +58,11 @@ module.exports = {
"fancy-card": "0px 4px 4px rgba(0, 0, 0, 0.1)", "fancy-card": "0px 4px 4px rgba(0, 0, 0, 0.1)",
"subtle-bevel": "subtle-bevel":
"inset -4px -4px 6px 0 rgba(0, 0, 0, 0.10), inset 4px 4px 4px 0 rgba(255, 255, 255, 0.10)", "inset -4px -4px 6px 0 rgba(0, 0, 0, 0.10), inset 4px 4px 4px 0 rgba(255, 255, 255, 0.10)",
above: "0px -4px 10px rgba(0, 0, 0, 0.25)" above: "0px -4px 10px rgba(0, 0, 0, 0.25)",
keycap: "15px 15px 20px -5px rgba(0, 0, 0, 0.3)"
},
fontFamily: {
"system-mono": ["ui-monospace", "Menlo", "Monaco", "monospace"]
}, },
textShadow: { textShadow: {
button: "1px 1px 0px rgba(0, 0, 0, 0.4)" button: "1px 1px 0px rgba(0, 0, 0, 0.4)"
@@ -86,7 +90,14 @@ module.exports = {
height: "calc(100vh - env(safe-area-inset-top) - env(safe-area-inset-bottom))" height: "calc(100vh - env(safe-area-inset-top) - env(safe-area-inset-bottom))"
}, },
"max-h-device": { "max-h-device": {
maxHeight: "calc(100vh - env(safe-area-inset-top) - env(safe-area-inset-bottom))" maxHeight:
"calc(100vh - env(safe-area-inset-top) - env(safe-area-inset-bottom))"
},
".h-dvh": {
height: "calc(100dvh - env(safe-area-inset-top) - env(safe-area-inset-bottom - env(keyboard-inset-height))"
},
".h-svh": {
height: "calc(100svh - env(safe-area-inset-top) - env(safe-area-inset-bottom))"
}, },
".disable-scrollbars": { ".disable-scrollbars": {
scrollbarWidth: "none", scrollbarWidth: "none",