diff --git a/android/app/build.gradle b/android/app/build.gradle index f717e28e..c34b866f 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -7,8 +7,8 @@ android { applicationId "com.mutinywallet.mutinywallet" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 44 - versionName "0.5.2" + versionCode 45 + versionName "0.5.3" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/e2e/fedimint.spec.ts b/e2e/fedimint.spec.ts new file mode 100644 index 00000000..575054fa --- /dev/null +++ b/e2e/fedimint.spec.ts @@ -0,0 +1,154 @@ +import { expect, test } from "@playwright/test"; + +const SIGNET_INVITE_CODE = + "fed11qgqzc2nhwden5te0vejkg6tdd9h8gepwvejkg6tdd9h8garhduhx6at5d9h8jmn9wshxxmmd9uqqzgxg6s3evnr6m9zdxr6hxkdkukexpcs3mn7mj3g5pc5dfh63l4tj6g9zk4er"; + +test.beforeEach(async ({ page }) => { + await page.goto("http://localhost:3420/"); +}); + +test("fedmint join, receive, send", async ({ page }) => { + // Expect a title "to contain" a substring. + await expect(page).toHaveTitle(/Mutiny Wallet/); + + // Wait for an element matching the selector to appear in DOM. + await page.waitForSelector("text=0 SATS"); + + console.log("Page loaded."); + + // Wait for a while just to make sure we can load everything + await page.waitForTimeout(1000); + + // Navigate to settings + const settingsLink = await page.getByRole("link", { name: "Settings" }); + + settingsLink.click(); + + // Wait for settings to load + await page.waitForSelector("text=Settings"); + + // Click "Manage Federations" link + await page.click("text=Manage Federations"); + + // Fill the input with the federation code + await page.fill("input[name='federation_code']", SIGNET_INVITE_CODE); + + const addButton = await page.getByRole("button", { name: "Add" }); + + // Click the "Add" button + await addButton.click(); + + // Wait for a header to appear with the text "MutinySignetFederation" + await page.waitForSelector("text=MutinySignetFederation"); + + // Navigate back home + await page.goBack(); + await page.goBack(); + + // Make sure there's a fedimint icon + await expect(page.getByRole("img", { name: "community" })).toBeVisible(); + + // Click the receive button + await page.click("text=Receive"); + + // Expect the url to conain receive + await expect(page).toHaveURL(/.*receive/); + + // At least one h1 should show "0 sats" + await expect(page.locator("h1")).toContainText(["0 SATS"]); + + // At least one h2 should show "0 USD" + await expect(page.locator("h2")).toContainText(["$0 USD"]); + + // Type 100 into the input + await page.locator("#sats-input").pressSequentially("100"); + + // Now the h1 should show "100,000 sats" + await expect(page.locator("h1")).toContainText(["100 SATS"]); + + // There should be a button with the text "Continue" and it should not be disabled + const continueButton = await page.locator("button", { + hasText: "Continue" + }); + await expect(continueButton).not.toBeDisabled(); + + // Wait one second + // TODO: figure out how to not get an error without waiting + await page.waitForTimeout(1000); + + continueButton.click(); + + await expect( + page.getByText("Keep Mutiny open to complete the payment.") + ).toBeVisible(); + + // Locate an SVG inside a div with id "qr" + const qrCode = await page.locator("#qr > svg"); + + await expect(qrCode).toBeVisible(); + + const value = await qrCode.getAttribute("value"); + + // The SVG's value property includes "bitcoin:t" + expect(value).toContain("bitcoin:t"); + + const lightningInvoice = value?.split("lightning=")[1]; + + // Post the lightning invoice to the server + const _response = await fetch( + "https://faucet.mutinynet.com/api/lightning", + { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + 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 }); + + // Click the "Nice" button + await page.click("text=Nice"); + + // Make sure we have 100 sats in the fedimint balance + await expect( + page + .locator("div") + .filter({ hasText: /^100 SATS$/ }) + .nth(1) + ).toBeVisible(); + + // Now we send + await page.click("text=Send"); + + // type refund@lnurl-staging.mutinywallet.com + const sendInput = await page.locator("input"); + await sendInput.fill("refund@lnurl-staging.mutinywallet.com"); + + await page.click("text=Continue"); + + // 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); + + // Type 90 into the input + await page.locator("#sats-input").fill("90"); + + // Now the h1 should show "90 sats" + await expect(page.locator("h1")).toContainText(["90 SATS"]); + + // 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(); + + confirmButton.click(); + + // Wait for an h1 to appear in the dom that says "Payment Sent" + await page.waitForSelector("text=Payment Sent", { timeout: 30000 }); +}); diff --git a/e2e/roundtrip.spec.ts b/e2e/roundtrip.spec.ts index 39c6253e..0acefd5b 100644 --- a/e2e/roundtrip.spec.ts +++ b/e2e/roundtrip.spec.ts @@ -1,67 +1,69 @@ -import { test, expect } from "@playwright/test"; +import { expect, test } from "@playwright/test"; test.beforeEach(async ({ page }) => { - await page.goto("http://localhost:3420/"); + await page.goto("http://localhost:3420/"); }); test("rountrip receive and send", async ({ page }) => { // Click the receive button await page.click("text=Receive"); - + // Expect the url to conain receive await expect(page).toHaveURL(/.*receive/); - + // At least one h1 should show "0 sats" await expect(page.locator("h1")).toContainText(["0 SATS"]); - + // 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"); - - // Now the h1 should show "10,000 sats" + + // Type 100000 into the input + await page.locator("#sats-input").pressSequentially("100000"); + + // 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 // TODO: figure out how to not get an error without waiting await page.waitForTimeout(1000); - + continueButton.click(); - + await expect( page.getByText("Keep Mutiny open to complete the payment.") ).toBeVisible(); - + // Locate an SVG inside a div with id "qr" const qrCode = await page.locator("#qr > svg"); - + await expect(qrCode).toBeVisible(); - + const value = await qrCode.getAttribute("value"); - + // The SVG's value property includes "bitcoin:t" expect(value).toContain("bitcoin:t"); const lightningInvoice = value?.split("lightning=")[1]; // Post the lightning invoice to the server - const _response = await fetch("https://faucet.mutinynet.com/api/lightning", { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify({ - bolt11: lightningInvoice - }) - }); + const _response = await fetch( + "https://faucet.mutinynet.com/api/lightning", + { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + 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 }); - }); - +}); diff --git a/e2e/routes.spec.ts b/e2e/routes.spec.ts index b95e4fca..1523fedf 100644 --- a/e2e/routes.spec.ts +++ b/e2e/routes.spec.ts @@ -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 diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index 9545748b..2fb89eb8 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -360,7 +360,7 @@ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance"; IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - MARKETING_VERSION = 1.5.2; + MARKETING_VERSION = 1.5.3; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; PRODUCT_BUNDLE_IDENTIFIER = com.mutinywallet.mutiny; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -387,7 +387,7 @@ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance"; IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - MARKETING_VERSION = 1.5.2; + MARKETING_VERSION = 1.5.3; PRODUCT_BUNDLE_IDENTIFIER = com.mutinywallet.mutiny; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/package.json b/package.json index c17f71fa..948aad2b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mutiny-wallet", - "version": "0.5.2", + "version": "0.5.3", "license": "MIT", "packageManager": "pnpm@8.6.6", "scripts": { @@ -55,19 +55,16 @@ "@kobalte/core": "^0.9.8", "@kobalte/tailwindcss": "^0.5.0", "@modular-forms/solid": "^0.18.1", - "@mutinywallet/mutiny-wasm": "0.5.2", + "@mutinywallet/mutiny-wasm": "0.5.3", "@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" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 162a8ac8..18ba86b4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,26 +54,20 @@ importers: specifier: ^0.18.1 version: 0.18.1(solid-js@1.8.5) '@mutinywallet/mutiny-wasm': - specifier: 0.5.2 - version: 0.5.2 + specifier: 0.5.3 + version: 0.5.3 '@mutinywallet/waila-wasm': specifier: ^0.2.6 version: 0.2.6 '@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: @@ -2584,8 +2570,8 @@ packages: solid-js: 1.8.5 dev: false - /@mutinywallet/mutiny-wasm@0.5.2: - resolution: {integrity: sha512-81wMXeE44HkO3B4h97DwoixM7tmgZIRhVdgvi7aTlNg3GxshjsSAMNw9ehU2osO1niTHFRdgwGCvGsFPLBdSSA==} + /@mutinywallet/mutiny-wasm@0.5.3: + resolution: {integrity: sha512-zbawC8rnKzRDw0uoMjdfzxtk89/Vfu/s+X+o5xPk69jZ5WjZFWBahBPJseJwB28+A1ZfNnHZQl+/brCkaDo+aA==} dev: false /@mutinywallet/waila-wasm@0.2.6: @@ -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'} diff --git a/src/components/Activity.tsx b/src/components/Activity.tsx index cc6abfb4..94f0b1cb 100644 --- a/src/components/Activity.tsx +++ b/src/components/Activity.tsx @@ -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; diff --git a/src/components/ActivityDetailsModal.tsx b/src/components/ActivityDetailsModal.tsx index 80d5afd5..f9fffc10 100644 --- a/src/components/ActivityDetailsModal.tsx +++ b/src/components/ActivityDetailsModal.tsx @@ -178,7 +178,7 @@ export function MiniStringShower(props: { text: string }) { ); } -export function FormatPrettyPrint(props: { ts: number }) { +function FormatPrettyPrint(props: { ts: number }) { return (
{prettyPrintTime(props.ts).split(",", 2).join(",")} diff --git a/src/components/ActivityItem.tsx b/src/components/ActivityItem.tsx index b942819e..eca041f5 100644 --- a/src/components/ActivityItem.tsx +++ b/src/components/ActivityItem.tsx @@ -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 ( -
- - - {"image"} - - - channel open - - - channel close - - {text()} - -
- ); -} - export type HackActivityType = | "Lightning" | "OnChain" diff --git a/src/components/AmountCard.tsx b/src/components/AmountCard.tsx deleted file mode 100644 index dc435ba7..00000000 --- a/src/components/AmountCard.tsx +++ /dev/null @@ -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 ( -
-
{props.key}
-
{props.children}
-
- ); -}; - -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 ( -
- {props.sign ? `${props.sign} ` : ""} - {prettyPrint()} {i18n.t("common.sats")} -
- ); -}; - -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 ( - - -
- {`${amountInFiat()} `} - {state.fiat.value} -
-
-
- ); -} - -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 ( - - - - -
- - - } - > - - - - - - -
-
-
- - - - -
-
- -
- - - - - - -
-
-
- - - - -
-
- -
- - - } - > - - - - -
-
-
-
-
- ); -} diff --git a/src/components/AmountEditable.tsx b/src/components/AmountEditable.tsx index d5d0b8f8..2fe4d965 100644 --- a/src/components/AmountEditable.tsx +++ b/src/components/AmountEditable.tsx @@ -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 | 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 - } - > - - - ); -} - -function BigScalingText(props: { - text: string; - fiat?: Currency; - mode: "fiat" | "sats"; - loading: boolean; -}) { - const chars = () => props.text.length; - const i18n = useI18n(); - - return ( -

= 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 - }} - > - - {!props.loading && props.mode === "sats"} - {props.mode === "fiat" && - //adds only the symbol - props.fiat?.hasSymbol} - {`${props.text} `} - - {props.fiat ? props.fiat.value : i18n.t("common.sats")} - - -

- ); -} - -function SmallSubtleAmount(props: { - text: string; - fiat?: Currency; - mode: "fiat" | "sats"; - loading: boolean; -}) { - const i18n = useI18n(); - - return ( -

- - {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 */} - {""} - - {props.fiat ? props.fiat.value : i18n.t("common.sats")} - - Swap currencies - -

- ); -} - -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,207 +48,12 @@ 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; + createEffect(() => { + if (focusState() === "focused") { + props.setAmountSats(BigInt(localSats())); } }); - const warningText = () => { - if (state.federations?.length !== 0) { - return undefined; - } - if ((state.balance?.lightning || 0n) === 0n) { - return i18n.t("receive.amount_editable.receive_too_small", { - amount: "100,000" - }); - } - - const parsed = Number(localSats()); - if (isNaN(parsed)) { - return undefined; - } - - if (parsed > (inboundCapacity() || 0)) { - return i18n.t("receive.amount_editable.setup_fee_lightning"); - } - - return undefined; - }; - - const betaWarning = () => { - const parsed = Number(localSats()); - if (isNaN(parsed)) { - return undefined; - } - - if (parsed >= 2099999997690000) { - // If over 21 million bitcoin, warn that too much - return i18n.t("receive.amount_editable.more_than_21m"); - } else if (parsed >= 4000000) { - // If over 4 million sats, warn that it's a beta bro - return i18n.t("receive.amount_editable.too_big_for_beta"); - } - }; - - /** Handling character inputs gives our virtual keyboard full functionality to add and remove digits in a UX friendly way - * When the input is dealing with sats there is no allowed decimals - * - * Special logic is required for BTC as we want to start from the 8th decimal - * if state.fiat.value === "BTC" - * input - 000123 - * result - 0.00000123 - * - * input - 11"DEL"11 - * result - 0.00000111 - * - * for other currencies the inputSanitizer seeks to limit the maximum decimal digits - * - * if state.fiat.value === "KWD" - * input - 123.456666 - * result - 123456.666 - */ - - function handleCharacterInput(characterInput: string) { - const isFiatMode = mode() === "fiat"; - const character = characterInput === "," ? "." : characterInput; - let inputSanitizer; - if (isFiatMode) { - inputSanitizer = fiatInputSanitizer; - } else { - inputSanitizer = satsInputSanitizer; - } - const localValue = isFiatMode ? localFiat : localSats; - - let sane; - - if ( - character === "DEL" || - character === i18n.t("receive.amount_editable.del") - ) { - if ( - localValue().length === 1 || - (state.fiat.maxFractionalDigits === 0 && - localValue().startsWith("0")) - ) { - sane = "0"; - } else if ( - state.fiat.value === "BTC" && - isFiatMode && - localValue() !== "0" - ) { - // This allows us to handle the backspace key and fight float rounding - sane = inputSanitizer( - btcFloatRounding(localValue()), - state.fiat.maxFractionalDigits - ); - } else { - sane = inputSanitizer( - localValue().slice(0, -1), - state.fiat.maxFractionalDigits - ); - } - } else { - if (localValue() === "0" && state.fiat.value !== "BTC") { - sane = inputSanitizer( - character, - state.fiat.maxFractionalDigits - ); - } else if (state.fiat.value === "BTC" && isFiatMode) { - sane = inputSanitizer( - Number(localValue()).toFixed(8) + character, - state.fiat.maxFractionalDigits - ); - } else { - sane = inputSanitizer( - localValue() + character, - state.fiat.maxFractionalDigits - ); - } - } - - if (isFiatMode) { - setLocalFiat(sane); - setLocalSats( - fiatToSats(state.price, parseFloat(sane || "0") || 0, false) - ); - } else { - setLocalSats(sane); - setLocalFiat( - satsToFiat(state.price, Number(sane) || 0, state.fiat) - ); - } - - // After a button press make sure we re-focus the input - focus(); - } - - function handleClear() { - const isFiatMode = mode() === "fiat"; - - if (isFiatMode) { - setLocalFiat("0"); - setLocalSats(fiatToSats(state.price, parseFloat("0") || 0, false)); - } else { - setLocalSats("0"); - setLocalFiat(satsToFiat(state.price, Number("0") || 0, state.fiat)); - } - - // After a button press make sure we re-focus the input - focus(); - } - - function setFixedAmount(amount: string) { - if (mode() === "fiat") { - setLocalFiat(amount); - setLocalSats( - fiatToSats(state.price, parseFloat(amount || "0") || 0, false) - ); - } else { - setLocalSats(amount); - setLocalFiat( - satsToFiat(state.price, Number(amount) || 0, state.fiat) - ); - } - } - - function handleClose(e: SubmitEvent | MouseEvent | KeyboardEvent) { - e.preventDefault(); - props.setAmountSats(BigInt(props.initialAmountSats)); - setIsOpen(false); - setLocalSats(props.initialAmountSats); - setLocalFiat( - satsToFiat( - state.price, - parseInt(props.initialAmountSats || "0") || 0, - state.fiat - ) - ); - props.exitRoute && navigate(props.exitRoute); - return false; - } - - // What we're all here for in the first place: returning a value - function handleSubmit(e: SubmitEvent | MouseEvent) { - e.preventDefault(); - props.setAmountSats(BigInt(localSats())); - setLocalFiat( - satsToFiat(state.price, Number(localSats()) || 0, state.fiat) - ); - setIsOpen(false); - return false; - } - function handleSatsInput(e: InputEvent) { const { value } = e.target as HTMLInputElement; const sane = satsInputSanitizer(value); @@ -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 = () => { + 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 { + toggle(state.price === 0); + focus(); + } + }; + + const handleClickOutside = (e: MouseEvent) => { + if (e.target instanceof Element && !divRef.contains(e.target)) { + setFocusState("unfocused"); + } + }; + + // When the keyboard on mobile is shown / hidden we should update our "focus" state + // TODO: find a way so this doesn't fire on devices without a virtual keyboard + function handleResize(e: Event) { + const VIEWPORT_VS_CLIENT_HEIGHT_RATIO = 0.75; + + const target = e.target as VisualViewport; + if ( - props.maxAmountSats && - props.fee && - props.maxAmountSats === BigInt(localSats()) + (target.height * target.scale) / window.screen.height < + VIEWPORT_VS_CLIENT_HEIGHT_RATIO ) { - return ( - Number(props.maxAmountSats) - Number(props.fee) - ).toLocaleString(navigator.languages[0]); + console.log("keyboard is shown"); + setFocusState("focused"); } else { - return localSats(); + console.log("keyboard is hidden"); + setFocusState("unfocused"); } - }; + } - return ( - -
- } - > - - - Edit - - -
- -
+ onMount(() => { + document.body.addEventListener("click", handleClickOutside); + if ("visualViewport" in window) { + window?.visualViewport?.addEventListener("resize", handleResize); + } + }); -
- -
-
- (satsInputRef = el)} - disabled={mode() === "fiat"} - type="text" - value={localSats()} - onInput={handleSatsInput} - inputMode="none" - /> - (fiatInputRef = el)} - disabled={mode() === "sats"} - type="text" - value={localFiat()} - onInput={handleFiatInput} - inputMode="none" - /> -
+ onCleanup(() => { + document.body.removeEventListener("click", handleClickOutside); + if ("visualViewport" in window) { + window?.visualViewport?.removeEventListener("resize", handleResize); + } + }); -
-
-
toggle(state.price === 0)} - > - - -
-
- - - - {betaWarning()} - - - - - {warningText()} - - - -
- - {(amount) => ( - - )} - - - - -
-
- - {(character) => ( - - )} - -
- - - -
-
- -
- - + return ( +
+
(divRef = el)} onMouseDown={handleMouseDown}> +
{ + e.preventDefault(); + props.onSubmit + ? props.onSubmit() + : setFocusState("unfocused"); + }} + > + + (satsInputRef = el)} + disabled={mode() === "fiat" || props.frozenAmount} + autofocus={mode() === "sats"} + type="text" + value={localSats()} + onInput={handleSatsInput} + inputMode={"decimal"} + autocomplete="off" + /> + (fiatInputRef = el)} + disabled={mode() === "sats" || props.frozenAmount} + autofocus={mode() === "fiat"} + type="text" + value={localFiat()} + onInput={handleFiatInput} + inputMode={"decimal"} + autocomplete="off" + /> +
+ toggle(state.price === 0)} + inputFocused={ + focusState() === "focused" && !props.frozenAmount + } + onFocus={() => focus()} + /> +
+ +

