mirror of
https://github.com/aljazceru/mutiny-web.git
synced 2025-12-24 01:24:28 +01:00
contact search and new amount editor
This commit is contained in:
committed by
Tony Giorgio
parent
077ccb2a63
commit
bc2fbf9b2f
@@ -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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
37
pnpm-lock.yaml
generated
@@ -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'}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(",")}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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
123
src/components/BigMoney.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
106
src/components/FeeDisplay.tsx
Normal file
106
src/components/FeeDisplay.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
63
src/components/LabelCircle.tsx
Normal file
63
src/components/LabelCircle.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
71
src/components/MethodChooser.tsx
Normal file
71
src/components/MethodChooser.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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")}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
77
src/components/ReceiveWarnings.tsx
Normal file
77
src/components/ReceiveWarnings.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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" />
|
||||||
|
|||||||
24
src/components/SimpleInput.tsx
Normal file
24
src/components/SimpleInput.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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()}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}</>;
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 */
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
297
src/routes/Search.tsx
Normal 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
@@ -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>
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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
121
src/utils/keypad.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user