mirror of
https://github.com/aljazceru/mutiny-web.git
synced 2025-12-19 07:14:22 +01:00
contact search and new amount editor
This commit is contained in:
committed by
Tony Giorgio
parent
077ccb2a63
commit
bc2fbf9b2f
@@ -1,4 +1,4 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("http://localhost:3420/");
|
||||
@@ -17,17 +17,16 @@ test("rountrip receive and send", async ({ page }) => {
|
||||
// At least one h2 should show "0 USD"
|
||||
await expect(page.locator("h2")).toContainText(["$0 USD"]);
|
||||
|
||||
// Click the 100k button
|
||||
await page.click("text=100k");
|
||||
// Type 100000 into the input
|
||||
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"]);
|
||||
|
||||
// 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
|
||||
const continueButton = await page.locator("button", { hasText: "Continue" });
|
||||
const continueButton = await page.locator("button", {
|
||||
hasText: "Continue"
|
||||
});
|
||||
await expect(continueButton).not.toBeDisabled();
|
||||
|
||||
// Wait one second
|
||||
@@ -53,7 +52,9 @@ test("rountrip receive and send", async ({ page }) => {
|
||||
const lightningInvoice = value?.split("lightning=")[1];
|
||||
|
||||
// Post the lightning invoice to the server
|
||||
const _response = await fetch("https://faucet.mutinynet.com/api/lightning", {
|
||||
const _response = await fetch(
|
||||
"https://faucet.mutinynet.com/api/lightning",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
@@ -61,7 +62,8 @@ test("rountrip receive and send", async ({ page }) => {
|
||||
body: JSON.stringify({
|
||||
bolt11: lightningInvoice
|
||||
})
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// Wait for an h1 to appear in the dom that says "Payment Received"
|
||||
await page.waitForSelector("text=Payment Received", { timeout: 30000 });
|
||||
@@ -73,20 +75,29 @@ test("rountrip receive and send", async ({ page }) => {
|
||||
await page.click("text=Send");
|
||||
|
||||
// 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 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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -183,7 +183,9 @@ test("visit each route", async ({ page }) => {
|
||||
|
||||
// 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);
|
||||
|
||||
// Gift
|
||||
|
||||
@@ -58,16 +58,13 @@
|
||||
"@mutinywallet/mutiny-wasm": "0.5.2",
|
||||
"@mutinywallet/waila-wasm": "^0.2.6",
|
||||
"@solid-primitives/upload": "^0.0.111",
|
||||
"@solid-primitives/websocket": "^1.2.0",
|
||||
"@solidjs/meta": "^0.29.1",
|
||||
"@solidjs/router": "^0.9.0",
|
||||
"@thisbeyond/solid-select": "^0.14.0",
|
||||
"i18next": "^22.5.1",
|
||||
"i18next-browser-languagedetector": "^7.1.0",
|
||||
"qr-scanner": "^1.4.2",
|
||||
"solid-js": "^1.8.5",
|
||||
"solid-qr-code": "^0.0.8",
|
||||
"undici": "^5.27.1"
|
||||
"solid-qr-code": "^0.0.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
|
||||
37
pnpm-lock.yaml
generated
37
pnpm-lock.yaml
generated
@@ -62,18 +62,12 @@ importers:
|
||||
'@solid-primitives/upload':
|
||||
specifier: ^0.0.111
|
||||
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':
|
||||
specifier: ^0.29.1
|
||||
version: 0.29.1(solid-js@1.8.5)
|
||||
'@solidjs/router':
|
||||
specifier: ^0.9.0
|
||||
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:
|
||||
specifier: ^22.5.1
|
||||
version: 22.5.1
|
||||
@@ -89,9 +83,6 @@ importers:
|
||||
solid-qr-code:
|
||||
specifier: ^0.0.8
|
||||
version: 0.0.8(qr.js@0.0.0)(solid-js@1.8.5)
|
||||
undici:
|
||||
specifier: ^5.27.1
|
||||
version: 5.27.1
|
||||
devDependencies:
|
||||
'@capacitor/assets':
|
||||
specifier: ^2.0.4
|
||||
@@ -2130,11 +2121,6 @@ packages:
|
||||
resolution: {integrity: sha512-cEee/Z+I12mZcFJshKcCqC8tuX5hG3s+d+9nZ3LabqKF1vKdF41B92pJVCBggjAGORAeOzyyDDKrZwIkLffeOQ==}
|
||||
dev: true
|
||||
|
||||
/@fastify/busboy@2.0.0:
|
||||
resolution: {integrity: sha512-JUFJad5lv7jxj926GPgymrWQxxjPYuJNiNjNMzqT+HiuP6Vl3dk5xzG+8sTX96np0ZAluvaMzPsjhHZ5rNuNQQ==}
|
||||
engines: {node: '>=14'}
|
||||
dev: false
|
||||
|
||||
/@floating-ui/core@1.4.1:
|
||||
resolution: {integrity: sha512-jk3WqquEJRlcyu7997NtR5PibI+y5bi+LS3hPmguVClypenMsCY3CBa3LAQnozRCtCrYWSEtAdiskpamuJRFOQ==}
|
||||
dependencies:
|
||||
@@ -3492,14 +3478,6 @@ packages:
|
||||
solid-js: 1.8.5
|
||||
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):
|
||||
resolution: {integrity: sha512-mplUfmp7tjGgDTiVbEAqkWDLpr0ZNyR1+OOETNyJt759MqPzh979X3oJUk8SZisGII0BNycmHDIGc0Shqx7bIg==}
|
||||
peerDependencies:
|
||||
@@ -4743,14 +4721,6 @@ packages:
|
||||
'@testing-library/dom': 9.3.1
|
||||
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:
|
||||
resolution: {integrity: sha512-yriBEyOkJ8K4mHCgoyUKQCyVI8tP4S513Wp6/9SCx6Ub8ZvSQUonqU3/OZB2G8FRfL4aijpFfMWtiVFJbX6V/w==}
|
||||
dev: true
|
||||
@@ -13060,13 +13030,6 @@ packages:
|
||||
dependencies:
|
||||
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:
|
||||
resolution: {integrity: sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
@@ -21,14 +21,7 @@ import { useI18n } from "~/i18n/context";
|
||||
import { useMegaStore } from "~/state/megaStore";
|
||||
import { createDeepSignal } from "~/utils";
|
||||
|
||||
export const THREE_COLUMNS =
|
||||
"grid grid-cols-[auto,1fr,auto] gap-4 py-2 px-2 border-b border-neutral-800 last:border-b-0";
|
||||
export const CENTER_COLUMN = "min-w-0 overflow-hidden max-w-full";
|
||||
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 {
|
||||
interface IActivityItem {
|
||||
kind: HackActivityType;
|
||||
id: string;
|
||||
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 (
|
||||
<div>
|
||||
{prettyPrintTime(props.ts).split(",", 2).join(",")}
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
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 chain from "~/assets/icons/chain.svg";
|
||||
import off from "~/assets/icons/download-channel.svg";
|
||||
import shuffle from "~/assets/icons/shuffle.svg";
|
||||
import on from "~/assets/icons/upload-channel.svg";
|
||||
import { AmountFiat, AmountSats } from "~/components";
|
||||
import { AmountFiat, AmountSats, LabelCircle } from "~/components";
|
||||
import { useI18n } from "~/i18n/context";
|
||||
import { useMegaStore } from "~/state/megaStore";
|
||||
import { generateGradient, timeAgo } from "~/utils";
|
||||
import { timeAgo } from "~/utils";
|
||||
|
||||
export const ActivityAmount: ParentComponent<{
|
||||
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 =
|
||||
| "Lightning"
|
||||
| "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 {
|
||||
createEffect,
|
||||
createResource,
|
||||
createSignal,
|
||||
For,
|
||||
Match,
|
||||
onCleanup,
|
||||
onMount,
|
||||
ParentComponent,
|
||||
Show,
|
||||
Switch
|
||||
Show
|
||||
} from "solid-js";
|
||||
|
||||
import close from "~/assets/icons/close.svg";
|
||||
import currencySwap from "~/assets/icons/currency-swap.svg";
|
||||
import pencil from "~/assets/icons/pencil.svg";
|
||||
import { Button, FeesModal, InfoBox, InlineAmount, VStack } from "~/components";
|
||||
import { AmountSmall, BigMoney } from "~/components";
|
||||
import { useI18n } from "~/i18n/context";
|
||||
import { useMegaStore } from "~/state/megaStore";
|
||||
import { DIALOG_CONTENT, DIALOG_POSITIONER } from "~/styles/dialogs";
|
||||
import { Currency, fiatToSats, satsToFiat } from "~/utils";
|
||||
|
||||
// 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);
|
||||
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
import {
|
||||
btcFloatRounding,
|
||||
fiatInputSanitizer,
|
||||
fiatToSats,
|
||||
satsInputSanitizer,
|
||||
satsToFiat,
|
||||
toDisplayHandleNaN
|
||||
} from "~/utils";
|
||||
|
||||
export const AmountEditable: ParentComponent<{
|
||||
initialAmountSats: string;
|
||||
initialOpen: boolean;
|
||||
initialAmountSats: string | bigint;
|
||||
setAmountSats: (s: bigint) => void;
|
||||
showWarnings: boolean;
|
||||
exitRoute?: string;
|
||||
maxAmountSats?: bigint;
|
||||
fee?: string;
|
||||
frozenAmount?: boolean;
|
||||
onSubmit?: () => void;
|
||||
}> = (props) => {
|
||||
const i18n = useI18n();
|
||||
const navigate = useNavigate();
|
||||
const [isOpen, setIsOpen] = createSignal(props.initialOpen);
|
||||
const [state, _actions] = useMegaStore();
|
||||
const [mode, setMode] = createSignal<"fiat" | "sats">("sats");
|
||||
const i18n = useI18n();
|
||||
const [localSats, setLocalSats] = createSignal(
|
||||
props.initialAmountSats || "0"
|
||||
props.initialAmountSats.toString() || "0"
|
||||
);
|
||||
const [localFiat, setLocalFiat] = createSignal(
|
||||
satsToFiat(
|
||||
state.price,
|
||||
parseInt(props.initialAmountSats || "0") || 0,
|
||||
parseInt(props.initialAmountSats.toString() || "0") || 0,
|
||||
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 displayFiat = () =>
|
||||
state.price !== 0 ? toDisplayHandleNaN(localFiat(), state.fiat) : "…";
|
||||
@@ -386,206 +48,11 @@ export const AmountEditable: ParentComponent<{
|
||||
let satsInputRef!: HTMLInputElement;
|
||||
let fiatInputRef!: HTMLInputElement;
|
||||
|
||||
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(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();
|
||||
createEffect(() => {
|
||||
if (focusState() === "focused") {
|
||||
props.setAmountSats(BigInt(localSats()));
|
||||
setLocalFiat(
|
||||
satsToFiat(state.price, Number(localSats()) || 0, state.fiat)
|
||||
);
|
||||
setIsOpen(false);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
function handleSatsInput(e: InputEvent) {
|
||||
const { value } = e.target as HTMLInputElement;
|
||||
@@ -625,6 +92,7 @@ export const AmountEditable: ParentComponent<{
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log("we're in the fiat branch");
|
||||
sane = fiatInputSanitizer(
|
||||
value.replace(",", "."),
|
||||
state.fiat.maxFractionalDigits
|
||||
@@ -640,7 +108,6 @@ export const AmountEditable: ParentComponent<{
|
||||
if (!disabled) {
|
||||
setMode((m) => (m === "sats" ? "fiat" : "sats"));
|
||||
}
|
||||
focus();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
@@ -649,7 +116,7 @@ export const AmountEditable: ParentComponent<{
|
||||
|
||||
function focus() {
|
||||
// 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") {
|
||||
satsInputRef.focus();
|
||||
} 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
|
||||
// Otherwise we just the actual amount they've entered
|
||||
const maxOrLocalSats = () => {
|
||||
if (
|
||||
props.maxAmountSats &&
|
||||
props.fee &&
|
||||
props.maxAmountSats === BigInt(localSats())
|
||||
) {
|
||||
return (
|
||||
Number(props.maxAmountSats) - Number(props.fee)
|
||||
).toLocaleString(navigator.languages[0]);
|
||||
let divRef: HTMLDivElement;
|
||||
|
||||
const [focusState, setFocusState] = createSignal<"focused" | "unfocused">(
|
||||
"focused"
|
||||
);
|
||||
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
// If it was already active, we'll need to toggle
|
||||
if (focusState() === "unfocused") {
|
||||
focus();
|
||||
setFocusState("focused");
|
||||
} else {
|
||||
return localSats();
|
||||
toggle(state.price === 0);
|
||||
focus();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog.Root open={isOpen()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(true)}
|
||||
class="flex items-center gap-2 rounded-xl border-2 border-m-blue px-4 py-2"
|
||||
>
|
||||
<Show
|
||||
when={localSats() !== "0"}
|
||||
fallback={
|
||||
<div class="inline-block font-semibold">
|
||||
{i18n.t("receive.amount_editable.set_amount")}
|
||||
</div>
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (e.target instanceof Element && !divRef.contains(e.target)) {
|
||||
setFocusState("unfocused");
|
||||
}
|
||||
>
|
||||
<InlineAmount amount={maxOrLocalSats()} />
|
||||
</Show>
|
||||
<img src={pencil} alt="Edit" />
|
||||
</button>
|
||||
<Dialog.Portal>
|
||||
<div class={DIALOG_POSITIONER}>
|
||||
<Dialog.Content
|
||||
class={DIALOG_CONTENT}
|
||||
// Should always be on top, even when nested in other dialogs
|
||||
classList={{
|
||||
"z-50": true,
|
||||
// h-device works for android, h-[100dvh] works for ios
|
||||
"h-device": Capacitor.getPlatform() === "android"
|
||||
}}
|
||||
onEscapeKeyDown={handleClose}
|
||||
>
|
||||
<div class="py-2" />
|
||||
};
|
||||
|
||||
<div class="flex w-full justify-end">
|
||||
<button
|
||||
onClick={handleClose}
|
||||
type="button"
|
||||
class="h-8 w-8 rounded-lg hover:bg-white/10 active:bg-m-blue"
|
||||
>
|
||||
<img src={close} alt="Close" />
|
||||
</button>
|
||||
</div>
|
||||
// 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 (
|
||||
<div class="mx-auto flex w-full max-w-[400px] flex-col items-center">
|
||||
<div ref={(el) => (divRef = el)} onMouseDown={handleMouseDown}>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
class="absolute -z-10 opacity-0"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
props.onSubmit
|
||||
? props.onSubmit()
|
||||
: setFocusState("unfocused");
|
||||
}}
|
||||
>
|
||||
<input type="submit" style={{ display: "none" }} />
|
||||
<input
|
||||
id="sats-input"
|
||||
ref={(el) => (satsInputRef = el)}
|
||||
disabled={mode() === "fiat"}
|
||||
disabled={mode() === "fiat" || props.frozenAmount}
|
||||
autofocus={mode() === "sats"}
|
||||
type="text"
|
||||
value={localSats()}
|
||||
onInput={handleSatsInput}
|
||||
inputMode="none"
|
||||
inputMode={"decimal"}
|
||||
autocomplete="off"
|
||||
/>
|
||||
<input
|
||||
id="fiat-input"
|
||||
ref={(el) => (fiatInputRef = el)}
|
||||
disabled={mode() === "sats"}
|
||||
disabled={mode() === "sats" || props.frozenAmount}
|
||||
autofocus={mode() === "fiat"}
|
||||
type="text"
|
||||
value={localFiat()}
|
||||
onInput={handleFiatInput}
|
||||
inputMode="none"
|
||||
inputMode={"decimal"}
|
||||
autocomplete="off"
|
||||
/>
|
||||
</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
|
||||
}
|
||||
<BigMoney
|
||||
mode={mode()}
|
||||
loading={state.price === 0}
|
||||
/>
|
||||
<SmallSubtleAmount
|
||||
text={
|
||||
mode() !== "fiat"
|
||||
? displayFiat()
|
||||
: displaySats()
|
||||
displayFiat={displayFiat()}
|
||||
displaySats={displaySats()}
|
||||
onToggle={() => toggle(state.price === 0)}
|
||||
inputFocused={
|
||||
focusState() === "focused" && !props.frozenAmount
|
||||
}
|
||||
fiat={
|
||||
mode() !== "fiat"
|
||||
? state.fiat
|
||||
: undefined
|
||||
}
|
||||
mode={mode()}
|
||||
loading={state.price === 0}
|
||||
onFocus={() => focus()}
|
||||
/>
|
||||
</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 when={props.maxAmountSats}>
|
||||
<p class="flex gap-2 px-4 py-2 text-sm font-light text-m-grey-400 md:text-base">
|
||||
{`${i18n.t("receive.amount_editable.balance")} `}
|
||||
<AmountSmall amountSats={props.maxAmountSats!} />
|
||||
</p>
|
||||
</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>
|
||||
<div class="flex gap-2 py-4">
|
||||
<Button
|
||||
onClick={() => navigate("/send")}
|
||||
onClick={() => navigate("/search")}
|
||||
disabled={emptyBalance() || props.loading}
|
||||
intent="green"
|
||||
>
|
||||
|
||||
@@ -35,7 +35,7 @@ export function BetaWarningModal() {
|
||||
);
|
||||
}
|
||||
|
||||
export const WarningModal: ParentComponent<{
|
||||
const WarningModal: ParentComponent<{
|
||||
linkText: string;
|
||||
title: string;
|
||||
}> = (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 { A } from "@solidjs/router";
|
||||
import { createSignal, Match, Switch } from "solid-js";
|
||||
|
||||
import close from "~/assets/icons/close.svg";
|
||||
import {
|
||||
ContactForm,
|
||||
ContactFormValues,
|
||||
SmallHeader,
|
||||
TinyButton
|
||||
SimpleDialog,
|
||||
SmallHeader
|
||||
} from "~/components";
|
||||
import { useI18n } from "~/i18n/context";
|
||||
import { DIALOG_CONTENT, DIALOG_POSITIONER } from "~/styles/dialogs";
|
||||
|
||||
export function ContactEditor(props: {
|
||||
createContact: (contact: ContactFormValues) => void;
|
||||
@@ -28,7 +26,7 @@ export function ContactEditor(props: {
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog.Root open={isOpen()}>
|
||||
<>
|
||||
<Switch>
|
||||
<Match when={props.list}>
|
||||
<button
|
||||
@@ -44,34 +42,35 @@ export function ContactEditor(props: {
|
||||
</button>
|
||||
</Match>
|
||||
<Match when={!props.list}>
|
||||
<TinyButton onClick={() => setIsOpen(true)}>
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
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")}
|
||||
</TinyButton>
|
||||
</h2>
|
||||
</button>
|
||||
</Match>
|
||||
</Switch>
|
||||
<Dialog.Portal>
|
||||
<div class={DIALOG_POSITIONER}>
|
||||
<Dialog.Content
|
||||
class={DIALOG_CONTENT}
|
||||
onEscapeKeyDown={() => setIsOpen(false)}
|
||||
>
|
||||
<div class="flex w-full justify-end">
|
||||
<button
|
||||
tabindex="-1"
|
||||
onClick={() => setIsOpen(false)}
|
||||
class="rounded-lg hover:bg-white/10 active:bg-m-blue"
|
||||
>
|
||||
<img src={close} alt="Close" />
|
||||
</button>
|
||||
</div>
|
||||
<ContactForm
|
||||
<SimpleDialog
|
||||
open={isOpen()}
|
||||
setOpen={setIsOpen}
|
||||
title={i18n.t("contacts.new_contact")}
|
||||
>
|
||||
<ContactForm
|
||||
cta={i18n.t("contacts.create_contact")}
|
||||
handleSubmit={handleSubmit}
|
||||
/>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
<A
|
||||
href="/settings/syncnostrcontacts"
|
||||
class="self-center font-semibold text-m-red no-underline active:text-m-red/80"
|
||||
state={{
|
||||
previous: location.pathname
|
||||
}}
|
||||
>
|
||||
Import Nostr Contacts
|
||||
</A>
|
||||
</SimpleDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,33 @@
|
||||
import {
|
||||
createForm,
|
||||
custom,
|
||||
email,
|
||||
required,
|
||||
SubmitHandler
|
||||
} from "@modular-forms/solid";
|
||||
|
||||
import {
|
||||
Button,
|
||||
ContactFormValues,
|
||||
LargeHeader,
|
||||
TextField,
|
||||
VStack
|
||||
} from "~/components";
|
||||
import { Button, ContactFormValues, TextField, VStack } from "~/components";
|
||||
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: {
|
||||
handleSubmit: SubmitHandler<ContactFormValues>;
|
||||
initialValues?: ContactFormValues;
|
||||
title: string;
|
||||
cta: string;
|
||||
}) {
|
||||
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"
|
||||
>
|
||||
<div>
|
||||
<LargeHeader>{props.title}</LargeHeader>
|
||||
<VStack>
|
||||
<Field
|
||||
name="name"
|
||||
@@ -49,7 +58,12 @@ export function ContactForm(props: {
|
||||
</Field>
|
||||
<Field
|
||||
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) => (
|
||||
<TextField
|
||||
@@ -61,6 +75,22 @@ export function ContactForm(props: {
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
<VStack>
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
import { Dialog } from "@kobalte/core";
|
||||
import { SubmitHandler } from "@modular-forms/solid";
|
||||
import { TagItem } from "@mutinywallet/mutiny-wasm";
|
||||
import { useNavigate } from "@solidjs/router";
|
||||
import { createSignal, Match, Show, Switch } from "solid-js";
|
||||
|
||||
import close from "~/assets/icons/close.svg";
|
||||
import {
|
||||
Button,
|
||||
ContactForm,
|
||||
KeyValue,
|
||||
MiniStringShower,
|
||||
showToast,
|
||||
SimpleDialog,
|
||||
SmallHeader,
|
||||
VStack
|
||||
} from "~/components";
|
||||
import { useI18n } from "~/i18n/context";
|
||||
import { toParsedParams } from "~/logic/waila";
|
||||
import { useMegaStore } from "~/state/megaStore";
|
||||
import { DIALOG_CONTENT, DIALOG_POSITIONER } from "~/styles/dialogs";
|
||||
|
||||
export type ContactFormValues = {
|
||||
name: string;
|
||||
@@ -71,7 +69,7 @@ export function ContactViewer(props: {
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog.Root open={isOpen()}>
|
||||
<>
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
class="flex w-16 flex-shrink-0 flex-col items-center gap-2 overflow-x-hidden"
|
||||
@@ -91,31 +89,14 @@ export function ContactViewer(props: {
|
||||
{props.contact.name}
|
||||
</SmallHeader>
|
||||
</button>
|
||||
<Dialog.Portal>
|
||||
<div class={DIALOG_POSITIONER}>
|
||||
<Dialog.Content
|
||||
class={DIALOG_CONTENT}
|
||||
onEscapeKeyDown={() => {
|
||||
setIsOpen(false);
|
||||
setIsEditing(false);
|
||||
}}
|
||||
<SimpleDialog
|
||||
open={isOpen()}
|
||||
setOpen={setIsOpen}
|
||||
title={isEditing() ? i18n.t("contacts.edit_contact") : ""}
|
||||
>
|
||||
<div class="flex w-full justify-end">
|
||||
<button
|
||||
tabindex="-1"
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
setIsEditing(false);
|
||||
}}
|
||||
class="rounded-lg hover:bg-white/10 active:bg-m-blue"
|
||||
>
|
||||
<img src={close} alt="Close" />
|
||||
</button>
|
||||
</div>
|
||||
<Switch>
|
||||
<Match when={isEditing()}>
|
||||
<ContactForm
|
||||
title={i18n.t("contacts.edit_contact")}
|
||||
cta={i18n.t("contacts.save_contact")}
|
||||
handleSubmit={handleSubmit}
|
||||
initialValues={props.contact}
|
||||
@@ -131,16 +112,9 @@ export function ContactViewer(props: {
|
||||
}}
|
||||
>
|
||||
<Switch>
|
||||
<Match
|
||||
when={
|
||||
props.contact.image_url
|
||||
}
|
||||
>
|
||||
<Match when={props.contact.image_url}>
|
||||
<img
|
||||
src={
|
||||
props.contact
|
||||
.image_url
|
||||
}
|
||||
src={props.contact.image_url}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
@@ -158,18 +132,11 @@ export function ContactViewer(props: {
|
||||
<Show when={props.contact.npub}>
|
||||
<KeyValue key={"Npub"}>
|
||||
<MiniStringShower
|
||||
text={
|
||||
props.contact
|
||||
.npub!
|
||||
}
|
||||
text={props.contact.npub!}
|
||||
/>
|
||||
</KeyValue>
|
||||
</Show>
|
||||
<Show
|
||||
when={
|
||||
props.contact.ln_address
|
||||
}
|
||||
>
|
||||
<Show when={props.contact.ln_address}>
|
||||
<KeyValue
|
||||
key={i18n.t(
|
||||
"contacts.lightning_address"
|
||||
@@ -185,22 +152,7 @@ export function ContactViewer(props: {
|
||||
</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"
|
||||
@@ -223,9 +175,7 @@ export function ContactViewer(props: {
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
</SimpleDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Title } from "@solidjs/meta";
|
||||
import { A } from "@solidjs/router";
|
||||
import { onMount } from "solid-js";
|
||||
|
||||
import { ExternalLink } from "~/components";
|
||||
import {
|
||||
@@ -23,6 +24,9 @@ export function SimpleErrorDisplay(props: { error: Error }) {
|
||||
|
||||
export function ErrorDisplay(props: { error: Error }) {
|
||||
const i18n = useI18n();
|
||||
onMount(() => {
|
||||
console.error(props.error);
|
||||
});
|
||||
return (
|
||||
<SafeArea>
|
||||
<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 {
|
||||
CopyButton,
|
||||
DIALOG_CONTENT,
|
||||
DIALOG_POSITIONER,
|
||||
ModalCloseButton,
|
||||
OVERLAY,
|
||||
SmallHeader
|
||||
} from "~/components";
|
||||
import { CopyButton, SimpleDialog } from "~/components";
|
||||
import { useI18n } from "~/i18n/context";
|
||||
|
||||
export function JsonModal(props: {
|
||||
@@ -25,34 +17,18 @@ export function JsonModal(props: {
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog.Root open={props.open} onOpenChange={props.setOpen}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class={OVERLAY} />
|
||||
<div class={DIALOG_POSITIONER}>
|
||||
<Dialog.Content class={DIALOG_CONTENT}>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<Dialog.Title>
|
||||
<SmallHeader>{props.title}</SmallHeader>
|
||||
</Dialog.Title>
|
||||
<Dialog.CloseButton>
|
||||
<ModalCloseButton />
|
||||
</Dialog.CloseButton>
|
||||
</div>
|
||||
<Dialog.Description class="flex flex-col items-center gap-4">
|
||||
<SimpleDialog
|
||||
title={props.title}
|
||||
open={props.open}
|
||||
setOpen={props.setOpen}
|
||||
>
|
||||
<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>
|
||||
<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 class="self-center">
|
||||
<CopyButton title={i18n.t("common.copy")} text={json()} />
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
</SimpleDialog>
|
||||
);
|
||||
}
|
||||
|
||||
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 { useMegaStore } from "~/state/megaStore";
|
||||
|
||||
export function LoadingBar(props: { value: number; max: number }) {
|
||||
function LoadingBar(props: { value: number; max: number }) {
|
||||
const i18n = useI18n();
|
||||
function valueToStage(value: number) {
|
||||
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 help from "~/assets/icons/help.svg";
|
||||
import {
|
||||
DIALOG_CONTENT,
|
||||
DIALOG_POSITIONER,
|
||||
ExternalLink,
|
||||
ModalCloseButton,
|
||||
OVERLAY,
|
||||
SmallHeader
|
||||
} from "~/components";
|
||||
import { ExternalLink, SimpleDialog } from "~/components";
|
||||
import { useI18n } from "~/i18n/context";
|
||||
|
||||
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;
|
||||
title: string;
|
||||
}> = (props) => {
|
||||
const [open, setOpen] = createSignal(false);
|
||||
|
||||
return (
|
||||
<Dialog.Root open={open()} onOpenChange={setOpen}>
|
||||
<Dialog.Trigger>
|
||||
<button class="font-semibold underline decoration-light-text hover:decoration-white">
|
||||
<>
|
||||
<button
|
||||
tabIndex={-1}
|
||||
onClick={() => setOpen(true)}
|
||||
class="font-semibold underline decoration-light-text hover:decoration-white"
|
||||
>
|
||||
{props.linkText}
|
||||
</button>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class={OVERLAY} />
|
||||
<div class={DIALOG_POSITIONER}>
|
||||
<Dialog.Content class={DIALOG_CONTENT}>
|
||||
<Dialog.Title class="mb-2 flex items-center justify-between">
|
||||
<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>
|
||||
<SimpleDialog open={open()} setOpen={setOpen} title={props.title}>
|
||||
{props.children}
|
||||
</SimpleDialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ import forward from "~/assets/icons/forward.svg";
|
||||
import { useI18n } from "~/i18n/context";
|
||||
import { useMegaStore } from "~/state/megaStore";
|
||||
|
||||
export const CtaCard: ParentComponent = (props) => {
|
||||
const CtaCard: ParentComponent = (props) => {
|
||||
return (
|
||||
<div class="w-full">
|
||||
<div class="relative">
|
||||
|
||||
@@ -34,7 +34,7 @@ import { useMegaStore } from "~/state/megaStore";
|
||||
import { fetchNostrProfile } from "~/utils";
|
||||
|
||||
type BudgetInterval = "Day" | "Week" | "Month" | "Year";
|
||||
export type BudgetForm = {
|
||||
type BudgetForm = {
|
||||
connection_name: string;
|
||||
auto_approve: boolean;
|
||||
budget_amount: string; // modular forms doesn't like bigint
|
||||
@@ -406,14 +406,12 @@ function NWCEditorForm(props: {
|
||||
<TinyText>
|
||||
{i18n.t("settings.connections.careful")}
|
||||
</TinyText>
|
||||
<KeyValue key={i18n.t("settings.connections.budget")}>
|
||||
|
||||
<Field name="budget_amount">
|
||||
{(field, _fieldProps) => (
|
||||
<div class="flex flex-col items-end gap-2">
|
||||
<Show
|
||||
when={
|
||||
props.budgetMode === "editable"
|
||||
}
|
||||
when={props.budgetMode === "editable"}
|
||||
fallback={
|
||||
<AmountSats
|
||||
amountSats={
|
||||
@@ -423,11 +421,9 @@ function NWCEditorForm(props: {
|
||||
}
|
||||
>
|
||||
<AmountEditable
|
||||
initialOpen={false}
|
||||
initialAmountSats={
|
||||
field.value || "0"
|
||||
}
|
||||
showWarnings={false}
|
||||
setAmountSats={(a) => {
|
||||
setValue(
|
||||
budgetForm,
|
||||
@@ -443,7 +439,6 @@ function NWCEditorForm(props: {
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
</KeyValue>
|
||||
<KeyValue
|
||||
key={i18n.t("settings.connections.resets_every")}
|
||||
>
|
||||
|
||||
@@ -49,7 +49,7 @@ export function NavBar(props: { activeTab: ActiveTab }) {
|
||||
alt="home"
|
||||
/>
|
||||
<NavBarItem
|
||||
href="/send"
|
||||
href="/search"
|
||||
icon={airplane}
|
||||
active={props.activeTab === "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 =
|
||||
"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: {
|
||||
receiveString: string;
|
||||
whiteBg?: boolean;
|
||||
}) {
|
||||
function ShareButton(props: { receiveString: string; whiteBg?: boolean }) {
|
||||
const i18n = useI18n();
|
||||
async function share(receiveString: string) {
|
||||
// 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")}
|
||||
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} />
|
||||
<button class="w-[2rem]" onClick={() => setOpen(true)}>
|
||||
<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 { eify } from "~/utils";
|
||||
|
||||
export type NostrContactsForm = {
|
||||
type NostrContactsForm = {
|
||||
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;
|
||||
title: string;
|
||||
description: string;
|
||||
|
||||
@@ -5,7 +5,6 @@ export * from "./Activity";
|
||||
export * from "./ActivityDetailsModal";
|
||||
export * from "./ActivityItem";
|
||||
export * from "./Amount";
|
||||
export * from "./AmountCard";
|
||||
export * from "./AmountEditable";
|
||||
export * from "./BalanceBox";
|
||||
export * from "./BetaWarningModal";
|
||||
@@ -38,7 +37,6 @@ export * from "./ResyncOnchain";
|
||||
export * from "./SeedWords";
|
||||
export * from "./SetupErrorDisplay";
|
||||
export * from "./ShareCard";
|
||||
export * from "./TagEditor";
|
||||
export * from "./Toaster";
|
||||
export * from "./NostrActivity";
|
||||
export * from "./SyncContactsForm";
|
||||
@@ -47,3 +45,9 @@ export * from "./MutinyPlusCta";
|
||||
export * from "./ToggleHodl";
|
||||
export * from "./IOSbanner";
|
||||
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 { JSXElement } from "solid-js";
|
||||
|
||||
import { BackButton } from "~/components";
|
||||
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
|
||||
|
||||
export type CommonButtonStyleProps = {
|
||||
type CommonButtonStyleProps = {
|
||||
intent?: "active" | "inactive" | "blue" | "red" | "green" | "text";
|
||||
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 (
|
||||
<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}
|
||||
{/* CSS is hard sometimes */}
|
||||
<div class="py-1" />
|
||||
{/* <div class="py-1" /> */}
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export const FullscreenLoader = () => {
|
||||
const FullscreenLoader = () => {
|
||||
const i18n = useI18n();
|
||||
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) => {
|
||||
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";
|
||||
export const SIMPLE_DIALOG_POSITIONER =
|
||||
const SIMPLE_OVERLAY = "fixed inset-0 z-50 bg-black/50 backdrop-blur-lg";
|
||||
const SIMPLE_DIALOG_POSITIONER =
|
||||
"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";
|
||||
|
||||
export const SimpleDialog: ParentComponent<{
|
||||
@@ -355,11 +341,12 @@ export const SimpleDialog: ParentComponent<{
|
||||
<Dialog.Root
|
||||
open={props.open}
|
||||
onOpenChange={props.setOpen && props.setOpen}
|
||||
modal={true}
|
||||
>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class={SIMPLE_OVERLAY} />
|
||||
<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">
|
||||
<Dialog.Title>
|
||||
<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 "./BackPop";
|
||||
export * from "./Button";
|
||||
export * from "./Linkify";
|
||||
export * from "./Misc";
|
||||
export * from "./ProgressBar";
|
||||
export * from "./Radio";
|
||||
export * from "./TextField";
|
||||
export * from "./ExternalLink";
|
||||
|
||||
@@ -42,7 +42,10 @@ export default {
|
||||
unimplemented: "Unimplemented",
|
||||
not_available: "We don't do that yet",
|
||||
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_bitcoin: "Receive Bitcoin",
|
||||
@@ -81,7 +84,7 @@ export default {
|
||||
"Something went wrong when creating the on-chain address",
|
||||
amount_editable: {
|
||||
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:
|
||||
"A lightning setup fee will be charged if paid over lightning.",
|
||||
too_big_for_beta:
|
||||
@@ -94,7 +97,8 @@ export default {
|
||||
one_hundred_k: "100k",
|
||||
one_million: "1m"
|
||||
},
|
||||
del: "DEL"
|
||||
del: "DEL",
|
||||
balance: "Balance"
|
||||
},
|
||||
integrated_qr: {
|
||||
onchain: "On-chain",
|
||||
@@ -102,7 +106,8 @@ export default {
|
||||
unified: "Unified",
|
||||
gift: "Lightning Gift"
|
||||
},
|
||||
remember_choice: "Remember my choice next time"
|
||||
remember_choice: "Remember my choice next time",
|
||||
what_for: "What's this for?"
|
||||
},
|
||||
send: {
|
||||
sending: "Sending...",
|
||||
@@ -119,11 +124,13 @@ export default {
|
||||
of: "of",
|
||||
sats_sent: "sats sent"
|
||||
},
|
||||
what_for: "What's this for?",
|
||||
error_low_balance:
|
||||
"We do not have enough balance to pay the given amount.",
|
||||
error_invoice_match:
|
||||
"Amount requested, {{amount}} SATS, does not equal amount set.",
|
||||
error_channel_reserves: "Not enough available funds.",
|
||||
error_address: "Invalid Lightning Address",
|
||||
error_channel_reserves_explained:
|
||||
"A portion of your channel balance is reserved for fees. Try sending a smaller amount or adding funds.",
|
||||
error_clipboard: "Clipboard not supported",
|
||||
|
||||
@@ -18,6 +18,7 @@ export type ParsedParams = {
|
||||
nostr_wallet_auth?: string;
|
||||
fedimint_invite?: string;
|
||||
is_lnurl_auth?: boolean;
|
||||
contact_id?: string;
|
||||
};
|
||||
|
||||
export function toParsedParams(
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
body {
|
||||
@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 {
|
||||
@@ -12,6 +12,10 @@ html {
|
||||
@apply bg-neutral-900;
|
||||
}
|
||||
|
||||
#root {
|
||||
@apply flex flex-1 flex-col;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
/* we don't support this but I want the browser to know I care */
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
NotFound,
|
||||
Receive,
|
||||
Scanner,
|
||||
Search,
|
||||
Send,
|
||||
Swap
|
||||
} from "~/routes";
|
||||
@@ -100,6 +101,7 @@ export function Router() {
|
||||
<Route path="/scanner" component={Scanner} />
|
||||
<Route path="/send" component={Send} />
|
||||
<Route path="/swap" component={Swap} />
|
||||
<Route path="/search" component={Search} />
|
||||
<Route path="/settings">
|
||||
<Route path="/" component={Settings} />
|
||||
<Route path="/admin" component={Admin} />
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
} from "~/components";
|
||||
import { useI18n } from "~/i18n/context";
|
||||
import { useMegaStore } from "~/state/megaStore";
|
||||
import { eify, gradientsPerContact } from "~/utils";
|
||||
import { eify, gradientsPerContact, hexpubFromNpub } from "~/utils";
|
||||
|
||||
function ContactRow() {
|
||||
const [state, _actions] = useMegaStore();
|
||||
@@ -73,6 +73,8 @@ function ContactRow() {
|
||||
|
||||
//
|
||||
async function saveContact(id: string, contact: ContactFormValues) {
|
||||
console.log("saving contact", id, contact);
|
||||
const hexpub = await hexpubFromNpub(contact.npub?.trim());
|
||||
try {
|
||||
const existing = state.mutiny_wallet?.get_tag_item(id);
|
||||
// This shouldn't happen
|
||||
@@ -80,7 +82,7 @@ function ContactRow() {
|
||||
await state.mutiny_wallet?.edit_contact(
|
||||
id,
|
||||
contact.name,
|
||||
contact.npub ? contact.npub.trim() : undefined,
|
||||
hexpub ? hexpub : undefined,
|
||||
contact.ln_address ? contact.ln_address.trim() : undefined,
|
||||
existing.lnurl,
|
||||
existing.image_url
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import {
|
||||
MutinyBip21RawMaterials,
|
||||
MutinyInvoice,
|
||||
TagItem
|
||||
MutinyInvoice
|
||||
} from "@mutinywallet/mutiny-wasm";
|
||||
import { useNavigate } from "@solidjs/router";
|
||||
import {
|
||||
@@ -20,13 +19,12 @@ import {
|
||||
import side2side from "~/assets/icons/side-to-side.svg";
|
||||
import {
|
||||
ActivityDetailsModal,
|
||||
AmountCard,
|
||||
AmountEditable,
|
||||
AmountFiat,
|
||||
AmountSats,
|
||||
BackButton,
|
||||
BackLink,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
DefaultMain,
|
||||
Fee,
|
||||
@@ -39,12 +37,12 @@ import {
|
||||
MegaCheck,
|
||||
MutinyWalletGuard,
|
||||
NavBar,
|
||||
SafeArea,
|
||||
ReceiveWarnings,
|
||||
showToast,
|
||||
SimpleDialog,
|
||||
SimpleInput,
|
||||
StyledRadioGroup,
|
||||
SuccessModal,
|
||||
TagEditor,
|
||||
VStack
|
||||
} from "~/components";
|
||||
import { useI18n } from "~/i18n/context";
|
||||
@@ -111,18 +109,15 @@ export function Receive() {
|
||||
const navigate = useNavigate();
|
||||
const i18n = useI18n();
|
||||
|
||||
const [amount, setAmount] = createSignal("");
|
||||
const [amount, setAmount] = createSignal<bigint>(0n);
|
||||
const [whatForInput, setWhatForInput] = createSignal("");
|
||||
|
||||
const [receiveState, setReceiveState] = createSignal<ReceiveState>("edit");
|
||||
const [bip21Raw, setBip21Raw] = createSignal<MutinyBip21RawMaterials>();
|
||||
const [unified, setUnified] = createSignal("");
|
||||
const [shouldShowAmountEditor, setShouldShowAmountEditor] =
|
||||
createSignal(true);
|
||||
|
||||
const [lspFee, setLspFee] = createSignal(0n);
|
||||
|
||||
// Tagging stuff
|
||||
const [selectedValues, setSelectedValues] = createSignal<TagItem[]>([]);
|
||||
|
||||
// The data we get after a payment
|
||||
const [paymentTx, setPaymentTx] = createSignal<OnChainTx>();
|
||||
const [paymentInvoice, setPaymentInvoice] = createSignal<MutinyInvoice>();
|
||||
@@ -174,13 +169,12 @@ export function Receive() {
|
||||
});
|
||||
|
||||
function clearAll() {
|
||||
setAmount("");
|
||||
setAmount(0n);
|
||||
setReceiveState("edit");
|
||||
setBip21Raw(undefined);
|
||||
setUnified("");
|
||||
setPaymentTx(undefined);
|
||||
setPaymentInvoice(undefined);
|
||||
setSelectedValues([]);
|
||||
}
|
||||
|
||||
function openDetailsModal() {
|
||||
@@ -207,39 +201,8 @@ export function Receive() {
|
||||
setDetailsOpen(true);
|
||||
}
|
||||
|
||||
async function processContacts(
|
||||
contacts: Partial<TagItem>[]
|
||||
): 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) {
|
||||
async function getUnifiedQr(amount: bigint) {
|
||||
console.log("get unified amount", amount);
|
||||
const bigAmount = BigInt(amount);
|
||||
setLoading(true);
|
||||
|
||||
@@ -247,7 +210,7 @@ export function Receive() {
|
||||
let tags;
|
||||
|
||||
try {
|
||||
tags = await processContacts(selectedValues());
|
||||
tags = whatForInput() ? [whatForInput().trim()] : [];
|
||||
} catch (e) {
|
||||
showToast(eify(e));
|
||||
console.error(e);
|
||||
@@ -258,6 +221,7 @@ export function Receive() {
|
||||
// Happy path
|
||||
// First we try to get both an invoice and an address
|
||||
try {
|
||||
console.log("big amount", bigAmount);
|
||||
const raw = await state.mutiny_wallet?.create_bip21(
|
||||
bigAmount,
|
||||
tags
|
||||
@@ -265,6 +229,8 @@ export function Receive() {
|
||||
// Save the raw info so we can watch the address and invoice
|
||||
setBip21Raw(raw);
|
||||
|
||||
console.log("raw", raw);
|
||||
|
||||
const params = objectToSearchParams({
|
||||
amount: raw?.btc_amount,
|
||||
lightning: raw?.invoice
|
||||
@@ -305,11 +271,16 @@ export function Receive() {
|
||||
async function onSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
|
||||
await getQr();
|
||||
}
|
||||
|
||||
async function getQr() {
|
||||
if (amount()) {
|
||||
const unifiedQr = await getUnifiedQr(amount());
|
||||
|
||||
setUnified(unifiedQr || "");
|
||||
setReceiveState("show");
|
||||
setShouldShowAmountEditor(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkIfPaid(
|
||||
@@ -378,12 +349,8 @@ export function Receive() {
|
||||
|
||||
return (
|
||||
<MutinyWalletGuard>
|
||||
<SafeArea>
|
||||
<DefaultMain>
|
||||
<Show
|
||||
when={receiveState() === "show"}
|
||||
fallback={<BackLink />}
|
||||
>
|
||||
<Show when={receiveState() === "show"} fallback={<BackLink />}>
|
||||
<BackButton
|
||||
onClick={() => setReceiveState("edit")}
|
||||
title={i18n.t("receive.edit")}
|
||||
@@ -393,9 +360,7 @@ export function Receive() {
|
||||
<LargeHeader
|
||||
action={
|
||||
receiveState() === "show" && (
|
||||
<Indicator>
|
||||
{i18n.t("receive.checking")}
|
||||
</Indicator>
|
||||
<Indicator>{i18n.t("receive.checking")}</Indicator>
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -403,28 +368,27 @@ export function Receive() {
|
||||
</LargeHeader>
|
||||
<Switch>
|
||||
<Match when={!unified() || receiveState() === "edit"}>
|
||||
<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>
|
||||
<AmountEditable
|
||||
initialAmountSats={amount() || "0"}
|
||||
setAmountSats={setAmount}
|
||||
onSubmit={getQr}
|
||||
/>
|
||||
<ReceiveWarnings amountSats={amount() || "0"} />
|
||||
</VStack>
|
||||
<div class="flex-1" />
|
||||
<VStack>
|
||||
<form onSubmit={onSubmit}>
|
||||
<SimpleInput
|
||||
type="text"
|
||||
value={whatForInput()}
|
||||
placeholder={i18n.t("receive.what_for")}
|
||||
onInput={(e) =>
|
||||
setWhatForInput(e.currentTarget.value)
|
||||
}
|
||||
/>
|
||||
</form>
|
||||
<Button
|
||||
disabled={!amount()}
|
||||
intent="green"
|
||||
@@ -434,7 +398,6 @@ export function Receive() {
|
||||
{i18n.t("common.continue")}
|
||||
</Button>
|
||||
</VStack>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={unified() && receiveState() === "show"}>
|
||||
<FeeWarning fee={lspFee()} flavor={flavor()} />
|
||||
@@ -445,7 +408,7 @@ export function Receive() {
|
||||
</Show>
|
||||
<IntegratedQr
|
||||
value={receiveString() ?? ""}
|
||||
amountSats={amount() || "0"}
|
||||
amountSats={amount() ? amount().toString() : "0"}
|
||||
kind={flavor()}
|
||||
/>
|
||||
<p class="text-center text-neutral-400">
|
||||
@@ -457,19 +420,13 @@ export function Receive() {
|
||||
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>
|
||||
<span>{i18n.t("receive.choose_format")}</span>
|
||||
<img class="h-4 w-4" src={side2side} />
|
||||
</button>
|
||||
<SimpleDialog
|
||||
title={i18n.t(
|
||||
"receive.choose_payment_format"
|
||||
)}
|
||||
title={i18n.t("receive.choose_payment_format")}
|
||||
open={methodChooserOpen()}
|
||||
setOpen={(open) =>
|
||||
setMethodChooserOpen(open)
|
||||
}
|
||||
setOpen={(open) => setMethodChooserOpen(open)}
|
||||
>
|
||||
<StyledRadioGroup
|
||||
initialValue={flavor()}
|
||||
@@ -480,9 +437,7 @@ export function Receive() {
|
||||
delayOnChange
|
||||
/>
|
||||
<Checkbox
|
||||
label={i18n.t(
|
||||
"receive.remember_choice"
|
||||
)}
|
||||
label={i18n.t("receive.remember_choice")}
|
||||
checked={rememberChoice()}
|
||||
onChange={setRememberChoice}
|
||||
/>
|
||||
@@ -521,8 +476,7 @@ export function Receive() {
|
||||
amountSats={
|
||||
receiveState() === "paid" &&
|
||||
paidState() === "lightning_paid"
|
||||
? paymentInvoice()
|
||||
?.amount_sats
|
||||
? paymentInvoice()?.amount_sats
|
||||
: paymentTx()?.received
|
||||
}
|
||||
icon="plus"
|
||||
@@ -533,8 +487,7 @@ export function Receive() {
|
||||
amountSats={
|
||||
receiveState() === "paid" &&
|
||||
paidState() === "lightning_paid"
|
||||
? paymentInvoice()
|
||||
?.amount_sats
|
||||
? paymentInvoice()?.amount_sats
|
||||
: paymentTx()?.received
|
||||
}
|
||||
denominationSize="sm"
|
||||
@@ -564,7 +517,6 @@ export function Receive() {
|
||||
</Switch>
|
||||
</DefaultMain>
|
||||
<NavBar activeTab="receive" />
|
||||
</SafeArea>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +1,46 @@
|
||||
import { Clipboard } from "@capacitor/clipboard";
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import { MutinyInvoice, TagItem } from "@mutinywallet/mutiny-wasm";
|
||||
import { MutinyInvoice } from "@mutinywallet/mutiny-wasm";
|
||||
import { A, useNavigate, useSearchParams } from "@solidjs/router";
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
createSignal,
|
||||
JSX,
|
||||
Match,
|
||||
onMount,
|
||||
Show,
|
||||
Suspense,
|
||||
Switch
|
||||
} from "solid-js";
|
||||
|
||||
import { Paste } from "~/assets/svg/Paste";
|
||||
import { Scan } from "~/assets/svg/Scan";
|
||||
import bolt from "~/assets/icons/bolt.svg";
|
||||
import chain from "~/assets/icons/chain.svg";
|
||||
import close from "~/assets/icons/close.svg";
|
||||
import {
|
||||
ActivityDetailsModal,
|
||||
AmountCard,
|
||||
AmountEditable,
|
||||
AmountFiat,
|
||||
AmountSats,
|
||||
BackButton,
|
||||
BackLink,
|
||||
BackPop,
|
||||
Button,
|
||||
ButtonLink,
|
||||
Card,
|
||||
DefaultMain,
|
||||
Fee,
|
||||
GiftLink,
|
||||
FeeDisplay,
|
||||
HackActivityType,
|
||||
HStack,
|
||||
InfoBox,
|
||||
LargeHeader,
|
||||
LabelCircle,
|
||||
LoadingShimmer,
|
||||
MegaCheck,
|
||||
MegaClock,
|
||||
MegaEx,
|
||||
MutinyWalletGuard,
|
||||
NavBar,
|
||||
SafeArea,
|
||||
showToast,
|
||||
SimpleInput,
|
||||
SmallHeader,
|
||||
StringShower,
|
||||
StyledRadioGroup,
|
||||
SuccessModal,
|
||||
TagEditor,
|
||||
UnstyledBackPop,
|
||||
VStack
|
||||
} from "~/components";
|
||||
import { useI18n } from "~/i18n/context";
|
||||
@@ -65,115 +63,6 @@ type SentDetails = {
|
||||
fee_estimate?: bigint | number;
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function DestinationInput(props: {
|
||||
fieldDestination: string;
|
||||
setFieldDestination: (destination: string) => void;
|
||||
handleDecode: () => void;
|
||||
handlePaste: () => void;
|
||||
}) {
|
||||
const i18n = useI18n();
|
||||
return (
|
||||
<VStack>
|
||||
<SmallHeader>{i18n.t("send.destination")}</SmallHeader>
|
||||
<textarea
|
||||
value={props.fieldDestination}
|
||||
onInput={(e) => {
|
||||
const trim = e.currentTarget.value.trim();
|
||||
props.setFieldDestination(trim);
|
||||
}}
|
||||
placeholder="bitcoin:..."
|
||||
class="rounded-lg bg-white/10 p-2 placeholder-neutral-400"
|
||||
/>
|
||||
<Button
|
||||
disabled={!props.fieldDestination}
|
||||
intent="blue"
|
||||
onClick={props.handleDecode}
|
||||
>
|
||||
{i18n.t("common.continue")}
|
||||
</Button>
|
||||
<HStack>
|
||||
<Button onClick={props.handlePaste}>
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<Paste />
|
||||
<span>{i18n.t("send.paste")}</span>
|
||||
</div>
|
||||
</Button>
|
||||
<ButtonLink href="/scanner">
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<Scan />
|
||||
<span>{i18n.t("send.scan_qr")}</span>
|
||||
</div>
|
||||
</ButtonLink>
|
||||
</HStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function DestinationShower(props: {
|
||||
source: SendSource;
|
||||
description?: string;
|
||||
@@ -182,25 +71,95 @@ function DestinationShower(props: {
|
||||
nodePubkey?: string;
|
||||
lnurl?: string;
|
||||
lightning_address?: string;
|
||||
clearAll: () => void;
|
||||
contact_id?: string;
|
||||
}) {
|
||||
const [state, _actions] = useMegaStore();
|
||||
|
||||
async function getContact(id: string) {
|
||||
console.log("fetching contact", id);
|
||||
try {
|
||||
const contact = state.mutiny_wallet?.get_tag_item(id);
|
||||
console.log("fetching contact", contact);
|
||||
// This shouldn't happen
|
||||
if (!contact) throw new Error("Contact not found");
|
||||
return contact;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showToast(eify(e));
|
||||
}
|
||||
}
|
||||
|
||||
const [contact] = createResource(() => props.contact_id, getContact);
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={contact.latest}>
|
||||
<DestinationItem
|
||||
title={contact()?.name || ""}
|
||||
value={contact()?.ln_address}
|
||||
icon={
|
||||
<LabelCircle
|
||||
name={contact()?.name || ""}
|
||||
image_url={contact()?.image_url}
|
||||
contact
|
||||
label={false}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.address && props.source === "onchain"}>
|
||||
<StringShower text={props.address || ""} />
|
||||
<DestinationItem
|
||||
title="On-chain"
|
||||
value={<StringShower text={props.address || ""} />}
|
||||
icon={
|
||||
<img
|
||||
class="h-[1rem] w-[1rem]"
|
||||
src={chain}
|
||||
alt="blockchain"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.invoice && props.source === "lightning"}>
|
||||
<StringShower text={props.invoice?.bolt11 || ""} />
|
||||
</Match>
|
||||
<Match when={props.nodePubkey && props.source === "lightning"}>
|
||||
<StringShower text={props.nodePubkey || ""} />
|
||||
<DestinationItem
|
||||
title="Lightning"
|
||||
value={<StringShower text={props.invoice?.bolt11 || ""} />}
|
||||
icon={
|
||||
<img
|
||||
class="h-[1rem] w-[1rem]"
|
||||
src={bolt}
|
||||
alt="lightning"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Match>
|
||||
<Match
|
||||
when={props.lightning_address && props.source === "lightning"}
|
||||
>
|
||||
<span class="overflow-hidden overflow-ellipsis whitespace-nowrap font-mono">
|
||||
{props.lightning_address || ""}
|
||||
</span>
|
||||
<DestinationItem
|
||||
title="Lightning"
|
||||
value={props.lightning_address || ""}
|
||||
icon={
|
||||
<img
|
||||
class="h-[1rem] w-[1rem]"
|
||||
src={bolt}
|
||||
alt="lightning"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.nodePubkey && props.source === "lightning"}>
|
||||
<DestinationItem
|
||||
title="Lightning"
|
||||
value={<StringShower text={props.nodePubkey || ""} />}
|
||||
icon={
|
||||
<img
|
||||
class="h-[1rem] w-[1rem]"
|
||||
src={bolt}
|
||||
alt="lightning"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Match>
|
||||
<Match
|
||||
when={
|
||||
@@ -209,12 +168,43 @@ function DestinationShower(props: {
|
||||
props.source === "lightning"
|
||||
}
|
||||
>
|
||||
<StringShower text={props.lnurl || ""} />
|
||||
<DestinationItem
|
||||
title="Lightning"
|
||||
value={<StringShower text={props.lnurl || ""} />}
|
||||
icon={
|
||||
<img
|
||||
class="h-[1rem] w-[1rem]"
|
||||
src={bolt}
|
||||
alt="lightning"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
function DestinationItem(props: {
|
||||
title: string;
|
||||
value: JSX.Element;
|
||||
icon: JSX.Element;
|
||||
}) {
|
||||
return (
|
||||
<div class="grid grid-cols-[auto_minmax(0,_1fr)_minmax(0,_max-content)] items-center gap-2 rounded-xl bg-neutral-800 p-2">
|
||||
{props.icon}
|
||||
<div class="flex flex-col gap-1">
|
||||
<SmallHeader>{props.title}</SmallHeader>
|
||||
<div class="text-sm text-neutral-500">{props.value}</div>
|
||||
</div>
|
||||
<UnstyledBackPop>
|
||||
<div class="h-8 w-8 rounded-full bg-m-grey-800 px-1 py-1">
|
||||
<img src={close} alt="Clear" class="h-6 w-6" />
|
||||
</div>
|
||||
</UnstyledBackPop>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Failure(props: { reason: string }) {
|
||||
const i18n = useI18n();
|
||||
|
||||
@@ -257,34 +247,28 @@ export function Send() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const i18n = useI18n();
|
||||
|
||||
// These can only be set by the user
|
||||
const [fieldDestination, setFieldDestination] = createSignal("");
|
||||
const [destination, setDestination] = createSignal<ParsedParams>();
|
||||
const [amountInput, setAmountInput] = createSignal("");
|
||||
const [whatForInput, setWhatForInput] = createSignal("");
|
||||
|
||||
// These can be derived from the "destination" signal or set by the user
|
||||
// These can be derived from the destination or set by the user
|
||||
const [amountSats, setAmountSats] = createSignal(0n);
|
||||
|
||||
// These are derived from the incoming destination
|
||||
const [isAmtEditable, setIsAmtEditable] = createSignal(true);
|
||||
const [source, setSource] = createSignal<SendSource>("lightning");
|
||||
|
||||
// These can only be derived from the "destination" signal
|
||||
const [invoice, setInvoice] = createSignal<MutinyInvoice>();
|
||||
const [nodePubkey, setNodePubkey] = createSignal<string>();
|
||||
const [lnurlp, setLnurlp] = createSignal<string>();
|
||||
const [lnAddress, setLnAddress] = createSignal<string>();
|
||||
const [address, setAddress] = createSignal<string>();
|
||||
const [description, setDescription] = createSignal<string>();
|
||||
|
||||
const [contactId, setContactId] = createSignal<string>();
|
||||
const [isHodlInvoice, setIsHodlInvoice] = createSignal<boolean>(false);
|
||||
|
||||
// Is sending / sent
|
||||
const [sending, setSending] = createSignal(false);
|
||||
const [sentDetails, setSentDetails] = createSignal<SentDetails>();
|
||||
|
||||
// Tagging stuff
|
||||
const [selectedContacts, setSelectedContacts] = createSignal<
|
||||
Partial<TagItem>[]
|
||||
>([]);
|
||||
|
||||
// Details Modal
|
||||
const [detailsOpen, setDetailsOpen] = createSignal(false);
|
||||
const [detailsKind, setDetailsKind] = createSignal<HackActivityType>();
|
||||
@@ -293,21 +277,6 @@ export function Send() {
|
||||
// Errors
|
||||
const [error, setError] = createSignal<string>();
|
||||
|
||||
function clearAll() {
|
||||
setDestination(undefined);
|
||||
setAmountSats(0n);
|
||||
setIsAmtEditable(true);
|
||||
setIsHodlInvoice(false);
|
||||
setSource("lightning");
|
||||
setInvoice(undefined);
|
||||
setAddress(undefined);
|
||||
setDescription(undefined);
|
||||
setNodePubkey(undefined);
|
||||
setLnurlp(undefined);
|
||||
setLnAddress(undefined);
|
||||
setFieldDestination("");
|
||||
}
|
||||
|
||||
function openDetailsModal() {
|
||||
const paymentTxId = sentDetails()?.txid
|
||||
? sentDetails()
|
||||
@@ -331,13 +300,19 @@ export function Send() {
|
||||
setDetailsOpen(true);
|
||||
}
|
||||
|
||||
// If we got here from a scan result we want to set the destination and clean up that scan result
|
||||
onMount(() => {
|
||||
if (state.scan_result) {
|
||||
setDestination(state.scan_result);
|
||||
actions.setScanResult(undefined);
|
||||
// TODO: can I dedupe this from the search page?
|
||||
function parsePaste(text: string) {
|
||||
actions.handleIncomingString(
|
||||
text,
|
||||
(error) => {
|
||||
showToast(error);
|
||||
},
|
||||
(result) => {
|
||||
actions.setScanResult(result);
|
||||
navigate("/send", { state: { previous: "/search" } });
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// send?invoice=... need to check for wallet because we can't parse until we have the wallet
|
||||
createEffect(() => {
|
||||
@@ -347,7 +322,6 @@ export function Send() {
|
||||
}
|
||||
});
|
||||
|
||||
// Three suspiciously similar "max" values we want to compute
|
||||
const maxOnchain = createMemo(() => {
|
||||
return (
|
||||
(state.balance?.confirmed ?? 0n) +
|
||||
@@ -355,8 +329,18 @@ export function Send() {
|
||||
);
|
||||
});
|
||||
|
||||
const maxLightning = createMemo(() => {
|
||||
const fed = state.balance?.federation ?? 0n;
|
||||
const ln = state.balance?.lightning ?? 0n;
|
||||
if (fed > ln) {
|
||||
return fed;
|
||||
} else {
|
||||
return ln;
|
||||
}
|
||||
});
|
||||
|
||||
const maxAmountSats = createMemo(() => {
|
||||
return source() === "onchain" ? maxOnchain() : undefined;
|
||||
return source() === "onchain" ? maxOnchain() : maxLightning();
|
||||
});
|
||||
|
||||
const isMax = createMemo(() => {
|
||||
@@ -424,17 +408,43 @@ export function Send() {
|
||||
return undefined;
|
||||
});
|
||||
|
||||
// Rerun every time the destination changes
|
||||
createEffect(() => {
|
||||
const source = destination();
|
||||
const [parsingDestination, setParsingDestination] = createSignal(false);
|
||||
|
||||
function handleDestination(source: ParsedParams | undefined) {
|
||||
if (!source) return;
|
||||
setParsingDestination(true);
|
||||
|
||||
try {
|
||||
if (source.address) setAddress(source.address);
|
||||
if (source.memo) setDescription(source.memo);
|
||||
if (source.contact_id) setContactId(source.contact_id);
|
||||
|
||||
if (source.invoice) {
|
||||
processInvoice(source as ParsedParams & { invoice: string });
|
||||
} else if (source.node_pubkey) {
|
||||
processNodePubkey(
|
||||
source as ParsedParams & { node_pubkey: string }
|
||||
);
|
||||
} else if (source.lnurl) {
|
||||
console.log("processing lnurl");
|
||||
processLnurl(source as ParsedParams & { lnurl: string });
|
||||
} else {
|
||||
setAmountSats(source.amount_sats || 0n);
|
||||
setSource("onchain");
|
||||
}
|
||||
// Return the source just to trigger `decodedDestination` as not undefined
|
||||
return source;
|
||||
} catch (e) {
|
||||
console.error("error", e);
|
||||
} finally {
|
||||
setParsingDestination(false);
|
||||
}
|
||||
}
|
||||
|
||||
// A ParsedParams with an invoice in it
|
||||
function processInvoice(source: ParsedParams & { invoice: string }) {
|
||||
state.mutiny_wallet
|
||||
?.decode_invoice(source.invoice)
|
||||
?.decode_invoice(source.invoice!)
|
||||
.then((invoice) => {
|
||||
if (invoice?.amount_sats) {
|
||||
setAmountSats(invoice.amount_sats);
|
||||
@@ -443,12 +453,19 @@ export function Send() {
|
||||
setInvoice(invoice);
|
||||
setIsHodlInvoice(invoice.potential_hodl_invoice);
|
||||
setSource("lightning");
|
||||
});
|
||||
} else if (source.node_pubkey) {
|
||||
})
|
||||
.catch((e) => showToast(eify(e)));
|
||||
}
|
||||
|
||||
// A ParsedParams with a node_pubkey in it
|
||||
function processNodePubkey(source: ParsedParams & { node_pubkey: string }) {
|
||||
setAmountSats(source.amount_sats || 0n);
|
||||
setNodePubkey(source.node_pubkey);
|
||||
setSource("lightning");
|
||||
} else if (source.lnurl) {
|
||||
}
|
||||
|
||||
// A ParsedParams with an lnurl in it
|
||||
function processLnurl(source: ParsedParams & { lnurl: string }) {
|
||||
state.mutiny_wallet
|
||||
?.decode_lnurl(source.lnurl)
|
||||
.then((lnurlParams) => {
|
||||
@@ -460,108 +477,40 @@ export function Send() {
|
||||
setAmountSats(source.amount_sats || 0n);
|
||||
}
|
||||
|
||||
// If it is a lightning address, set the address so we can display it
|
||||
if (source.lightning_address) {
|
||||
setLnAddress(source.lightning_address);
|
||||
// check for hodl invoices
|
||||
setIsHodlInvoice(
|
||||
source.lightning_address
|
||||
.toLowerCase()
|
||||
.includes("zeuspay.com")
|
||||
);
|
||||
}
|
||||
|
||||
setLnurlp(source.lnurl);
|
||||
setSource("lightning");
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
showToast(eify(e));
|
||||
});
|
||||
} else {
|
||||
setAmountSats(source.amount_sats || 0n);
|
||||
setSource("onchain");
|
||||
.catch((e) => showToast(eify(e)));
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (amountInput() === "") {
|
||||
setAmountSats(0n);
|
||||
} else {
|
||||
const parsed = BigInt(amountInput());
|
||||
console.log("parsed", parsed);
|
||||
if (parsed > 0n) {
|
||||
setAmountSats(parsed);
|
||||
}
|
||||
// Return the source just to trigger `decodedDestination` as not undefined
|
||||
return source;
|
||||
} catch (e) {
|
||||
console.error("error", e);
|
||||
clearAll();
|
||||
}
|
||||
});
|
||||
|
||||
function parsePaste(text: string) {
|
||||
actions.handleIncomingString(
|
||||
text,
|
||||
(error) => {
|
||||
showToast(error);
|
||||
},
|
||||
(result) => {
|
||||
setDestination(result);
|
||||
// Important! we need to clear the scan result once we've used it
|
||||
// If we got here from a scan or search
|
||||
onMount(() => {
|
||||
if (state.scan_result) {
|
||||
handleDestination(state.scan_result);
|
||||
actions.setScanResult(undefined);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function handleDecode() {
|
||||
const text = fieldDestination();
|
||||
parsePaste(text);
|
||||
}
|
||||
|
||||
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();
|
||||
setFieldDestination(trimText);
|
||||
parsePaste(trimText);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function processContacts(
|
||||
contacts: Partial<TagItem>[]
|
||||
): 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 handleSend() {
|
||||
try {
|
||||
@@ -569,7 +518,11 @@ export function Send() {
|
||||
const bolt11 = invoice()?.bolt11;
|
||||
const sentDetails: Partial<SentDetails> = {};
|
||||
|
||||
const tags = await processContacts(selectedContacts());
|
||||
const tags = contactId() ? [contactId()!] : [];
|
||||
|
||||
if (whatForInput()) {
|
||||
tags.push(whatForInput().trim());
|
||||
}
|
||||
|
||||
if (source() === "lightning" && invoice() && bolt11) {
|
||||
sentDetails.destination = bolt11;
|
||||
@@ -580,8 +533,8 @@ export function Send() {
|
||||
undefined,
|
||||
tags
|
||||
);
|
||||
sentDetails.amount = invoice()?.amount_sats;
|
||||
sentDetails.payment_hash = invoice()?.payment_hash;
|
||||
sentDetails.amount = payment?.amount_sats;
|
||||
sentDetails.payment_hash = payment?.payment_hash;
|
||||
sentDetails.fee_estimate = payment?.fees_paid || 0;
|
||||
} else {
|
||||
const payment = await state.mutiny_wallet?.pay_invoice(
|
||||
@@ -589,8 +542,8 @@ export function Send() {
|
||||
amountSats(),
|
||||
tags
|
||||
);
|
||||
sentDetails.amount = amountSats();
|
||||
sentDetails.payment_hash = invoice()?.payment_hash;
|
||||
sentDetails.amount = payment?.amount_sats;
|
||||
sentDetails.payment_hash = payment?.payment_hash;
|
||||
sentDetails.fee_estimate = payment?.fees_paid || 0;
|
||||
}
|
||||
} else if (source() === "lightning" && nodePubkey()) {
|
||||
@@ -605,8 +558,8 @@ export function Send() {
|
||||
if (!payment?.paid) {
|
||||
throw new Error(i18n.t("send.error_keysend"));
|
||||
} else {
|
||||
sentDetails.amount = amountSats();
|
||||
sentDetails.payment_hash = invoice()?.payment_hash;
|
||||
sentDetails.amount = payment?.amount_sats;
|
||||
sentDetails.payment_hash = payment?.payment_hash;
|
||||
sentDetails.fee_estimate = payment?.fees_paid || 0;
|
||||
}
|
||||
} else if (source() === "lightning" && lnurlp()) {
|
||||
@@ -616,13 +569,13 @@ export function Send() {
|
||||
undefined, // zap_npub
|
||||
tags
|
||||
);
|
||||
sentDetails.payment_hash = invoice()?.payment_hash;
|
||||
sentDetails.payment_hash = payment?.payment_hash;
|
||||
|
||||
if (!payment?.paid) {
|
||||
throw new Error(i18n.t("send.error_LNURL"));
|
||||
} else {
|
||||
sentDetails.amount = amountSats();
|
||||
sentDetails.payment_hash = invoice()?.payment_hash;
|
||||
sentDetails.amount = payment?.amount_sats;
|
||||
sentDetails.payment_hash = payment?.payment_hash;
|
||||
sentDetails.fee_estimate = payment?.fees_paid || 0;
|
||||
}
|
||||
} else if (source() === "onchain" && address()) {
|
||||
@@ -651,9 +604,13 @@ export function Send() {
|
||||
sentDetails.fee_estimate = feeEstimate() ?? 0;
|
||||
}
|
||||
}
|
||||
if (sentDetails.payment_hash || sentDetails.txid) {
|
||||
setSentDetails(sentDetails as SentDetails);
|
||||
clearAll();
|
||||
await vibrateSuccess();
|
||||
} else {
|
||||
// TODO: what should we do here? hopefully this never happens?
|
||||
console.error("failed to send: no payment hash or txid");
|
||||
}
|
||||
} catch (e) {
|
||||
const error = eify(e);
|
||||
setSentDetails({ failure_reason: error.message });
|
||||
@@ -666,32 +623,18 @@ export function Send() {
|
||||
}
|
||||
|
||||
const sendButtonDisabled = createMemo(() => {
|
||||
return !destination() || sending() || amountSats() === 0n || !!error();
|
||||
});
|
||||
|
||||
const shouldShowGiftLink = createMemo(() => {
|
||||
// iOS users should only see gift link if they're mutiny+ subscribers
|
||||
const isIOS = Capacitor.getPlatform() === "ios";
|
||||
|
||||
return !isIOS || state.mutiny_plus;
|
||||
return (
|
||||
parsingDestination() ||
|
||||
sending() ||
|
||||
amountSats() === 0n ||
|
||||
!!error()
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<MutinyWalletGuard>
|
||||
<SafeArea>
|
||||
<DefaultMain>
|
||||
<Show
|
||||
when={
|
||||
address() || invoice() || nodePubkey() || lnurlp()
|
||||
}
|
||||
fallback={<BackLink />}
|
||||
>
|
||||
<BackButton
|
||||
onClick={() => clearAll()}
|
||||
title={i18n.t("send.start_over")}
|
||||
/>
|
||||
</Show>
|
||||
<LargeHeader>{i18n.t("send.send_bitcoin")}</LargeHeader>
|
||||
<BackPop />
|
||||
<SuccessModal
|
||||
confirmText={
|
||||
sentDetails()?.amount
|
||||
@@ -758,23 +701,8 @@ export function Send() {
|
||||
</Match>
|
||||
</Switch>
|
||||
</SuccessModal>
|
||||
<VStack biggap>
|
||||
<Switch>
|
||||
<Match
|
||||
when={
|
||||
address() ||
|
||||
invoice() ||
|
||||
nodePubkey() ||
|
||||
lnurlp()
|
||||
}
|
||||
>
|
||||
<MethodChooser
|
||||
source={source()}
|
||||
setSource={setSource}
|
||||
both={!!address() && !!invoice()}
|
||||
/>
|
||||
<Card title={i18n.t("send.destination")}>
|
||||
<VStack>
|
||||
<div class="flex flex-1 flex-col justify-between gap-2">
|
||||
<Suspense fallback={<LoadingShimmer />}>
|
||||
<DestinationShower
|
||||
source={source()}
|
||||
description={description()}
|
||||
@@ -783,39 +711,44 @@ export function Send() {
|
||||
nodePubkey={nodePubkey()}
|
||||
lnurl={lnurlp()}
|
||||
lightning_address={lnAddress()}
|
||||
clearAll={clearAll}
|
||||
contact_id={contactId()}
|
||||
/>
|
||||
<SmallHeader>
|
||||
{i18n.t("common.private_tags")}
|
||||
</SmallHeader>
|
||||
<TagEditor
|
||||
autoFillTag={
|
||||
destination()?.privateTag
|
||||
}
|
||||
selectedValues={selectedContacts()}
|
||||
setSelectedValues={
|
||||
setSelectedContacts
|
||||
}
|
||||
placeholder={i18n.t(
|
||||
"send.contact_placeholder"
|
||||
)}
|
||||
/>
|
||||
</VStack>
|
||||
</Card>
|
||||
<AmountCard
|
||||
amountSats={amountSats().toString()}
|
||||
setAmountSats={setAmountSats}
|
||||
</Suspense>
|
||||
<div class="flex-1" />
|
||||
{/* Need both these versions so that we make sure to get the right initial amount on load */}
|
||||
<Show when={isAmtEditable()}>
|
||||
<AmountEditable
|
||||
initialAmountSats={amountSats()}
|
||||
setAmountSats={setAmountInput}
|
||||
maxAmountSats={maxAmountSats()}
|
||||
fee={feeEstimate()?.toString()}
|
||||
isAmountEditable={isAmtEditable()}
|
||||
onSubmit={() =>
|
||||
sendButtonDisabled() ? undefined : handleSend()
|
||||
}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={!isAmtEditable()}>
|
||||
<AmountEditable
|
||||
initialAmountSats={amountSats()}
|
||||
setAmountSats={setAmountInput}
|
||||
maxAmountSats={maxAmountSats()}
|
||||
fee={feeEstimate()?.toString()}
|
||||
frozenAmount={true}
|
||||
onSubmit={() =>
|
||||
sendButtonDisabled() ? undefined : handleSend()
|
||||
}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={feeEstimate()}>
|
||||
<FeeDisplay
|
||||
amountSats={amountSats().toString()}
|
||||
fee={feeEstimate()!.toString()}
|
||||
maxAmountSats={maxAmountSats()}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={isHodlInvoice()}>
|
||||
<InfoBox accent="red">
|
||||
<p>
|
||||
{i18n.t(
|
||||
"send.hodl_invoice_warning"
|
||||
)}
|
||||
</p>
|
||||
<p>{i18n.t("send.hodl_invoice_warning")}</p>
|
||||
</InfoBox>
|
||||
</Show>
|
||||
<Show when={error()}>
|
||||
@@ -823,25 +756,26 @@ export function Send() {
|
||||
<p>{error()}</p>
|
||||
</InfoBox>
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<DestinationInput
|
||||
fieldDestination={fieldDestination()}
|
||||
setFieldDestination={setFieldDestination}
|
||||
handleDecode={handleDecode}
|
||||
handlePaste={handlePaste}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
<Show
|
||||
when={
|
||||
address() ||
|
||||
invoice() ||
|
||||
nodePubkey() ||
|
||||
lnurlp()
|
||||
}
|
||||
>
|
||||
<div class="flex-1" />
|
||||
|
||||
<VStack>
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
if (!sendButtonDisabled()) {
|
||||
await handleSend();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SimpleInput
|
||||
type="text"
|
||||
placeholder={i18n.t("send.what_for")}
|
||||
onInput={(e) =>
|
||||
setWhatForInput(e.currentTarget.value)
|
||||
}
|
||||
value={whatForInput()}
|
||||
/>
|
||||
</form>
|
||||
<Button
|
||||
disabled={sendButtonDisabled()}
|
||||
intent="blue"
|
||||
@@ -853,16 +787,9 @@ export function Send() {
|
||||
: i18n.t("send.confirm_send")}
|
||||
</Button>
|
||||
</VStack>
|
||||
</Show>
|
||||
<Show when={shouldShowGiftLink()}>
|
||||
<div class="flex justify-center">
|
||||
<GiftLink />
|
||||
</div>
|
||||
</Show>
|
||||
</VStack>
|
||||
</DefaultMain>
|
||||
<NavBar activeTab="send" />
|
||||
</SafeArea>
|
||||
</MutinyWalletGuard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,17 +13,19 @@ import {
|
||||
|
||||
import {
|
||||
ActivityDetailsModal,
|
||||
AmountCard,
|
||||
AmountEditable,
|
||||
AmountFiat,
|
||||
BackLink,
|
||||
Button,
|
||||
Card,
|
||||
DefaultMain,
|
||||
FeeDisplay,
|
||||
HackActivityType,
|
||||
InfoBox,
|
||||
LargeHeader,
|
||||
MegaCheck,
|
||||
MegaEx,
|
||||
MethodChooser,
|
||||
MutinyWalletGuard,
|
||||
NavBar,
|
||||
SafeArea,
|
||||
@@ -34,7 +36,7 @@ import {
|
||||
} from "~/components";
|
||||
import { useI18n } from "~/i18n/context";
|
||||
import { Network } from "~/logic/mutinyWalletSetup";
|
||||
import { MethodChooser, SendSource } from "~/routes/Send";
|
||||
import { SendSource } from "~/routes/Send";
|
||||
import { useMegaStore } from "~/state/megaStore";
|
||||
import { eify, vibrateSuccess } from "~/utils";
|
||||
|
||||
@@ -441,13 +443,19 @@ export function Swap() {
|
||||
</Card>
|
||||
</Show>
|
||||
</VStack>
|
||||
<AmountCard
|
||||
amountSats={amountSats().toString()}
|
||||
<AmountEditable
|
||||
initialAmountSats={amountSats()}
|
||||
setAmountSats={setAmountSats}
|
||||
fee={feeEstimate()?.toString()}
|
||||
isAmountEditable={true}
|
||||
maxAmountSats={maxOnchain()}
|
||||
/>
|
||||
<Show when={feeEstimate() && amountSats() > 0n}>
|
||||
<FeeDisplay
|
||||
amountSats={amountSats().toString()}
|
||||
fee={feeEstimate()!.toString()}
|
||||
maxAmountSats={maxOnchain()}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={amountWarning() && amountSats() > 0n}>
|
||||
<InfoBox accent={"red"}>{amountWarning()}</InfoBox>
|
||||
</Show>
|
||||
|
||||
@@ -7,3 +7,4 @@ export * from "./Receive";
|
||||
export * from "./Scanner";
|
||||
export * from "./Send";
|
||||
export * from "./Swap";
|
||||
export * from "./Search";
|
||||
|
||||
@@ -169,7 +169,7 @@ function SingleChannelItem(props: { channel: MutinyChannel }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function LiquidityMonitor() {
|
||||
function LiquidityMonitor() {
|
||||
const i18n = useI18n();
|
||||
const [state, _actions] = useMegaStore();
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
} from "solid-js";
|
||||
|
||||
import {
|
||||
AmountCard,
|
||||
AmountEditable,
|
||||
BackPop,
|
||||
Button,
|
||||
Collapser,
|
||||
@@ -49,10 +49,7 @@ type CreateGiftForm = {
|
||||
amount: string;
|
||||
};
|
||||
|
||||
export function SingleGift(props: {
|
||||
profile: NwcProfile;
|
||||
onDelete?: () => void;
|
||||
}) {
|
||||
function SingleGift(props: { profile: NwcProfile; onDelete?: () => void }) {
|
||||
const i18n = useI18n();
|
||||
const [state, _actions] = useMegaStore();
|
||||
|
||||
@@ -268,6 +265,22 @@ export function Gift() {
|
||||
<NiceP>
|
||||
{i18n.t("settings.gift.send_explainer")}
|
||||
</NiceP>
|
||||
<Field name="amount">
|
||||
{(field) => (
|
||||
<AmountEditable
|
||||
initialAmountSats={
|
||||
field.value || "0"
|
||||
}
|
||||
setAmountSats={(newAmount) =>
|
||||
setValue(
|
||||
giftForm,
|
||||
"amount",
|
||||
newAmount.toString()
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field
|
||||
name="name"
|
||||
validate={[
|
||||
@@ -290,23 +303,7 @@ export function Gift() {
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="amount">
|
||||
{(field) => (
|
||||
<>
|
||||
<AmountCard
|
||||
amountSats={field.value || "0"}
|
||||
isAmountEditable
|
||||
setAmountSats={(newAmount) =>
|
||||
setValue(
|
||||
giftForm,
|
||||
"amount",
|
||||
newAmount.toString()
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Show when={lessThanMinChannelSize()}>
|
||||
<InfoBox accent="green">
|
||||
{i18n.t(
|
||||
|
||||
@@ -45,7 +45,7 @@ function validateWord(word?: string): boolean {
|
||||
return WORDS_EN.includes(word?.trim() ?? "");
|
||||
}
|
||||
|
||||
export function SeedTextField(props: TextFieldProps) {
|
||||
function SeedTextField(props: TextFieldProps) {
|
||||
const [fieldProps] = splitProps(props, [
|
||||
"placeholder",
|
||||
"ref",
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
} from "~/logic/mutinyWalletSetup";
|
||||
import { eify } from "~/utils";
|
||||
|
||||
export function SettingsStringsEditor(props: {
|
||||
function SettingsStringsEditor(props: {
|
||||
initialSettings: MutinyWalletSettingStrings;
|
||||
}) {
|
||||
const i18n = useI18n();
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createForm, required, SubmitHandler } from "@modular-forms/solid";
|
||||
import { createSignal, Match, Show, Switch } from "solid-js";
|
||||
|
||||
import {
|
||||
BackLink,
|
||||
BackPop,
|
||||
Button,
|
||||
DefaultMain,
|
||||
FancyCard,
|
||||
@@ -26,7 +26,7 @@ type NostrContactsForm = {
|
||||
|
||||
const PRIMAL_API = import.meta.env.VITE_PRIMAL;
|
||||
|
||||
export function SyncContactsForm() {
|
||||
function SyncContactsForm() {
|
||||
const i18n = useI18n();
|
||||
const [state, actions] = useMegaStore();
|
||||
const [error, setError] = createSignal<Error>();
|
||||
@@ -121,7 +121,7 @@ export function SyncNostrContacts() {
|
||||
<MutinyWalletGuard>
|
||||
<SafeArea>
|
||||
<DefaultMain>
|
||||
<BackLink href="/settings" title="Settings" />
|
||||
<BackPop />
|
||||
<LargeHeader>Sync Nostr Contacts</LargeHeader>
|
||||
<Switch>
|
||||
<Match when={state.npub}>
|
||||
|
||||
@@ -36,14 +36,14 @@ import {
|
||||
|
||||
const MegaStoreContext = createContext<MegaStore>();
|
||||
|
||||
export type LoadStage =
|
||||
type LoadStage =
|
||||
| "fresh"
|
||||
| "checking_double_init"
|
||||
| "downloading"
|
||||
| "setup"
|
||||
| "done";
|
||||
|
||||
export type MegaStore = [
|
||||
type MegaStore = [
|
||||
{
|
||||
mutiny_wallet?: MutinyWallet;
|
||||
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 bech32m = getLibraryFromEncoding("bech32m");
|
||||
export interface Decoded {
|
||||
interface Decoded {
|
||||
prefix: string;
|
||||
words: number[];
|
||||
}
|
||||
export interface BechLib {
|
||||
interface BechLib {
|
||||
decodeUnsafe: (
|
||||
str: string,
|
||||
LIMIT?: number | undefined
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ResourceFetcher } from "solid-js";
|
||||
import { useMegaStore } from "~/state/megaStore";
|
||||
import { hexpubFromNpub, NostrKind, NostrTag } from "~/utils/nostr";
|
||||
|
||||
export type NostrEvent = {
|
||||
type NostrEvent = {
|
||||
created_at: number;
|
||||
content: string;
|
||||
tags: NostrTag[];
|
||||
@@ -16,7 +16,7 @@ export type NostrEvent = {
|
||||
sig?: string;
|
||||
};
|
||||
|
||||
export type SimpleZapItem = {
|
||||
type SimpleZapItem = {
|
||||
kind: "public" | "private" | "anonymous";
|
||||
from_hexpub: string;
|
||||
to_hexpub: string;
|
||||
@@ -28,7 +28,7 @@ export type SimpleZapItem = {
|
||||
content?: string;
|
||||
};
|
||||
|
||||
export type NostrProfile = {
|
||||
type NostrProfile = {
|
||||
id: string;
|
||||
pubkey: string;
|
||||
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[]> {
|
||||
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 "./download";
|
||||
export * from "./eify";
|
||||
export * from "./getHostname";
|
||||
export * from "./gradientHash";
|
||||
export * from "./mempoolTxUrl";
|
||||
export * from "./objectToSearchParams";
|
||||
export * from "./prettyPrintTime";
|
||||
export * from "./subscriptions";
|
||||
export * from "./tags";
|
||||
export * from "./timeout";
|
||||
export * from "./typescript";
|
||||
export * from "./useCopy";
|
||||
@@ -19,3 +17,4 @@ export * from "./openLinkProgrammatically";
|
||||
export * from "./nostr";
|
||||
export * from "./currencies";
|
||||
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(
|
||||
npub: string
|
||||
npub?: string
|
||||
): Promise<string | undefined> {
|
||||
if (!npub) {
|
||||
return undefined;
|
||||
}
|
||||
if (!npub.toLowerCase().startsWith("npub")) {
|
||||
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 { createSignal } from "solid-js";
|
||||
|
||||
export type UseCopyProps = {
|
||||
type UseCopyProps = {
|
||||
copiedTimeout?: number;
|
||||
};
|
||||
type CopyFn = (text: string) => Promise<void>;
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
import { Haptics } from "@capacitor/haptics";
|
||||
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 () => {
|
||||
try {
|
||||
await Haptics.notification({ type: NotificationType.Success });
|
||||
|
||||
@@ -37,7 +37,7 @@ module.exports = {
|
||||
"m-grey-750": "hsla(0, 0%, 17%, 1)",
|
||||
"m-grey-800": "hsla(0, 0%, 12%, 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: {
|
||||
"fade-to-blue":
|
||||
@@ -58,7 +58,11 @@ module.exports = {
|
||||
"fancy-card": "0px 4px 4px rgba(0, 0, 0, 0.1)",
|
||||
"subtle-bevel":
|
||||
"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: {
|
||||
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))"
|
||||
},
|
||||
"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": {
|
||||
scrollbarWidth: "none",
|
||||
|
||||
Reference in New Issue
Block a user