+ {`${i18n.t("receive.amount_editable.balance")} `} + +

+
+
); }; diff --git a/src/components/BalanceBox.tsx b/src/components/BalanceBox.tsx index 5ad76782..40821db5 100644 --- a/src/components/BalanceBox.tsx +++ b/src/components/BalanceBox.tsx @@ -155,7 +155,7 @@ export function BalanceBox(props: { loading?: boolean }) {
- -
- setIsOpen(false)} - > -
- -
- -
-
-
- + + + + Import Nostr Contacts + + + ); } diff --git a/src/components/ContactForm.tsx b/src/components/ContactForm.tsx index 1c480492..06548841 100644 --- a/src/components/ContactForm.tsx +++ b/src/components/ContactForm.tsx @@ -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; 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" >
- {props.title} {(field, props) => ( )} + + {(field, props) => ( + + )} +
diff --git a/src/components/ContactViewer.tsx b/src/components/ContactViewer.tsx index c9aa42b0..92536137 100644 --- a/src/components/ContactViewer.tsx +++ b/src/components/ContactViewer.tsx @@ -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 ( - + <> - -
- { - setIsOpen(false); - setIsEditing(false); - }} - > -
- -
- - - - - -
-
-
- - - - - - {props.contact.name[0]} - - -
+ + + + + + +
+
+
+ + + + + + {props.contact.name[0]} + + +
-

