contact search and new amount editor

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

View File

@@ -1,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 });
});

View File

@@ -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

View File

@@ -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
View File

@@ -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'}

View File

@@ -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;

View File

@@ -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(",")}

View File

@@ -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"

View File

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

View File

@@ -1,384 +1,46 @@
import { Capacitor } from "@capacitor/core";
import { Dialog } from "@kobalte/core";
import { useNavigate } from "@solidjs/router";
import {
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>
);
};

View File

@@ -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"
>

View File

@@ -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
View File

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

View File

@@ -1,16 +1,14 @@
import { Dialog } from "@kobalte/core";
import { SubmitHandler } from "@modular-forms/solid";
import { 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>
</>
);
}

View File

@@ -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>

View File

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

View File

@@ -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>

View File

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

View File

@@ -1,14 +1,6 @@
import { Dialog } from "@kobalte/core";
import { createMemo, JSX } from "solid-js";
import {
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>
);
}

View File

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

View File

@@ -4,7 +4,7 @@ import { Show } from "solid-js";
import { useI18n } from "~/i18n/context";
import { 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) {

View File

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

View File

@@ -1,15 +1,7 @@
import { Dialog } from "@kobalte/core";
import { createSignal, JSXElement, ParentComponent } from "solid-js";
import 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>
</>
);
};

View File

@@ -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">

View File

@@ -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")}
>

View File

@@ -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"

View File

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

View File

@@ -12,10 +12,7 @@ import { useCopy } from "~/utils";
const STYLE =
"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" />

View File

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

View File

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

View File

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

View File

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

View File

@@ -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";

View File

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

View File

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

View File

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

View File

@@ -129,17 +129,23 @@ export const SafeArea: ParentComponent = (props) => {
);
};
export const DefaultMain: ParentComponent = (props) => {
export const DefaultMain = (props: {
children?: JSX.Element;
zeroBottomPadding?: boolean;
}) => {
return (
<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>

View File

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

View File

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

View File

@@ -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",

View File

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

View File

@@ -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 */
}

View File

@@ -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} />

View File

@@ -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

View File

@@ -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
View File

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

View File

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

View File

@@ -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>

View File

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

View File

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

View File

@@ -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(

View File

@@ -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",

View File

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

View File

@@ -2,7 +2,7 @@ import { createForm, required, SubmitHandler } from "@modular-forms/solid";
import { createSignal, Match, Show, Switch } from "solid-js";
import {
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}>

View File

@@ -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;

View File

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

View File

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

View File

@@ -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;

View File

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

View File

@@ -2,13 +2,11 @@ export * from "./conversions";
export * from "./deepSignal";
export * from "./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
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",