- {props.contact.name} -

+

+ {props.contact.name} +

-
- - - - - - - + + + + + + + + + - - - - - -
- {/* TODO: show payment history for a contact */} - {/* - - {i18n.t("contacts.no_payments")}{" "} - - {props.contact.name} - - - */} -
- - {/* TODO: implement contact editing */} -
- - -
+ /> + + +
-
-
- -
- - +
+
+ + +
+
+ + + + ); } diff --git a/src/components/ErrorDisplay.tsx b/src/components/ErrorDisplay.tsx index 0ba0b3d1..dafc33e9 100644 --- a/src/components/ErrorDisplay.tsx +++ b/src/components/ErrorDisplay.tsx @@ -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 ( {i18n.t("error.general.oh_no")} diff --git a/src/components/FeeDisplay.tsx b/src/components/FeeDisplay.tsx new file mode 100644 index 00000000..b99335d1 --- /dev/null +++ b/src/components/FeeDisplay.tsx @@ -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 ( +
+
{props.key}
+
{props.children}
+
+ ); +}; + +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 ( + + +
+ {`${amountInFiat()} `} + {state.fiat.value} +
+
+
+ ); +} + +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 ( +
+ {props.sign ? `${props.sign} ` : ""} + {prettyPrint()} {i18n.t("common.sats")} +
+ ); +}; + +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 ( +
+ +
+ + + +
+
+
+ + + + +
+
+
+ ); +} diff --git a/src/components/JsonModal.tsx b/src/components/JsonModal.tsx index 0f36e3c1..829fb1c0 100644 --- a/src/components/JsonModal.tsx +++ b/src/components/JsonModal.tsx @@ -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 ( - - - -
- -
- - {props.title} - - - - -
- -
-
-                                    {json()}
-                                
-
- {props.children} - -
-
-
-
-
+ +
+
{json()}
+
+ {props.children} +
+ +
+
); } diff --git a/src/components/LabelCircle.tsx b/src/components/LabelCircle.tsx new file mode 100644 index 00000000..dc287b74 --- /dev/null +++ b/src/components/LabelCircle.tsx @@ -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 ( +
+ + {text()} + + {"image"} { + props.onError && props.onError(); + setErrored(true); + }} + /> + + + channel open + + + channel close + + {text()} + +
+ ); +} diff --git a/src/components/LoadingIndicator.tsx b/src/components/LoadingIndicator.tsx index 1073fbb7..3a2328e3 100644 --- a/src/components/LoadingIndicator.tsx +++ b/src/components/LoadingIndicator.tsx @@ -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) { diff --git a/src/components/MethodChooser.tsx b/src/components/MethodChooser.tsx new file mode 100644 index 00000000..05d2323b --- /dev/null +++ b/src/components/MethodChooser.tsx @@ -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 ( + + + + + + + + + + + + ); +} diff --git a/src/components/MoreInfoModal.tsx b/src/components/MoreInfoModal.tsx index 13eb8613..ba095755 100644 --- a/src/components/MoreInfoModal.tsx +++ b/src/components/MoreInfoModal.tsx @@ -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 ( - - - - - - -
- - - {props.title} - - - - - -
{props.children}
-
-
-
-
-
+ <> + + + {props.children} + + ); }; diff --git a/src/components/MutinyPlusCta.tsx b/src/components/MutinyPlusCta.tsx index e8297aec..de7c1f2b 100644 --- a/src/components/MutinyPlusCta.tsx +++ b/src/components/MutinyPlusCta.tsx @@ -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 (
diff --git a/src/components/NWCEditor.tsx b/src/components/NWCEditor.tsx index 3c9f8da0..05db75c0 100644 --- a/src/components/NWCEditor.tsx +++ b/src/components/NWCEditor.tsx @@ -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,44 +406,39 @@ function NWCEditorForm(props: { {i18n.t("settings.connections.careful")} - - - {(field, _fieldProps) => ( -
- - } - > - + {(field, _fieldProps) => ( +
+ { - setValue( - budgetForm, - "budget_amount", - a.toString() - ); - }} /> - -

- {field.error} -

-
- )} - - + } + > + { + setValue( + budgetForm, + "budget_amount", + a.toString() + ); + }} + /> +
+

+ {field.error} +

+
+ )} +
diff --git a/src/components/NavBar.tsx b/src/components/NavBar.tsx index 06afc889..de71ec47 100644 --- a/src/components/NavBar.tsx +++ b/src/components/NavBar.tsx @@ -49,7 +49,7 @@ export function NavBar(props: { activeTab: ActiveTab }) { alt="home" /> { + 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 ( + + + {betaWarning()} + + + + {warningText()} + + + + ); +} diff --git a/src/components/ShareCard.tsx b/src/components/ShareCard.tsx index 2100f346..4f134eb8 100644 --- a/src/components/ShareCard.tsx +++ b/src/components/ShareCard.tsx @@ -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} /> -
+
+ + + + +
+ + + +
+
+

Contacts

+
+ 0}> + + {(contact) => ( + + )} + + + + + +

Global Search

+ }> + + +
+
+
+ + ); +} + +function GlobalSearch(props: { + searchValue: string; + sendToContact: (contact: TagItem) => void; + foundNpubs: (string | undefined)[]; +}) { + const hexpubs = createMemo(() => { + const hexpubs: string[] = []; + for (const npub of props.foundNpubs) { + hexpubFromNpub(npub) + .then((h) => { + if (h) { + hexpubs.push(h); + } + }) + .catch((e) => { + console.error(e); + }); + } + return hexpubs; + }); + + async function searchFetcher(args: { value?: string; hexpubs?: string[] }) { + try { + // Handling case when value starts with "npub" + if (args.value?.startsWith("npub")) { + const hexpub = await hexpubFromNpub(args.value); + if (!hexpub) return []; + + const profile = await actuallyFetchNostrProfile(hexpub); + if (!profile) return []; + + const contact = profileToPseudoContact(profile); + return contact.ln_address ? [contact] : []; + } + + // Handling case for other values (name, nip-05, whatever else primal searches) + const contacts = await searchProfiles(args.value!.toLowerCase()); + return contacts.filter( + (c) => c.ln_address && !args.hexpubs?.includes(c.hexpub) + ); + } catch (e) { + console.error(e); + return []; + } + } + + const searchArgs = createMemo(() => { + if (props.searchValue) { + return { + value: props.searchValue, + hexpubs: hexpubs() + }; + } else { + return { + value: "", + hexpubs: undefined + }; + } + }); + + const [searchResults] = createResource(searchArgs, searchFetcher); + + return ( + + +

+ No results found for "{props.searchValue}" +

+
+ + + {(contact) => ( + + )} + + +
+ ); +} + +function SingleContact(props: { + contact: PseudoContact; + sendToContact: (contact: TagItem) => void; +}) { + const [state, _actions] = useMegaStore(); + async function createContactFromSearchResult(contact: PseudoContact) { + try { + const contactId = await state.mutiny_wallet?.create_new_contact( + contact.name, + contact.hexpub ? contact.hexpub : undefined, + contact.ln_address ? contact.ln_address : undefined, + undefined, + contact.image_url ? contact.image_url : 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"); + } + + props.sendToContact(tagItem); + } catch (e) { + console.error(e); + } + } + + return ( + + ); +} diff --git a/src/routes/Send.tsx b/src/routes/Send.tsx index 0223ad45..d9a7c532 100644 --- a/src/routes/Send.tsx +++ b/src/routes/Send.tsx @@ -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 ( - - - - - - - - - - - - ); -} - -function DestinationInput(props: { - fieldDestination: string; - setFieldDestination: (destination: string) => void; - handleDecode: () => void; - handlePaste: () => void; -}) { - const i18n = useI18n(); - return ( - - {i18n.t("send.destination")} -