format everything with prettier

This commit is contained in:
Paul Miller
2023-06-06 09:56:59 -05:00
parent 2d99da5245
commit 53272434d8
93 changed files with 5806 additions and 4364 deletions

View File

@@ -1,48 +1,50 @@
module.exports = { module.exports = {
"env": { env: {
"browser": true, browser: true,
"es2021": true es2021: true
}, },
"extends": [ extends: [
"eslint:recommended", "eslint:recommended",
"plugin:@typescript-eslint/recommended", "plugin:@typescript-eslint/recommended",
"plugin:solid/typescript", "plugin:solid/typescript",
"plugin:import/typescript", "plugin:import/typescript",
"plugin:import/recommended" "plugin:import/recommended"
], ],
"overrides": [ overrides: [],
], parser: "@typescript-eslint/parser",
"parser": "@typescript-eslint/parser", parserOptions: {
"parserOptions": { tsconfigRootDir: "./",
"tsconfigRootDir": "./", project: ["./tsconfig.json"],
"project": ["./tsconfig.json"], ecmaVersion: "latest",
"ecmaVersion": "latest", sourceType: "module",
"sourceType": "module", ecmaFeatures: {
"ecmaFeatures": { jsx: true
"jsx": true
} }
}, },
"plugins": [ plugins: ["@typescript-eslint", "solid", "import"],
"@typescript-eslint", rules: {
"solid", "@typescript-eslint/no-unused-vars": [
"import" "warn",
{
argsIgnorePattern: "^_",
destructuredArrayIgnorePattern: "^_",
varsIgnorePattern: "^_"
}
], ],
"rules": {
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_", destructuredArrayIgnorePattern: "^_", varsIgnorePattern: "^_" }],
"solid/reactivity": "warn", "solid/reactivity": "warn",
"solid/no-destructure": "warn", "solid/no-destructure": "warn",
"solid/jsx-no-undef": "error", "solid/jsx-no-undef": "error",
"@typescript-eslint/no-non-null-assertion": "off", "@typescript-eslint/no-non-null-assertion": "off"
}, },
"settings": { settings: {
"import/parsers": { "import/parsers": {
"@typescript-eslint/parser": [".ts", ".tsx"] "@typescript-eslint/parser": [".ts", ".tsx"]
}, },
"import/resolver": { "import/resolver": {
"typescript": { typescript: {
"project": ["./tsconfig.json"], project: ["./tsconfig.json"],
"alwaysTryTypes": true alwaysTryTypes: true
} }
} }
} }
} };

32
.prettierignore Normal file
View File

@@ -0,0 +1,32 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# PWA dev stuff
dev-dist
.solid
/test-results/
/tests-examples/
/playwright-report/
/playwright/.cache/

View File

@@ -1,9 +1,9 @@
{ {
"trailingComma": "none", "trailingComma": "none",
"tabWidth": 2, "tabWidth": 4,
"semi": true, "semi": true,
"singleQuote": false, "singleQuote": false,
"arrowParens": "always", "arrowParens": "always",
"printWidth": 100, "printWidth": 80,
"useTabs": false "useTabs": false
} }

View File

@@ -9,9 +9,31 @@ pnpm install
pnpm run dev pnpm run dev
``` ```
### Env
The easiest way to get start with development is to create a file called `.env.local` and copy the contents of `.env.example` into it. This is basically identical to the env that `signet-app.mutinywallet.com` uses.
### Testing
We have a couple Playwright e2e tests in the e2e folder. You can run these with:
```
just test
```
Or get a visual look into what's happening:
```
just test-ui
```
### Formatting
Hopefully your editor picks up on the `.prettirrc` file and auto formats accordingly. If you want to format everything in the project run `pnpm run format`.
### Local ### Local
To make local development easier with a latest local version of [the node manager](https://github.com/MutinyWallet/mutiny-node), you may want to `pnpm link` it. If you want to develop against a local version of [the node manager](https://github.com/MutinyWallet/mutiny-node), you may want to `pnpm link` it.
Due to how [Vite's dev server works](https://vitejs.dev/config/server-options.html#server-fs-allow), the linked `mutiny-node` project folder should be a sibling of this `mutiny-web` folder. Alternatively you can change the allow path in `vite.config.ts`. Due to how [Vite's dev server works](https://vitejs.dev/config/server-options.html#server-fs-allow), the linked `mutiny-node` project folder should be a sibling of this `mutiny-web` folder. Alternatively you can change the allow path in `vite.config.ts`.

View File

@@ -8,3 +8,9 @@ local:
remote: remote:
pnpm unlink "@mutinywallet/mutiny-wasm" && pnpm install pnpm unlink "@mutinywallet/mutiny-wasm" && pnpm install
test:
pnpm exec playwright test
test-ui:
pnpm exec playwright test --ui

View File

@@ -8,7 +8,8 @@
"host": "solid-start dev --host", "host": "solid-start dev --host",
"build": "solid-start build", "build": "solid-start build",
"start": "solid-start start", "start": "solid-start start",
"lint": "eslint src --ext .ts,.tsx,.js" "lint": "eslint src --ext .ts,.tsx,.js",
"format": "npx prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,scss,md}\""
}, },
"type": "module", "type": "module",
"devDependencies": { "devDependencies": {
@@ -24,6 +25,7 @@
"eslint-plugin-prettier": "4.2.1", "eslint-plugin-prettier": "4.2.1",
"eslint-plugin-solid": "0.11.0", "eslint-plugin-solid": "0.11.0",
"postcss": "^8.4.23", "postcss": "^8.4.23",
"prettier": "^2.8.8",
"solid-start-node": "^0.2.26", "solid-start-node": "^0.2.26",
"tailwindcss": "^3.3.2", "tailwindcss": "^3.3.2",
"typescript": "^4.9.5", "typescript": "^4.9.5",

3
pnpm-lock.yaml generated
View File

@@ -87,6 +87,9 @@ devDependencies:
postcss: postcss:
specifier: ^8.4.23 specifier: ^8.4.23
version: 8.4.23 version: 8.4.23
prettier:
specifier: ^2.8.8
version: 2.8.8
solid-start-node: solid-start-node:
specifier: ^0.2.26 specifier: ^0.2.26
version: 0.2.26(solid-start@0.2.26)(undici@5.22.1)(vite@4.3.9) version: 0.2.26(solid-start@0.2.26)(undici@5.22.1)(vite@4.3.9)

View File

@@ -1,6 +1,18 @@
export function Back() { export function Back() {
return (<svg width="36" height="36" fill="none" xmlns="http://www.w3.org/2000/svg"> return (
<path d="M17.546 8 8 17.546l9.546 9.546" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" /> <svg
width="36"
height="36"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M17.546 8 8 17.546l9.546 9.546"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg> </svg>
) );
} }

View File

@@ -1,8 +1,24 @@
export function Paste() { export function Paste() {
return (<svg width="36" height="36" fill="none" xmlns="http://www.w3.org/2000/svg"> return (
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.025 4.275A3.5 3.5 0 0 1 7.5 3.25h5.25a2 2 0 1 1 0 4H8V31h20V7.25h-4.75a2 2 0 1 1 0-4h5.25a3.5 3.5 0 0 1 3.5 3.5V31.5a3.5 3.5 0 0 1-3.5 3.5h-21A3.5 3.5 0 0 1 4 31.5V6.75a3.5 3.5 0 0 1 1.025-2.475Z" fill="currentColor" /> <svg
width="36"
height="36"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M5.025 4.275A3.5 3.5 0 0 1 7.5 3.25h5.25a2 2 0 1 1 0 4H8V31h20V7.25h-4.75a2 2 0 1 1 0-4h5.25a3.5 3.5 0 0 1 3.5 3.5V31.5a3.5 3.5 0 0 1-3.5 3.5h-21A3.5 3.5 0 0 1 4 31.5V6.75a3.5 3.5 0 0 1 1.025-2.475Z"
fill="currentColor"
/>
<path d="M12.75 3h10.5v4.5h-10.5V3Z" fill="currentColor" /> <path d="M12.75 3h10.5v4.5h-10.5V3Z" fill="currentColor" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.75 3a2 2 0 0 1 2-2h10.5a2 2 0 0 1 2 2v4.5a2 2 0 0 1-2 2h-10.5a2 2 0 0 1-2-2V3Zm4 2v.5h6.5V5h-6.5Z" fill="currentColor" /> <path
</svg>) fill-rule="evenodd"
clip-rule="evenodd"
d="M10.75 3a2 2 0 0 1 2-2h10.5a2 2 0 0 1 2 2v4.5a2 2 0 0 1-2 2h-10.5a2 2 0 0 1-2-2V3Zm4 2v.5h6.5V5h-6.5Z"
fill="currentColor"
/>
</svg>
);
} }

View File

@@ -1,6 +1,18 @@
export function Scan() { export function Scan() {
return (<svg width="37" height="36" viewBox="0 0 37 36" fill="none" xmlns="http://www.w3.org/2000/svg"> return (
<path fill-rule="evenodd" clip-rule="evenodd" d="M26 3H30.5C32.1569 3 33.5 4.34315 33.5 6V10.5H36.5V6C36.5 2.68629 33.8137 0 30.5 0H26V3ZM11 3V0H6.5C3.18629 0 0.5 2.68629 0.5 6V10.5H3.5V6C3.5 4.34315 4.84315 3 6.5 3H11ZM3.5 25.5H0.5V30C0.5 33.3137 3.18629 36 6.5 36H11V33H6.5C4.84315 33 3.5 31.6569 3.5 30V25.5ZM26 33V36H30.5C33.8137 36 36.5 33.3137 36.5 30V25.5H33.5V30C33.5 31.6569 32.1569 33 30.5 33H26Z" fill="currentColor" /> <svg
</svg>) width="37"
height="36"
viewBox="0 0 37 36"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M26 3H30.5C32.1569 3 33.5 4.34315 33.5 6V10.5H36.5V6C36.5 2.68629 33.8137 0 30.5 0H26V3ZM11 3V0H6.5C3.18629 0 0.5 2.68629 0.5 6V10.5H3.5V6C3.5 4.34315 4.84315 3 6.5 3H11ZM3.5 25.5H0.5V30C0.5 33.3137 3.18629 36 6.5 36H11V33H6.5C4.84315 33 3.5 31.6569 3.5 30V25.5ZM26 33V36H30.5C33.8137 36 36.5 33.3137 36.5 30V25.5H33.5V30C33.5 31.6569 32.1569 33 30.5 33H26Z"
fill="currentColor"
/>
</svg>
);
} }

View File

@@ -8,8 +8,10 @@ import { DetailsIdModal } from "./DetailsModal";
export const THREE_COLUMNS = 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"; "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 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 MISSING_LABEL =
export const REDSHIFT_LABEL = "py-1 px-2 bg-white text-m-red rounded inline-block text-sm"; "py-1 px-2 bg-white/10 rounded inline-block text-sm";
export const REDSHIFT_LABEL =
"py-1 px-2 bg-white text-m-red rounded inline-block text-sm";
export const RIGHT_COLUMN = "flex flex-col items-right text-right max-w-[8rem]"; export const RIGHT_COLUMN = "flex flex-col items-right text-right max-w-[8rem]";
export type OnChainTx = { export type OnChainTx = {
@@ -42,7 +44,10 @@ function UnifiedActivityItem(props: {
onClick: (id: string, kind: HackActivityType) => void; onClick: (id: string, kind: HackActivityType) => void;
}) { }) {
const click = () => { const click = () => {
props.onClick(props.item.id, props.item.kind as unknown as HackActivityType); props.onClick(
props.item.id,
props.item.kind as unknown as HackActivityType
);
}; };
return ( return (
@@ -97,17 +102,25 @@ export function CombinedActivity(props: { limit?: number }) {
<NiceP>Receive some sats to get started</NiceP> <NiceP>Receive some sats to get started</NiceP>
</div> </div>
</Match> </Match>
<Match when={props.limit && state.activity.length > props.limit}> <Match
when={props.limit && state.activity.length > props.limit}
>
<For each={state.activity.slice(0, props.limit)}> <For each={state.activity.slice(0, props.limit)}>
{(activityItem) => ( {(activityItem) => (
<UnifiedActivityItem item={activityItem} onClick={openDetailsModal} /> <UnifiedActivityItem
item={activityItem}
onClick={openDetailsModal}
/>
)} )}
</For> </For>
</Match> </Match>
<Match when={state.activity.length >= 0}> <Match when={state.activity.length >= 0}>
<For each={state.activity}> <For each={state.activity}>
{(activityItem) => ( {(activityItem) => (
<UnifiedActivityItem item={activityItem} onClick={openDetailsModal} /> <UnifiedActivityItem
item={activityItem}
onClick={openDetailsModal}
/>
)} )}
</For> </For>
</Match> </Match>

View File

@@ -1,4 +1,10 @@
import { Match, ParentComponent, Switch, createMemo, createResource } from "solid-js"; import {
Match,
ParentComponent,
Switch,
createMemo,
createResource
} from "solid-js";
import { satsToUsd } from "~/utils/conversions"; import { satsToUsd } from "~/utils/conversions";
import bolt from "~/assets/icons/bolt.svg"; import bolt from "~/assets/icons/bolt.svg";
import chain from "~/assets/icons/chain.svg"; import chain from "~/assets/icons/chain.svg";
@@ -35,27 +41,42 @@ export const ActivityAmount: ParentComponent<{
return ( return (
<div <div
class="flex flex-col" class="flex flex-col"
classList={{ "items-end": !props.center, "items-center": props.center }} classList={{
"items-end": !props.center,
"items-center": props.center
}}
>
<div
class="text-base"
classList={{ "text-m-green": props.positive }}
> >
<div class="text-base" classList={{ "text-m-green": props.positive }}>
{props.positive && "+ "} {props.positive && "+ "}
{prettyPrint()}&nbsp;<span class="text-sm">SATS</span> {prettyPrint()}&nbsp;<span class="text-sm">SATS</span>
</div> </div>
<div class="text-sm text-neutral-500"> <div class="text-sm text-neutral-500">
&#8776;&nbsp;{amountInUsd()}&nbsp;<span class="text-sm">USD</span> &#8776;&nbsp;{amountInUsd()}&nbsp;
<span class="text-sm">USD</span>
</div> </div>
</div> </div>
); );
}; };
function LabelCircle(props: { name?: string; contact: boolean; label: boolean }) { function LabelCircle(props: {
name?: string;
contact: boolean;
label: boolean;
}) {
// TODO: don't need to run this if it's not a contact // TODO: don't need to run this if it's not a contact
const [gradient] = createResource(async () => { const [gradient] = createResource(async () => {
return generateGradient(props.name || "?"); return generateGradient(props.name || "?");
}); });
const text = () => const text = () =>
props.contact && props.name && props.name.length ? props.name[0] : props.label ? "≡" : "?"; props.contact && props.name && props.name.length
? props.name[0]
: props.label
? "≡"
: "?";
const bg = () => (props.name && props.contact ? gradient() : "gray"); const bg = () => (props.name && props.contact ? gradient() : "gray");
return ( return (
@@ -82,7 +103,8 @@ export function ActivityItem(props: {
}) { }) {
const [state, _actions] = useMegaStore(); const [state, _actions] = useMegaStore();
const firstContact = () => (props.contacts?.length ? props.contacts[0] : null); const firstContact = () =>
props.contacts?.length ? props.contacts[0] : null;
return ( return (
<div <div
@@ -115,13 +137,19 @@ export function ActivityItem(props: {
<div class="flex flex-col"> <div class="flex flex-col">
<Switch> <Switch>
<Match when={firstContact()?.name}> <Match when={firstContact()?.name}>
<span class="text-base font-semibold truncate">{firstContact()?.name}</span> <span class="text-base font-semibold truncate">
{firstContact()?.name}
</span>
</Match> </Match>
<Match when={props.labels.length > 0}> <Match when={props.labels.length > 0}>
<span class="text-base font-semibold truncate">{props.labels[0]}</span> <span class="text-base font-semibold truncate">
{props.labels[0]}
</span>
</Match> </Match>
<Match when={true}> <Match when={true}>
<span class="text-base font-semibold text-neutral-500">Unknown</span> <span class="text-base font-semibold text-neutral-500">
Unknown
</span>
</Match> </Match>
</Switch> </Switch>
<Switch> <Switch>
@@ -129,7 +157,9 @@ export function ActivityItem(props: {
<time class="text-sm text-neutral-500">Pending</time> <time class="text-sm text-neutral-500">Pending</time>
</Match> </Match>
<Match when={true}> <Match when={true}>
<time class="text-sm text-neutral-500">{timeAgo(props.date)}</time> <time class="text-sm text-neutral-500">
{timeAgo(props.date)}
</time>
</Match> </Match>
</Switch> </Switch>
</div> </div>

View File

@@ -1,12 +1,12 @@
import { Show } from "solid-js" import { Show } from "solid-js";
import { useMegaStore } from "~/state/megaStore" import { useMegaStore } from "~/state/megaStore";
import { satsToUsd } from "~/utils/conversions" import { satsToUsd } from "~/utils/conversions";
function prettyPrintAmount(n?: number | bigint): string { function prettyPrintAmount(n?: number | bigint): string {
if (!n || n.valueOf() === 0) { if (!n || n.valueOf() === 0) {
return "0" return "0";
} }
return n.toLocaleString() return n.toLocaleString();
} }
export function Amount(props: { export function Amount(props: {
@@ -17,12 +17,17 @@ export function Amount(props: {
}) { }) {
const [state, _] = useMegaStore(); const [state, _] = useMegaStore();
const amountInUsd = () => satsToUsd(state.price, Number(props.amountSats) || 0, true); const amountInUsd = () =>
satsToUsd(state.price, Number(props.amountSats) || 0, true);
return ( return (
<div class="flex flex-col gap-2" classList={{ "items-center": props.centered }}> <div
class="flex flex-col gap-2"
classList={{ "items-center": props.centered }}
>
<h1 class="text-4xl font-light"> <h1 class="text-4xl font-light">
{props.loading ? "..." : prettyPrintAmount(props.amountSats)}&nbsp; {props.loading ? "..." : prettyPrintAmount(props.amountSats)}
&nbsp;
<span class="text-xl">SATS</span> <span class="text-xl">SATS</span>
</h1> </h1>
<Show when={props.showFiat}> <Show when={props.showFiat}>
@@ -36,13 +41,15 @@ export function Amount(props: {
} }
export function AmountSmall(props: { export function AmountSmall(props: {
amountSats: bigint | number | undefined amountSats: bigint | number | undefined;
}) { }) {
return ( return (
<span class="font-light"> <span class="font-light">
{prettyPrintAmount(props.amountSats)}&nbsp; {prettyPrintAmount(props.amountSats)}&nbsp;
<span class="text-sm"> <span class="text-sm">
{props.amountSats === 1 || props.amountSats === 1n ? "SAT" : "SATS"} {props.amountSats === 1 || props.amountSats === 1n
? "SAT"
: "SATS"}
</span> </span>
</span> </span>
); );

View File

@@ -10,16 +10,21 @@ const noop = () => {
const KeyValue: ParentComponent<{ key: string; gray?: boolean }> = (props) => { const KeyValue: ParentComponent<{ key: string; gray?: boolean }> = (props) => {
return ( return (
<div class="flex justify-between items-center" classList={{ "text-neutral-400": props.gray }}> <div
class="flex justify-between items-center"
classList={{ "text-neutral-400": props.gray }}
>
<div class="font-semibold uppercase">{props.key}</div> <div class="font-semibold uppercase">{props.key}</div>
<div class="font-light">{props.children}</div> <div class="font-light">{props.children}</div>
</div> </div>
); );
}; };
export const InlineAmount: ParentComponent<{ amount: string; sign?: string; fiat?: boolean }> = ( export const InlineAmount: ParentComponent<{
props amount: string;
) => { sign?: string;
fiat?: boolean;
}> = (props) => {
const prettyPrint = createMemo(() => { const prettyPrint = createMemo(() => {
const parsed = Number(props.amount); const parsed = Number(props.amount);
if (isNaN(parsed)) { if (isNaN(parsed)) {
@@ -33,20 +38,23 @@ export const InlineAmount: ParentComponent<{ amount: string; sign?: string; fiat
<div class="inline-block text-lg"> <div class="inline-block text-lg">
{props.sign ? `${props.sign} ` : ""} {props.sign ? `${props.sign} ` : ""}
{props.fiat ? "$" : ""} {props.fiat ? "$" : ""}
{prettyPrint()} <span class="text-sm">{props.fiat ? "USD" : "SATS"}</span> {prettyPrint()}{" "}
<span class="text-sm">{props.fiat ? "USD" : "SATS"}</span>
</div> </div>
); );
}; };
function USDShower(props: { amountSats: string; fee?: string }) { function USDShower(props: { amountSats: string; fee?: string }) {
const [state, _] = useMegaStore(); const [state, _] = useMegaStore();
const amountInUsd = () => satsToUsd(state.price, add(props.amountSats, props.fee), true); const amountInUsd = () =>
satsToUsd(state.price, add(props.amountSats, props.fee), true);
return ( return (
<Show when={!(props.amountSats === "0")}> <Show when={!(props.amountSats === "0")}>
<KeyValue gray key=""> <KeyValue gray key="">
<div class="self-end"> <div class="self-end">
&#8776; {amountInUsd()}&nbsp;<span class="text-sm">USD</span> &#8776; {amountInUsd()}&nbsp;
<span class="text-sm">USD</span>
</div> </div>
</KeyValue> </KeyValue>
</Show> </Show>
@@ -74,12 +82,20 @@ export function AmountCard(props: {
<KeyValue key="Amount"> <KeyValue key="Amount">
<Show <Show
when={props.isAmountEditable} when={props.isAmountEditable}
fallback={<InlineAmount amount={props.amountSats} />} fallback={
<InlineAmount
amount={props.amountSats}
/>
}
> >
<AmountEditable <AmountEditable
initialOpen={props.initialOpen ?? false} initialOpen={props.initialOpen ?? false}
initialAmountSats={props.amountSats.toString()} initialAmountSats={props.amountSats.toString()}
setAmountSats={props.setAmountSats ? props.setAmountSats : noop} setAmountSats={
props.setAmountSats
? props.setAmountSats
: noop
}
/> />
</Show> </Show>
</KeyValue> </KeyValue>
@@ -90,15 +106,28 @@ export function AmountCard(props: {
<hr class="border-white/20" /> <hr class="border-white/20" />
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<KeyValue key="Total"> <KeyValue key="Total">
<InlineAmount amount={add(props.amountSats, props.fee).toString()} /> <InlineAmount
amount={add(
props.amountSats,
props.fee
).toString()}
/>
</KeyValue> </KeyValue>
<USDShower amountSats={props.amountSats} fee={props.fee} /> <USDShower
amountSats={props.amountSats}
fee={props.fee}
/>
</div> </div>
</Match> </Match>
<Match when={props.reserve}> <Match when={props.reserve}>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<KeyValue key="Channel size"> <KeyValue key="Channel size">
<InlineAmount amount={add(props.amountSats, props.reserve).toString()} /> <InlineAmount
amount={add(
props.amountSats,
props.reserve
).toString()}
/>
</KeyValue> </KeyValue>
<KeyValue gray key="- Channel Reserve"> <KeyValue gray key="- Channel Reserve">
<InlineAmount amount={props.reserve || "0"} /> <InlineAmount amount={props.reserve || "0"} />
@@ -109,7 +138,10 @@ export function AmountCard(props: {
<KeyValue key="Spendable"> <KeyValue key="Spendable">
<InlineAmount amount={props.amountSats} /> <InlineAmount amount={props.amountSats} />
</KeyValue> </KeyValue>
<USDShower amountSats={props.amountSats} fee={props.reserve} /> <USDShower
amountSats={props.amountSats}
fee={props.reserve}
/>
</div> </div>
</Match> </Match>
<Match when={!props.fee && !props.reserve}> <Match when={!props.fee && !props.reserve}>
@@ -117,12 +149,20 @@ export function AmountCard(props: {
<KeyValue key="Amount"> <KeyValue key="Amount">
<Show <Show
when={props.isAmountEditable} when={props.isAmountEditable}
fallback={<InlineAmount amount={props.amountSats} />} fallback={
<InlineAmount
amount={props.amountSats}
/>
}
> >
<AmountEditable <AmountEditable
initialOpen={props.initialOpen ?? false} initialOpen={props.initialOpen ?? false}
initialAmountSats={props.amountSats.toString()} initialAmountSats={props.amountSats.toString()}
setAmountSats={props.setAmountSats ? props.setAmountSats : noop} setAmountSats={
props.setAmountSats
? props.setAmountSats
: noop
}
/> />
</Show> </Show>
</KeyValue> </KeyValue>

View File

@@ -1,4 +1,11 @@
import { For, ParentComponent, Show, createResource, createSignal, onMount } from "solid-js"; import {
For,
ParentComponent,
Show,
createResource,
createSignal,
onMount
} from "solid-js";
import { Button } from "~/components/layout"; import { Button } from "~/components/layout";
import { useMegaStore } from "~/state/megaStore"; import { useMegaStore } from "~/state/megaStore";
import { satsToUsd, usdToSats } from "~/utils/conversions"; import { satsToUsd, usdToSats } from "~/utils/conversions";
@@ -10,7 +17,20 @@ import { DIALOG_CONTENT, DIALOG_POSITIONER } from "~/styles/dialogs";
import { InfoBox } from "./InfoBox"; import { InfoBox } from "./InfoBox";
import { Network } from "~/logic/mutinyWalletSetup"; import { Network } from "~/logic/mutinyWalletSetup";
const CHARACTERS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", ".", "0", "DEL"]; const CHARACTERS = [
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
".",
"0",
"DEL"
];
const FIXED_AMOUNTS_SATS = [ const FIXED_AMOUNTS_SATS = [
{ label: "10k", amount: "10000" }, { label: "10k", amount: "10000" },
@@ -32,7 +52,9 @@ function fiatInputSanitizer(input: string): string {
const cleaned = numeric.replace(/^0([^.]|$)/g, "$1").replace(/^\./g, "0."); const cleaned = numeric.replace(/^0([^.]|$)/g, "$1").replace(/^\./g, "0.");
// If there are three characters after the decimal, shift the decimal // If there are three characters after the decimal, shift the decimal
const shifted = cleaned.match(/(\.[0-9]{3}).*/g) ? (parseFloat(cleaned) * 10).toFixed(2) : cleaned; const shifted = cleaned.match(/(\.[0-9]{3}).*/g)
? (parseFloat(cleaned) * 10).toFixed(2)
: cleaned;
// Truncate any numbers two past the decimal // Truncate any numbers two past the decimal
const twoDecimals = shifted.replace(/(\.[0-9]{2}).*/g, "$1"); const twoDecimals = shifted.replace(/(\.[0-9]{2}).*/g, "$1");
@@ -56,7 +78,10 @@ function SingleDigitButton(props: {
}) { }) {
return ( return (
// Skip the "." if it's fiat // Skip the "." if it's fiat
<Show when={props.fiat || !(props.character === ".")} fallback={<div />}> <Show
when={props.fiat || !(props.character === ".")}
fallback={<div />}
>
<button <button
class="disabled:opacity-50 p-2 rounded-lg md:hover:bg-white/10 active:bg-m-blue text-white text-4xl font-semi font-mono" class="disabled:opacity-50 p-2 rounded-lg md:hover:bg-white/10 active:bg-m-blue text-white text-4xl font-semi font-mono"
onClick={() => props.onClick(props.character)} onClick={() => props.onClick(props.character)}
@@ -83,7 +108,8 @@ function BigScalingText(props: { text: string; fiat: boolean }) {
"scale-150": chars() <= 4 "scale-150": chars() <= 4
}} }}
> >
{props.text}&nbsp;<span class="text-xl">{props.fiat ? "USD" : "SATS"}</span> {props.text}&nbsp;
<span class="text-xl">{props.fiat ? "USD" : "SATS"}</span>
</h1> </h1>
); );
} }
@@ -91,7 +117,8 @@ function BigScalingText(props: { text: string; fiat: boolean }) {
function SmallSubtleAmount(props: { text: string; fiat: boolean }) { function SmallSubtleAmount(props: { text: string; fiat: boolean }) {
return ( return (
<h2 class="text-xl font-light text-neutral-400"> <h2 class="text-xl font-light text-neutral-400">
&#8776;&nbsp;{props.text}&nbsp;<span class="text-sm">{props.fiat ? "USD" : "SATS"}</span> &#8776;&nbsp;{props.text}&nbsp;
<span class="text-sm">{props.fiat ? "USD" : "SATS"}</span>
</h2> </h2>
); );
} }
@@ -113,9 +140,15 @@ export const AmountEditable: ParentComponent<{
const [isOpen, setIsOpen] = createSignal(props.initialOpen); const [isOpen, setIsOpen] = createSignal(props.initialOpen);
const [state, _actions] = useMegaStore(); const [state, _actions] = useMegaStore();
const [mode, setMode] = createSignal<"fiat" | "sats">("sats"); const [mode, setMode] = createSignal<"fiat" | "sats">("sats");
const [localSats, setLocalSats] = createSignal(props.initialAmountSats || "0"); const [localSats, setLocalSats] = createSignal(
props.initialAmountSats || "0"
);
const [localFiat, setLocalFiat] = createSignal( const [localFiat, setLocalFiat] = createSignal(
satsToUsd(state.price, parseInt(props.initialAmountSats || "0") || 0, false) satsToUsd(
state.price,
parseInt(props.initialAmountSats || "0") || 0,
false
)
); );
const displaySats = () => toDisplayHandleNaN(localSats(), false); const displaySats = () => toDisplayHandleNaN(localSats(), false);
@@ -159,7 +192,9 @@ export const AmountEditable: ParentComponent<{
function handleCharacterInput(character: string) { function handleCharacterInput(character: string) {
const isFiatMode = mode() === "fiat"; const isFiatMode = mode() === "fiat";
const inputSanitizer = isFiatMode ? fiatInputSanitizer : satsInputSanitizer; const inputSanitizer = isFiatMode
? fiatInputSanitizer
: satsInputSanitizer;
const localValue = isFiatMode ? localFiat : localSats; const localValue = isFiatMode ? localFiat : localSats;
let sane; let sane;
@@ -176,7 +211,9 @@ export const AmountEditable: ParentComponent<{
if (isFiatMode) { if (isFiatMode) {
setLocalFiat(sane); setLocalFiat(sane);
setLocalSats(usdToSats(state.price, parseFloat(sane || "0") || 0, false)); setLocalSats(
usdToSats(state.price, parseFloat(sane || "0") || 0, false)
);
} else { } else {
setLocalSats(sane); setLocalSats(sane);
setLocalFiat(satsToUsd(state.price, Number(sane) || 0, false)); setLocalFiat(satsToUsd(state.price, Number(sane) || 0, false));
@@ -189,7 +226,9 @@ export const AmountEditable: ParentComponent<{
function setFixedAmount(amount: string) { function setFixedAmount(amount: string) {
if (mode() === "fiat") { if (mode() === "fiat") {
setLocalFiat(amount); setLocalFiat(amount);
setLocalSats(usdToSats(state.price, parseFloat(amount || "0") || 0, false)); setLocalSats(
usdToSats(state.price, parseFloat(amount || "0") || 0, false)
);
} else { } else {
setLocalSats(amount); setLocalSats(amount);
setLocalFiat(satsToUsd(state.price, Number(amount) || 0, false)); setLocalFiat(satsToUsd(state.price, Number(amount) || 0, false));
@@ -214,7 +253,9 @@ export const AmountEditable: ParentComponent<{
const { value } = e.target as HTMLInputElement; const { value } = e.target as HTMLInputElement;
const sane = fiatInputSanitizer(value); const sane = fiatInputSanitizer(value);
setLocalFiat(sane); setLocalFiat(sane);
setLocalSats(usdToSats(state.price, parseFloat(sane || "0") || 0, false)); setLocalSats(
usdToSats(state.price, parseFloat(sane || "0") || 0, false)
);
} }
function toggle() { function toggle() {
@@ -245,7 +286,9 @@ export const AmountEditable: ParentComponent<{
> >
<Show <Show
when={localSats() !== "0"} when={localSats() !== "0"}
fallback={<div class="inline-block font-semibold">Set amount</div>} fallback={
<div class="inline-block font-semibold">Set amount</div>
}
> >
<InlineAmount amount={localSats()} /> <InlineAmount amount={localSats()} />
</Show> </Show>
@@ -255,7 +298,10 @@ export const AmountEditable: ParentComponent<{
<Dialog.Portal> <Dialog.Portal>
{/* <Dialog.Overlay class={OVERLAY} /> */} {/* <Dialog.Overlay class={OVERLAY} /> */}
<div class={DIALOG_POSITIONER}> <div class={DIALOG_POSITIONER}>
<Dialog.Content class={DIALOG_CONTENT} onEscapeKeyDown={() => setIsOpen(false)}> <Dialog.Content
class={DIALOG_CONTENT}
onEscapeKeyDown={() => setIsOpen(false)}
>
{/* TODO: figure out how to submit on enter */} {/* TODO: figure out how to submit on enter */}
<div class="w-full flex justify-end"> <div class="w-full flex justify-end">
<button <button
@@ -266,7 +312,10 @@ export const AmountEditable: ParentComponent<{
</button> </button>
</div> </div>
{/* <form onSubmit={handleSubmit} class="text-black"> */} {/* <form onSubmit={handleSubmit} class="text-black"> */}
<form onSubmit={handleSubmit} class="opacity-0 absolute -z-10"> <form
onSubmit={handleSubmit}
class="opacity-0 absolute -z-10"
>
<input <input
ref={(el) => (satsInputRef = el)} ref={(el) => (satsInputRef = el)}
disabled={mode() === "fiat"} disabled={mode() === "fiat"}
@@ -286,21 +335,40 @@ export const AmountEditable: ParentComponent<{
</form> </form>
<div class="flex flex-col flex-1 justify-around gap-2 max-w-[400px] mx-auto w-full"> <div class="flex flex-col flex-1 justify-around gap-2 max-w-[400px] mx-auto w-full">
<div class="p-4 flex flex-col gap-4 items-center justify-center" onClick={toggle}> <div
class="p-4 flex flex-col gap-4 items-center justify-center"
onClick={toggle}
>
<BigScalingText <BigScalingText
text={mode() === "fiat" ? displayFiat() : displaySats()} text={
mode() === "fiat"
? displayFiat()
: displaySats()
}
fiat={mode() === "fiat"} fiat={mode() === "fiat"}
/> />
<SmallSubtleAmount <SmallSubtleAmount
text={mode() === "fiat" ? displaySats() : displayFiat()} text={
mode() === "fiat"
? displaySats()
: displayFiat()
}
fiat={mode() !== "fiat"} fiat={mode() !== "fiat"}
/> />
</div> </div>
<Show when={warningText()}> <Show when={warningText()}>
<InfoBox accent="green">{warningText()}</InfoBox> <InfoBox accent="green">
{warningText()}
</InfoBox>
</Show> </Show>
<div class="flex justify-center gap-4 my-2"> <div class="flex justify-center gap-4 my-2">
<For each={mode() === "fiat" ? FIXED_AMOUNTS_USD : FIXED_AMOUNTS_SATS}> <For
each={
mode() === "fiat"
? FIXED_AMOUNTS_USD
: FIXED_AMOUNTS_SATS
}
>
{(amount) => ( {(amount) => (
<button <button
onClick={() => { onClick={() => {
@@ -325,7 +393,11 @@ export const AmountEditable: ParentComponent<{
)} )}
</For> </For>
</div> </div>
<Button intent="blue" class="w-full flex-none" onClick={handleSubmit}> <Button
intent="blue"
class="w-full flex-none"
onClick={handleSubmit}
>
Set Amount Set Amount
</Button> </Button>
</div> </div>

View File

@@ -1,14 +1,14 @@
import logo from '~/assets/icons/mutiny-logo.svg'; import logo from "~/assets/icons/mutiny-logo.svg";
import { DefaultMain, SafeArea, VStack, Card } from "~/components/layout"; import { DefaultMain, SafeArea, VStack, Card } from "~/components/layout";
import BalanceBox, { LoadingShimmer } from "~/components/BalanceBox"; import BalanceBox, { LoadingShimmer } from "~/components/BalanceBox";
import NavBar from "~/components/NavBar"; import NavBar from "~/components/NavBar";
import ReloadPrompt from "~/components/Reload"; import ReloadPrompt from "~/components/Reload";
import { A } from 'solid-start'; import { A } from "solid-start";
import { OnboardWarning } from '~/components/OnboardWarning'; import { OnboardWarning } from "~/components/OnboardWarning";
import { CombinedActivity } from './Activity'; import { CombinedActivity } from "./Activity";
import userClock from '~/assets/icons/user-clock.svg'; import userClock from "~/assets/icons/user-clock.svg";
import { useMegaStore } from '~/state/megaStore'; import { useMegaStore } from "~/state/megaStore";
import { Show } from 'solid-js'; import { Show } from "solid-js";
import { ExternalLink } from "./layout/ExternalLink"; import { ExternalLink } from "./layout/ExternalLink";
export default function App() { export default function App() {
@@ -19,7 +19,10 @@ export default function App() {
<DefaultMain> <DefaultMain>
<header class="w-full flex justify-between items-center mt-4 mb-2"> <header class="w-full flex justify-between items-center mt-4 mb-2">
<img src={logo} class="h-10" alt="logo" /> <img src={logo} class="h-10" alt="logo" />
<A class="md:hidden p-2 hover:bg-white/5 rounded-lg active:bg-m-blue" href="/activity"> <A
class="md:hidden p-2 hover:bg-white/5 rounded-lg active:bg-m-blue"
href="/activity"
>
<img src={userClock} alt="Activity" class="h-8 w-8" /> <img src={userClock} alt="Activity" class="h-8 w-8" />
</A> </A>
</header> </header>
@@ -31,7 +34,10 @@ export default function App() {
<Card title="Activity"> <Card title="Activity">
<div class="p-1" /> <div class="p-1" />
<VStack> <VStack>
<Show when={!state.wallet_loading} fallback={<LoadingShimmer />}> <Show
when={!state.wallet_loading}
fallback={<LoadingShimmer />}
>
<CombinedActivity limit={3} /> <CombinedActivity limit={3} />
</Show> </Show>
{/* <ButtonLink href="/activity">View All</ButtonLink> */} {/* <ButtonLink href="/activity">View All</ButtonLink> */}

View File

@@ -32,19 +32,30 @@ export default function BalanceBox(props: { loading?: boolean }) {
const navigate = useNavigate(); const navigate = useNavigate();
const totalOnchain = () => (state.balance?.confirmed || 0n) + (state.balance?.unconfirmed || 0n) + (state.balance?.force_close || 0n); const totalOnchain = () =>
(state.balance?.confirmed || 0n) +
(state.balance?.unconfirmed || 0n) +
(state.balance?.force_close || 0n);
return ( return (
<> <>
<FancyCard title="Lightning"> <FancyCard title="Lightning">
<Show when={!props.loading} fallback={<LoadingShimmer />}> <Show when={!props.loading} fallback={<LoadingShimmer />}>
<Amount amountSats={state.balance?.lightning || 0} showFiat /> <Amount
amountSats={state.balance?.lightning || 0}
showFiat
/>
</Show> </Show>
</FancyCard> </FancyCard>
<FancyCard <FancyCard
title="On-Chain" title="On-Chain"
subtitle={((Number(state.balance?.unconfirmed) || 0) + (Number(state.balance?.force_close) || 0)) ? "Unconfirmed" : undefined} subtitle={
(Number(state.balance?.unconfirmed) || 0) +
(Number(state.balance?.force_close) || 0)
? "Unconfirmed"
: undefined
}
> >
<Show when={!props.loading} fallback={<LoadingShimmer />}> <Show when={!props.loading} fallback={<LoadingShimmer />}>
<div class="flex justify-between"> <div class="flex justify-between">
@@ -52,7 +63,11 @@ export default function BalanceBox(props: { loading?: boolean }) {
<Show when={!emptyBalance()}> <Show when={!emptyBalance()}>
<div class="self-end justify-self-end"> <div class="self-end justify-self-end">
<A href="/swap" class={STYLE}> <A href="/swap" class={STYLE}>
<img src={shuffle} alt="swap" class="h-8 w-8" /> <img
src={shuffle}
alt="swap"
class="h-8 w-8"
/>
</A> </A>
</div> </div>
</Show> </Show>
@@ -67,7 +82,11 @@ export default function BalanceBox(props: { loading?: boolean }) {
> >
Send Send
</Button> </Button>
<Button onClick={() => navigate("/receive")} disabled={props.loading} intent="blue"> <Button
onClick={() => navigate("/receive")}
disabled={props.loading}
intent="blue"
>
Receive Receive
</Button> </Button>
</div> </div>

View File

@@ -1,50 +1,69 @@
import { Match, Switch, createSignal } from 'solid-js'; import { Match, Switch, createSignal } from "solid-js";
import { SmallHeader, TinyButton } from '~/components/layout'; import { SmallHeader, TinyButton } from "~/components/layout";
import { Dialog } from '@kobalte/core'; import { Dialog } from "@kobalte/core";
import close from "~/assets/icons/close.svg"; import close from "~/assets/icons/close.svg";
import { SubmitHandler } from '@modular-forms/solid'; import { SubmitHandler } from "@modular-forms/solid";
import { ContactForm } from './ContactForm'; import { ContactForm } from "./ContactForm";
import { ContactFormValues } from './ContactViewer'; import { ContactFormValues } from "./ContactViewer";
import { DIALOG_CONTENT, DIALOG_POSITIONER } from '~/styles/dialogs'; import { DIALOG_CONTENT, DIALOG_POSITIONER } from "~/styles/dialogs";
export function ContactEditor(props: { createContact: (contact: ContactFormValues) => void, list?: boolean }) { export function ContactEditor(props: {
createContact: (contact: ContactFormValues) => void;
list?: boolean;
}) {
const [isOpen, setIsOpen] = createSignal(false); const [isOpen, setIsOpen] = createSignal(false);
// What we're all here for in the first place: returning a value // What we're all here for in the first place: returning a value
const handleSubmit: SubmitHandler<ContactFormValues> = (c: ContactFormValues) => { const handleSubmit: SubmitHandler<ContactFormValues> = (
props.createContact(c) c: ContactFormValues
) => {
props.createContact(c);
setIsOpen(false); setIsOpen(false);
} };
return ( return (
<Dialog.Root open={isOpen()}> <Dialog.Root open={isOpen()}>
<Switch> <Switch>
<Match when={props.list}> <Match when={props.list}>
<button onClick={() => setIsOpen(true)} class="flex flex-col items-center gap-2"> <button
onClick={() => setIsOpen(true)}
class="flex flex-col items-center gap-2"
>
<div class="bg-neutral-500 flex-none h-16 w-16 rounded-full flex items-center justify-center text-4xl uppercase "> <div class="bg-neutral-500 flex-none h-16 w-16 rounded-full flex items-center justify-center text-4xl uppercase ">
<span class="leading-[4rem]">+</span> <span class="leading-[4rem]">+</span>
</div> </div>
<SmallHeader class="overflow-ellipsis"> <SmallHeader class="overflow-ellipsis">new</SmallHeader>
new
</SmallHeader>
</button> </button>
</Match> </Match>
<Match when={!props.list}> <Match when={!props.list}>
<TinyButton onClick={() => setIsOpen(true)}>+ Add Contact</TinyButton> <TinyButton onClick={() => setIsOpen(true)}>
+ Add Contact
</TinyButton>
</Match> </Match>
</Switch> </Switch>
<Dialog.Portal> <Dialog.Portal>
<div class={DIALOG_POSITIONER}> <div class={DIALOG_POSITIONER}>
<Dialog.Content class={DIALOG_CONTENT} onEscapeKeyDown={() => setIsOpen(false)}> <Dialog.Content
class={DIALOG_CONTENT}
onEscapeKeyDown={() => setIsOpen(false)}
>
<div class="w-full flex justify-end"> <div class="w-full flex justify-end">
<button tabindex="-1" onClick={() => setIsOpen(false)} class="hover:bg-white/10 rounded-lg active:bg-m-blue"> <button
tabindex="-1"
onClick={() => setIsOpen(false)}
class="hover:bg-white/10 rounded-lg active:bg-m-blue"
>
<img src={close} alt="Close" /> <img src={close} alt="Close" />
</button> </button>
</div> </div>
<ContactForm title="New contact" cta="Create contact" handleSubmit={handleSubmit} /> <ContactForm
title="New contact"
cta="Create contact"
handleSubmit={handleSubmit}
/>
</Dialog.Content> </Dialog.Content>
</div> </div>
</Dialog.Portal> </Dialog.Portal>
</Dialog.Root > </Dialog.Root>
); );
} }

View File

@@ -3,17 +3,36 @@ import { Button, LargeHeader, VStack } from "~/components/layout";
import { TextField } from "~/components/layout/TextField"; import { TextField } from "~/components/layout/TextField";
import { ContactFormValues } from "./ContactViewer"; import { ContactFormValues } from "./ContactViewer";
export function ContactForm(props: { handleSubmit: SubmitHandler<ContactFormValues>, initialValues?: ContactFormValues, title: string, cta: string }) { export function ContactForm(props: {
const [_contactForm, { Form, Field }] = createForm<ContactFormValues>({ initialValues: props.initialValues }); handleSubmit: SubmitHandler<ContactFormValues>;
initialValues?: ContactFormValues;
title: string;
cta: string;
}) {
const [_contactForm, { Form, Field }] = createForm<ContactFormValues>({
initialValues: props.initialValues
});
return ( return (
<Form onSubmit={props.handleSubmit} class="flex flex-col flex-1 justify-around gap-4 max-w-[400px] mx-auto w-full"> <Form
onSubmit={props.handleSubmit}
class="flex flex-col flex-1 justify-around gap-4 max-w-[400px] mx-auto w-full"
>
<div> <div>
<LargeHeader>{props.title}</LargeHeader> <LargeHeader>{props.title}</LargeHeader>
<VStack> <VStack>
<Field name="name" validate={[required("We at least need a name")]}> <Field
name="name"
validate={[required("We at least need a name")]}
>
{(field, props) => ( {(field, props) => (
<TextField {...props} placeholder='Satoshi' value={field.value} error={field.error} label="Name" /> <TextField
{...props}
placeholder="Satoshi"
value={field.value}
error={field.error}
label="Name"
/>
)} )}
</Field> </Field>
{/* <Field name="npub" validate={[]}> {/* <Field name="npub" validate={[]}>
@@ -27,5 +46,5 @@ export function ContactForm(props: { handleSubmit: SubmitHandler<ContactFormValu
{props.cta} {props.cta}
</Button> </Button>
</Form> </Form>
) );
} }

View File

@@ -1,34 +1,44 @@
import { Match, Switch, createSignal } from 'solid-js'; import { Match, Switch, createSignal } from "solid-js";
import { Button, Card, NiceP, SmallHeader } from '~/components/layout'; import { Button, Card, NiceP, SmallHeader } from "~/components/layout";
import { Dialog } from '@kobalte/core'; import { Dialog } from "@kobalte/core";
import close from "~/assets/icons/close.svg"; import close from "~/assets/icons/close.svg";
import { SubmitHandler } from '@modular-forms/solid'; import { SubmitHandler } from "@modular-forms/solid";
import { ContactForm } from './ContactForm'; import { ContactForm } from "./ContactForm";
import { showToast } from './Toaster'; import { showToast } from "./Toaster";
import { Contact } from '@mutinywallet/mutiny-wasm'; import { Contact } from "@mutinywallet/mutiny-wasm";
import { DIALOG_CONTENT, DIALOG_POSITIONER } from '~/styles/dialogs'; import { DIALOG_CONTENT, DIALOG_POSITIONER } from "~/styles/dialogs";
export type ContactFormValues = { export type ContactFormValues = {
name: string, name: string;
npub?: string, npub?: string;
} };
export function ContactViewer(props: { contact: Contact, gradient: string, saveContact: (contact: Contact) => void }) { export function ContactViewer(props: {
contact: Contact;
gradient: string;
saveContact: (contact: Contact) => void;
}) {
const [isOpen, setIsOpen] = createSignal(false); const [isOpen, setIsOpen] = createSignal(false);
const [isEditing, setIsEditing] = createSignal(false); const [isEditing, setIsEditing] = createSignal(false);
const handleSubmit: SubmitHandler<ContactFormValues> = (c: ContactFormValues) => { const handleSubmit: SubmitHandler<ContactFormValues> = (
c: ContactFormValues
) => {
// FIXME: merge with existing contact if saving (need edit contact method) // FIXME: merge with existing contact if saving (need edit contact method)
// FIXME: npub not valid? other undefineds // FIXME: npub not valid? other undefineds
const contact = new Contact(c.name, undefined, undefined, undefined) const contact = new Contact(c.name, undefined, undefined, undefined);
props.saveContact(contact) props.saveContact(contact);
setIsEditing(false) setIsEditing(false);
} };
return ( return (
<Dialog.Root open={isOpen()}> <Dialog.Root open={isOpen()}>
<button onClick={() => setIsOpen(true)} class="flex flex-col items-center gap-2 w-16 flex-shrink-0 overflow-x-hidden"> <button
<div class="flex-none h-16 w-16 rounded-full flex items-center justify-center text-4xl uppercase border-t border-b border-t-white/50 border-b-white/10" onClick={() => setIsOpen(true)}
class="flex flex-col items-center gap-2 w-16 flex-shrink-0 overflow-x-hidden"
>
<div
class="flex-none h-16 w-16 rounded-full flex items-center justify-center text-4xl uppercase border-t border-b border-t-white/50 border-b-white/10"
style={{ background: props.gradient }} style={{ background: props.gradient }}
> >
{props.contact.name[0]} {props.contact.name[0]}
@@ -39,33 +49,72 @@ export function ContactViewer(props: { contact: Contact, gradient: string, saveC
</button> </button>
<Dialog.Portal> <Dialog.Portal>
<div class={DIALOG_POSITIONER}> <div class={DIALOG_POSITIONER}>
<Dialog.Content class={DIALOG_CONTENT} onEscapeKeyDown={() => setIsOpen(false)}> <Dialog.Content
class={DIALOG_CONTENT}
onEscapeKeyDown={() => setIsOpen(false)}
>
<div class="w-full flex justify-end"> <div class="w-full flex justify-end">
<button tabindex="-1" onClick={() => setIsOpen(false)} class="hover:bg-white/10 rounded-lg active:bg-m-blue"> <button
tabindex="-1"
onClick={() => setIsOpen(false)}
class="hover:bg-white/10 rounded-lg active:bg-m-blue"
>
<img src={close} alt="Close" /> <img src={close} alt="Close" />
</button> </button>
</div> </div>
<Switch> <Switch>
<Match when={isEditing()}> <Match when={isEditing()}>
<ContactForm title="Edit contact" cta="Save contact" handleSubmit={handleSubmit} initialValues={props.contact} /> <ContactForm
title="Edit contact"
cta="Save contact"
handleSubmit={handleSubmit}
initialValues={props.contact}
/>
</Match> </Match>
<Match when={!isEditing()}> <Match when={!isEditing()}>
<div class="flex flex-col flex-1 justify-around items-center gap-4 max-w-[400px] mx-auto w-full"> <div class="flex flex-col flex-1 justify-around items-center gap-4 max-w-[400px] mx-auto w-full">
<div class="flex flex-col items-center w-full"> <div class="flex flex-col items-center w-full">
<div class="flex-none h-32 w-32 rounded-full flex items-center justify-center text-8xl uppercase border-t border-b border-t-white/50 border-b-white/10" <div
style={{ background: props.gradient }} class="flex-none h-32 w-32 rounded-full flex items-center justify-center text-8xl uppercase border-t border-b border-t-white/50 border-b-white/10"
style={{
background: props.gradient
}}
> >
{props.contact.name[0]} {props.contact.name[0]}
</div> </div>
<h1 class="text-2xl font-semibold uppercase mt-2 mb-4">{props.contact.name}</h1> <h1 class="text-2xl font-semibold uppercase mt-2 mb-4">
{props.contact.name}
</h1>
<Card title="Payment history"> <Card title="Payment history">
<NiceP>No payments yet with <span class="font-semibold">{props.contact.name}</span></NiceP> <NiceP>
No payments yet with{" "}
<span class="font-semibold">
{props.contact.name}
</span>
</NiceP>
</Card> </Card>
</div> </div>
<div class="flex w-full gap-2"> <div class="flex w-full gap-2">
<Button layout="flex" intent="green" onClick={() => setIsEditing(true)}>Edit</Button> <Button
<Button intent="blue" onClick={() => { showToast({ title: "Unimplemented", description: "We don't do that yet" }) }}>Pay</Button> layout="flex"
intent="green"
onClick={() => setIsEditing(true)}
>
Edit
</Button>
<Button
intent="blue"
onClick={() => {
showToast({
title: "Unimplemented",
description:
"We don't do that yet"
});
}}
>
Pay
</Button>
</div> </div>
</div> </div>
</Match> </Match>
@@ -73,6 +122,6 @@ export function ContactViewer(props: { contact: Contact, gradient: string, saveC
</Dialog.Content> </Dialog.Content>
</div> </div>
</Dialog.Portal> </Dialog.Portal>
</Dialog.Root > </Dialog.Root>
); );
} }

View File

@@ -5,13 +5,20 @@ import { useCopy } from "~/utils/useCopy";
export function CopyableQR(props: { value: string }) { export function CopyableQR(props: { value: string }) {
const [copy, copied] = useCopy({ copiedTimeout: 1000 }); const [copy, copied] = useCopy({ copiedTimeout: 1000 });
return ( return (
<div id="qr" class="w-full bg-white rounded-xl relative" onClick={() => copy(props.value)}> <div
id="qr"
class="w-full bg-white rounded-xl relative"
onClick={() => copy(props.value)}
>
<Show when={copied()}> <Show when={copied()}>
<div class="absolute w-full h-full bg-neutral-900/60 z-50 rounded-xl flex flex-col items-center justify-center transition-all"> <div class="absolute w-full h-full bg-neutral-900/60 z-50 rounded-xl flex flex-col items-center justify-center transition-all">
<p class="text-xl font-bold">Copied</p> <p class="text-xl font-bold">Copied</p>
</div> </div>
</Show> </Show>
<QRCodeSVG value={props.value} class="w-full h-full p-8 max-h-[400px]" /> <QRCodeSVG
value={props.value}
class="w-full h-full p-8 max-h-[400px]"
/>
</div> </div>
); );
} }

View File

@@ -15,19 +15,18 @@ export function DeleteEverything() {
const [confirmOpen, setConfirmOpen] = createSignal(false); const [confirmOpen, setConfirmOpen] = createSignal(false);
const [confirmLoading, setConfirmLoading] = createSignal(false); const [confirmLoading, setConfirmLoading] = createSignal(false);
async function resetNode() { async function resetNode() {
try { try {
setConfirmLoading(true); setConfirmLoading(true);
await actions.deleteMutinyWallet(); await actions.deleteMutinyWallet();
showToast({ title: "Deleted", description: `Deleted all data` }) showToast({ title: "Deleted", description: `Deleted all data` });
setTimeout(() => { setTimeout(() => {
window.location.href = "/"; window.location.href = "/";
}, 1000); }, 1000);
} catch (e) { } catch (e) {
console.error(e) console.error(e);
showToast(eify(e)) showToast(eify(e));
} finally { } finally {
setConfirmOpen(false); setConfirmOpen(false);
setConfirmLoading(false); setConfirmLoading(false);
@@ -37,9 +36,14 @@ export function DeleteEverything() {
return ( return (
<> <>
<Button onClick={confirmReset}>Delete Everything</Button> <Button onClick={confirmReset}>Delete Everything</Button>
<ConfirmDialog loading={confirmLoading()} open={confirmOpen()} onConfirm={resetNode} onCancel={() => setConfirmOpen(false)}> <ConfirmDialog
loading={confirmLoading()}
open={confirmOpen()}
onConfirm={resetNode}
onCancel={() => setConfirmOpen(false)}
>
This will delete your node's state. This can't be undone! This will delete your node's state. This can't be undone!
</ConfirmDialog> </ConfirmDialog>
</> </>
) );
} }

View File

@@ -1,4 +1,4 @@
import { Dialog } from "@kobalte/core" import { Dialog } from "@kobalte/core";
import { import {
For, For,
Match, Match,
@@ -29,7 +29,8 @@ import { Network } from "~/logic/mutinyWalletSetup";
import { AmountSmall } from "./Amount"; import { AmountSmall } from "./Amount";
export const OVERLAY = "fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"; export const OVERLAY = "fixed inset-0 z-50 bg-black/50 backdrop-blur-sm";
export const DIALOG_POSITIONER = "fixed inset-0 z-50 flex items-center justify-center"; export const DIALOG_POSITIONER =
"fixed inset-0 z-50 flex items-center justify-center";
export const DIALOG_CONTENT = export const DIALOG_CONTENT =
"max-w-[500px] w-[90vw] max-h-[100dvh] overflow-y-scroll disable-scrollbars mx-4 p-4 bg-neutral-800/80 backdrop-blur-md shadow-xl rounded-xl border border-white/10"; "max-w-[500px] w-[90vw] max-h-[100dvh] overflow-y-scroll disable-scrollbars mx-4 p-4 bg-neutral-800/80 backdrop-blur-md shadow-xl rounded-xl border border-white/10";
@@ -38,7 +39,9 @@ function LightningHeader(props: { info: MutinyInvoice }) {
const tags = createMemo(() => { const tags = createMemo(() => {
if (props.info.labels.length) { if (props.info.labels.length) {
const contact = state.mutiny_wallet?.get_contact(props.info.labels[0]); const contact = state.mutiny_wallet?.get_contact(
props.info.labels[0]
);
if (contact) { if (contact) {
return [tagToMutinyTag(contact)]; return [tagToMutinyTag(contact)];
} else { } else {
@@ -84,7 +87,9 @@ function OnchainHeader(props: { info: OnChainTx }) {
const tags = createMemo(() => { const tags = createMemo(() => {
if (props.info.labels.length) { if (props.info.labels.length) {
const contact = state.mutiny_wallet?.get_contact(props.info.labels[0]); const contact = state.mutiny_wallet?.get_contact(
props.info.labels[0]
);
if (contact) { if (contact) {
return [tagToMutinyTag(contact)]; return [tagToMutinyTag(contact)];
} else { } else {
@@ -112,8 +117,15 @@ function OnchainHeader(props: { info: OnChainTx }) {
<div class="p-4 bg-neutral-100 rounded-full"> <div class="p-4 bg-neutral-100 rounded-full">
<img src={chain} alt="blockchain" class="w-8 h-8" /> <img src={chain} alt="blockchain" class="w-8 h-8" />
</div> </div>
<h1 class="uppercase font-semibold">{isSend() ? "On-chain send" : "On-chain receive"}</h1> <h1 class="uppercase font-semibold">
<ActivityAmount center amount={amount() ?? "0"} price={state.price} positive={!isSend()} /> {isSend() ? "On-chain send" : "On-chain receive"}
</h1>
<ActivityAmount
center
amount={amount() ?? "0"}
price={state.price}
positive={!isSend()}
/>
<For each={tags()}> <For each={tags()}>
{(tag) => ( {(tag) => (
<TinyButton <TinyButton
@@ -133,7 +145,9 @@ function OnchainHeader(props: { info: OnChainTx }) {
const KeyValue: ParentComponent<{ key: string }> = (props) => { const KeyValue: ParentComponent<{ key: string }> = (props) => {
return ( return (
<li class="flex justify-between items-center gap-4"> <li class="flex justify-between items-center gap-4">
<span class="uppercase font-semibold whitespace-nowrap">{props.key}</span> <span class="uppercase font-semibold whitespace-nowrap">
{props.key}
</span>
<span class="font-light">{props.children}</span> <span class="font-light">{props.children}</span>
</li> </li>
); );
@@ -157,14 +171,20 @@ function LightningDetails(props: { info: MutinyInvoice }) {
<VStack> <VStack>
<ul class="flex flex-col gap-4"> <ul class="flex flex-col gap-4">
<KeyValue key="Status"> <KeyValue key="Status">
<span class="text-neutral-300">{props.info.paid ? "Paid" : "Unpaid"}</span> <span class="text-neutral-300">
{props.info.paid ? "Paid" : "Unpaid"}
</span>
</KeyValue> </KeyValue>
<KeyValue key="When"> <KeyValue key="When">
<span class="text-neutral-300">{prettyPrintTime(Number(props.info.last_updated))}</span> <span class="text-neutral-300">
{prettyPrintTime(Number(props.info.last_updated))}
</span>
</KeyValue> </KeyValue>
<Show when={props.info.description}> <Show when={props.info.description}>
<KeyValue key="Description"> <KeyValue key="Description">
<span class="text-neutral-300 truncate">{props.info.description}</span> <span class="text-neutral-300 truncate">
{props.info.description}
</span>
</KeyValue> </KeyValue>
</Show> </Show>
<KeyValue key="Fees"> <KeyValue key="Fees">
@@ -200,12 +220,16 @@ function OnchainDetails(props: { info: OnChainTx }) {
{/* <pre>{JSON.stringify(props.info, null, 2)}</pre> */} {/* <pre>{JSON.stringify(props.info, null, 2)}</pre> */}
<ul class="flex flex-col gap-4"> <ul class="flex flex-col gap-4">
<KeyValue key="Status"> <KeyValue key="Status">
<span class="text-neutral-300">{confirmationTime() ? "Confirmed" : "Unconfirmed"}</span> <span class="text-neutral-300">
{confirmationTime() ? "Confirmed" : "Unconfirmed"}
</span>
</KeyValue> </KeyValue>
<Show when={confirmationTime()}> <Show when={confirmationTime()}>
<KeyValue key="When"> <KeyValue key="When">
<span class="text-neutral-300"> <span class="text-neutral-300">
{confirmationTime() ? prettyPrintTime(Number(confirmationTime())) : "Pending"} {confirmationTime()
? prettyPrintTime(Number(confirmationTime()))
: "Pending"}
</span> </span>
</KeyValue> </KeyValue>
</Show> </Show>
@@ -247,7 +271,9 @@ export function DetailsIdModal(props: {
const [data, { refetch }] = createResource(async () => { const [data, { refetch }] = createResource(async () => {
if (kind() === "Lightning") { if (kind() === "Lightning") {
console.log("reading invoice: ", id()); console.log("reading invoice: ", id());
const invoice = await state.mutiny_wallet?.get_invoice_by_hash(id()); const invoice = await state.mutiny_wallet?.get_invoice_by_hash(
id()
);
return invoice; return invoice;
} else { } else {
console.log("reading tx: ", id()); console.log("reading tx: ", id());
@@ -284,10 +310,14 @@ export function DetailsIdModal(props: {
<Dialog.Title> <Dialog.Title>
<Switch> <Switch>
<Match when={isInvoice()}> <Match when={isInvoice()}>
<LightningHeader info={data() as MutinyInvoice} /> <LightningHeader
info={data() as MutinyInvoice}
/>
</Match> </Match>
<Match when={true}> <Match when={true}>
<OnchainHeader info={data() as OnChainTx} /> <OnchainHeader
info={data() as OnChainTx}
/>
</Match> </Match>
</Switch> </Switch>
</Dialog.Title> </Dialog.Title>
@@ -295,10 +325,14 @@ export function DetailsIdModal(props: {
<Dialog.Description class="flex flex-col gap-4"> <Dialog.Description class="flex flex-col gap-4">
<Switch> <Switch>
<Match when={isInvoice()}> <Match when={isInvoice()}>
<LightningDetails info={data() as MutinyInvoice} /> <LightningDetails
info={data() as MutinyInvoice}
/>
</Match> </Match>
<Match when={true}> <Match when={true}>
<OnchainDetails info={data() as OnChainTx} /> <OnchainDetails
info={data() as OnChainTx}
/>
</Match> </Match>
</Switch> </Switch>
<div class="flex justify-center"> <div class="flex justify-center">

View File

@@ -2,12 +2,18 @@ import { Dialog } from "@kobalte/core";
import { ParentComponent } from "solid-js"; import { ParentComponent } from "solid-js";
import { Button, SmallHeader } from "./layout"; import { Button, SmallHeader } from "./layout";
const OVERLAY = "fixed inset-0 z-50 bg-black/50 backdrop-blur-sm" const OVERLAY = "fixed inset-0 z-50 bg-black/50 backdrop-blur-sm";
const DIALOG_POSITIONER = "fixed inset-0 z-50 flex items-center justify-center" const DIALOG_POSITIONER = "fixed inset-0 z-50 flex items-center justify-center";
const DIALOG_CONTENT = "w-[80vw] max-w-[400px] p-4 bg-gray/50 backdrop-blur-md shadow-xl rounded-xl border border-white/10" const DIALOG_CONTENT =
"w-[80vw] max-w-[400px] p-4 bg-gray/50 backdrop-blur-md shadow-xl rounded-xl border border-white/10";
// TODO: implement this like toast so it's just one global confirm and I can call it with `confirm({ title: "Are you sure?", description: "This will delete your node" })` // TODO: implement this like toast so it's just one global confirm and I can call it with `confirm({ title: "Are you sure?", description: "This will delete your node" })`
export const ConfirmDialog: ParentComponent<{ open: boolean; loading: boolean; onCancel: () => void, onConfirm: () => void }> = (props) => { export const ConfirmDialog: ParentComponent<{
open: boolean;
loading: boolean;
onCancel: () => void;
onConfirm: () => void;
}> = (props) => {
return ( return (
<Dialog.Root open={props.open} onOpenChange={props.onCancel}> <Dialog.Root open={props.open} onOpenChange={props.onCancel}>
<Dialog.Portal> <Dialog.Portal>
@@ -15,18 +21,27 @@ export const ConfirmDialog: ParentComponent<{ open: boolean; loading: boolean; o
<div class={DIALOG_POSITIONER}> <div class={DIALOG_POSITIONER}>
<Dialog.Content class={DIALOG_CONTENT}> <Dialog.Content class={DIALOG_CONTENT}>
<div class="flex justify-between mb-2"> <div class="flex justify-between mb-2">
<Dialog.Title><SmallHeader>Are you sure?</SmallHeader></Dialog.Title> <Dialog.Title>
<SmallHeader>Are you sure?</SmallHeader>
</Dialog.Title>
</div> </div>
<Dialog.Description class="flex flex-col gap-4"> <Dialog.Description class="flex flex-col gap-4">
{props.children} {props.children}
<div class="flex gap-4 w-full justify-end"> <div class="flex gap-4 w-full justify-end">
<Button onClick={props.onCancel}>Cancel</Button> <Button onClick={props.onCancel}>Cancel</Button>
<Button intent="red" onClick={props.onConfirm} loading={props.loading} disabled={props.loading}>Confirm</Button> <Button
intent="red"
onClick={props.onConfirm}
loading={props.loading}
disabled={props.loading}
>
Confirm
</Button>
</div> </div>
</Dialog.Description> </Dialog.Description>
</Dialog.Content> </Dialog.Content>
</div> </div>
</Dialog.Portal> </Dialog.Portal>
</Dialog.Root > </Dialog.Root>
) );
} };

View File

@@ -1,5 +1,11 @@
import { Title } from "solid-start"; import { Title } from "solid-start";
import { Button, DefaultMain, LargeHeader, SafeArea, SmallHeader } from "~/components/layout"; import {
Button,
DefaultMain,
LargeHeader,
SafeArea,
SmallHeader
} from "~/components/layout";
export default function ErrorDisplay(props: { error: Error }) { export default function ErrorDisplay(props: { error: Error }) {
return ( return (
@@ -9,11 +15,16 @@ export default function ErrorDisplay(props: { error: Error }) {
<LargeHeader>Error</LargeHeader> <LargeHeader>Error</LargeHeader>
<SmallHeader>This never should've happened</SmallHeader> <SmallHeader>This never should've happened</SmallHeader>
<p class="bg-white/10 rounded-xl p-4 font-mono"> <p class="bg-white/10 rounded-xl p-4 font-mono">
<span class="font-bold"> <span class="font-bold">{props.error.name}</span>:{" "}
{props.error.name}</span>: {props.error.message} {props.error.message}
</p> </p>
<div class="h-full" /> <div class="h-full" />
<Button onClick={() => window.location.href = "/"} intent="red">Dangit</Button> <Button
onClick={() => (window.location.href = "/")}
intent="red"
>
Dangit
</Button>
</DefaultMain> </DefaultMain>
</SafeArea> </SafeArea>
); );

View File

@@ -4,16 +4,16 @@ import { createSignal } from "solid-js";
import eify from "~/utils/eify"; import eify from "~/utils/eify";
import { showToast } from "./Toaster"; import { showToast } from "./Toaster";
import { downloadTextFile } from "~/utils/download"; import { downloadTextFile } from "~/utils/download";
import { createFileUploader } from "@solid-primitives/upload" import { createFileUploader } from "@solid-primitives/upload";
import { ConfirmDialog } from "./Dialog"; import { ConfirmDialog } from "./Dialog";
import { MutinyWallet } from "@mutinywallet/mutiny-wasm"; import { MutinyWallet } from "@mutinywallet/mutiny-wasm";
export function ImportExport() { export function ImportExport() {
const [state, _] = useMegaStore() const [state, _] = useMegaStore();
async function handleSave() { async function handleSave() {
const json = await state.mutiny_wallet?.export_json() const json = await state.mutiny_wallet?.export_json();
downloadTextFile(json || "", "mutiny-state.json") downloadTextFile(json || "", "mutiny-state.json");
} }
const { files, selectFiles } = createFileUploader(); const { files, selectFiles } = createFileUploader();
@@ -26,7 +26,7 @@ export function ImportExport() {
const file: File = files()[0].file; const file: File = files()[0].file;
const text = await new Promise<string | null>((resolve, reject) => { const text = await new Promise<string | null>((resolve, reject) => {
fileReader.onload = e => { fileReader.onload = (e) => {
const result = e.target?.result?.toString(); const result = e.target?.result?.toString();
if (result) { if (result) {
resolve(result); resolve(result);
@@ -34,7 +34,8 @@ export function ImportExport() {
reject(new Error("No text found in file")); reject(new Error("No text found in file"));
} }
}; };
fileReader.onerror = _e => reject(new Error("File read error")); fileReader.onerror = (_e) =>
reject(new Error("File read error"));
fileReader.readAsText(file, "UTF-8"); fileReader.readAsText(file, "UTF-8");
}); });
@@ -49,7 +50,6 @@ export function ImportExport() {
} }
window.location.href = "/"; window.location.href = "/";
} catch (e) { } catch (e) {
showToast(eify(e)); showToast(eify(e));
} finally { } finally {
@@ -59,12 +59,12 @@ export function ImportExport() {
} }
async function uploadFile() { async function uploadFile() {
selectFiles(async files => { selectFiles(async (files) => {
if (files.length) { if (files.length) {
setConfirmOpen(true); setConfirmOpen(true);
return; return;
} }
}) });
} }
const [confirmOpen, setConfirmOpen] = createSignal(false); const [confirmOpen, setConfirmOpen] = createSignal(false);
@@ -78,9 +78,14 @@ export function ImportExport() {
<Button onClick={uploadFile}>Upload Saved State</Button> <Button onClick={uploadFile}>Upload Saved State</Button>
</VStack> </VStack>
</InnerCard> </InnerCard>
<ConfirmDialog loading={confirmLoading()} open={confirmOpen()} onConfirm={importJson} onCancel={() => setConfirmOpen(false)}> <ConfirmDialog
loading={confirmLoading()}
open={confirmOpen()}
onConfirm={importJson}
onCancel={() => setConfirmOpen(false)}
>
Do you want to replace your state with {files()[0].name}? Do you want to replace your state with {files()[0].name}?
</ConfirmDialog> </ConfirmDialog>
</> </>
) );
} }

View File

@@ -1,7 +1,9 @@
import { ParentComponent } from "solid-js"; import { ParentComponent } from "solid-js";
import info from "~/assets/icons/info.svg" import info from "~/assets/icons/info.svg";
export const InfoBox: ParentComponent<{ accent: "red" | "blue" | "green" | "white" }> = (props) => { export const InfoBox: ParentComponent<{
accent: "red" | "blue" | "green" | "white";
}> = (props) => {
return ( return (
<div <div
class="grid grid-cols-[auto_minmax(0,_1fr)] rounded-xl px-4 py-2 md:p-4 gap-4 bg-neutral-950/50 border" class="grid grid-cols-[auto_minmax(0,_1fr)] rounded-xl px-4 py-2 md:p-4 gap-4 bg-neutral-950/50 border"
@@ -20,4 +22,4 @@ export const InfoBox: ParentComponent<{ accent: "red" | "blue" | "green" | "whit
</div> </div>
</div> </div>
); );
} };

View File

@@ -1,11 +1,24 @@
import { Dialog } from "@kobalte/core"; import { Dialog } from "@kobalte/core";
import { JSX, createMemo } from "solid-js"; import { JSX, createMemo } from "solid-js";
import { ModalCloseButton, SmallHeader } from "~/components/layout"; import { ModalCloseButton, SmallHeader } from "~/components/layout";
import { DIALOG_CONTENT, DIALOG_POSITIONER, OVERLAY } from "~/components/DetailsModal"; import {
DIALOG_CONTENT,
DIALOG_POSITIONER,
OVERLAY
} from "~/components/DetailsModal";
import { CopyButton } from "./ShareCard"; import { CopyButton } from "./ShareCard";
export function JsonModal(props: { title: string, open: boolean, plaintext?: string, data?: unknown, setOpen: (open: boolean) => void, children?: JSX.Element }) { export function JsonModal(props: {
const json = createMemo(() => props.plaintext ? props.plaintext : JSON.stringify(props.data, null, 2)); title: string;
open: boolean;
plaintext?: string;
data?: unknown;
setOpen: (open: boolean) => void;
children?: JSX.Element;
}) {
const json = createMemo(() =>
props.plaintext ? props.plaintext : JSON.stringify(props.data, null, 2)
);
return ( return (
<Dialog.Root open={props.open} onOpenChange={props.setOpen}> <Dialog.Root open={props.open} onOpenChange={props.setOpen}>
@@ -15,9 +28,7 @@ export function JsonModal(props: { title: string, open: boolean, plaintext?: str
<Dialog.Content class={DIALOG_CONTENT}> <Dialog.Content class={DIALOG_CONTENT}>
<div class="flex justify-between mb-2 items-center"> <div class="flex justify-between mb-2 items-center">
<Dialog.Title> <Dialog.Title>
<SmallHeader> <SmallHeader>{props.title}</SmallHeader>
{props.title}
</SmallHeader>
</Dialog.Title> </Dialog.Title>
<Dialog.CloseButton> <Dialog.CloseButton>
<ModalCloseButton /> <ModalCloseButton />
@@ -35,6 +46,6 @@ export function JsonModal(props: { title: string, open: boolean, plaintext?: str
</Dialog.Content> </Dialog.Content>
</div> </div>
</Dialog.Portal> </Dialog.Portal>
</Dialog.Root > </Dialog.Root>
) );
} }

View File

@@ -1,8 +1,23 @@
import { useMegaStore } from "~/state/megaStore"; import { useMegaStore } from "~/state/megaStore";
import { Card, Hr, SmallHeader, Button, InnerCard, VStack } from "~/components/layout"; import {
Card,
Hr,
SmallHeader,
Button,
InnerCard,
VStack
} from "~/components/layout";
import PeerConnectModal from "~/components/PeerConnectModal"; import PeerConnectModal from "~/components/PeerConnectModal";
import NostrWalletConnectModal from "~/components/NostrWalletConnectModal"; import NostrWalletConnectModal from "~/components/NostrWalletConnectModal";
import { For, Show, Suspense, createEffect, createResource, createSignal, onCleanup } from "solid-js"; import {
For,
Show,
Suspense,
createEffect,
createResource,
createSignal,
onCleanup
} from "solid-js";
import { MutinyChannel, MutinyPeer } from "@mutinywallet/mutiny-wasm"; import { MutinyChannel, MutinyPeer } from "@mutinywallet/mutiny-wasm";
import { Collapsible, TextField } from "@kobalte/core"; import { Collapsible, TextField } from "@kobalte/core";
import mempoolTxUrl from "~/utils/mempoolTxUrl"; import mempoolTxUrl from "~/utils/mempoolTxUrl";
@@ -26,9 +41,15 @@ function PeerItem(props: { peer: MutinyPeer }) {
const firstNode = (nodes[0] as string) || ""; const firstNode = (nodes[0] as string) || "";
if (props.peer.is_connected) { if (props.peer.is_connected) {
await state.mutiny_wallet?.disconnect_peer(firstNode, props.peer.pubkey); await state.mutiny_wallet?.disconnect_peer(
firstNode,
props.peer.pubkey
);
} else { } else {
await state.mutiny_wallet?.delete_peer(firstNode, props.peer.pubkey); await state.mutiny_wallet?.delete_peer(
firstNode,
props.peer.pubkey
);
} }
}; };
@@ -36,7 +57,8 @@ function PeerItem(props: { peer: MutinyPeer }) {
<Collapsible.Root> <Collapsible.Root>
<Collapsible.Trigger class="w-full"> <Collapsible.Trigger class="w-full">
<h2 class="truncate text-start text-lg font-mono bg-neutral-200 text-black rounded px-4 py-2"> <h2 class="truncate text-start text-lg font-mono bg-neutral-200 text-black rounded px-4 py-2">
{">"} {props.peer.alias ? props.peer.alias : props.peer.pubkey} {">"}{" "}
{props.peer.alias ? props.peer.alias : props.peer.pubkey}
</h2> </h2>
</Collapsible.Trigger> </Collapsible.Trigger>
<Collapsible.Content> <Collapsible.Content>
@@ -44,7 +66,11 @@ function PeerItem(props: { peer: MutinyPeer }) {
<pre class="overflow-x-auto whitespace-pre-wrap break-all"> <pre class="overflow-x-auto whitespace-pre-wrap break-all">
{JSON.stringify(props.peer, null, 2)} {JSON.stringify(props.peer, null, 2)}
</pre> </pre>
<Button intent="glowy" layout="xs" onClick={handleDisconnectPeer}> <Button
intent="glowy"
layout="xs"
onClick={handleDisconnectPeer}
>
Disconnect Disconnect
</Button> </Button>
</VStack> </VStack>
@@ -57,7 +83,9 @@ function PeersList() {
const [state, _] = useMegaStore(); const [state, _] = useMegaStore();
const getPeers = async () => { const getPeers = async () => {
return (await state.mutiny_wallet?.list_peers()) as Promise<MutinyPeer[]>; return (await state.mutiny_wallet?.list_peers()) as Promise<
MutinyPeer[]
>;
}; };
const [peers, { refetch }] = createResource(getPeers); const [peers, { refetch }] = createResource(getPeers);
@@ -103,7 +131,10 @@ function ConnectPeer(props: { refetchPeers: RefetchPeersType }) {
const nodes = await state.mutiny_wallet?.list_nodes(); const nodes = await state.mutiny_wallet?.list_nodes();
const firstNode = (nodes[0] as string) || ""; const firstNode = (nodes[0] as string) || "";
await state.mutiny_wallet?.connect_to_peer(firstNode, peerConnectString); await state.mutiny_wallet?.connect_to_peer(
firstNode,
peerConnectString
);
await props.refetchPeers(); await props.refetchPeers();
@@ -119,7 +150,9 @@ function ConnectPeer(props: { refetchPeers: RefetchPeersType }) {
validationState={value() == "" ? "valid" : "invalid"} validationState={value() == "" ? "valid" : "invalid"}
class="flex flex-col gap-4" class="flex flex-col gap-4"
> >
<TextField.Label class="text-sm font-semibold uppercase">Connect Peer</TextField.Label> <TextField.Label class="text-sm font-semibold uppercase">
Connect Peer
</TextField.Label>
<TextField.Input <TextField.Input
class="w-full p-2 rounded-lg text-black" class="w-full p-2 rounded-lg text-black"
placeholder="mutiny:028241..." placeholder="mutiny:028241..."
@@ -153,7 +186,9 @@ function ChannelItem(props: { channel: MutinyChannel; network?: Network }) {
async function confirmCloseChannel() { async function confirmCloseChannel() {
setConfirmLoading(true); setConfirmLoading(true);
try { try {
await state.mutiny_wallet?.close_channel(props.channel.outpoint as string); await state.mutiny_wallet?.close_channel(
props.channel.outpoint as string
);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
showToast(eify(e)); showToast(eify(e));
@@ -174,10 +209,19 @@ function ChannelItem(props: { channel: MutinyChannel; network?: Network }) {
<pre class="overflow-x-auto whitespace-pre-wrap break-all"> <pre class="overflow-x-auto whitespace-pre-wrap break-all">
{JSON.stringify(props.channel, null, 2)} {JSON.stringify(props.channel, null, 2)}
</pre> </pre>
<ExternalLink href={mempoolTxUrl(props.channel.outpoint?.split(":")[0], props.network)}> <ExternalLink
href={mempoolTxUrl(
props.channel.outpoint?.split(":")[0],
props.network
)}
>
View Transaction View Transaction
</ExternalLink> </ExternalLink>
<Button intent="glowy" layout="xs" onClick={handleCloseChannel}> <Button
intent="glowy"
layout="xs"
onClick={handleCloseChannel}
>
Close Channel Close Channel
</Button> </Button>
</VStack> </VStack>
@@ -195,10 +239,12 @@ function ChannelItem(props: { channel: MutinyChannel; network?: Network }) {
} }
function ChannelsList() { function ChannelsList() {
const [state, _] = useMegaStore() const [state, _] = useMegaStore();
const getChannels = async () => { const getChannels = async () => {
return await state.mutiny_wallet?.list_channels() as Promise<MutinyChannel[]> return (await state.mutiny_wallet?.list_channels()) as Promise<
MutinyChannel[]
>;
}; };
const [channels, { refetch }] = createResource(getChannels); const [channels, { refetch }] = createResource(getChannels);
@@ -211,32 +257,38 @@ function ChannelsList() {
onCleanup(() => { onCleanup(() => {
clearInterval(interval); clearInterval(interval);
}); });
}) });
const network = state.mutiny_wallet?.get_network() as Network; const network = state.mutiny_wallet?.get_network() as Network;
return ( return (
<> <>
<SmallHeader> <SmallHeader>Channels</SmallHeader>
Channels
</SmallHeader>
{/* By wrapping this in a suspense I don't cause the page to jump to the top */} {/* By wrapping this in a suspense I don't cause the page to jump to the top */}
<Suspense> <Suspense>
<For each={channels()} fallback={<code>No channels</code>}> <For each={channels()} fallback={<code>No channels</code>}>
{(channel) => ( {(channel) => (
<ChannelItem channel={channel} network={network} /> <ChannelItem channel={channel} network={network} />
)} )}
</For> </For>
</Suspense> </Suspense>
<Button type="button" layout="small" onClick={(e) => { e.preventDefault(); refetch() }}>Refresh Channels</Button> <Button
type="button"
layout="small"
onClick={(e) => {
e.preventDefault();
refetch();
}}
>
Refresh Channels
</Button>
<OpenChannel refetchChannels={refetch} /> <OpenChannel refetchChannels={refetch} />
</> </>
) );
} }
function OpenChannel(props: { refetchChannels: RefetchChannelsListType }) { function OpenChannel(props: { refetchChannels: RefetchChannelsListType }) {
const [state, _] = useMegaStore() const [state, _] = useMegaStore();
const [creationError, setCreationError] = createSignal<Error>(); const [creationError, setCreationError] = createSignal<Error>();
@@ -256,19 +308,22 @@ function OpenChannel(props: { refetchChannels: RefetchChannelsListType }) {
const bigAmount = BigInt(amount()); const bigAmount = BigInt(amount());
const nodes = await state.mutiny_wallet?.list_nodes(); const nodes = await state.mutiny_wallet?.list_nodes();
const firstNode = nodes[0] as string || "" const firstNode = (nodes[0] as string) || "";
const new_channel = await state.mutiny_wallet?.open_channel(firstNode, pubkey, bigAmount) const new_channel = await state.mutiny_wallet?.open_channel(
firstNode,
pubkey,
bigAmount
);
setNewChannel(new_channel) setNewChannel(new_channel);
await props.refetchChannels() await props.refetchChannels();
setAmount(""); setAmount("");
setPeerPubkey(""); setPeerPubkey("");
} catch (e) { } catch (e) {
setCreationError(eify(e)) setCreationError(eify(e));
} }
}; };
@@ -283,12 +338,23 @@ function OpenChannel(props: { refetchChannels: RefetchChannelsListType }) {
onChange={setPeerPubkey} onChange={setPeerPubkey}
class="flex flex-col gap-2" class="flex flex-col gap-2"
> >
<TextField.Label class="text-sm font-semibold uppercase">Pubkey</TextField.Label> <TextField.Label class="text-sm font-semibold uppercase">
Pubkey
</TextField.Label>
<TextField.Input class="w-full p-2 rounded-lg text-black" /> <TextField.Input class="w-full p-2 rounded-lg text-black" />
</TextField.Root> </TextField.Root>
<TextField.Root value={amount()} onChange={setAmount} class="flex flex-col gap-2"> <TextField.Root
<TextField.Label class="text-sm font-semibold uppercase">Amount</TextField.Label> value={amount()}
<TextField.Input type="number" class="w-full p-2 rounded-lg text-black" /> onChange={setAmount}
class="flex flex-col gap-2"
>
<TextField.Label class="text-sm font-semibold uppercase">
Amount
</TextField.Label>
<TextField.Input
type="number"
class="w-full p-2 rounded-lg text-black"
/>
</TextField.Root> </TextField.Root>
<Button layout="small" type="submit"> <Button layout="small" type="submit">
Open Channel Open Channel
@@ -300,7 +366,12 @@ function OpenChannel(props: { refetchChannels: RefetchChannelsListType }) {
{JSON.stringify(newChannel()?.outpoint, null, 2)} {JSON.stringify(newChannel()?.outpoint, null, 2)}
</pre> </pre>
<pre>{newChannel()?.outpoint}</pre> <pre>{newChannel()?.outpoint}</pre>
<ExternalLink href={mempoolTxUrl(newChannel()?.outpoint?.split(":")[0], network)}> <ExternalLink
href={mempoolTxUrl(
newChannel()?.outpoint?.split(":")[0],
network
)}
>
View Transaction View Transaction
</ExternalLink> </ExternalLink>
</Show> </Show>
@@ -312,7 +383,7 @@ function OpenChannel(props: { refetchChannels: RefetchChannelsListType }) {
} }
function LnUrlAuth() { function LnUrlAuth() {
const [state, _] = useMegaStore() const [state, _] = useMegaStore();
const [value, setValue] = createSignal(""); const [value, setValue] = createSignal("");
@@ -320,37 +391,50 @@ function LnUrlAuth() {
e.preventDefault(); e.preventDefault();
const lnurl = value().trim(); const lnurl = value().trim();
await state.mutiny_wallet?.lnurl_auth(0, lnurl) await state.mutiny_wallet?.lnurl_auth(0, lnurl);
setValue(""); setValue("");
}; };
return ( return (
<InnerCard> <InnerCard>
<form class="flex flex-col gap-4" onSubmit={onSubmit} > <form class="flex flex-col gap-4" onSubmit={onSubmit}>
<TextField.Root <TextField.Root
value={value()} value={value()}
onChange={setValue} onChange={setValue}
validationState={(value() == "" || value().toLowerCase().startsWith("lnurl")) ? "valid" : "invalid"} validationState={
value() == "" ||
value().toLowerCase().startsWith("lnurl")
? "valid"
: "invalid"
}
class="flex flex-col gap-4" class="flex flex-col gap-4"
> >
<TextField.Label class="text-sm font-semibold uppercase" >LNURL Auth</TextField.Label> <TextField.Label class="text-sm font-semibold uppercase">
<TextField.Input class="w-full p-2 rounded-lg text-black" placeholder="LNURL..." /> LNURL Auth
<TextField.ErrorMessage class="text-red-500">Expecting something like LNURL...</TextField.ErrorMessage> </TextField.Label>
<TextField.Input
class="w-full p-2 rounded-lg text-black"
placeholder="LNURL..."
/>
<TextField.ErrorMessage class="text-red-500">
Expecting something like LNURL...
</TextField.ErrorMessage>
</TextField.Root> </TextField.Root>
<Button layout="small" type="submit">Auth</Button> <Button layout="small" type="submit">
</form > Auth
</Button>
</form>
</InnerCard> </InnerCard>
) );
} }
function ListTags() { function ListTags() {
const [_state, actions] = useMegaStore() const [_state, actions] = useMegaStore();
const [tags] = createResource(actions.listTags) const [tags] = createResource(actions.listTags);
return ( return (
<Collapsible.Root> <Collapsible.Root>
<Collapsible.Trigger class="w-full"> <Collapsible.Trigger class="w-full">
<h2 class="truncate text-start text-lg font-mono bg-neutral-200 text-black rounded px-4 py-2"> <h2 class="truncate text-start text-lg font-mono bg-neutral-200 text-black rounded px-4 py-2">
@@ -365,13 +449,9 @@ function ListTags() {
</VStack> </VStack>
</Collapsible.Content> </Collapsible.Content>
</Collapsible.Root> </Collapsible.Root>
);
)
} }
export default function KitchenSink() { export default function KitchenSink() {
return ( return (
<Card title="Kitchen Sink"> <Card title="Kitchen Sink">
@@ -390,5 +470,5 @@ export default function KitchenSink() {
<Hr /> <Hr />
<ImportExport /> <ImportExport />
</Card> </Card>
) );
} }

View File

@@ -3,11 +3,11 @@ import { useMegaStore } from "~/state/megaStore";
import { downloadTextFile } from "~/utils/download"; import { downloadTextFile } from "~/utils/download";
export function Logs() { export function Logs() {
const [state, _] = useMegaStore() const [state, _] = useMegaStore();
async function handleSave() { async function handleSave() {
const logs = await state.mutiny_wallet?.get_logs() const logs = await state.mutiny_wallet?.get_logs();
downloadTextFile(logs.join("") || "", "mutiny-logs.txt", "text/plain") downloadTextFile(logs.join("") || "", "mutiny-logs.txt", "text/plain");
} }
return ( return (
@@ -17,6 +17,5 @@ export function Logs() {
<Button onClick={handleSave}>Download Logs</Button> <Button onClick={handleSave}>Download Logs</Button>
</VStack> </VStack>
</Card> </Card>
);
)
} }

View File

@@ -1,51 +1,91 @@
import mutiny_m from '~/assets/icons/m.svg'; import mutiny_m from "~/assets/icons/m.svg";
import airplane from '~/assets/icons/airplane.svg'; import airplane from "~/assets/icons/airplane.svg";
import settings from '~/assets/icons/settings.svg'; import settings from "~/assets/icons/settings.svg";
import receive from '~/assets/icons/big-receive.svg'; import receive from "~/assets/icons/big-receive.svg";
import redshift from '~/assets/icons/rs.svg'; import redshift from "~/assets/icons/rs.svg";
import userClock from '~/assets/icons/user-clock.svg'; import userClock from "~/assets/icons/user-clock.svg";
import { A } from "solid-start"; import { A } from "solid-start";
type ActiveTab = 'home' | 'scan' | 'send' | 'receive' | 'settings' | 'redshift' | 'activity' | 'none'; type ActiveTab =
| "home"
| "scan"
| "send"
| "receive"
| "settings"
| "redshift"
| "activity"
| "none";
export default function NavBar(props: { activeTab: ActiveTab }) { export default function NavBar(props: { activeTab: ActiveTab }) {
const activeStyle = 'border-t-0 border-b-0 p-2 bg-black rounded-lg' const activeStyle = "border-t-0 border-b-0 p-2 bg-black rounded-lg";
const inactiveStyle = "p-2 hover:bg-white/5 rounded-lg active:bg-m-blue" const inactiveStyle = "p-2 hover:bg-white/5 rounded-lg active:bg-m-blue";
return ( return (
<nav class='hidden md:block fixed shadow-none z-40 safe-bottom top-0 bottom-auto left-0 h-full'> <nav class="hidden md:block fixed shadow-none z-40 safe-bottom top-0 bottom-auto left-0 h-full">
<ul class='h-16 flex flex-col justify-start gap-4 px-4 mt-4'> <ul class="h-16 flex flex-col justify-start gap-4 px-4 mt-4">
<li class={props.activeTab === "home" ? activeStyle : inactiveStyle}> <li
class={
props.activeTab === "home" ? activeStyle : inactiveStyle
}
>
<A href="/"> <A href="/">
<img src={mutiny_m} alt="home" /> <img src={mutiny_m} alt="home" />
</A> </A>
</li> </li>
<li class={props.activeTab === "send" ? activeStyle : inactiveStyle}> <li
class={
props.activeTab === "send" ? activeStyle : inactiveStyle
}
>
<A href="/send"> <A href="/send">
<img src={airplane} alt="send" /> <img src={airplane} alt="send" />
</A> </A>
</li> </li>
<li class={props.activeTab === "receive" ? activeStyle : inactiveStyle}> <li
class={
props.activeTab === "receive"
? activeStyle
: inactiveStyle
}
>
<A href="/receive"> <A href="/receive">
<img src={receive} alt="receive" /> <img src={receive} alt="receive" />
</A> </A>
</li> </li>
<li class={props.activeTab === "activity" ? activeStyle : inactiveStyle}> <li
class={
props.activeTab === "activity"
? activeStyle
: inactiveStyle
}
>
<A href="/activity"> <A href="/activity">
<img src={userClock} alt="activity" /> <img src={userClock} alt="activity" />
</A> </A>
</li> </li>
<li class={props.activeTab === "redshift" ? activeStyle : inactiveStyle}> <li
class={
props.activeTab === "redshift"
? activeStyle
: inactiveStyle
}
>
<A href="/redshift"> <A href="/redshift">
<img src={redshift} alt="redshift" width={36} /> <img src={redshift} alt="redshift" width={36} />
</A> </A>
</li> </li>
<li class={props.activeTab === "settings" ? activeStyle : inactiveStyle}> <li
class={
props.activeTab === "settings"
? activeStyle
: inactiveStyle
}
>
<A href="/settings"> <A href="/settings">
<img src={settings} alt="settings" /> <img src={settings} alt="settings" />
</A> </A>
</li> </li>
</ul> </ul>
</nav > </nav>
) );
} }

View File

@@ -1,22 +1,23 @@
import {QRCodeSVG} from "solid-qr-code"; import { QRCodeSVG } from "solid-qr-code";
import {As, Dialog} from "@kobalte/core"; import { As, Dialog } from "@kobalte/core";
import {Button, Card} from "~/components/layout"; import { Button, Card } from "~/components/layout";
import {useMegaStore} from "~/state/megaStore"; import { useMegaStore } from "~/state/megaStore";
import {createResource, Show} from "solid-js"; import { createResource, Show } from "solid-js";
const OVERLAY = "fixed inset-0 z-50 bg-black/50 backdrop-blur-sm" const OVERLAY = "fixed inset-0 z-50 bg-black/50 backdrop-blur-sm";
const DIALOG_POSITIONER = "fixed inset-0 z-50 flex items-center justify-center" const DIALOG_POSITIONER = "fixed inset-0 z-50 flex items-center justify-center";
const DIALOG_CONTENT = "w-[80vw] max-w-[400px] max-h-[100dvh] overflow-y-auto disable-scrollbars p-4 bg-gray/50 backdrop-blur-md shadow-xl rounded-xl border border-white/10" const DIALOG_CONTENT =
const SMALL_HEADER = "text-sm font-semibold uppercase" "w-[80vw] max-w-[400px] max-h-[100dvh] overflow-y-auto disable-scrollbars p-4 bg-gray/50 backdrop-blur-md shadow-xl rounded-xl border border-white/10";
const SMALL_HEADER = "text-sm font-semibold uppercase";
export default function NostrWalletConnectModal() { export default function NostrWalletConnectModal() {
const [state, actions] = useMegaStore() const [state, actions] = useMegaStore();
const getConnectionURI = () => { const getConnectionURI = () => {
if (state.mutiny_wallet) { if (state.mutiny_wallet) {
return state.mutiny_wallet.get_nwc_uri() return state.mutiny_wallet.get_nwc_uri();
} else { } else {
return undefined return undefined;
} }
}; };
@@ -24,15 +25,15 @@ export default function NostrWalletConnectModal() {
const toggleNwc = async () => { const toggleNwc = async () => {
if (state.nwc_enabled) { if (state.nwc_enabled) {
actions.setNwc(false) actions.setNwc(false);
window.location.reload() window.location.reload();
} else { } else {
actions.setNwc(true) actions.setNwc(true);
const nodes = await state.mutiny_wallet?.list_nodes(); const nodes = await state.mutiny_wallet?.list_nodes();
const firstNode = nodes[0] as string || ""; const firstNode = (nodes[0] as string) || "";
await state.mutiny_wallet?.start_nostr_wallet_connect(firstNode); await state.mutiny_wallet?.start_nostr_wallet_connect(firstNode);
} }
} };
// TODO: a lot of this markup is probably reusable as a "Modal" component // TODO: a lot of this markup is probably reusable as a "Modal" component
return ( return (
@@ -45,7 +46,9 @@ export default function NostrWalletConnectModal() {
<div class={DIALOG_POSITIONER}> <div class={DIALOG_POSITIONER}>
<Dialog.Content class={DIALOG_CONTENT}> <Dialog.Content class={DIALOG_CONTENT}>
<div class="flex justify-between mb-2"> <div class="flex justify-between mb-2">
<Dialog.Title class={SMALL_HEADER}>Nostr Wallet Connect</Dialog.Title> <Dialog.Title class={SMALL_HEADER}>
Nostr Wallet Connect
</Dialog.Title>
<Dialog.CloseButton class="dialog__close-button"> <Dialog.CloseButton class="dialog__close-button">
<code>X</code> <code>X</code>
</Dialog.CloseButton> </Dialog.CloseButton>
@@ -53,17 +56,24 @@ export default function NostrWalletConnectModal() {
<Dialog.Description class="flex flex-col gap-4"> <Dialog.Description class="flex flex-col gap-4">
<Show when={connectionURI()}> <Show when={connectionURI()}>
<div class="w-full bg-white rounded-xl"> <div class="w-full bg-white rounded-xl">
<QRCodeSVG value={connectionURI() || ""} class="w-full h-full p-8 max-h-[400px]" /> <QRCodeSVG
value={connectionURI() || ""}
class="w-full h-full p-8 max-h-[400px]"
/>
</div> </div>
<Card> <Card>
<code class="break-all">{connectionURI() || ""}</code> <code class="break-all">
{connectionURI() || ""}
</code>
</Card> </Card>
</Show> </Show>
<Button onClick={toggleNwc}>{state.nwc_enabled ? "Disable" : "Enable"}</Button> <Button onClick={toggleNwc}>
{state.nwc_enabled ? "Disable" : "Enable"}
</Button>
</Dialog.Description> </Dialog.Description>
</Dialog.Content> </Dialog.Content>
</div> </div>
</Dialog.Portal> </Dialog.Portal>
</Dialog.Root > </Dialog.Root>
) );
} }

View File

@@ -11,7 +11,12 @@ export function OnboardWarning() {
const [dismissedBackup, setDismissedBackup] = createSignal(false); const [dismissedBackup, setDismissedBackup] = createSignal(false);
function hasMoney() { function hasMoney() {
return state.balance?.confirmed || state.balance?.lightning || state.balance?.unconfirmed || state.balance?.force_close; return (
state.balance?.confirmed ||
state.balance?.lightning ||
state.balance?.unconfirmed ||
state.balance?.force_close
);
} }
return ( return (
@@ -26,8 +31,9 @@ export function OnboardWarning() {
<div class="flex flex-col"> <div class="flex flex-col">
<SmallHeader>Welcome!</SmallHeader> <SmallHeader>Welcome!</SmallHeader>
<p class="text-base font-light"> <p class="text-base font-light">
If you've used Mutiny before you can restore from a backup. Otherwise you can skip If you've used Mutiny before you can restore
this and enjoy your new wallet! from a backup. Otherwise you can skip this and
enjoy your new wallet!
</p> </p>
</div> </div>
<Button <Button
@@ -35,7 +41,10 @@ export function OnboardWarning() {
layout="xs" layout="xs"
class="self-start md:self-auto" class="self-start md:self-auto"
onClick={() => { onClick={() => {
showToast({ title: "Unimplemented", description: "We don't do that yet" }); showToast({
title: "Unimplemented",
description: "We don't do that yet"
});
}} }}
> >
Restore Restore
@@ -52,7 +61,9 @@ export function OnboardWarning() {
</button> </button>
</div> </div>
</Show> </Show>
<Show when={!state.has_backed_up && hasMoney() && !dismissedBackup()}> <Show
when={!state.has_backed_up && hasMoney() && !dismissedBackup()}
>
<div class="grid grid-cols-[auto_minmax(0,_1fr)_auto] rounded-xl p-4 gap-4 bg-neutral-950/50"> <div class="grid grid-cols-[auto_minmax(0,_1fr)_auto] rounded-xl p-4 gap-4 bg-neutral-950/50">
<div class="self-center"> <div class="self-center">
<img src={save} alt="backup" class="w-8 h-8" /> <img src={save} alt="backup" class="w-8 h-8" />
@@ -61,11 +72,17 @@ export function OnboardWarning() {
<div class="flex flex-col"> <div class="flex flex-col">
<SmallHeader>Secure your funds</SmallHeader> <SmallHeader>Secure your funds</SmallHeader>
<p class="text-base font-light max-md:hidden"> <p class="text-base font-light max-md:hidden">
You have money stored in this browser. Let's make sure you have a backup. You have money stored in this browser. Let's
make sure you have a backup.
</p> </p>
</div> </div>
<div class="flex items-center"> <div class="flex items-center">
<ButtonLink intent="blue" layout="xs" class="self-auto" href="/backup"> <ButtonLink
intent="blue"
layout="xs"
class="self-auto"
href="/backup"
>
Backup Backup
</ButtonLink> </ButtonLink>
</div> </div>

View File

@@ -6,30 +6,30 @@ import { Show, createResource } from "solid-js";
import { getExistingSettings } from "~/logic/mutinyWalletSetup"; import { getExistingSettings } from "~/logic/mutinyWalletSetup";
import getHostname from "~/utils/getHostname"; import getHostname from "~/utils/getHostname";
const OVERLAY = "fixed inset-0 z-50 bg-black/50 backdrop-blur-sm" const OVERLAY = "fixed inset-0 z-50 bg-black/50 backdrop-blur-sm";
const DIALOG_POSITIONER = "fixed inset-0 z-50 flex items-center justify-center" const DIALOG_POSITIONER = "fixed inset-0 z-50 flex items-center justify-center";
const DIALOG_CONTENT = "w-[80vw] max-w-[400px] p-4 bg-gray/50 backdrop-blur-md shadow-xl rounded-xl border border-white/10" const DIALOG_CONTENT =
const SMALL_HEADER = "text-sm font-semibold uppercase" "w-[80vw] max-w-[400px] p-4 bg-gray/50 backdrop-blur-md shadow-xl rounded-xl border border-white/10";
const SMALL_HEADER = "text-sm font-semibold uppercase";
export default function PeerConnectModal() { export default function PeerConnectModal() {
const [state, _] = useMegaStore() const [state, _] = useMegaStore();
const getPeerConnectString = async () => { const getPeerConnectString = async () => {
if (state.mutiny_wallet) { if (state.mutiny_wallet) {
const { proxy } = getExistingSettings(); const { proxy } = getExistingSettings();
const nodes = await state.mutiny_wallet.list_nodes(); const nodes = await state.mutiny_wallet.list_nodes();
const firstNode = nodes[0] as string || "" const firstNode = (nodes[0] as string) || "";
const hostName = getHostname(proxy || "") const hostName = getHostname(proxy || "");
const connectString = `mutiny:${firstNode}@${hostName}` const connectString = `mutiny:${firstNode}@${hostName}`;
return connectString return connectString;
} else { } else {
return undefined return undefined;
} }
}; };
const [peerConnectString] = createResource(getPeerConnectString); const [peerConnectString] = createResource(getPeerConnectString);
// TODO: a lot of this markup is probably reusable as a "Modal" component // TODO: a lot of this markup is probably reusable as a "Modal" component
return ( return (
<Dialog.Root> <Dialog.Root>
@@ -41,7 +41,9 @@ export default function PeerConnectModal() {
<div class={DIALOG_POSITIONER}> <div class={DIALOG_POSITIONER}>
<Dialog.Content class={DIALOG_CONTENT}> <Dialog.Content class={DIALOG_CONTENT}>
<div class="flex justify-between mb-2"> <div class="flex justify-between mb-2">
<Dialog.Title class={SMALL_HEADER}>Peer connect info</Dialog.Title> <Dialog.Title class={SMALL_HEADER}>
Peer connect info
</Dialog.Title>
<Dialog.CloseButton class="dialog__close-button"> <Dialog.CloseButton class="dialog__close-button">
<code>X</code> <code>X</code>
</Dialog.CloseButton> </Dialog.CloseButton>
@@ -49,16 +51,21 @@ export default function PeerConnectModal() {
<Dialog.Description class="flex flex-col gap-4"> <Dialog.Description class="flex flex-col gap-4">
<Show when={peerConnectString()}> <Show when={peerConnectString()}>
<div class="w-full bg-white rounded-xl"> <div class="w-full bg-white rounded-xl">
<QRCodeSVG value={peerConnectString() || ""} class="w-full h-full p-8 max-h-[400px]" /> <QRCodeSVG
value={peerConnectString() || ""}
class="w-full h-full p-8 max-h-[400px]"
/>
</div> </div>
<Card> <Card>
<code class="break-all">{peerConnectString() || ""}</code> <code class="break-all">
{peerConnectString() || ""}
</code>
</Card> </Card>
</Show> </Show>
</Dialog.Description> </Dialog.Description>
</Dialog.Content> </Dialog.Content>
</div> </div>
</Dialog.Portal> </Dialog.Portal>
</Dialog.Root > </Dialog.Root>
) );
} }

View File

@@ -1,5 +1,5 @@
import QrScanner from 'qr-scanner'; import QrScanner from "qr-scanner";
import { createSignal, onCleanup, onMount } from 'solid-js'; import { createSignal, onCleanup, onMount } from "solid-js";
export default function Scanner(props: { onResult: (result: string) => void }) { export default function Scanner(props: { onResult: (result: string) => void }) {
let container: HTMLVideoElement | null; let container: HTMLVideoElement | null;
@@ -9,19 +9,15 @@ export default function Scanner(props: { onResult: (result: string) => void }) {
const handleResult = (result: { data: string }) => { const handleResult = (result: { data: string }) => {
props.onResult(result.data); props.onResult(result.data);
} };
onMount(() => { onMount(() => {
if (container) { if (container) {
const newScanner = new QrScanner( const newScanner = new QrScanner(container, handleResult, {
container, returnDetailedScanResult: true
handleResult, });
{
returnDetailedScanResult: true,
}
);
newScanner.start(); newScanner.start();
setScanner(newScanner) setScanner(newScanner);
} }
}); });
@@ -34,7 +30,10 @@ export default function Scanner(props: { onResult: (result: string) => void }) {
return ( return (
<> <>
<div id="video-container"> <div id="video-container">
<video ref={el => container = el} class="w-full h-full fixed object-cover bg-gray" /> <video
ref={(el) => (container = el)}
class="w-full h-full fixed object-cover bg-gray"
/>
</div> </div>
</> </>
); );

View File

@@ -1,22 +1,22 @@
import type { Component } from 'solid-js' import type { Component } from "solid-js";
import { Show } from 'solid-js' import { Show } from "solid-js";
// eslint-disable-next-line import/no-unresolved // eslint-disable-next-line import/no-unresolved
import { useRegisterSW } from 'virtual:pwa-register/solid' import { useRegisterSW } from "virtual:pwa-register/solid";
const ReloadPrompt: Component = () => { const ReloadPrompt: Component = () => {
const { const {
offlineReady: [offlineReady, _setOfflineReady], offlineReady: [offlineReady, _setOfflineReady],
needRefresh: [needRefresh, _setNeedRefresh], needRefresh: [needRefresh, _setNeedRefresh],
updateServiceWorker: _update, updateServiceWorker: _update
} = useRegisterSW({ } = useRegisterSW({
immediate: true, immediate: true,
onRegisteredSW(swUrl, r) { onRegisteredSW(swUrl, r) {
console.log('SW Registered: ' + r?.scope) console.log("SW Registered: " + r?.scope);
}, },
onRegisterError(error: Error) { onRegisterError(error: Error) {
console.log('SW registration error', error) console.log("SW registration error", error);
}, }
}) });
// const close = () => { // const close = () => {
// setOfflineReady(false) // setOfflineReady(false)
@@ -40,7 +40,7 @@ const ReloadPrompt: Component = () => {
<Button onClick={() => close()}>Close</Button> <Button onClick={() => close()}>Close</Button>
</Card> */} </Card> */}
</Show> </Show>
) );
} };
export default ReloadPrompt export default ReloadPrompt;

View File

@@ -2,19 +2,20 @@ import { Button, Card, NiceP, VStack } from "~/components/layout";
import { useMegaStore } from "~/state/megaStore"; import { useMegaStore } from "~/state/megaStore";
export function Restart() { export function Restart() {
const [state, _] = useMegaStore() const [state, _] = useMegaStore();
async function handleStop() { async function handleStop() {
await state.mutiny_wallet?.stop() await state.mutiny_wallet?.stop();
} }
return ( return (
<Card> <Card>
<VStack> <VStack>
<NiceP>Something *extra* screwy going on? Stop the nodes!</NiceP> <NiceP>
Something *extra* screwy going on? Stop the nodes!
</NiceP>
<Button onClick={handleStop}>Stop</Button> <Button onClick={handleStop}>Stop</Button>
</VStack> </VStack>
</Card> </Card>
);
)
} }

View File

@@ -1,18 +1,25 @@
import { For, Match, Switch, createMemo, createSignal } from "solid-js" import { For, Match, Switch, createMemo, createSignal } from "solid-js";
export function SeedWords(props: { words: string, setHasSeen?: (hasSeen: boolean) => void }) { export function SeedWords(props: {
const [shouldShow, setShouldShow] = createSignal(false) words: string;
setHasSeen?: (hasSeen: boolean) => void;
}) {
const [shouldShow, setShouldShow] = createSignal(false);
function toggleShow() { function toggleShow() {
setShouldShow(!shouldShow()) setShouldShow(!shouldShow());
if (shouldShow()) { if (shouldShow()) {
props.setHasSeen?.(true) props.setHasSeen?.(true);
} }
} }
const splitWords = createMemo(() => props.words.split(" ")) const splitWords = createMemo(() => props.words.split(" "));
return (<button class="flex items-center gap-4 bg-m-red p-4 rounded-xl overflow-hidden" onClick={toggleShow}> return (
<button
class="flex items-center gap-4 bg-m-red p-4 rounded-xl overflow-hidden"
onClick={toggleShow}
>
<Switch> <Switch>
<Match when={!shouldShow()}> <Match when={!shouldShow()}>
<div class="cursor-pointer"> <div class="cursor-pointer">
@@ -24,13 +31,12 @@ export function SeedWords(props: { words: string, setHasSeen?: (hasSeen: boolean
<ol class="cursor-pointer overflow-hidden grid grid-cols-2 w-full list-decimal list-inside"> <ol class="cursor-pointer overflow-hidden grid grid-cols-2 w-full list-decimal list-inside">
<For each={splitWords()}> <For each={splitWords()}>
{(word) => ( {(word) => (
<li class="font-mono text-left"> <li class="font-mono text-left">{word}</li>
{word}
</li>
)} )}
</For> </For>
</ol> </ol>
</Match> </Match>
</Switch> </Switch>
</button >) </button>
);
} }

View File

@@ -1,61 +1,100 @@
import { createForm, url } from '@modular-forms/solid'; import { createForm, url } from "@modular-forms/solid";
import { TextField } from '~/components/layout/TextField'; import { TextField } from "~/components/layout/TextField";
import { MutinyWalletSettingStrings, getExistingSettings } from '~/logic/mutinyWalletSetup'; import {
import { Button, Card, SmallHeader } from '~/components/layout'; MutinyWalletSettingStrings,
import { showToast } from './Toaster'; getExistingSettings
import eify from '~/utils/eify'; } from "~/logic/mutinyWalletSetup";
import { useMegaStore } from '~/state/megaStore'; import { Button, Card, SmallHeader } from "~/components/layout";
import { showToast } from "./Toaster";
import eify from "~/utils/eify";
import { useMegaStore } from "~/state/megaStore";
export function SettingsStringsEditor() { export function SettingsStringsEditor() {
const existingSettings = getExistingSettings(); const existingSettings = getExistingSettings();
const [_settingsForm, { Form, Field }] = createForm<MutinyWalletSettingStrings>({ initialValues: existingSettings }); const [_settingsForm, { Form, Field }] =
createForm<MutinyWalletSettingStrings>({
initialValues: existingSettings
});
const [_store, actions] = useMegaStore(); const [_store, actions] = useMegaStore();
async function handleSubmit(values: MutinyWalletSettingStrings) { async function handleSubmit(values: MutinyWalletSettingStrings) {
try { try {
const existing = getExistingSettings(); const existing = getExistingSettings();
const newSettings = { ...existing, ...values } const newSettings = { ...existing, ...values };
await actions.setupMutinyWallet(newSettings); await actions.setupMutinyWallet(newSettings);
window.location.reload(); window.location.reload();
} catch (e) { } catch (e) {
console.error(e) console.error(e);
showToast(eify(e)) showToast(eify(e));
} }
console.log(values) console.log(values);
} }
return <Card> return (
<Card>
<Form onSubmit={handleSubmit} class="flex flex-col gap-4"> <Form onSubmit={handleSubmit} class="flex flex-col gap-4">
<h2 class="text-2xl font-light">Don't trust us! Use your own servers to back Mutiny.</h2> <h2 class="text-2xl font-light">
Don't trust us! Use your own servers to back Mutiny.
</h2>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<SmallHeader>Network</SmallHeader> <SmallHeader>Network</SmallHeader>
<pre> <pre>{existingSettings.network}</pre>
{existingSettings.network}
</pre>
</div> </div>
<Field name="proxy" validate={[url("Should be a url starting with wss://")]}> <Field
name="proxy"
validate={[url("Should be a url starting with wss://")]}
>
{(field, props) => ( {(field, props) => (
<TextField {...props} value={field.value} error={field.error} label="Websockets Proxy" /> <TextField
{...props}
value={field.value}
error={field.error}
label="Websockets Proxy"
/>
)} )}
</Field> </Field>
<Field name="esplora" validate={[url("That doesn't look like a URL")]}> <Field
name="esplora"
validate={[url("That doesn't look like a URL")]}
>
{(field, props) => ( {(field, props) => (
<TextField {...props} value={field.value} error={field.error} label="Esplora" /> <TextField
{...props}
value={field.value}
error={field.error}
label="Esplora"
/>
)} )}
</Field> </Field>
<Field name="rgs" validate={[url("That doesn't look like a URL")]}> <Field
name="rgs"
validate={[url("That doesn't look like a URL")]}
>
{(field, props) => ( {(field, props) => (
<TextField {...props} value={field.value} error={field.error} label="RGS" /> <TextField
{...props}
value={field.value}
error={field.error}
label="RGS"
/>
)} )}
</Field> </Field>
<Field name="lsp" validate={[url("That doesn't look like a URL")]}> <Field
name="lsp"
validate={[url("That doesn't look like a URL")]}
>
{(field, props) => ( {(field, props) => (
<TextField {...props} value={field.value} error={field.error} label="LSP" /> <TextField
{...props}
value={field.value}
error={field.error}
label="LSP"
/>
)} )}
</Field> </Field>
<Button type="submit">Save</Button> <Button type="submit">Save</Button>
</Form> </Form>
</Card> </Card>
);
} }

View File

@@ -1,40 +1,46 @@
import { Card, VStack } from "~/components/layout"; import { Card, VStack } from "~/components/layout";
import { useCopy } from "~/utils/useCopy"; import { useCopy } from "~/utils/useCopy";
import copyIcon from "~/assets/icons/copy.svg" import copyIcon from "~/assets/icons/copy.svg";
import shareIcon from "~/assets/icons/share.svg" import shareIcon from "~/assets/icons/share.svg";
import eyeIcon from "~/assets/icons/eye.svg" import eyeIcon from "~/assets/icons/eye.svg";
import { Show, createSignal } from "solid-js"; import { Show, createSignal } from "solid-js";
import { JsonModal } from "./JsonModal"; import { JsonModal } from "./JsonModal";
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" 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 }) { export function ShareButton(props: { receiveString: string }) {
async function share(receiveString: string) { async function share(receiveString: string) {
// If the browser doesn't support share we can just copy the address // If the browser doesn't support share we can just copy the address
if (!navigator.share) { if (!navigator.share) {
console.error("Share not supported") console.error("Share not supported");
} }
const shareData: ShareData = { const shareData: ShareData = {
title: "Mutiny Wallet", title: "Mutiny Wallet",
text: receiveString, text: receiveString
} };
try { try {
await navigator.share(shareData) await navigator.share(shareData);
} catch (e) { } catch (e) {
console.error(e) console.error(e);
} }
} }
return ( return (
<button class={STYLE} onClick={(_) => share(props.receiveString)}><span>Share</span><img src={shareIcon} alt="share" /></button> <button class={STYLE} onClick={(_) => share(props.receiveString)}>
) <span>Share</span>
<img src={shareIcon} alt="share" />
</button>
);
} }
function TruncateMiddle(props: { text: string }) { function TruncateMiddle(props: { text: string }) {
return ( return (
<div class="flex text-neutral-400 font-mono"> <div class="flex text-neutral-400 font-mono">
<span class="truncate">{props.text}</span> <span class="truncate">{props.text}</span>
<span class="pr-2">{props.text.length > 8 ? props.text.slice(-8) : ""}</span> <span class="pr-2">
{props.text.length > 8 ? props.text.slice(-8) : ""}
</span>
</div> </div>
); );
} }
@@ -43,7 +49,12 @@ export function StringShower(props: { text: string }) {
const [open, setOpen] = createSignal(false); const [open, setOpen] = createSignal(false);
return ( return (
<> <>
<JsonModal open={open()} plaintext={props.text} title="Details" setOpen={setOpen} /> <JsonModal
open={open()}
plaintext={props.text}
title="Details"
setOpen={setOpen}
/>
<div class="w-full grid grid-cols-[minmax(0,_1fr)_auto]"> <div class="w-full grid grid-cols-[minmax(0,_1fr)_auto]">
<TruncateMiddle text={props.text} /> <TruncateMiddle text={props.text} />
<button class="w-[2rem]" onClick={() => setOpen(true)}> <button class="w-[2rem]" onClick={() => setOpen(true)}>
@@ -54,16 +65,19 @@ export function StringShower(props: { text: string }) {
); );
} }
export function CopyButton(props: { text?: string, title?: string }) { export function CopyButton(props: { text?: string; title?: string }) {
const [copy, copied] = useCopy({ copiedTimeout: 1000 }); const [copy, copied] = useCopy({ copiedTimeout: 1000 });
function handleCopy() { function handleCopy() {
copy(props.text ?? "") copy(props.text ?? "");
} }
return ( return (
<button class={STYLE} onClick={handleCopy}>{copied() ? "Copied" : props.title ?? "Copy"}<img src={copyIcon} alt="copy" /></button> <button class={STYLE} onClick={handleCopy}>
) {copied() ? "Copied" : props.title ?? "Copy"}
<img src={copyIcon} alt="copy" />
</button>
);
} }
export function ShareCard(props: { text?: string }) { export function ShareCard(props: { text?: string }) {
@@ -78,7 +92,6 @@ export function ShareCard(props: { text?: string }) {
</Show> </Show>
</div> </div>
</VStack> </VStack>
</Card > </Card>
) );
} }

View File

@@ -1,5 +1,5 @@
import { Select, createOptions } from "@thisbeyond/solid-select"; import { Select, createOptions } from "@thisbeyond/solid-select";
import "~/styles/solid-select.css" import "~/styles/solid-select.css";
import { For, Show, createMemo, createSignal, onMount } from "solid-js"; import { For, Show, createMemo, createSignal, onMount } from "solid-js";
import { TinyButton } from "./layout"; import { TinyButton } from "./layout";
import { MutinyTagItem, sortByLastUsed } from "~/utils/tags"; import { MutinyTagItem, sortByLastUsed } from "~/utils/tags";
@@ -10,36 +10,43 @@ const createLabelValue = (label: string): Partial<MutinyTagItem> => {
}; };
export function TagEditor(props: { export function TagEditor(props: {
selectedValues: Partial<MutinyTagItem>[], selectedValues: Partial<MutinyTagItem>[];
setSelectedValues: (value: Partial<MutinyTagItem>[]) => void, setSelectedValues: (value: Partial<MutinyTagItem>[]) => void;
placeholder: string placeholder: string;
}) { }) {
const [_state, actions] = useMegaStore(); const [_state, actions] = useMegaStore();
const [availableTags, setAvailableTags] = createSignal<MutinyTagItem[]>([]); const [availableTags, setAvailableTags] = createSignal<MutinyTagItem[]>([]);
onMount(async () => { onMount(async () => {
const tags = await actions.listTags() const tags = await actions.listTags();
if (tags) { if (tags) {
setAvailableTags(tags.filter((tag) => tag.kind === "Contact").sort(sortByLastUsed)) setAvailableTags(
tags
.filter((tag) => tag.kind === "Contact")
.sort(sortByLastUsed)
);
} }
}) });
const selectProps = createMemo(() => { const selectProps = createMemo(() => {
return createOptions(availableTags() || [], { return createOptions(availableTags() || [], {
key: "name", key: "name",
filterable: true, // Default filterable: true, // Default
createable: createLabelValue, createable: createLabelValue
});
}); });
})
const onChange = (selected: MutinyTagItem[]) => { const onChange = (selected: MutinyTagItem[]) => {
props.setSelectedValues(selected); props.setSelectedValues(selected);
console.log(selected) console.log(selected);
const lastValue = selected[selected.length - 1]; const lastValue = selected[selected.length - 1];
if (lastValue && availableTags() && !availableTags()!.includes(lastValue)) { if (
lastValue &&
availableTags() &&
!availableTags()!.includes(lastValue)
) {
setAvailableTags([...availableTags(), lastValue]); setAvailableTags([...availableTags(), lastValue]);
} }
}; };
@@ -50,7 +57,7 @@ export function TagEditor(props: {
}; };
return ( return (
<div class="flex flex-col gap-2 flex-shrink flex-1" > <div class="flex flex-col gap-2 flex-shrink flex-1">
<Select <Select
multiple multiple
initialValue={props.selectedValues} initialValue={props.selectedValues}
@@ -70,6 +77,6 @@ export function TagEditor(props: {
</For> </For>
</Show> </Show>
</div> </div>
</div > </div>
) );
} }

View File

@@ -10,37 +10,52 @@ export function Toaster() {
<Toast.List class="z-[9999] max-w-[100vw] w-[400px] mt-8 flex flex-col gap-4" /> <Toast.List class="z-[9999] max-w-[100vw] w-[400px] mt-8 flex flex-col gap-4" />
</Toast.Region> </Toast.Region>
</Portal> </Portal>
) );
} }
type ToastArg = { title: string, description: string } | Error type ToastArg = { title: string; description: string } | Error;
export function showToast(arg: ToastArg) { export function showToast(arg: ToastArg) {
if (arg instanceof Error) { if (arg instanceof Error) {
return toaster.show(props => ( return toaster.show((props) => (
<ToastItem title="Error" description={arg.message} isError {...props} /> <ToastItem
)) title="Error"
description={arg.message}
isError
{...props}
/>
));
} else { } else {
return toaster.show(props => ( return toaster.show((props) => (
<ToastItem title={arg.title} description={arg.description} {...props} /> <ToastItem
)) title={arg.title}
description={arg.description}
{...props}
/>
));
} }
} }
export function ToastItem(props: { toastId: number, title: string, description: string, isError?: boolean }) { export function ToastItem(props: {
toastId: number;
title: string;
description: string;
isError?: boolean;
}) {
return ( return (
<Toast.Root toastId={props.toastId} class={`w-[80vw] max-w-[400px] mx-auto p-4 bg-neutral-900/80 backdrop-blur-md shadow-xl rounded-xl border ${props.isError ? "border-m-red/50" : "border-white/10"} `}> <Toast.Root
toastId={props.toastId}
class={`w-[80vw] max-w-[400px] mx-auto p-4 bg-neutral-900/80 backdrop-blur-md shadow-xl rounded-xl border ${
props.isError ? "border-m-red/50" : "border-white/10"
} `}
>
<div class="flex gap-4 w-full justify-between items-start"> <div class="flex gap-4 w-full justify-between items-start">
<div class="flex-1"> <div class="flex-1">
<Toast.Title> <Toast.Title>
<SmallHeader> <SmallHeader>{props.title}</SmallHeader>
{props.title}
</SmallHeader>
</Toast.Title> </Toast.Title>
<Toast.Description> <Toast.Description>
<p> <p>{props.description}</p>
{props.description}
</p>
</Toast.Description> </Toast.Description>
</div> </div>
<Toast.CloseButton class="hover:bg-white/10 rounded-lg active:bg-m-blue flex-0"> <Toast.CloseButton class="hover:bg-white/10 rounded-lg active:bg-m-blue flex-0">
@@ -48,5 +63,5 @@ export function ToastItem(props: { toastId: number, title: string, description:
</Toast.CloseButton> </Toast.CloseButton>
</div> </div>
</Toast.Root> </Toast.Root>
) );
} }

View File

@@ -1,6 +1,14 @@
import { A } from "solid-start"; import { A } from "solid-start";
import { Back } from "~/assets/svg/Back"; import { Back } from "~/assets/svg/Back";
export function BackLink(props: { href?: string, title?: string }) { export function BackLink(props: { href?: string; title?: string }) {
return (<A href={props.href ? props.href : "/"} class="text-m-red active:text-m-red/80 text-xl font-semibold no-underline md:hidden flex items-center"><Back />{props.title ? props.title : "Home"}</A>) return (
<A
href={props.href ? props.href : "/"}
class="text-m-red active:text-m-red/80 text-xl font-semibold no-underline md:hidden flex items-center"
>
<Back />
{props.title ? props.title : "Home"}
</A>
);
} }

View File

@@ -11,9 +11,9 @@ const button = cva(
// TODO: button hover has to work different than buttonlinks (like disabled state) // TODO: button hover has to work different than buttonlinks (like disabled state)
intent: { intent: {
active: "bg-white text-black border border-white hover:text-[#3B6CCC]", active: "bg-white text-black border border-white hover:text-[#3B6CCC]",
inactive: "bg-black text-white border border-white hover:text-[#3B6CCC]", inactive:
glowy: "bg-black text-white border border-white hover:text-[#3B6CCC]",
"bg-black/10 shadow-xl text-white border border-m-blue hover:m-blue-dark hover:text-m-blue", glowy: "bg-black/10 shadow-xl text-white border border-m-blue hover:m-blue-dark hover:text-m-blue",
blue: "bg-m-blue text-white shadow-inner-button hover:bg-m-blue-dark text-shadow-button", blue: "bg-m-blue text-white shadow-inner-button hover:bg-m-blue-dark text-shadow-button",
red: "bg-m-red text-white shadow-inner-button hover:bg-m-red-dark text-shadow-button", red: "bg-m-red text-white shadow-inner-button hover:bg-m-red-dark text-shadow-button",
green: "bg-m-green text-white shadow-inner-button hover:bg-m-green-dark text-shadow-button" green: "bg-m-green text-white shadow-inner-button hover:bg-m-green-dark text-shadow-button"
@@ -34,15 +34,22 @@ const button = cva(
// Help from https://github.com/arpadgabor/credee/blob/main/packages/www/src/components/ui/button.tsx // Help from https://github.com/arpadgabor/credee/blob/main/packages/www/src/components/ui/button.tsx
type StyleProps = VariantProps<typeof button> type StyleProps = VariantProps<typeof button>;
interface ButtonProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement>, StyleProps { interface ButtonProps
loading?: boolean, extends JSX.ButtonHTMLAttributes<HTMLButtonElement>,
disabled?: boolean, StyleProps {
loading?: boolean;
disabled?: boolean;
} }
export const Button: ParentComponent<ButtonProps> = props => { export const Button: ParentComponent<ButtonProps> = (props) => {
const slot = children(() => props.children) const slot = children(() => props.children);
const [local, attrs] = splitProps(props, ['children', 'intent', 'layout', 'class']) const [local, attrs] = splitProps(props, [
"children",
"intent",
"layout",
"class"
]);
return ( return (
<button <button
@@ -62,21 +69,31 @@ export const Button: ParentComponent<ButtonProps> = props => {
</Show> </Show>
</button> </button>
); );
};
interface ButtonLinkProps
extends JSX.ButtonHTMLAttributes<HTMLAnchorElement>,
StyleProps {
href: string;
target?: string;
rel?: string;
} }
interface ButtonLinkProps extends JSX.ButtonHTMLAttributes<HTMLAnchorElement>, StyleProps { export const ButtonLink: ParentComponent<ButtonLinkProps> = (props) => {
href: string const slot = children(() => props.children);
target?: string const [local, attrs] = splitProps(props, [
rel?: string "children",
} "intent",
"layout",
export const ButtonLink: ParentComponent<ButtonLinkProps> = props => { "class",
const slot = children(() => props.children) "href",
const [local, attrs] = splitProps(props, ['children', 'intent', 'layout', 'class', 'href', 'target', 'rel']) "target",
"rel"
]);
return ( return (
<Dynamic <Dynamic
component={local.href?.includes('://') ? 'a' : A} component={local.href?.includes("://") ? "a" : A}
href={local.href} href={local.href}
target={local.target} target={local.target}
rel={local.rel} rel={local.rel}
@@ -84,10 +101,10 @@ export const ButtonLink: ParentComponent<ButtonLinkProps> = props => {
class={button({ class={button({
class: `flex justify-center no-underline ${local.class || ""}`, class: `flex justify-center no-underline ${local.class || ""}`,
intent: local.intent, intent: local.intent,
layout: local.layout, layout: local.layout
})} })}
> >
{slot()} {slot()}
</Dynamic> </Dynamic>
) );
} };

View File

@@ -1,4 +1,4 @@
import { JSX } from 'solid-js'; import { JSX } from "solid-js";
interface LinkifyProps { interface LinkifyProps {
initialText: string; initialText: string;
@@ -15,7 +15,7 @@ export default function Linkify(props: LinkifyProps): JSX.Element {
while ((match = pattern.exec(text)) !== null) { while ((match = pattern.exec(text)) !== null) {
const link = match[1]; const link = match[1];
const href = link.startsWith('http') ? link : `https://${link}`; const href = link.startsWith("http") ? link : `https://${link}`;
const beforeLink = text.slice(lastIndex, match.index); const beforeLink = text.slice(lastIndex, match.index);
lastIndex = pattern.lastIndex; lastIndex = pattern.lastIndex;
@@ -23,7 +23,16 @@ export default function Linkify(props: LinkifyProps): JSX.Element {
links.push(beforeLink); links.push(beforeLink);
} }
links.push(<a href={href} class="break-all" target="_blank" rel="noopener noreferrer">{link}</a>); links.push(
<a
href={href}
class="break-all"
target="_blank"
rel="noopener noreferrer"
>
{link}
</a>
);
} }
const remainingText = text.slice(lastIndex); const remainingText = text.slice(lastIndex);

View File

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

View File

@@ -1,10 +1,21 @@
import { RadioGroup } from "@kobalte/core"; import { RadioGroup } from "@kobalte/core";
import { For, Show } from "solid-js"; import { For, Show } from "solid-js";
type Choices = { value: string; label: string; caption: string; disabled?: boolean }[]; type Choices = {
value: string;
label: string;
caption: string;
disabled?: boolean;
}[];
// TODO: how could would it be if we could just pass the estimated fees in here? // TODO: how could would it be if we could just pass the estimated fees in here?
export function StyledRadioGroup(props: { value: string, choices: Choices, onValueChange: (value: string) => void, small?: boolean, accent?: "red" | "white" }) { export function StyledRadioGroup(props: {
value: string;
choices: Choices;
onValueChange: (value: string) => void;
small?: boolean;
accent?: "red" | "white";
}) {
return ( return (
// TODO: rewrite this with CVA, props are bad for tailwind // TODO: rewrite this with CVA, props are bad for tailwind
<RadioGroup.Root <RadioGroup.Root
@@ -24,7 +35,8 @@ export function StyledRadioGroup(props: { value: string, choices: Choices, onVal
class={`ui-checked:bg-neutral-950 bg-white/10 rounded outline outline-black/50 ui-checked:outline-m-blue ui-checked:outline-2`} class={`ui-checked:bg-neutral-950 bg-white/10 rounded outline outline-black/50 ui-checked:outline-m-blue ui-checked:outline-2`}
classList={{ classList={{
"ui-checked:outline-m-red": props.accent === "red", "ui-checked:outline-m-red": props.accent === "red",
"ui-checked:outline-white": props.accent === "white", "ui-checked:outline-white":
props.accent === "white",
"ui-disabled:opacity-50": choice.disabled "ui-disabled:opacity-50": choice.disabled
}} }}
disabled={choice.disabled} disabled={choice.disabled}
@@ -37,13 +49,18 @@ export function StyledRadioGroup(props: { value: string, choices: Choices, onVal
<RadioGroup.ItemLabel class="ui-checked:text-white text-neutral-400"> <RadioGroup.ItemLabel class="ui-checked:text-white text-neutral-400">
<div class="block"> <div class="block">
<div <div
classList={{ "text-base": props.small, "text-lg": !props.small }} classList={{
"text-base": props.small,
"text-lg": !props.small
}}
class={`font-semibold max-sm:text-sm`} class={`font-semibold max-sm:text-sm`}
> >
{choice.label} {choice.label}
</div> </div>
<Show when={!props.small}> <Show when={!props.small}>
<div class="text-sm font-light">{choice.caption}</div> <div class="text-sm font-light">
{choice.caption}
</div>
</Show> </Show>
</div> </div>
</RadioGroup.ItemLabel> </RadioGroup.ItemLabel>

View File

@@ -1,9 +1,9 @@
import { TextField as KTextField } from '@kobalte/core'; import { TextField as KTextField } from "@kobalte/core";
import { type JSX, Show, splitProps } from 'solid-js'; import { type JSX, Show, splitProps } from "solid-js";
type TextFieldProps = { type TextFieldProps = {
name: string; name: string;
type?: 'text' | 'email' | 'tel' | 'password' | 'url' | 'date'; type?: "text" | "email" | "tel" | "password" | "url" | "date";
label?: string; label?: string;
placeholder?: string; placeholder?: string;
value: string | undefined; value: string | undefined;
@@ -11,25 +11,31 @@ type TextFieldProps = {
required?: boolean; required?: boolean;
multiline?: boolean; multiline?: boolean;
ref: (element: HTMLInputElement | HTMLTextAreaElement) => void; ref: (element: HTMLInputElement | HTMLTextAreaElement) => void;
onInput: JSX.EventHandler<HTMLInputElement | HTMLTextAreaElement, InputEvent>; onInput: JSX.EventHandler<
HTMLInputElement | HTMLTextAreaElement,
InputEvent
>;
onChange: JSX.EventHandler<HTMLInputElement | HTMLTextAreaElement, Event>; onChange: JSX.EventHandler<HTMLInputElement | HTMLTextAreaElement, Event>;
onBlur: JSX.EventHandler<HTMLInputElement | HTMLTextAreaElement, FocusEvent>; onBlur: JSX.EventHandler<
HTMLInputElement | HTMLTextAreaElement,
FocusEvent
>;
}; };
export function TextField(props: TextFieldProps) { export function TextField(props: TextFieldProps) {
const [fieldProps] = splitProps(props, [ const [fieldProps] = splitProps(props, [
'placeholder', "placeholder",
'ref', "ref",
'onInput', "onInput",
'onChange', "onChange",
'onBlur', "onBlur"
]); ]);
return ( return (
<KTextField.Root <KTextField.Root
class="flex flex-col gap-2" class="flex flex-col gap-2"
name={props.name} name={props.name}
value={props.value} value={props.value}
validationState={props.error ? 'invalid' : 'valid'} validationState={props.error ? "invalid" : "valid"}
isRequired={props.required} isRequired={props.required}
> >
<Show when={props.label}> <Show when={props.label}>
@@ -39,9 +45,19 @@ export function TextField(props: TextFieldProps) {
</Show> </Show>
<Show <Show
when={props.multiline} when={props.multiline}
fallback={<KTextField.Input {...fieldProps} type={props.type} class="w-full p-2 rounded-lg bg-white/10 placeholder-neutral-400" />} fallback={
<KTextField.Input
{...fieldProps}
type={props.type}
class="w-full p-2 rounded-lg bg-white/10 placeholder-neutral-400"
/>
}
> >
<KTextField.TextArea {...fieldProps} autoResize class="w-full p-2 rounded-lg bg-white/10 placeholder-neutral-400" /> <KTextField.TextArea
{...fieldProps}
autoResize
class="w-full p-2 rounded-lg bg-white/10 placeholder-neutral-400"
/>
</Show> </Show>
<KTextField.ErrorMessage>{props.error}</KTextField.ErrorMessage> <KTextField.ErrorMessage>{props.error}</KTextField.ErrorMessage>
</KTextField.Root> </KTextField.Root>

View File

@@ -1,41 +1,44 @@
import { JSX, ParentComponent, Show, Suspense, createResource } from "solid-js" import { JSX, ParentComponent, Show, Suspense, createResource } from "solid-js";
import Linkify from "./Linkify" import Linkify from "./Linkify";
import { Button, ButtonLink } from "./Button" import { Button, ButtonLink } from "./Button";
import { Checkbox as KCheckbox, Separator } from "@kobalte/core" import { Checkbox as KCheckbox, Separator } from "@kobalte/core";
import { useMegaStore } from "~/state/megaStore" import { useMegaStore } from "~/state/megaStore";
import check from "~/assets/icons/check.svg" import check from "~/assets/icons/check.svg";
import { MutinyTagItem } from "~/utils/tags" import { MutinyTagItem } from "~/utils/tags";
import { generateGradient } from "~/utils/gradientHash" import { generateGradient } from "~/utils/gradientHash";
import close from "~/assets/icons/close.svg" import close from "~/assets/icons/close.svg";
export { export { Button, ButtonLink, Linkify };
Button,
ButtonLink,
Linkify,
}
export const SmallHeader: ParentComponent<{ class?: string }> = (props) => { export const SmallHeader: ParentComponent<{ class?: string }> = (props) => {
return <header class={`text-sm font-semibold uppercase ${props.class}`}>{props.children}</header>
}
export const Card: ParentComponent<{ title?: string, titleElement?: JSX.Element }> = (props) => {
return ( return (
<div class='rounded-xl p-4 flex flex-col gap-2 bg-neutral-950/50 w-full'> <header class={`text-sm font-semibold uppercase ${props.class}`}>
{props.children}
</header>
);
};
export const Card: ParentComponent<{
title?: string;
titleElement?: JSX.Element;
}> = (props) => {
return (
<div class="rounded-xl p-4 flex flex-col gap-2 bg-neutral-950/50 w-full">
{props.title && <SmallHeader>{props.title}</SmallHeader>} {props.title && <SmallHeader>{props.title}</SmallHeader>}
{props.titleElement && props.titleElement} {props.titleElement && props.titleElement}
{props.children} {props.children}
</div> </div>
) );
} };
export const InnerCard: ParentComponent<{ title?: string }> = (props) => { export const InnerCard: ParentComponent<{ title?: string }> = (props) => {
return ( return (
<div class='rounded-xl p-4 flex flex-col gap-2 border border-white/10 bg-[rgba(255,255,255,0.05)]'> <div class="rounded-xl p-4 flex flex-col gap-2 border border-white/10 bg-[rgba(255,255,255,0.05)]">
{props.title && <SmallHeader>{props.title}</SmallHeader>} {props.title && <SmallHeader>{props.title}</SmallHeader>}
{props.children} {props.children}
</div> </div>
) );
} };
export const FancyCard: ParentComponent<{ export const FancyCard: ParentComponent<{
title?: string; title?: string;
@@ -47,7 +50,11 @@ export const FancyCard: ParentComponent<{
<div class="w-full flex justify-between items-center"> <div class="w-full flex justify-between items-center">
<div class="flex gap-2"> <div class="flex gap-2">
{props.title && <SmallHeader>{props.title}</SmallHeader>} {props.title && <SmallHeader>{props.title}</SmallHeader>}
{props.subtitle && <SmallHeader class="text-neutral-500">{props.subtitle}</SmallHeader>} {props.subtitle && (
<SmallHeader class="text-neutral-500">
{props.subtitle}
</SmallHeader>
)}
</div> </div>
{props.tag && props.tag} {props.tag && props.tag}
</div> </div>
@@ -62,9 +69,9 @@ export const SafeArea: ParentComponent = (props) => {
{/* <div class="flex-1 disable-scrollbars overflow-y-scroll md:pl-[8rem] md:pr-[6rem]"> */} {/* <div class="flex-1 disable-scrollbars overflow-y-scroll md:pl-[8rem] md:pr-[6rem]"> */}
{props.children} {props.children}
{/* </div> */} {/* </div> */}
</div > </div>
) );
} };
export const DefaultMain: ParentComponent = (props) => { export const DefaultMain: ParentComponent = (props) => {
return ( return (
@@ -73,8 +80,8 @@ export const DefaultMain: ParentComponent = (props) => {
{/* CSS is hard sometimes */} {/* CSS is hard sometimes */}
<div class="py-4" /> <div class="py-4" />
</main> </main>
) );
} };
export const FullscreenLoader = () => { export const FullscreenLoader = () => {
return ( return (
@@ -82,23 +89,28 @@ export const FullscreenLoader = () => {
<LoadingSpinner wide /> <LoadingSpinner wide />
</div> </div>
); );
} };
export const MutinyWalletGuard: ParentComponent = (props) => { export const MutinyWalletGuard: ParentComponent = (props) => {
const [state, _] = useMegaStore(); const [state, _] = useMegaStore();
return ( return (
<Suspense fallback={<FullscreenLoader />}> <Suspense fallback={<FullscreenLoader />}>
<Show when={state.mutiny_wallet && !state.wallet_loading}>{props.children}</Show> <Show when={state.mutiny_wallet && !state.wallet_loading}>
{props.children}
</Show>
</Suspense> </Suspense>
); );
} };
export const LoadingSpinner = (props: { big?: boolean, wide?: boolean }) => { export const LoadingSpinner = (props: { big?: boolean; wide?: boolean }) => {
return ( return (
<div <div
role="status" role="status"
class="w-full" class="w-full"
classList={{ "flex justify-center": props.wide, "h-full grid": props.big }} classList={{
"flex justify-center": props.wide,
"h-full grid": props.big
}}
> >
<svg <svg
aria-hidden="true" aria-hidden="true"
@@ -119,78 +131,114 @@ export const LoadingSpinner = (props: { big?: boolean, wide?: boolean }) => {
<span class="sr-only">Loading...</span> <span class="sr-only">Loading...</span>
</div> </div>
); );
} };
export const Hr = () => <Separator.Root class="my-4 border-white/20" /> export const Hr = () => <Separator.Root class="my-4 border-white/20" />;
export const LargeHeader: ParentComponent<{ action?: JSX.Element }> = (props) => { export const LargeHeader: ParentComponent<{ action?: JSX.Element }> = (
props
) => {
return ( return (
<header class="w-full flex justify-between items-center mt-4 mb-2"> <header class="w-full flex justify-between items-center mt-4 mb-2">
<h1 class="text-3xl font-semibold">{props.children}</h1> <h1 class="text-3xl font-semibold">{props.children}</h1>
<Show when={props.action}> <Show when={props.action}>{props.action}</Show>
{props.action}
</Show>
</header> </header>
) );
} };
export const VStack: ParentComponent<{ biggap?: boolean }> = (props) => { export const VStack: ParentComponent<{ biggap?: boolean }> = (props) => {
return (<div class={`flex flex-col gap-${props.biggap ? "8" : "4"}`}>{props.children}</div>) return (
} <div class={`flex flex-col gap-${props.biggap ? "8" : "4"}`}>
{props.children}
</div>
);
};
export const HStack: ParentComponent<{ biggap?: boolean }> = (props) => { export const HStack: ParentComponent<{ biggap?: boolean }> = (props) => {
return (<div class={`flex gap-${props.biggap ? "8" : "4"}`}>{props.children}</div>) return (
} <div class={`flex gap-${props.biggap ? "8" : "4"}`}>
{props.children}
</div>
);
};
export const SmallAmount: ParentComponent<{ amount: number | bigint, sign?: string }> = (props) => { export const SmallAmount: ParentComponent<{
return (<h2 class="font-light text-lg">{props.sign ? `${props.sign} ` : ""}{props.amount.toLocaleString()} <span class="text-sm">SATS</span></h2>) amount: number | bigint;
} sign?: string;
}> = (props) => {
return (
<h2 class="font-light text-lg">
{props.sign ? `${props.sign} ` : ""}
{props.amount.toLocaleString()} <span class="text-sm">SATS</span>
</h2>
);
};
export const NiceP: ParentComponent = (props) => { export const NiceP: ParentComponent = (props) => {
return (<p class="text-xl font-light">{props.children}</p>) return <p class="text-xl font-light">{props.children}</p>;
} };
export const TinyButton: ParentComponent<{ onClick: () => void, tag?: MutinyTagItem }> = (props) => { export const TinyButton: ParentComponent<{
onClick: () => void;
tag?: MutinyTagItem;
}> = (props) => {
// TODO: don't need to run this if it's not a contact // TODO: don't need to run this if it's not a contact
const [gradient] = createResource(async () => { const [gradient] = createResource(async () => {
return generateGradient(props.tag?.name || "?") return generateGradient(props.tag?.name || "?");
}) });
const bg = () => (props.tag?.name && props.tag?.kind === "Contact") ? gradient() : "rgb(255 255 255 / 0.1)" const bg = () =>
props.tag?.name && props.tag?.kind === "Contact"
? gradient()
: "rgb(255 255 255 / 0.1)";
return ( return (
<button class="py-1 px-2 rounded-lg bg-white/10" onClick={() => props.onClick()} <button
class="py-1 px-2 rounded-lg bg-white/10"
onClick={() => props.onClick()}
style={{ background: bg() }} style={{ background: bg() }}
> >
{props.children} {props.children}
</button> </button>
) );
} };
export const Indicator: ParentComponent = (props) => { export const Indicator: ParentComponent = (props) => {
return ( return (
<div class="box-border animate-pulse px-2 py-1 -my-1 bg-white/70 rounded text-xs uppercase text-black">{props.children}</div> <div class="box-border animate-pulse px-2 py-1 -my-1 bg-white/70 rounded text-xs uppercase text-black">
) {props.children}
} </div>
);
};
export function Checkbox(props: { label: string, checked: boolean, onChange: (checked: boolean) => void }) { export function Checkbox(props: {
label: string;
checked: boolean;
onChange: (checked: boolean) => void;
}) {
return ( return (
<KCheckbox.Root class="inline-flex items-center gap-2" checked={props.checked} onChange={props.onChange}> <KCheckbox.Root
class="inline-flex items-center gap-2"
checked={props.checked}
onChange={props.onChange}
>
<KCheckbox.Input class="" /> <KCheckbox.Input class="" />
<KCheckbox.Control class="flex-0 w-8 h-8 rounded-lg border-2 border-white bg-neutral-800 ui-checked:bg-m-red"> <KCheckbox.Control class="flex-0 w-8 h-8 rounded-lg border-2 border-white bg-neutral-800 ui-checked:bg-m-red">
<KCheckbox.Indicator> <KCheckbox.Indicator>
<img src={check} class="w-8 h-8" alt="check" /> <img src={check} class="w-8 h-8" alt="check" />
</KCheckbox.Indicator> </KCheckbox.Indicator>
</KCheckbox.Control> </KCheckbox.Control>
<KCheckbox.Label class="flex-1 text-xl font-light">{props.label}</KCheckbox.Label> <KCheckbox.Label class="flex-1 text-xl font-light">
{props.label}
</KCheckbox.Label>
</KCheckbox.Root> </KCheckbox.Root>
) );
} }
export function ModalCloseButton() { export function ModalCloseButton() {
return (<button return (
class="self-center justify-self-center hover:bg-white/10 rounded-lg active:bg-m-blue" <button class="self-center justify-self-center hover:bg-white/10 rounded-lg active:bg-m-blue">
>
<img src={close} alt="Close" class="w-8 h-8" /> <img src={close} alt="Close" class="w-8 h-8" />
</button>) </button>
);
} }

View File

@@ -1,5 +1,11 @@
import megacheck from "~/assets/icons/megacheck.png"; import megacheck from "~/assets/icons/megacheck.png";
export function MegaCheck() { export function MegaCheck() {
return <img src={megacheck} alt="success" class="w-1/2 mx-auto max-w-[50vh] flex-shrink" />; return (
<img
src={megacheck}
alt="success"
class="w-1/2 mx-auto max-w-[50vh] flex-shrink"
/>
);
} }

View File

@@ -1,5 +1,11 @@
import megaex from "~/assets/icons/megaex.png"; import megaex from "~/assets/icons/megaex.png";
export function MegaEx() { export function MegaEx() {
return <img src={megaex} alt="fail" class="w-1/2 mx-auto max-w-[30vh] flex-shrink" />; return (
<img
src={megaex}
alt="fail"
class="w-1/2 mx-auto max-w-[30vh] flex-shrink"
/>
);
} }

View File

@@ -33,7 +33,9 @@ export function SuccessModal(props: SuccessModalProps) {
{props.children} {props.children}
</Dialog.Description> </Dialog.Description>
<div class="w-full flex max-w-[300px] mx-auto"> <div class="w-full flex max-w-[300px] mx-auto">
<Button onClick={onNice}>{props.confirmText ?? "Nice"}</Button> <Button onClick={onNice}>
{props.confirmText ?? "Nice"}
</Button>
</div> </div>
</Dialog.Content> </Dialog.Content>
</div> </div>

View File

@@ -1,11 +1,13 @@
import { Component, For, createEffect, createSignal } from "solid-js"; import { Component, For, createEffect, createSignal } from "solid-js";
import { Event, nip19 } from "nostr-tools" import { Event, nip19 } from "nostr-tools";
import { Linkify } from "~/components/layout"; import { Linkify } from "~/components/layout";
type NostrEvent = { type NostrEvent = {
"content": string, "created_at": number, id?: string content: string;
} created_at: number;
id?: string;
};
const Note: Component<{ e: NostrEvent }> = (props) => { const Note: Component<{ e: NostrEvent }> = (props) => {
const linkRoot = "https://snort.social/e/"; const linkRoot = "https://snort.social/e/";
@@ -14,46 +16,62 @@ const Note: Component<{ e: NostrEvent }> = (props) => {
createEffect(() => { createEffect(() => {
if (props.e.id) { if (props.e.id) {
setNoteId(nip19.noteEncode(props.e.id)) setNoteId(nip19.noteEncode(props.e.id));
} }
}) });
return ( return (
<div class="flex gap-4 border-b border-faint-white py-6 items-start w-full"> <div class="flex gap-4 border-b border-faint-white py-6 items-start w-full">
<img class="bg-black rounded-xl flex-0" src="../180.png" width={45} height={45} /> <img
class="bg-black rounded-xl flex-0"
src="../180.png"
width={45}
height={45}
/>
<div class="flex flex-col gap-2 flex-1"> <div class="flex flex-col gap-2 flex-1">
<p class="break-words"> <p class="break-words">
{/* {props.e.content} */} {/* {props.e.content} */}
<Linkify initialText={props.e.content} /> <Linkify initialText={props.e.content} />
</p> </p>
<a class="no-underline hover:underline hover:decoration-light-text" href={`${linkRoot}${noteId()}`}> <a
<small class="text-light-text">{(new Date(props.e.created_at * 1000)).toLocaleString()}</small> class="no-underline hover:underline hover:decoration-light-text"
href={`${linkRoot}${noteId()}`}
>
<small class="text-light-text">
{new Date(props.e.created_at * 1000).toLocaleString()}
</small>
</a> </a>
</div> </div>
</div> </div>
) );
} };
function filterReplies(event: Event) { function filterReplies(event: Event) {
// If there's a "p" tag or an "e" tag we want to return false, otherwise true // If there's a "p" tag or an "e" tag we want to return false, otherwise true
for (const tag of event.tags) { for (const tag of event.tags) {
if (tag[0] === "p" || tag[0] === "e") { if (tag[0] === "p" || tag[0] === "e") {
return false return false;
} }
} }
return true return true;
} }
const Notes: Component<{ notes: Event[] }> = (props) => { const Notes: Component<{ notes: Event[] }> = (props) => {
return (<ul class="flex flex-col"> return (
<For each={props.notes.filter(filterReplies).sort((a, b) => b.created_at - a.created_at)}> <ul class="flex flex-col">
{(item) => <For
<li class="w-full"><Note e={item as NostrEvent} /></li> each={props.notes
} .filter(filterReplies)
.sort((a, b) => b.created_at - a.created_at)}
>
{(item) => (
<li class="w-full">
<Note e={item as NostrEvent} />
</li>
)}
</For> </For>
</ul>) </ul>
);
};
} export default Notes;
export default Notes

View File

@@ -7,17 +7,16 @@ const relayUrls = [
"wss://nostr.fmt.wiz.biz", "wss://nostr.fmt.wiz.biz",
"wss://relay.damus.io", "wss://relay.damus.io",
"wss://eden.nostr.land" "wss://eden.nostr.land"
] ];
import { SimplePool } from 'nostr-tools' import { SimplePool } from "nostr-tools";
import { LoadingSpinner } from "~/components/layout"; import { LoadingSpinner } from "~/components/layout";
import Notes from "~/components/waitlist/Notes"; import Notes from "~/components/waitlist/Notes";
import logo from '~/assets/icons/mutiny-logo.svg'; import logo from "~/assets/icons/mutiny-logo.svg";
const pool = new SimplePool() const pool = new SimplePool();
const postsFetcher = async () => { const postsFetcher = async () => {
const filter = { const filter = {
authors: [ authors: [
"df173277182f3155d37b330211ba1de4a81500c02d195e964f91be774ec96708" "df173277182f3155d37b330211ba1de4a81500c02d195e964f91be774ec96708"
@@ -26,10 +25,10 @@ const postsFetcher = async () => {
kinds: [1] kinds: [1]
}; };
const events = await pool.list(relayUrls, [filter]) const events = await pool.list(relayUrls, [filter]);
return events; return events;
} };
export function WaitlistAlreadyIn() { export function WaitlistAlreadyIn() {
const [posts] = createResource("", postsFetcher); const [posts] = createResource("", postsFetcher);
@@ -40,7 +39,9 @@ export function WaitlistAlreadyIn() {
<img src={logo} class="h-10" alt="logo" /> <img src={logo} class="h-10" alt="logo" />
</a> </a>
<h1 class="text-4xl font-bold">You're on a list!</h1> <h1 class="text-4xl font-bold">You're on a list!</h1>
<h2 class="text-xl pr-4">We'll message you when Mutiny Wallet is ready.</h2> <h2 class="text-xl pr-4">
We'll message you when Mutiny Wallet is ready.
</h2>
<div class="px-4 sm:px-8 py-8 rounded-xl bg-half-black w-full"> <div class="px-4 sm:px-8 py-8 rounded-xl bg-half-black w-full">
<h2 class="text-sm font-semibold uppercase">Recent Updates</h2> <h2 class="text-sm font-semibold uppercase">Recent Updates</h2>
<Show <Show

View File

@@ -2,109 +2,190 @@ import { Match, Switch, createSignal } from "solid-js";
import { Button } from "~/components/layout"; import { Button } from "~/components/layout";
import { StyledRadioGroup } from "../layout/Radio"; import { StyledRadioGroup } from "../layout/Radio";
import { TextField } from "../layout/TextField"; import { TextField } from "../layout/TextField";
import { SubmitHandler, createForm, email, getValue, required, setValue } from "@modular-forms/solid"; import {
SubmitHandler,
createForm,
email,
getValue,
required,
setValue
} from "@modular-forms/solid";
import { showToast } from "../Toaster"; import { showToast } from "../Toaster";
import eify from "~/utils/eify"; import eify from "~/utils/eify";
import logo from '~/assets/icons/mutiny-logo.svg'; import logo from "~/assets/icons/mutiny-logo.svg";
const WAITLIST_ENDPOINT = "https://waitlist.mutiny-waitlist.workers.dev/waitlist"; const WAITLIST_ENDPOINT =
"https://waitlist.mutiny-waitlist.workers.dev/waitlist";
const COMMUNICATION_METHODS = [{ value: "nostr", label: "Nostr", caption: "Your freshest npub" }, { value: "email", label: "Email", caption: "Burners welcome" }] const COMMUNICATION_METHODS = [
{ value: "nostr", label: "Nostr", caption: "Your freshest npub" },
{ value: "email", label: "Email", caption: "Burners welcome" }
];
type WaitlistForm = { type WaitlistForm = {
user_type: "nostr" | "email", user_type: "nostr" | "email";
id: string id: string;
comment?: string comment?: string;
} };
const initialValues: WaitlistForm = { user_type: "nostr", id: "", comment: "" }; const initialValues: WaitlistForm = { user_type: "nostr", id: "", comment: "" };
export default function WaitlistForm() { export default function WaitlistForm() {
const [waitlistForm, { Form, Field }] = createForm<WaitlistForm>({ initialValues }); const [waitlistForm, { Form, Field }] = createForm<WaitlistForm>({
initialValues
});
const [loading, setLoading] = createSignal(false); const [loading, setLoading] = createSignal(false);
const newHandleSubmit: SubmitHandler<WaitlistForm> = async (f: WaitlistForm) => { const newHandleSubmit: SubmitHandler<WaitlistForm> = async (
f: WaitlistForm
) => {
console.log(f); console.log(f);
// TODO: not sure why waitlistForm.submitting doesn't work for me // TODO: not sure why waitlistForm.submitting doesn't work for me
// https://modularforms.dev/solid/guides/handle-submission // https://modularforms.dev/solid/guides/handle-submission
setLoading(true) setLoading(true);
try { try {
const res = await fetch(WAITLIST_ENDPOINT, { const res = await fetch(WAITLIST_ENDPOINT, {
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'application/json' "Content-Type": "application/json"
}, },
body: JSON.stringify(f) body: JSON.stringify(f)
}) });
if (res.status !== 200) { if (res.status !== 200) {
throw new Error("nope"); throw new Error("nope");
} else { } else {
// On success set the id in local storage and reload the page // On success set the id in local storage and reload the page
localStorage.setItem('waitlist_id', f.id); localStorage.setItem("waitlist_id", f.id);
window.location.reload(); window.location.reload();
} }
} catch (e) { } catch (e) {
if (f.user_type === "nostr") { if (f.user_type === "nostr") {
const error = new Error("Something went wrong. Are you sure that's a valid npub?") const error = new Error(
showToast(eify(error)) "Something went wrong. Are you sure that's a valid npub?"
);
showToast(eify(error));
} else { } else {
const error = new Error("Something went wrong. Not sure what.") const error = new Error("Something went wrong. Not sure what.");
showToast(eify(error)) showToast(eify(error));
} }
return return;
} finally { } finally {
setLoading(false) setLoading(false);
}
} }
};
return ( return (
<main class='flex flex-col gap-8 py-8 px-4 max-w-xl mx-auto'> <main class="flex flex-col gap-8 py-8 px-4 max-w-xl mx-auto">
<a href="https://mutinywallet.com"> <a href="https://mutinywallet.com">
<img src={logo} class="h-10" alt="logo" /> <img src={logo} class="h-10" alt="logo" />
</a> </a>
<h1 class='text-4xl font-bold'>Join Waitlist</h1> <h1 class="text-4xl font-bold">Join Waitlist</h1>
<h2 class="text-xl"> <h2 class="text-xl">
Sign up for our waitlist and we'll send a message when Mutiny Wallet is ready for you. Sign up for our waitlist and we'll send a message when Mutiny
Wallet is ready for you.
</h2> </h2>
<Form onSubmit={newHandleSubmit} class="flex flex-col gap-8"> <Form onSubmit={newHandleSubmit} class="flex flex-col gap-8">
<Field name="user_type"> <Field name="user_type">
{(field, _props) => ( {(field, _props) => (
// TODO: there's probably a "real" way to do this with modular-forms // TODO: there's probably a "real" way to do this with modular-forms
<StyledRadioGroup value={field.value || "nostr"} onValueChange={(newValue) => setValue(waitlistForm, "user_type", newValue as "nostr" | "email")} choices={COMMUNICATION_METHODS} /> <StyledRadioGroup
value={field.value || "nostr"}
onValueChange={(newValue) =>
setValue(
waitlistForm,
"user_type",
newValue as "nostr" | "email"
)
}
choices={COMMUNICATION_METHODS}
/>
)} )}
</Field> </Field>
<Switch> <Switch>
<Match when={getValue(waitlistForm, 'user_type', { shouldActive: false }) === 'nostr'}> <Match
<Field name="id" when={
validate={[required("We need some way to contact you")]} getValue(waitlistForm, "user_type", {
shouldActive: false
}) === "nostr"
}
>
<Field
name="id"
validate={[
required("We need some way to contact you")
]}
> >
{(field, props) => ( {(field, props) => (
<TextField {...props} value={field.value} error={field.error} label="Nostr npub or NIP-05" placeholder="npub..." /> <TextField
{...props}
value={field.value}
error={field.error}
label="Nostr npub or NIP-05"
placeholder="npub..."
/>
)} )}
</Field> </Field>
</Match> </Match>
<Match when={getValue(waitlistForm, 'user_type', { shouldActive: false }) === 'email'}> <Match
<Field name="id" when={
validate={[required("We need some way to contact you"), email("That doesn't look like an email address to me")]} getValue(waitlistForm, "user_type", {
shouldActive: false
}) === "email"
}
>
<Field
name="id"
validate={[
required("We need some way to contact you"),
email(
"That doesn't look like an email address to me"
)
]}
> >
{(field, props) => ( {(field, props) => (
<TextField {...props} value={field.value} error={field.error} type="email" label="Email" placeholder="email@nokycemail.com" /> <TextField
{...props}
value={field.value}
error={field.error}
type="email"
label="Email"
placeholder="email@nokycemail.com"
/>
)} )}
</Field> </Field>
</Match> </Match>
</Switch> </Switch>
<Field name="comment"> <Field name="comment">
{(field, props) => ( {(field, props) => (
<TextField multiline {...props} value={field.value} error={field.error} label="Comments" placeholder="I want a lightning wallet that does..." /> <TextField
multiline
{...props}
value={field.value}
error={field.error}
label="Comments"
placeholder="I want a lightning wallet that does..."
/>
)} )}
</Field> </Field>
<Button loading={loading()} disabled={loading() || !waitlistForm.dirty || waitlistForm.submitting || waitlistForm.invalid} class="self-start" intent="red" type="submit" layout="pad">Submit</Button> <Button
loading={loading()}
disabled={
loading() ||
!waitlistForm.dirty ||
waitlistForm.submitting ||
waitlistForm.invalid
}
class="self-start"
intent="red"
type="submit"
layout="pad"
>
Submit
</Button>
</Form> </Form>
</main> </main>
) );
} }

View File

@@ -1,7 +1,7 @@
import { import {
createHandler, createHandler,
renderAsync, renderAsync,
StartServer, StartServer
} from "solid-start/entry-server"; } from "solid-start/entry-server";
export default createHandler( export default createHandler(

View File

@@ -1,6 +1,5 @@
import initMutinyWallet, { MutinyWallet } from "@mutinywallet/mutiny-wasm";
import initMutinyWallet, { MutinyWallet } from '@mutinywallet/mutiny-wasm'; import initWaila from "@mutinywallet/waila-wasm";
import initWaila from '@mutinywallet/waila-wasm'
// export type MutinyWalletSettingStrings = { // export type MutinyWalletSettingStrings = {
// network?: string, proxy?: string, esplora?: string, rgs?: string, lsp?: string, // network?: string, proxy?: string, esplora?: string, rgs?: string, lsp?: string,
@@ -8,20 +7,34 @@ import initWaila from '@mutinywallet/waila-wasm'
export type Network = "bitcoin" | "testnet" | "regtest" | "signet"; export type Network = "bitcoin" | "testnet" | "regtest" | "signet";
export type MutinyWalletSettingStrings = { export type MutinyWalletSettingStrings = {
network?: Network, proxy?: string, esplora?: string, rgs?: string, lsp?: string, network?: Network;
} proxy?: string;
esplora?: string;
rgs?: string;
lsp?: string;
};
export function getExistingSettings(): MutinyWalletSettingStrings { export function getExistingSettings(): MutinyWalletSettingStrings {
const network = localStorage.getItem('MUTINY_SETTINGS_network') || import.meta.env.VITE_NETWORK; const network =
const proxy = localStorage.getItem('MUTINY_SETTINGS_proxy') || import.meta.env.VITE_PROXY; localStorage.getItem("MUTINY_SETTINGS_network") ||
const esplora = localStorage.getItem('MUTINY_SETTINGS_esplora') || import.meta.env.VITE_ESPLORA; import.meta.env.VITE_NETWORK;
const rgs = localStorage.getItem('MUTINY_SETTINGS_rgs') || import.meta.env.VITE_RGS; const proxy =
const lsp = localStorage.getItem('MUTINY_SETTINGS_lsp') || import.meta.env.VITE_LSP; localStorage.getItem("MUTINY_SETTINGS_proxy") ||
import.meta.env.VITE_PROXY;
const esplora =
localStorage.getItem("MUTINY_SETTINGS_esplora") ||
import.meta.env.VITE_ESPLORA;
const rgs =
localStorage.getItem("MUTINY_SETTINGS_rgs") || import.meta.env.VITE_RGS;
const lsp =
localStorage.getItem("MUTINY_SETTINGS_lsp") || import.meta.env.VITE_LSP;
return { network, proxy, esplora, rgs, lsp } return { network, proxy, esplora, rgs, lsp };
} }
export async function setAndGetMutinySettings(settings?: MutinyWalletSettingStrings): Promise<MutinyWalletSettingStrings> { export async function setAndGetMutinySettings(
settings?: MutinyWalletSettingStrings
): Promise<MutinyWalletSettingStrings> {
let { network, proxy, esplora, rgs, lsp } = settings || {}; let { network, proxy, esplora, rgs, lsp } = settings || {};
const existingSettings = getExistingSettings(); const existingSettings = getExistingSettings();
@@ -33,66 +46,84 @@ export async function setAndGetMutinySettings(settings?: MutinyWalletSettingStri
lsp = lsp || existingSettings.lsp; lsp = lsp || existingSettings.lsp;
if (!network || !proxy || !esplora) { if (!network || !proxy || !esplora) {
throw new Error("Missing a default setting for network, proxy, or esplora. Check your .env file to make sure it looks like .env.sample") throw new Error(
"Missing a default setting for network, proxy, or esplora. Check your .env file to make sure it looks like .env.sample"
);
} }
localStorage.setItem('MUTINY_SETTINGS_network', network); localStorage.setItem("MUTINY_SETTINGS_network", network);
localStorage.setItem('MUTINY_SETTINGS_proxy', proxy); localStorage.setItem("MUTINY_SETTINGS_proxy", proxy);
localStorage.setItem('MUTINY_SETTINGS_esplora', esplora); localStorage.setItem("MUTINY_SETTINGS_esplora", esplora);
if (!rgs || !lsp) { if (!rgs || !lsp) {
console.warn("RGS or LSP not set") console.warn("RGS or LSP not set");
} }
rgs && localStorage.setItem('MUTINY_SETTINGS_rgs', rgs); rgs && localStorage.setItem("MUTINY_SETTINGS_rgs", rgs);
lsp && localStorage.setItem('MUTINY_SETTINGS_lsp', lsp); lsp && localStorage.setItem("MUTINY_SETTINGS_lsp", lsp);
return { network, proxy, esplora, rgs, lsp } return { network, proxy, esplora, rgs, lsp };
} catch (error) { } catch (error) {
console.error(error) console.error(error);
throw error throw error;
} }
} }
export async function checkForWasm() { export async function checkForWasm() {
try { try {
if (typeof WebAssembly === "object" if (
&& typeof WebAssembly.instantiate === "function") { typeof WebAssembly === "object" &&
const module = new WebAssembly.Module(Uint8Array.of(0x0, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00)); typeof WebAssembly.instantiate === "function"
) {
const module = new WebAssembly.Module(
Uint8Array.of(0x0, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00)
);
if (!(module instanceof WebAssembly.Module)) { if (!(module instanceof WebAssembly.Module)) {
throw new Error("Couldn't instantiate WASM Module") throw new Error("Couldn't instantiate WASM Module");
} }
} else { } else {
throw new Error("No WebAssembly global object found") throw new Error("No WebAssembly global object found");
} }
} catch (e) { } catch (e) {
console.error(e) console.error(e);
} }
} }
export async function setupMutinyWallet(settings?: MutinyWalletSettingStrings): Promise<MutinyWallet> { export async function setupMutinyWallet(
settings?: MutinyWalletSettingStrings
): Promise<MutinyWallet> {
await initMutinyWallet(); await initMutinyWallet();
// Might as well init waila while we're at it // Might as well init waila while we're at it
await initWaila(); await initWaila();
console.time("Setup"); console.time("Setup");
console.log("Starting setup...") console.log("Starting setup...");
const { network, proxy, esplora, rgs, lsp } = await setAndGetMutinySettings(settings) const { network, proxy, esplora, rgs, lsp } = await setAndGetMutinySettings(
console.log("Initializing Mutiny Manager") settings
);
console.log("Initializing Mutiny Manager");
console.log("Using network", network); console.log("Using network", network);
console.log("Using proxy", proxy); console.log("Using proxy", proxy);
console.log("Using esplora address", esplora); console.log("Using esplora address", esplora);
console.log("Using rgs address", rgs); console.log("Using rgs address", rgs);
console.log("Using lsp address", lsp); console.log("Using lsp address", lsp);
const mutinyWallet = await new MutinyWallet("", undefined, proxy, network, esplora, rgs, lsp) const mutinyWallet = await new MutinyWallet(
"",
undefined,
proxy,
network,
esplora,
rgs,
lsp
);
const nodes = await mutinyWallet.list_nodes(); const nodes = await mutinyWallet.list_nodes();
// If we don't have any nodes yet, create one // If we don't have any nodes yet, create one
if (!nodes.length) { if (!nodes.length) {
await mutinyWallet?.new_node() await mutinyWallet?.new_node();
} }
return mutinyWallet return mutinyWallet;
} }

View File

@@ -10,7 +10,7 @@ import {
Meta, Meta,
Routes, Routes,
Scripts, Scripts,
Title, Title
} from "solid-start"; } from "solid-start";
import "./root.css"; import "./root.css";
import { Provider as MegaStoreProvider } from "~/state/megaStore"; import { Provider as MegaStoreProvider } from "~/state/megaStore";
@@ -29,10 +29,21 @@ export default function Root() {
/> />
<Link rel="manifest" href="/manifest.webmanifest" /> <Link rel="manifest" href="/manifest.webmanifest" />
<Meta name="theme-color" content="rgb(23,23,23)" /> <Meta name="theme-color" content="rgb(23,23,23)" />
<Meta name="description" content="Lightning wallet for the web" /> <Meta
name="description"
content="Lightning wallet for the web"
/>
<Link rel="icon" href="/favicon.ico" /> <Link rel="icon" href="/favicon.ico" />
<Link rel="apple-touch-icon" href="/images/icon.png" sizes="512x512" /> <Link
<Link rel="mask-icon" href="/mutiny_logo_mask.svg" color="#000" /> rel="apple-touch-icon"
href="/images/icon.png"
sizes="512x512"
/>
<Link
rel="mask-icon"
href="/mutiny_logo_mask.svg"
color="#000"
/>
</Head> </Head>
<Body> <Body>
<Suspense> <Suspense>

View File

@@ -112,7 +112,10 @@ export default function Activity() {
<Card title="Activity"> <Card title="Activity">
<div class="p-1" /> <div class="p-1" />
<VStack> <VStack>
<Show when={!state.wallet_loading} fallback={<LoadingShimmer />}> <Show
when={!state.wallet_loading}
fallback={<LoadingShimmer />}
>
<CombinedActivity /> <CombinedActivity />
</Show> </Show>
</VStack> </VStack>
@@ -121,7 +124,10 @@ export default function Activity() {
<Tabs.Content value="nostr"> <Tabs.Content value="nostr">
<VStack> <VStack>
<div class="my-8 flex flex-col items-center gap-4 text-center max-w-[20rem] mx-auto"> <div class="my-8 flex flex-col items-center gap-4 text-center max-w-[20rem] mx-auto">
<NiceP>Import your contacts from nostr to see who they're zapping.</NiceP> <NiceP>
Import your contacts from nostr to see
who they're zapping.
</NiceP>
<Button disabled intent="blue"> <Button disabled intent="blue">
Coming soon Coming soon
</Button> </Button>

View File

@@ -1,7 +1,15 @@
import { DeleteEverything } from "~/components/DeleteEverything"; import { DeleteEverything } from "~/components/DeleteEverything";
import KitchenSink from "~/components/KitchenSink"; import KitchenSink from "~/components/KitchenSink";
import NavBar from "~/components/NavBar"; import NavBar from "~/components/NavBar";
import { Card, DefaultMain, LargeHeader, MutinyWalletGuard, SafeArea, SmallHeader, VStack } from "~/components/layout"; import {
Card,
DefaultMain,
LargeHeader,
MutinyWalletGuard,
SafeArea,
SmallHeader,
VStack
} from "~/components/layout";
import { BackLink } from "~/components/layout/BackLink"; import { BackLink } from "~/components/layout/BackLink";
export default function Admin() { export default function Admin() {
@@ -12,9 +20,14 @@ export default function Admin() {
<BackLink href="/settings" title="Settings" /> <BackLink href="/settings" title="Settings" />
<LargeHeader>Admin</LargeHeader> <LargeHeader>Admin</LargeHeader>
<VStack> <VStack>
<Card><p>If you know what you're doing you're in the right place!</p></Card> <Card>
<p>
If you know what you're doing you're in the
right place!
</p>
</Card>
<KitchenSink /> <KitchenSink />
<div class='rounded-xl p-4 flex flex-col gap-2 bg-m-red overflow-x-hidden'> <div class="rounded-xl p-4 flex flex-col gap-2 bg-m-red overflow-x-hidden">
<SmallHeader>Danger zone</SmallHeader> <SmallHeader>Danger zone</SmallHeader>
<DeleteEverything /> <DeleteEverything />
</div> </div>
@@ -23,5 +36,5 @@ export default function Admin() {
<NavBar activeTab="none" /> <NavBar activeTab="none" />
</SafeArea> </SafeArea>
</MutinyWalletGuard> </MutinyWalletGuard>
) );
} }

View File

@@ -1,9 +1,18 @@
import { Button, DefaultMain, LargeHeader, NiceP, MutinyWalletGuard, SafeArea, VStack, Checkbox } from "~/components/layout"; import {
Button,
DefaultMain,
LargeHeader,
NiceP,
MutinyWalletGuard,
SafeArea,
VStack,
Checkbox
} from "~/components/layout";
import NavBar from "~/components/NavBar"; import NavBar from "~/components/NavBar";
import { useNavigate } from 'solid-start'; import { useNavigate } from "solid-start";
import { SeedWords } from '~/components/SeedWords'; import { SeedWords } from "~/components/SeedWords";
import { useMegaStore } from '~/state/megaStore'; import { useMegaStore } from "~/state/megaStore";
import { Show, createEffect, createSignal } from 'solid-js'; import { Show, createEffect, createSignal } from "solid-js";
import { BackLink } from "~/components/layout/BackLink"; import { BackLink } from "~/components/layout/BackLink";
function Quiz(props: { setHasCheckedAll: (hasChecked: boolean) => void }) { function Quiz(props: { setHasCheckedAll: (hasChecked: boolean) => void }) {
@@ -13,19 +22,31 @@ function Quiz(props: { setHasCheckedAll: (hasChecked: boolean) => void }) {
createEffect(() => { createEffect(() => {
if (one() && two() && three()) { if (one() && two() && three()) {
props.setHasCheckedAll(true) props.setHasCheckedAll(true);
} else { } else {
props.setHasCheckedAll(false) props.setHasCheckedAll(false);
} }
}) });
return ( return (
<VStack> <VStack>
<Checkbox checked={one()} onChange={setOne} label="I wrote down the words" /> <Checkbox
<Checkbox checked={two()} onChange={setTwo} label="I understand that my funds are my responsibility" /> checked={one()}
<Checkbox checked={three()} onChange={setThree} label="I'm not lying just to get this over with" /> onChange={setOne}
label="I wrote down the words"
/>
<Checkbox
checked={two()}
onChange={setTwo}
label="I understand that my funds are my responsibility"
/>
<Checkbox
checked={three()}
onChange={setThree}
label="I'm not lying just to get this over with"
/>
</VStack> </VStack>
) );
} }
export default function App() { export default function App() {
@@ -36,8 +57,8 @@ export default function App() {
const [hasCheckedAll, setHasCheckedAll] = createSignal(false); const [hasCheckedAll, setHasCheckedAll] = createSignal(false);
function wroteDownTheWords() { function wroteDownTheWords() {
actions.setHasBackedUp() actions.setHasBackedUp();
navigate("/") navigate("/");
} }
return ( return (
@@ -49,16 +70,32 @@ export default function App() {
<VStack> <VStack>
<NiceP>Let's get these funds secured.</NiceP> <NiceP>Let's get these funds secured.</NiceP>
<NiceP>We'll show you 12 words. You write down the 12 words.</NiceP>
<NiceP> <NiceP>
If you clear your browser history, or lose your device, these 12 words are the only way you can restore your wallet. We'll show you 12 words. You write down the 12
words.
</NiceP> </NiceP>
<NiceP>Mutiny is self-custodial. It's all up to you...</NiceP> <NiceP>
<SeedWords words={store.mutiny_wallet?.show_seed() || ""} setHasSeen={setHasSeenBackup} /> If you clear your browser history, or lose your
device, these 12 words are the only way you can
restore your wallet.
</NiceP>
<NiceP>
Mutiny is self-custodial. It's all up to you...
</NiceP>
<SeedWords
words={store.mutiny_wallet?.show_seed() || ""}
setHasSeen={setHasSeenBackup}
/>
<Show when={hasSeenBackup()}> <Show when={hasSeenBackup()}>
<Quiz setHasCheckedAll={setHasCheckedAll} /> <Quiz setHasCheckedAll={setHasCheckedAll} />
</Show> </Show>
<Button disabled={!hasSeenBackup() || !hasCheckedAll()} intent="blue" onClick={wroteDownTheWords}>I wrote down the words</Button> <Button
disabled={!hasSeenBackup() || !hasCheckedAll()}
intent="blue"
onClick={wroteDownTheWords}
>
I wrote down the words
</Button>
</VStack> </VStack>
</DefaultMain> </DefaultMain>
<NavBar activeTab="none" /> <NavBar activeTab="none" />

View File

@@ -1,4 +1,8 @@
import { Contact, MutinyBip21RawMaterials, MutinyInvoice } from "@mutinywallet/mutiny-wasm"; import {
Contact,
MutinyBip21RawMaterials,
MutinyInvoice
} from "@mutinywallet/mutiny-wasm";
import { import {
createEffect, createEffect,
createMemo, createMemo,
@@ -80,14 +84,16 @@ function FeeWarning(props: { fee: bigint; flavor: ReceiveFlavor }) {
<Switch> <Switch>
<Match when={props.flavor === "unified"}> <Match when={props.flavor === "unified"}>
<InfoBox accent="green"> <InfoBox accent="green">
A lightning setup fee of <AmountSmall amountSats={props.fee} /> will be charged if paid A lightning setup fee of{" "}
over lightning. <AmountSmall amountSats={props.fee} /> will be charged
if paid over lightning.
</InfoBox> </InfoBox>
</Match> </Match>
<Match when={props.flavor === "lightning"}> <Match when={props.flavor === "lightning"}>
<InfoBox accent="green"> <InfoBox accent="green">
A lightning setup fee of <AmountSmall amountSats={props.fee} /> will be charged for this A lightning setup fee of{" "}
receive. <AmountSmall amountSats={props.fee} /> will be charged
for this receive.
</InfoBox> </InfoBox>
</Match> </Match>
</Switch> </Switch>
@@ -101,13 +107,15 @@ function FeeExplanation(props: { fee: bigint }) {
<Switch> <Switch>
<Match when={props.fee > 1000n}> <Match when={props.fee > 1000n}>
<InfoBox accent="green"> <InfoBox accent="green">
A lightning setup fee of <AmountSmall amountSats={props.fee} /> was charged for this A lightning setup fee of{" "}
<AmountSmall amountSats={props.fee} /> was charged for this
receive. receive.
</InfoBox> </InfoBox>
</Match> </Match>
<Match when={props.fee > 0n}> <Match when={props.fee > 0n}>
<InfoBox accent="green"> <InfoBox accent="green">
A lightning service fee of <AmountSmall amountSats={props.fee} /> was charged for this A lightning service fee of{" "}
<AmountSmall amountSats={props.fee} /> was charged for this
receive. receive.
</InfoBox> </InfoBox>
</Match> </Match>
@@ -123,12 +131,15 @@ export default function Receive() {
const [receiveState, setReceiveState] = createSignal<ReceiveState>("edit"); const [receiveState, setReceiveState] = createSignal<ReceiveState>("edit");
const [bip21Raw, setBip21Raw] = createSignal<MutinyBip21RawMaterials>(); const [bip21Raw, setBip21Raw] = createSignal<MutinyBip21RawMaterials>();
const [unified, setUnified] = createSignal(""); const [unified, setUnified] = createSignal("");
const [shouldShowAmountEditor, setShouldShowAmountEditor] = createSignal(true); const [shouldShowAmountEditor, setShouldShowAmountEditor] =
createSignal(true);
const [lspFee, setLspFee] = createSignal(0n); const [lspFee, setLspFee] = createSignal(0n);
// Tagging stuff // Tagging stuff
const [selectedValues, setSelectedValues] = createSignal<MutinyTagItem[]>([]); const [selectedValues, setSelectedValues] = createSignal<MutinyTagItem[]>(
[]
);
// The data we get after a payment // The data we get after a payment
const [paymentTx, setPaymentTx] = createSignal<OnChainTx>(); const [paymentTx, setPaymentTx] = createSignal<OnChainTx>();
@@ -159,21 +170,31 @@ export default function Receive() {
setSelectedValues([]); setSelectedValues([]);
} }
async function processContacts(contacts: Partial<MutinyTagItem>[]): Promise<string[]> { async function processContacts(
contacts: Partial<MutinyTagItem>[]
): Promise<string[]> {
console.log("Processing contacts", contacts); console.log("Processing contacts", contacts);
if (contacts.length) { if (contacts.length) {
const first = contacts![0]; const first = contacts![0];
if (!first.name) { if (!first.name) {
console.error("Something went wrong with contact creation, proceeding anyway"); console.error(
"Something went wrong with contact creation, proceeding anyway"
);
return []; return [];
} }
if (!first.id && first.name) { if (!first.id && first.name) {
console.error("Creating new contact", first.name); console.error("Creating new contact", first.name);
const c = new Contact(first.name, undefined, undefined, undefined); const c = new Contact(
const newContactId = await state.mutiny_wallet?.create_new_contact(c); first.name,
undefined,
undefined,
undefined
);
const newContactId =
await state.mutiny_wallet?.create_new_contact(c);
if (newContactId) { if (newContactId) {
return [newContactId]; return [newContactId];
} }
@@ -185,7 +206,9 @@ export default function Receive() {
} }
} }
console.error("Something went wrong with contact creation, proceeding anyway"); console.error(
"Something went wrong with contact creation, proceeding anyway"
);
return []; return [];
} }
@@ -193,7 +216,10 @@ export default function Receive() {
const bigAmount = BigInt(amount); const bigAmount = BigInt(amount);
try { try {
const tags = await processContacts(selectedValues()); const tags = await processContacts(selectedValues());
const raw = await state.mutiny_wallet?.create_bip21(bigAmount, tags); const raw = await state.mutiny_wallet?.create_bip21(
bigAmount,
tags
);
// Save the raw info so we can watch the address and invoice // Save the raw info so we can watch the address and invoice
setBip21Raw(raw); setBip21Raw(raw);
@@ -204,7 +230,9 @@ export default function Receive() {
return `bitcoin:${raw?.address}?${params}`; return `bitcoin:${raw?.address}?${params}`;
} catch (e) { } catch (e) {
showToast(new Error("Couldn't create invoice. Are you asking for enough?")); showToast(
new Error("Couldn't create invoice. Are you asking for enough?")
);
console.error(e); console.error(e);
} }
} }
@@ -219,7 +247,9 @@ export default function Receive() {
setShouldShowAmountEditor(false); setShouldShowAmountEditor(false);
} }
async function checkIfPaid(bip21?: MutinyBip21RawMaterials): Promise<PaidState | undefined> { async function checkIfPaid(
bip21?: MutinyBip21RawMaterials
): Promise<PaidState | undefined> {
if (bip21) { if (bip21) {
console.debug("checking if paid..."); console.debug("checking if paid...");
const lightning = bip21.invoice; const lightning = bip21.invoice;
@@ -238,7 +268,9 @@ export default function Receive() {
return "lightning_paid"; return "lightning_paid";
} }
const tx = (await state.mutiny_wallet?.check_address(address)) as OnChainTx | undefined; const tx = (await state.mutiny_wallet?.check_address(address)) as
| OnChainTx
| undefined;
if (tx) { if (tx) {
setReceiveState("paid"); setReceiveState("paid");
@@ -265,10 +297,23 @@ export default function Receive() {
<MutinyWalletGuard> <MutinyWalletGuard>
<SafeArea> <SafeArea>
<DefaultMain> <DefaultMain>
<Show when={receiveState() === "show"} fallback={<BackLink />}> <Show
<BackButton onClick={() => setReceiveState("edit")} title="Edit" showOnDesktop /> when={receiveState() === "show"}
fallback={<BackLink />}
>
<BackButton
onClick={() => setReceiveState("edit")}
title="Edit"
showOnDesktop
/>
</Show> </Show>
<LargeHeader action={receiveState() === "show" && <Indicator>Checking</Indicator>}> <LargeHeader
action={
receiveState() === "show" && (
<Indicator>Checking</Indicator>
)
}
>
Receive Bitcoin Receive Bitcoin
</LargeHeader> </LargeHeader>
<Switch> <Switch>
@@ -306,13 +351,16 @@ export default function Receive() {
<p class="text-neutral-400 text-center"> <p class="text-neutral-400 text-center">
<Switch> <Switch>
<Match when={flavor() === "lightning"}> <Match when={flavor() === "lightning"}>
Show or share this invoice with the sender. Show or share this invoice with the
sender.
</Match> </Match>
<Match when={flavor() === "onchain"}> <Match when={flavor() === "onchain"}>
Show or share this address with the sender. Show or share this address with the
sender.
</Match> </Match>
<Match when={flavor() === "unified"}> <Match when={flavor() === "unified"}>
Show or share this code with the sender. Sender decides method of payment. Show or share this code with the sender.
Sender decides method of payment.
</Match> </Match>
</Switch> </Switch>
</p> </p>
@@ -325,7 +373,12 @@ export default function Receive() {
/>{" "} />{" "}
<ShareCard text={receiveString() ?? ""} /> <ShareCard text={receiveString() ?? ""} />
</Match> </Match>
<Match when={receiveState() === "paid" && paidState() === "lightning_paid"}> <Match
when={
receiveState() === "paid" &&
paidState() === "lightning_paid"
}
>
<SuccessModal <SuccessModal
title="Payment Received" title="Payment Received"
open={!!paidState()} open={!!paidState()}
@@ -339,10 +392,19 @@ export default function Receive() {
> >
<MegaCheck /> <MegaCheck />
<FeeExplanation fee={lspFee()} /> <FeeExplanation fee={lspFee()} />
<Amount amountSats={paymentInvoice()?.amount_sats} showFiat centered /> <Amount
amountSats={paymentInvoice()?.amount_sats}
showFiat
centered
/>
</SuccessModal> </SuccessModal>
</Match> </Match>
<Match when={receiveState() === "paid" && paidState() === "onchain_paid"}> <Match
when={
receiveState() === "paid" &&
paidState() === "onchain_paid"
}
>
<SuccessModal <SuccessModal
title="Payment Received" title="Payment Received"
open={!!paidState()} open={!!paidState()}
@@ -355,8 +417,17 @@ export default function Receive() {
}} }}
> >
<MegaCheck /> <MegaCheck />
<Amount amountSats={paymentTx()?.received} showFiat centered /> <Amount
<ExternalLink href={mempoolTxUrl(paymentTx()?.txid, network)}> amountSats={paymentTx()?.received}
showFiat
centered
/>
<ExternalLink
href={mempoolTxUrl(
paymentTx()?.txid,
network
)}
>
View Transaction View Transaction
</ExternalLink> </ExternalLink>
</SuccessModal> </SuccessModal>

View File

@@ -9,16 +9,16 @@ import {
ParentComponent, ParentComponent,
Show, Show,
Suspense, Suspense,
Switch, Switch
} from "solid-js" } from "solid-js";
import { import {
CENTER_COLUMN, CENTER_COLUMN,
MISSING_LABEL, MISSING_LABEL,
REDSHIFT_LABEL, REDSHIFT_LABEL,
RIGHT_COLUMN, RIGHT_COLUMN,
THREE_COLUMNS, THREE_COLUMNS,
UtxoItem, UtxoItem
} from "~/components/Activity" } from "~/components/Activity";
import { import {
Card, Card,
DefaultMain, DefaultMain,
@@ -29,43 +29,43 @@ import {
SafeArea, SafeArea,
SmallAmount, SmallAmount,
SmallHeader, SmallHeader,
VStack, VStack
} from "~/components/layout" } from "~/components/layout";
import { BackLink } from "~/components/layout/BackLink" import { BackLink } from "~/components/layout/BackLink";
import { StyledRadioGroup } from "~/components/layout/Radio" import { StyledRadioGroup } from "~/components/layout/Radio";
import NavBar from "~/components/NavBar" import NavBar from "~/components/NavBar";
import { useMegaStore } from "~/state/megaStore" import { useMegaStore } from "~/state/megaStore";
import wave from "~/assets/wave.gif" import wave from "~/assets/wave.gif";
import utxoIcon from "~/assets/icons/coin.svg" import utxoIcon from "~/assets/icons/coin.svg";
import { Button } from "~/components/layout/Button" import { Button } from "~/components/layout/Button";
import { ProgressBar } from "~/components/layout/ProgressBar" import { ProgressBar } from "~/components/layout/ProgressBar";
import { MutinyChannel } from "@mutinywallet/mutiny-wasm" import { MutinyChannel } from "@mutinywallet/mutiny-wasm";
import mempoolTxUrl from "~/utils/mempoolTxUrl" import mempoolTxUrl from "~/utils/mempoolTxUrl";
import { Amount } from "~/components/Amount" import { Amount } from "~/components/Amount";
import { getRedshifted, setRedshifted } from "~/utils/fakeLabels" import { getRedshifted, setRedshifted } from "~/utils/fakeLabels";
import { Network } from "~/logic/mutinyWalletSetup" import { Network } from "~/logic/mutinyWalletSetup";
type ShiftOption = "utxo" | "lightning" type ShiftOption = "utxo" | "lightning";
type ShiftStage = "choose" | "observe" | "success" | "failure" type ShiftStage = "choose" | "observe" | "success" | "failure";
type OutPoint = string // Replace with the actual TypeScript type for OutPoint type OutPoint = string; // Replace with the actual TypeScript type for OutPoint
type RedshiftStatus = string // Replace with the actual TypeScript type for RedshiftStatus type RedshiftStatus = string; // Replace with the actual TypeScript type for RedshiftStatus
type RedshiftRecipient = unknown // Replace with the actual TypeScript type for RedshiftRecipient type RedshiftRecipient = unknown; // Replace with the actual TypeScript type for RedshiftRecipient
type PublicKey = unknown // Replace with the actual TypeScript type for PublicKey type PublicKey = unknown; // Replace with the actual TypeScript type for PublicKey
interface RedshiftResult { interface RedshiftResult {
id: string id: string;
input_utxo: OutPoint input_utxo: OutPoint;
status: RedshiftStatus status: RedshiftStatus;
recipient: RedshiftRecipient recipient: RedshiftRecipient;
output_utxo?: OutPoint output_utxo?: OutPoint;
introduction_channel?: OutPoint introduction_channel?: OutPoint;
output_channel?: OutPoint output_channel?: OutPoint;
introduction_node: PublicKey introduction_node: PublicKey;
amount_sats: bigint amount_sats: bigint;
change_amt?: bigint change_amt?: bigint;
fees_paid: bigint fees_paid: bigint;
} }
const dummyRedshift: RedshiftResult = { const dummyRedshift: RedshiftResult = {
@@ -83,16 +83,16 @@ const dummyRedshift: RedshiftResult = {
introduction_node: {}, // Replace with a dummy value for PublicKey introduction_node: {}, // Replace with a dummy value for PublicKey
amount_sats: BigInt(1000000), amount_sats: BigInt(1000000),
change_amt: BigInt(12345), change_amt: BigInt(12345),
fees_paid: BigInt(2500), fees_paid: BigInt(2500)
} };
function RedshiftReport(props: { redshift: RedshiftResult; utxo: UtxoItem }) { function RedshiftReport(props: { redshift: RedshiftResult; utxo: UtxoItem }) {
const [state, _actions] = useMegaStore() const [state, _actions] = useMegaStore();
const getUtXos = async () => { const getUtXos = async () => {
console.log("Getting utxos") console.log("Getting utxos");
return (await state.mutiny_wallet?.list_utxos()) as UtxoItem[] return (await state.mutiny_wallet?.list_utxos()) as UtxoItem[];
} };
// function findUtxoByOutpoint( // function findUtxoByOutpoint(
// outpoint?: string, // outpoint?: string,
@@ -102,7 +102,7 @@ function RedshiftReport(props: { redshift: RedshiftResult; utxo: UtxoItem }) {
// return utxos.find((utxo) => utxo.outpoint === outpoint) // return utxos.find((utxo) => utxo.outpoint === outpoint)
// } // }
const [_utxos, { refetch: _refetchUtxos }] = createResource(getUtXos) const [_utxos, { refetch: _refetchUtxos }] = createResource(getUtXos);
// const inputUtxo = createMemo(() => { // const inputUtxo = createMemo(() => {
// console.log(utxos()) // console.log(utxos())
@@ -112,14 +112,15 @@ function RedshiftReport(props: { redshift: RedshiftResult; utxo: UtxoItem }) {
// }) // })
const [redshiftResource, { refetch: _refetchRedshift }] = createResource( const [redshiftResource, { refetch: _refetchRedshift }] = createResource(
async () => { async () => {
console.log("Checking redshift", props.redshift.id) console.log("Checking redshift", props.redshift.id);
const redshift = await state.mutiny_wallet?.get_redshift(props.redshift.id) const redshift = await state.mutiny_wallet?.get_redshift(
console.log(redshift) props.redshift.id
return redshift );
console.log(redshift);
return redshift;
} }
) );
onMount(() => { onMount(() => {
// const interval = setInterval(() => { // const interval = setInterval(() => {
// if (redshiftResource()) refetch() // if (redshiftResource()) refetch()
@@ -127,23 +128,21 @@ function RedshiftReport(props: { redshift: RedshiftResult; utxo: UtxoItem }) {
// // clearInterval(interval) // // clearInterval(interval)
// // props.setShiftStage("success"); // // props.setShiftStage("success");
// // // setSentAmount((0)) // // // setSentAmount((0))
// // } else { // // } else {
// // setSentAmount((sentAmount() + 50000)) // // setSentAmount((sentAmount() + 50000))
// // } // // }
// }, 1000) // }, 1000)
}) });
// const outputUtxo = createMemo(() => { // const outputUtxo = createMemo(() => {
// return findUtxoByOutpoint(redshiftResource()?.output_utxo, utxos()) // return findUtxoByOutpoint(redshiftResource()?.output_utxo, utxos())
// }) // })
createEffect(() => { createEffect(() => {
setRedshifted(true, redshiftResource()?.output_utxo) setRedshifted(true, redshiftResource()?.output_utxo);
}) });
const network = state.mutiny_wallet?.get_network() as Network const network = state.mutiny_wallet?.get_network() as Network;
return ( return (
<VStack biggap> <VStack biggap>
@@ -166,23 +165,34 @@ function RedshiftReport(props: { redshift: RedshiftResult; utxo: UtxoItem }) {
</Show> </Show>
</KV> */} </KV> */}
<KV key="Starting amount"> <KV key="Starting amount">
<Amount amountSats={redshiftResource()!.amount_sats} /> <Amount
amountSats={redshiftResource()!.amount_sats}
/>
</KV> </KV>
<KV key="Fees paid"> <KV key="Fees paid">
<Amount amountSats={redshiftResource()!.fees_paid} /> <Amount
amountSats={redshiftResource()!.fees_paid}
/>
</KV> </KV>
<KV key="Change"> <KV key="Change">
<Amount amountSats={redshiftResource()!.change_amt} /> <Amount
amountSats={redshiftResource()!.change_amt}
/>
</KV> </KV>
<KV key="Outbound channel"> <KV key="Outbound channel">
<VStack> <VStack>
<pre class="whitespace-pre-wrap break-all"> <pre class="whitespace-pre-wrap break-all">
{redshiftResource()!.introduction_channel} {
redshiftResource()!
.introduction_channel
}
</pre> </pre>
<a <a
class="" class=""
href={mempoolTxUrl( href={mempoolTxUrl(
redshiftResource()!.introduction_channel?.split(":")[0], redshiftResource()!.introduction_channel?.split(
":"
)[0],
network network
)} )}
target="_blank" target="_blank"
@@ -201,7 +211,9 @@ function RedshiftReport(props: { redshift: RedshiftResult; utxo: UtxoItem }) {
<a <a
class="" class=""
href={mempoolTxUrl( href={mempoolTxUrl(
redshiftResource()!.output_channel?.split(":")[0], redshiftResource()!.output_channel?.split(
":"
)[0],
network network
)} )}
target="_blank" target="_blank"
@@ -217,23 +229,30 @@ function RedshiftReport(props: { redshift: RedshiftResult; utxo: UtxoItem }) {
</Show> </Show>
</VStack> </VStack>
</VStack> </VStack>
) );
} }
const SHIFT_OPTIONS = [ const SHIFT_OPTIONS = [
{ value: "utxo", label: "UTXO", caption: "Trade your UTXO for a fresh UTXO" }, {
value: "utxo",
label: "UTXO",
caption: "Trade your UTXO for a fresh UTXO"
},
{ {
value: "lightning", value: "lightning",
label: "Lightning", label: "Lightning",
caption: "Convert your UTXO into Lightning", caption: "Convert your UTXO into Lightning"
}, }
] ];
export function Utxo(props: { item: UtxoItem; onClick?: () => void }) { export function Utxo(props: { item: UtxoItem; onClick?: () => void }) {
const redshifted = createMemo(() => getRedshifted(props.item.outpoint)) const redshifted = createMemo(() => getRedshifted(props.item.outpoint));
return ( return (
<> <>
<div class={THREE_COLUMNS} onClick={() => props.onClick && props.onClick()}> <div
class={THREE_COLUMNS}
onClick={() => props.onClick && props.onClick()}
>
<div class="flex items-center"> <div class="flex items-center">
<img src={utxoIcon} alt="coin" /> <img src={utxoIcon} alt="coin" />
</div> </div>
@@ -250,14 +269,16 @@ export function Utxo(props: { item: UtxoItem; onClick?: () => void }) {
</div> </div>
<div class={RIGHT_COLUMN}> <div class={RIGHT_COLUMN}>
<SmallHeader <SmallHeader
class={props.item?.is_spent ? "text-m-red" : "text-m-green"} class={
props.item?.is_spent ? "text-m-red" : "text-m-green"
}
> >
{/* {props.item?.is_spent ? "SPENT" : "UNSPENT"} */} {/* {props.item?.is_spent ? "SPENT" : "UNSPENT"} */}
</SmallHeader> </SmallHeader>
</div> </div>
</div> </div>
</> </>
) );
} }
const FAKE_STATES = [ const FAKE_STATES = [
@@ -265,30 +286,30 @@ const FAKE_STATES = [
"Opening a channel", "Opening a channel",
"Sending funds through", "Sending funds through",
"Closing the channel", "Closing the channel",
"Redshift complete", "Redshift complete"
] ];
function ShiftObserver(props: { function ShiftObserver(props: {
setShiftStage: (stage: ShiftStage) => void setShiftStage: (stage: ShiftStage) => void;
redshiftId: string redshiftId: string;
}) { }) {
const [_state, _actions] = useMegaStore() const [_state, _actions] = useMegaStore();
const [fakeStage, _setFakeStage] = createSignal(2) const [fakeStage, _setFakeStage] = createSignal(2);
const [sentAmount, setSentAmount] = createSignal(0) const [sentAmount, setSentAmount] = createSignal(0);
onMount(() => { onMount(() => {
const interval = setInterval(() => { const interval = setInterval(() => {
if (sentAmount() === 200000) { if (sentAmount() === 200000) {
clearInterval(interval) clearInterval(interval);
props.setShiftStage("success") props.setShiftStage("success");
// setSentAmount((0)) // setSentAmount((0))
} else { } else {
setSentAmount(sentAmount() + 50000) setSentAmount(sentAmount() + 50000);
} }
}, 1000) }, 1000);
}) });
// async function checkRedshift(id: string) { // async function checkRedshift(id: string) {
// console.log("Checking redshift", id) // console.log("Checking redshift", id)
@@ -336,7 +357,7 @@ function ShiftObserver(props: {
</VStack> </VStack>
</Card> </Card>
</> </>
) );
} }
const KV: ParentComponent<{ key: string }> = (props) => { const KV: ParentComponent<{ key: string }> = (props) => {
@@ -345,68 +366,72 @@ const KV: ParentComponent<{ key: string }> = (props) => {
<p class="text-sm font-semibold uppercase">{props.key}</p> <p class="text-sm font-semibold uppercase">{props.key}</p>
{props.children} {props.children}
</div> </div>
) );
} };
export default function Redshift() { export default function Redshift() {
const [state, _actions] = useMegaStore() const [state, _actions] = useMegaStore();
const [shiftStage, setShiftStage] = createSignal<ShiftStage>("choose") const [shiftStage, setShiftStage] = createSignal<ShiftStage>("choose");
const [shiftType, setShiftType] = createSignal<ShiftOption>("utxo") const [shiftType, setShiftType] = createSignal<ShiftOption>("utxo");
const [chosenUtxo, setChosenUtxo] = createSignal<UtxoItem>() const [chosenUtxo, setChosenUtxo] = createSignal<UtxoItem>();
const getUtXos = async () => { const getUtXos = async () => {
console.log("Getting utxos") console.log("Getting utxos");
return (await state.mutiny_wallet?.list_utxos()) as UtxoItem[] return (await state.mutiny_wallet?.list_utxos()) as UtxoItem[];
} };
const getChannels = async () => { const getChannels = async () => {
console.log("Getting channels") console.log("Getting channels");
await state.mutiny_wallet?.sync() await state.mutiny_wallet?.sync();
const channels = (await state.mutiny_wallet?.list_channels()) as Promise< const channels =
(await state.mutiny_wallet?.list_channels()) as Promise<
MutinyChannel[] MutinyChannel[]
> >;
console.log(channels) console.log(channels);
return channels return channels;
} };
const [utxos, { refetch: _refetchUtxos }] = createResource(getUtXos) const [utxos, { refetch: _refetchUtxos }] = createResource(getUtXos);
const [_channels, { refetch: _refetchChannels }] = createResource(getChannels) const [_channels, { refetch: _refetchChannels }] =
createResource(getChannels);
const redshiftedUtxos = createMemo(() => { const redshiftedUtxos = createMemo(() => {
return utxos()?.filter((utxo) => getRedshifted(utxo.outpoint)) return utxos()?.filter((utxo) => getRedshifted(utxo.outpoint));
}) });
const unredshiftedUtxos = createMemo(() => { const unredshiftedUtxos = createMemo(() => {
return utxos()?.filter((utxo) => !getRedshifted(utxo.outpoint)) return utxos()?.filter((utxo) => !getRedshifted(utxo.outpoint));
}) });
function resetState() { function resetState() {
setShiftStage("choose") setShiftStage("choose");
setShiftType("utxo") setShiftType("utxo");
setChosenUtxo(undefined) setChosenUtxo(undefined);
} }
async function redshiftUtxo(utxo: UtxoItem) { async function redshiftUtxo(utxo: UtxoItem) {
console.log("Redshifting utxo", utxo.outpoint) console.log("Redshifting utxo", utxo.outpoint);
const redshift = await state.mutiny_wallet?.init_redshift(utxo.outpoint) const redshift = await state.mutiny_wallet?.init_redshift(
console.log("Redshift initialized:") utxo.outpoint
console.log(redshift) );
return redshift console.log("Redshift initialized:");
console.log(redshift);
return redshift;
} }
const [initializedRedshift, { refetch: _refetchRedshift }] = createResource( const [initializedRedshift, { refetch: _refetchRedshift }] = createResource(
chosenUtxo, chosenUtxo,
redshiftUtxo redshiftUtxo
) );
createEffect(() => { createEffect(() => {
if (chosenUtxo() && initializedRedshift()) { if (chosenUtxo() && initializedRedshift()) {
// window.location.href = "/" // window.location.href = "/"
setShiftStage("observe") setShiftStage("observe");
} }
}) });
return ( return (
<MutinyWalletGuard> <MutinyWalletGuard>
@@ -425,7 +450,9 @@ export default function Redshift() {
accent="red" accent="red"
value={shiftType()} value={shiftType()}
onValueChange={(newValue) => onValueChange={(newValue) =>
setShiftType(newValue as ShiftOption) setShiftType(
newValue as ShiftOption
)
} }
choices={SHIFT_OPTIONS} choices={SHIFT_OPTIONS}
/> />
@@ -434,7 +461,11 @@ export default function Redshift() {
<NiceP> <NiceP>
Choose your{" "} Choose your{" "}
<span class="inline-block"> <span class="inline-block">
<img class="h-4" src={wave} alt="sine wave" /> <img
class="h-4"
src={wave}
alt="sine wave"
/>
</span>{" "} </span>{" "}
UTXO to begin UTXO to begin
</NiceP> </NiceP>
@@ -446,24 +477,37 @@ export default function Redshift() {
</Match> </Match>
<Match <Match
when={ when={
utxos.state === "ready" && utxos.state ===
unredshiftedUtxos()?.length === 0 "ready" &&
unredshiftedUtxos()
?.length === 0
} }
> >
<code>No utxos (empty state)</code> <code>
No utxos (empty
state)
</code>
</Match> </Match>
<Match <Match
when={ when={
utxos.state === "ready" && utxos.state ===
"ready" &&
unredshiftedUtxos() && unredshiftedUtxos() &&
unredshiftedUtxos()!.length >= 0 unredshiftedUtxos()!
.length >= 0
} }
> >
<For each={unredshiftedUtxos()}> <For
each={unredshiftedUtxos()}
>
{(utxo) => ( {(utxo) => (
<Utxo <Utxo
item={utxo} item={utxo}
onClick={() => setChosenUtxo(utxo)} onClick={() =>
setChosenUtxo(
utxo
)
}
/> />
)} )}
</For> </For>
@@ -475,7 +519,10 @@ export default function Redshift() {
<Card <Card
titleElement={ titleElement={
<SmallHeader> <SmallHeader>
<span class="text-m-red">Redshifted </span>UTXOs <span class="text-m-red">
Redshifted{" "}
</span>
UTXOs
</SmallHeader> </SmallHeader>
} }
> >
@@ -485,21 +532,34 @@ export default function Redshift() {
</Match> </Match>
<Match <Match
when={ when={
utxos.state === "ready" && utxos.state ===
redshiftedUtxos()?.length === 0 "ready" &&
redshiftedUtxos()
?.length === 0
} }
> >
<code>No utxos (empty state)</code> <code>
No utxos (empty
state)
</code>
</Match> </Match>
<Match <Match
when={ when={
utxos.state === "ready" && utxos.state ===
"ready" &&
redshiftedUtxos() && redshiftedUtxos() &&
redshiftedUtxos()!.length >= 0 redshiftedUtxos()!
.length >= 0
} }
> >
<For each={redshiftedUtxos()}> <For
{(utxo) => <Utxo item={utxo} />} each={redshiftedUtxos()}
>
{(utxo) => (
<Utxo
item={utxo}
/>
)}
</For> </For>
</Match> </Match>
</Switch> </Switch>
@@ -507,19 +567,32 @@ export default function Redshift() {
</Suspense> </Suspense>
</VStack> </VStack>
</Match> </Match>
<Match when={shiftStage() === "observe" && chosenUtxo()}> <Match
when={
shiftStage() === "observe" &&
chosenUtxo()
}
>
<ShiftObserver <ShiftObserver
setShiftStage={setShiftStage} setShiftStage={setShiftStage}
redshiftId="dummy-redshift" redshiftId="dummy-redshift"
/> />
</Match> </Match>
<Match when={shiftStage() === "success" && chosenUtxo()}> <Match
when={
shiftStage() === "success" &&
chosenUtxo()
}
>
<VStack biggap> <VStack biggap>
<RedshiftReport <RedshiftReport
redshift={dummyRedshift} redshift={dummyRedshift}
utxo={chosenUtxo()!} utxo={chosenUtxo()!}
/> />
<Button intent="red" onClick={resetState}> <Button
intent="red"
onClick={resetState}
>
Nice Nice
</Button> </Button>
</VStack> </VStack>
@@ -538,5 +611,5 @@ export default function Redshift() {
<NavBar activeTab="redshift" /> <NavBar activeTab="redshift" />
</SafeArea> </SafeArea>
</MutinyWalletGuard> </MutinyWalletGuard>
) );
} }

View File

@@ -15,27 +15,40 @@ export type ParsedParams = {
memo?: string; memo?: string;
node_pubkey?: string; node_pubkey?: string;
lnurl?: string; lnurl?: string;
} };
export function toParsedParams(str: string, ourNetwork: string): Result<ParsedParams> { export function toParsedParams(
str: string,
ourNetwork: string
): Result<ParsedParams> {
let params; let params;
try { try {
params = new PaymentParams(str || "") params = new PaymentParams(str || "");
} catch (e) { } catch (e) {
console.error(e) console.error(e);
return { ok: false, error: new Error("Invalid payment request") } return { ok: false, error: new Error("Invalid payment request") };
} }
// If WAILA doesn't return a network we should default to our own // If WAILA doesn't return a network we should default to our own
// If the networks is testnet and we're on signet we should use signet // If the networks is testnet and we're on signet we should use signet
const network = !params.network ? ourNetwork : params.network === "testnet" && ourNetwork === "signet" ? "signet" : params.network; const network = !params.network
? ourNetwork
: params.network === "testnet" && ourNetwork === "signet"
? "signet"
: params.network;
if (network !== ourNetwork) { if (network !== ourNetwork) {
return { ok: false, error: new Error(`Destination is for ${params.network} but you're on ${ourNetwork}`) } return {
ok: false,
error: new Error(
`Destination is for ${params.network} but you're on ${ourNetwork}`
)
};
} }
return { return {
ok: true, value: { ok: true,
value: {
address: params.address, address: params.address,
invoice: params.invoice, invoice: params.invoice,
amount_sats: params.amount_sats, amount_sats: params.amount_sats,
@@ -44,7 +57,7 @@ export function toParsedParams(str: string, ourNetwork: string): Result<ParsedPa
node_pubkey: params.node_pubkey, node_pubkey: params.node_pubkey,
lnurl: params.lnurl lnurl: params.lnurl
} }
} };
} }
export default function Scanner() { export default function Scanner() {
@@ -62,14 +75,14 @@ export default function Scanner() {
} }
function handlePaste() { function handlePaste() {
navigator.clipboard.readText().then(text => { navigator.clipboard.readText().then((text) => {
setScanResult(text); setScanResult(text);
}); });
} }
onMount(async () => { onMount(async () => {
await init() await init();
}) });
// When we have a nice result we can head over to the send screen // When we have a nice result we can head over to the send screen
createEffect(() => { createEffect(() => {
@@ -80,20 +93,27 @@ export default function Scanner() {
showToast(result.error); showToast(result.error);
return; return;
} else { } else {
if (result.value?.address || result.value?.invoice || result.value?.node_pubkey || result.value?.lnurl) { if (
result.value?.address ||
result.value?.invoice ||
result.value?.node_pubkey ||
result.value?.lnurl
) {
actions.setScanResult(result.value); actions.setScanResult(result.value);
navigate("/send") navigate("/send");
} }
} }
} }
}) });
return ( return (
<div class="safe-top safe-left safe-right safe-bottom h-full"> <div class="safe-top safe-left safe-right safe-bottom h-full">
<Reader onResult={onResult} /> <Reader onResult={onResult} />
<div class="w-full flex flex-col items-center fixed bottom-[2rem] gap-8 px-8"> <div class="w-full flex flex-col items-center fixed bottom-[2rem] gap-8 px-8">
<div class="w-full max-w-[800px] flex flex-col gap-2"> <div class="w-full max-w-[800px] flex flex-col gap-2">
<Button intent="blue" onClick={handlePaste}>Paste Something</Button> <Button intent="blue" onClick={handlePaste}>
Paste Something
</Button>
<Button onClick={exit}>Cancel</Button> <Button onClick={exit}>Cancel</Button>
</div> </div>
</div> </div>

View File

@@ -1,4 +1,12 @@
import { Match, Show, Switch, createEffect, createMemo, createSignal, onMount } from "solid-js"; import {
Match,
Show,
Switch,
createEffect,
createMemo,
createSignal,
onMount
} from "solid-js";
import { Amount } from "~/components/Amount"; import { Amount } from "~/components/Amount";
import NavBar from "~/components/NavBar"; import NavBar from "~/components/NavBar";
import { import {
@@ -58,18 +66,26 @@ export function MethodChooser(props: {
const methods = createMemo(() => { const methods = createMemo(() => {
const lnBalance = store.balance?.lightning || 0n; const lnBalance = store.balance?.lightning || 0n;
const onchainBalance = (store.balance?.confirmed || 0n) + (store.balance?.unconfirmed || 0n); const onchainBalance =
(store.balance?.confirmed || 0n) +
(store.balance?.unconfirmed || 0n);
return [ return [
{ {
value: "lightning", value: "lightning",
label: "Lightning Balance", label: "Lightning Balance",
caption: lnBalance > 0n ? `${lnBalance.toLocaleString()} SATS` : "No balance", caption:
lnBalance > 0n
? `${lnBalance.toLocaleString()} SATS`
: "No balance",
disabled: lnBalance === 0n disabled: lnBalance === 0n
}, },
{ {
value: "onchain", value: "onchain",
label: "On-chain Balance", label: "On-chain Balance",
caption: onchainBalance > 0n ? `${onchainBalance.toLocaleString()} SATS` : "No balance", caption:
onchainBalance > 0n
? `${onchainBalance.toLocaleString()} SATS`
: "No balance",
disabled: onchainBalance === 0n disabled: onchainBalance === 0n
} }
]; ];
@@ -115,11 +131,17 @@ function DestinationInput(props: {
<SmallHeader>Destination</SmallHeader> <SmallHeader>Destination</SmallHeader>
<textarea <textarea
value={props.fieldDestination} value={props.fieldDestination}
onInput={(e) => props.setFieldDestination(e.currentTarget.value)} onInput={(e) =>
props.setFieldDestination(e.currentTarget.value)
}
placeholder="bitcoin:..." placeholder="bitcoin:..."
class="p-2 rounded-lg bg-white/10 placeholder-neutral-400" class="p-2 rounded-lg bg-white/10 placeholder-neutral-400"
/> />
<Button disabled={!props.fieldDestination} intent="blue" onClick={props.handleDecode}> <Button
disabled={!props.fieldDestination}
intent="blue"
onClick={props.handleDecode}
>
Continue Continue
</Button> </Button>
<HStack> <HStack>
@@ -191,7 +213,9 @@ export default function Send() {
const [sentDetails, setSentDetails] = createSignal<SentDetails>(); const [sentDetails, setSentDetails] = createSignal<SentDetails>();
// Tagging stuff // Tagging stuff
const [selectedContacts, setSelectedContacts] = createSignal<Partial<MutinyTagItem>[]>([]); const [selectedContacts, setSelectedContacts] = createSignal<
Partial<MutinyTagItem>[]
>([]);
// Errors // Errors
const [error, setError] = createSignal<string>(); const [error, setError] = createSignal<string>();
@@ -211,10 +235,19 @@ export default function Send() {
const feeEstimate = () => { const feeEstimate = () => {
if (source() === "lightning") return undefined; if (source() === "lightning") return undefined;
if (source() === "onchain" && amountSats() && amountSats() > 0n && address()) { if (
source() === "onchain" &&
amountSats() &&
amountSats() > 0n &&
address()
) {
setError(undefined); setError(undefined);
try { try {
return state.mutiny_wallet?.estimate_tx_fee(address()!, amountSats(), undefined); return state.mutiny_wallet?.estimate_tx_fee(
address()!,
amountSats(),
undefined
);
} catch (e) { } catch (e) {
setError(eify(e).message); setError(eify(e).message);
} }
@@ -239,8 +272,11 @@ export default function Send() {
if (source.memo) setDescription(source.memo); if (source.memo) setDescription(source.memo);
if (source.invoice) { if (source.invoice) {
state.mutiny_wallet?.decode_invoice(source.invoice).then((invoice) => { state.mutiny_wallet
if (invoice?.amount_sats) setAmountSats(invoice.amount_sats); ?.decode_invoice(source.invoice)
.then((invoice) => {
if (invoice?.amount_sats)
setAmountSats(invoice.amount_sats);
setInvoice(invoice); setInvoice(invoice);
setSource("lightning"); setSource("lightning");
}); });
@@ -249,7 +285,9 @@ export default function Send() {
setNodePubkey(source.node_pubkey); setNodePubkey(source.node_pubkey);
setSource("lightning"); setSource("lightning");
} else if (source.lnurl) { } else if (source.lnurl) {
state.mutiny_wallet?.decode_lnurl(source.lnurl).then((lnurlParams) => { state.mutiny_wallet
?.decode_lnurl(source.lnurl)
.then((lnurlParams) => {
if (lnurlParams.tag === "payRequest") { if (lnurlParams.tag === "payRequest") {
setAmountSats(source.amount_sats || 0n); setAmountSats(source.amount_sats || 0n);
setLnurlp(source.lnurl); setLnurlp(source.lnurl);
@@ -303,7 +341,8 @@ export default function Send() {
} }
function handlePaste() { function handlePaste() {
if (!navigator.clipboard.readText) return showToast(new Error("Clipboard not supported")); if (!navigator.clipboard.readText)
return showToast(new Error("Clipboard not supported"));
navigator.clipboard navigator.clipboard
.readText() .readText()
@@ -316,21 +355,31 @@ export default function Send() {
}); });
} }
async function processContacts(contacts: Partial<MutinyTagItem>[]): Promise<string[]> { async function processContacts(
contacts: Partial<MutinyTagItem>[]
): Promise<string[]> {
console.log("Processing contacts", contacts); console.log("Processing contacts", contacts);
if (contacts.length) { if (contacts.length) {
const first = contacts![0]; const first = contacts![0];
if (!first.name) { if (!first.name) {
console.error("Something went wrong with contact creation, proceeding anyway"); console.error(
"Something went wrong with contact creation, proceeding anyway"
);
return []; return [];
} }
if (!first.id && first.name) { if (!first.id && first.name) {
console.error("Creating new contact", first.name); console.error("Creating new contact", first.name);
const c = new Contact(first.name, undefined, undefined, undefined); const c = new Contact(
const newContactId = await state.mutiny_wallet?.create_new_contact(c); first.name,
undefined,
undefined,
undefined
);
const newContactId =
await state.mutiny_wallet?.create_new_contact(c);
if (newContactId) { if (newContactId) {
return [newContactId]; return [newContactId];
} }
@@ -342,7 +391,9 @@ export default function Send() {
} }
} }
console.error("Something went wrong with contact creation, proceeding anyway"); console.error(
"Something went wrong with contact creation, proceeding anyway"
);
return []; return [];
} }
@@ -360,10 +411,20 @@ export default function Send() {
sentDetails.destination = bolt11; sentDetails.destination = bolt11;
// If the invoice has sats use that, otherwise we pass the user-defined amount // If the invoice has sats use that, otherwise we pass the user-defined amount
if (invoice()?.amount_sats) { if (invoice()?.amount_sats) {
await state.mutiny_wallet?.pay_invoice(firstNode, bolt11, undefined, tags); await state.mutiny_wallet?.pay_invoice(
firstNode,
bolt11,
undefined,
tags
);
sentDetails.amount = invoice()?.amount_sats; sentDetails.amount = invoice()?.amount_sats;
} else { } else {
await state.mutiny_wallet?.pay_invoice(firstNode, bolt11, amountSats(), tags); await state.mutiny_wallet?.pay_invoice(
firstNode,
bolt11,
amountSats(),
tags
);
sentDetails.amount = amountSats(); sentDetails.amount = amountSats();
} }
} else if (source() === "lightning" && nodePubkey()) { } else if (source() === "lightning" && nodePubkey()) {
@@ -399,7 +460,11 @@ export default function Send() {
} }
} else if (source() === "onchain" && address()) { } else if (source() === "onchain" && address()) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const txid = await state.mutiny_wallet?.send_to_address(address()!, amountSats(), tags); const txid = await state.mutiny_wallet?.send_to_address(
address()!,
amountSats(),
tags
);
sentDetails.amount = amountSats(); sentDetails.amount = amountSats();
sentDetails.destination = address(); sentDetails.destination = address();
sentDetails.txid = txid; sentDetails.txid = txid;
@@ -427,12 +492,22 @@ export default function Send() {
<MutinyWalletGuard> <MutinyWalletGuard>
<SafeArea> <SafeArea>
<DefaultMain> <DefaultMain>
<Show when={address() || invoice() || nodePubkey() || lnurlp()} fallback={<BackLink />}> <Show
<BackButton onClick={() => clearAll()} title="Start Over" /> when={
address() || invoice() || nodePubkey() || lnurlp()
}
fallback={<BackLink />}
>
<BackButton
onClick={() => clearAll()}
title="Start Over"
/>
</Show> </Show>
<LargeHeader>Send Bitcoin</LargeHeader> <LargeHeader>Send Bitcoin</LargeHeader>
<SuccessModal <SuccessModal
title={sentDetails()?.amount ? "Sent" : "Payment Failed"} title={
sentDetails()?.amount ? "Sent" : "Payment Failed"
}
confirmText={sentDetails()?.amount ? "Nice" : "Too Bad"} confirmText={sentDetails()?.amount ? "Nice" : "Too Bad"}
open={!!sentDetails()} open={!!sentDetails()}
setOpen={(open: boolean) => { setOpen={(open: boolean) => {
@@ -445,16 +520,33 @@ export default function Send() {
> >
<Switch> <Switch>
<Match when={sentDetails()?.failure_reason}> <Match when={sentDetails()?.failure_reason}>
<img src={megaex} alt="fail" class="w-1/2 mx-auto max-w-[50vh]" /> <img
src={megaex}
alt="fail"
class="w-1/2 mx-auto max-w-[50vh]"
/>
<p class="text-xl font-light py-2 px-4 rounded-xl bg-white/10"> <p class="text-xl font-light py-2 px-4 rounded-xl bg-white/10">
{sentDetails()?.failure_reason} {sentDetails()?.failure_reason}
</p> </p>
</Match> </Match>
<Match when={true}> <Match when={true}>
<img src={megacheck} alt="success" class="w-1/2 mx-auto max-w-[50vh]" /> <img
<Amount amountSats={sentDetails()?.amount} showFiat centered /> src={megacheck}
alt="success"
class="w-1/2 mx-auto max-w-[50vh]"
/>
<Amount
amountSats={sentDetails()?.amount}
showFiat
centered
/>
<Show when={sentDetails()?.txid}> <Show when={sentDetails()?.txid}>
<ExternalLink href={mempoolTxUrl(sentDetails()?.txid, network)}> <ExternalLink
href={mempoolTxUrl(
sentDetails()?.txid,
network
)}
>
View Transaction View Transaction
</ExternalLink> </ExternalLink>
</Show> </Show>
@@ -463,7 +555,14 @@ export default function Send() {
</SuccessModal> </SuccessModal>
<VStack biggap> <VStack biggap>
<Switch> <Switch>
<Match when={address() || invoice() || nodePubkey() || lnurlp()}> <Match
when={
address() ||
invoice() ||
nodePubkey() ||
lnurlp()
}
>
<MethodChooser <MethodChooser
source={source()} source={source()}
setSource={setSource} setSource={setSource}
@@ -483,7 +582,9 @@ export default function Send() {
<SmallHeader>Private tags</SmallHeader> <SmallHeader>Private tags</SmallHeader>
<TagEditor <TagEditor
selectedValues={selectedContacts()} selectedValues={selectedContacts()}
setSelectedValues={setSelectedContacts} setSelectedValues={
setSelectedContacts
}
placeholder="Add the receiver for your records" placeholder="Add the receiver for your records"
/> />
</VStack> </VStack>

View File

@@ -1,4 +1,11 @@
import { ButtonLink, DefaultMain, LargeHeader, MutinyWalletGuard, SafeArea, VStack } from "~/components/layout"; import {
ButtonLink,
DefaultMain,
LargeHeader,
MutinyWalletGuard,
SafeArea,
VStack
} from "~/components/layout";
import { BackLink } from "~/components/layout/BackLink"; import { BackLink } from "~/components/layout/BackLink";
import { Logs } from "~/components/Logs"; import { Logs } from "~/components/Logs";
import { Restart } from "~/components/Restart"; import { Restart } from "~/components/Restart";
@@ -18,17 +25,23 @@ export default function Settings() {
<LargeHeader>Settings</LargeHeader> <LargeHeader>Settings</LargeHeader>
<VStack biggap> <VStack biggap>
<VStack> <VStack>
<p class="text-2xl font-light">Write down these words or you'll die!</p> <p class="text-2xl font-light">
<SeedWords words={store.mutiny_wallet?.show_seed() || ""} /> Write down these words or you'll die!
</p>
<SeedWords
words={store.mutiny_wallet?.show_seed() || ""}
/>
</VStack> </VStack>
<SettingsStringsEditor /> <SettingsStringsEditor />
<Logs /> <Logs />
<Restart /> <Restart />
<ButtonLink href="/admin">"I know what I'm doing"</ButtonLink> <ButtonLink href="/admin">
"I know what I'm doing"
</ButtonLink>
</VStack> </VStack>
</DefaultMain> </DefaultMain>
<NavBar activeTab="settings" /> <NavBar activeTab="settings" />
</SafeArea> </SafeArea>
</MutinyWalletGuard> </MutinyWalletGuard>
) );
} }

View File

@@ -2,7 +2,12 @@ import { AmountCard } from "~/components/AmountCard";
import NavBar from "~/components/NavBar"; import NavBar from "~/components/NavBar";
import { OnboardWarning } from "~/components/OnboardWarning"; import { OnboardWarning } from "~/components/OnboardWarning";
import { ShareCard } from "~/components/ShareCard"; import { ShareCard } from "~/components/ShareCard";
import { DefaultMain, LargeHeader, SafeArea, VStack } from "~/components/layout"; import {
DefaultMain,
LargeHeader,
SafeArea,
VStack
} from "~/components/layout";
const SAMPLE = const SAMPLE =
"bitcoin:tb1prqm8xtlgme0vmw5s30lgf0a4f5g4mkgsqundwmpu6thrg8zr6uvq2qrhzq?amount=0.001&lightning=lntbs1m1pj9n9xjsp5xgdrmvprtm67p7nq4neparalexlhlmtxx87zx6xeqthsplu842zspp546d6zd2seyaxpapaxx62m88yz3xueqtjmn9v6wj8y56np8weqsxqdqqnp4qdn2hj8tfknpuvdg6tz9yrf3e27ltrx9y58c24jh89lnm43yjwfc5xqrpwjcqpj9qrsgq5sdgh0m3ur5mu5hrmmag4mx9yvy86f83pd0x9ww80kgck6tac3thuzkj0mrtltaxwnlfea95h2re7tj4qsnwzxlvrdmyq2h9mgapnycpppz6k6"; "bitcoin:tb1prqm8xtlgme0vmw5s30lgf0a4f5g4mkgsqundwmpu6thrg8zr6uvq2qrhzq?amount=0.001&lightning=lntbs1m1pj9n9xjsp5xgdrmvprtm67p7nq4neparalexlhlmtxx87zx6xeqthsplu842zspp546d6zd2seyaxpapaxx62m88yz3xueqtjmn9v6wj8y56np8weqsxqdqqnp4qdn2hj8tfknpuvdg6tz9yrf3e27ltrx9y58c24jh89lnm43yjwfc5xqrpwjcqpj9qrsgq5sdgh0m3ur5mu5hrmmag4mx9yvy86f83pd0x9ww80kgck6tac3thuzkj0mrtltaxwnlfea95h2re7tj4qsnwzxlvrdmyq2h9mgapnycpppz6k6";
@@ -19,5 +24,5 @@ export default function Admin() {
</DefaultMain> </DefaultMain>
<NavBar activeTab="none" /> <NavBar activeTab="none" />
</SafeArea> </SafeArea>
) );
} }

View File

@@ -1,6 +1,13 @@
import { createForm, required } from "@modular-forms/solid"; import { createForm, required } from "@modular-forms/solid";
import { MutinyChannel, MutinyPeer } from "@mutinywallet/mutiny-wasm"; import { MutinyChannel, MutinyPeer } from "@mutinywallet/mutiny-wasm";
import { For, Match, Show, Switch, createResource, createSignal } from "solid-js"; import {
For,
Match,
Show,
Switch,
createResource,
createSignal
} from "solid-js";
import { AmountCard } from "~/components/AmountCard"; import { AmountCard } from "~/components/AmountCard";
import NavBar from "~/components/NavBar"; import NavBar from "~/components/NavBar";
import { showToast } from "~/components/Toaster"; import { showToast } from "~/components/Toaster";
@@ -49,7 +56,8 @@ export default function Swap() {
const [selectedPeer, setSelectedPeer] = createSignal<string>(""); const [selectedPeer, setSelectedPeer] = createSignal<string>("");
const [channelOpenResult, setChannelOpenResult] = createSignal<ChannelOpenDetails>(); const [channelOpenResult, setChannelOpenResult] =
createSignal<ChannelOpenDetails>();
const feeEstimate = () => { const feeEstimate = () => {
if (amountSats()) { if (amountSats()) {
@@ -69,11 +77,16 @@ export default function Swap() {
}; };
const hasLsp = () => { const hasLsp = () => {
return !!localStorage.getItem("MUTINY_SETTINGS_lsp") || !!import.meta.env.VITE_LSP; return (
!!localStorage.getItem("MUTINY_SETTINGS_lsp") ||
!!import.meta.env.VITE_LSP
);
}; };
const getPeers = async () => { const getPeers = async () => {
return (await state.mutiny_wallet?.list_peers()) as Promise<MutinyPeer[]>; return (await state.mutiny_wallet?.list_peers()) as Promise<
MutinyPeer[]
>;
}; };
const [peers, { refetch }] = createResource(getPeers); const [peers, { refetch }] = createResource(getPeers);
@@ -87,12 +100,17 @@ export default function Swap() {
const nodes = await state.mutiny_wallet?.list_nodes(); const nodes = await state.mutiny_wallet?.list_nodes();
const firstNode = (nodes[0] as string) || ""; const firstNode = (nodes[0] as string) || "";
await state.mutiny_wallet?.connect_to_peer(firstNode, peerConnectString); await state.mutiny_wallet?.connect_to_peer(
firstNode,
peerConnectString
);
await refetch(); await refetch();
// If peers list contains the peer we just connected to, select it // If peers list contains the peer we just connected to, select it
const peer = peers()?.find((p) => p.pubkey === peerConnectString.split("@")[0]); const peer = peers()?.find(
(p) => p.pubkey === peerConnectString.split("@")[0]
);
if (peer) { if (peer) {
setSelectedPeer(peer.pubkey); setSelectedPeer(peer.pubkey);
@@ -146,13 +164,23 @@ export default function Swap() {
}; };
const canSwap = () => { const canSwap = () => {
const balance = (state.balance?.confirmed || 0n) + (state.balance?.unconfirmed || 0n); const balance =
(state.balance?.confirmed || 0n) +
(state.balance?.unconfirmed || 0n);
const network = state.mutiny_wallet?.get_network() as Network; const network = state.mutiny_wallet?.get_network() as Network;
if (network === "bitcoin") { if (network === "bitcoin") {
return (!!selectedPeer() || !!hasLsp()) && amountSats() >= 50000n && amountSats() <= balance; return (
(!!selectedPeer() || !!hasLsp()) &&
amountSats() >= 50000n &&
amountSats() <= balance
);
} else { } else {
return (!!selectedPeer() || !!hasLsp()) && amountSats() >= 10000n && amountSats() <= balance; return (
(!!selectedPeer() || !!hasLsp()) &&
amountSats() >= 10000n &&
amountSats() <= balance
);
} }
}; };
@@ -168,7 +196,9 @@ export default function Swap() {
} }
if ( if (
amountSats() > (state.balance?.confirmed || 0n) + (state.balance?.unconfirmed || 0n) || amountSats() >
(state.balance?.confirmed || 0n) +
(state.balance?.unconfirmed || 0n) ||
!feeEstimate() !feeEstimate()
) { ) {
return "You don't have enough funds to make this channel"; return "You don't have enough funds to make this channel";
@@ -186,8 +216,14 @@ export default function Swap() {
<BackLink /> <BackLink />
<LargeHeader>Swap to Lightning</LargeHeader> <LargeHeader>Swap to Lightning</LargeHeader>
<SuccessModal <SuccessModal
title={channelOpenResult()?.channel ? "Swap Success" : "Swap Failed"} title={
confirmText={channelOpenResult()?.channel ? "Nice" : "Too Bad"} channelOpenResult()?.channel
? "Swap Success"
: "Swap Failed"
}
confirmText={
channelOpenResult()?.channel ? "Nice" : "Too Bad"
}
open={!!channelOpenResult()} open={!!channelOpenResult()}
setOpen={(open: boolean) => { setOpen={(open: boolean) => {
if (!open) setChannelOpenResult(undefined); if (!open) setChannelOpenResult(undefined);
@@ -199,22 +235,45 @@ export default function Swap() {
> >
<Switch> <Switch>
<Match when={channelOpenResult()?.failure_reason}> <Match when={channelOpenResult()?.failure_reason}>
<img src={megaex} alt="fail" class="w-1/2 mx-auto max-w-[30vh] flex-shrink" /> <img
src={megaex}
alt="fail"
class="w-1/2 mx-auto max-w-[30vh] flex-shrink"
/>
<p class="text-xl font-light py-2 px-4 rounded-xl bg-white/10"> <p class="text-xl font-light py-2 px-4 rounded-xl bg-white/10">
{channelOpenResult()?.failure_reason?.message} {
channelOpenResult()?.failure_reason
?.message
}
</p> </p>
</Match> </Match>
<Match when={true}> <Match when={true}>
<img src={megacheck} alt="success" class="w-1/2 mx-auto max-w-[30vh] flex-shrink" /> <img
<AmountCard src={megacheck}
amountSats={channelOpenResult()?.channel?.balance?.toString() || ""} alt="success"
reserve={channelOpenResult()?.channel?.reserve?.toString() || ""} class="w-1/2 mx-auto max-w-[30vh] flex-shrink"
/> />
<Show when={channelOpenResult()?.channel?.outpoint}> <AmountCard
amountSats={
channelOpenResult()?.channel?.balance?.toString() ||
""
}
reserve={
channelOpenResult()?.channel?.reserve?.toString() ||
""
}
/>
<Show
when={
channelOpenResult()?.channel?.outpoint
}
>
<ExternalLink <ExternalLink
href={mempoolTxUrl( href={mempoolTxUrl(
channelOpenResult()?.channel?.outpoint?.split(":")[0], channelOpenResult()?.channel?.outpoint?.split(
":"
)[0],
network network
)} )}
> >
@@ -226,13 +285,20 @@ export default function Swap() {
</Switch> </Switch>
</SuccessModal> </SuccessModal>
<VStack biggap> <VStack biggap>
<MethodChooser source={source()} setSource={setSource} both={false} /> <MethodChooser
source={source()}
setSource={setSource}
both={false}
/>
<VStack> <VStack>
<Show when={!hasLsp()}> <Show when={!hasLsp()}>
<Card> <Card>
<VStack> <VStack>
<div class="w-full flex flex-col gap-2"> <div class="w-full flex flex-col gap-2">
<label for="peerselect" class="uppercase font-semibold text-sm"> <label
for="peerselect"
class="uppercase font-semibold text-sm"
>
Use existing peer Use existing peer
</label> </label>
<select <select
@@ -241,19 +307,34 @@ export default function Swap() {
onChange={handlePeerSelect} onChange={handlePeerSelect}
value={selectedPeer()} value={selectedPeer()}
> >
<option value="" class="" selected> <option
value=""
class=""
selected
>
Choose a peer Choose a peer
</option> </option>
<For each={peers()}> <For each={peers()}>
{(peer) => ( {(peer) => (
<option value={peer.pubkey}>{peer.alias ?? peer.pubkey}</option> <option
value={peer.pubkey}
>
{peer.alias ??
peer.pubkey}
</option>
)} )}
</For> </For>
</select> </select>
</div> </div>
<Show when={!selectedPeer()}> <Show when={!selectedPeer()}>
<Form onSubmit={onSubmit} class="flex flex-col gap-4"> <Form
<Field name="peer" validate={[required("")]}> onSubmit={onSubmit}
class="flex flex-col gap-4"
>
<Field
name="peer"
validate={[required("")]}
>
{(field, props) => ( {(field, props) => (
<TextField <TextField
{...props} {...props}
@@ -264,8 +345,14 @@ export default function Swap() {
/> />
)} )}
</Field> </Field>
<Button layout="small" type="submit" disabled={isConnecting()}> <Button
{isConnecting() ? "Connecting..." : "Connect"} layout="small"
type="submit"
disabled={isConnecting()}
>
{isConnecting()
? "Connecting..."
: "Connect"}
</Button> </Button>
</Form> </Form>
</Show> </Show>

View File

@@ -1,6 +1,11 @@
import { Title } from "solid-start"; import { Title } from "solid-start";
import { HttpStatusCode } from "solid-start/server"; import { HttpStatusCode } from "solid-start/server";
import { ButtonLink, DefaultMain, LargeHeader, SafeArea } from "~/components/layout"; import {
ButtonLink,
DefaultMain,
LargeHeader,
SafeArea
} from "~/components/layout";
export default function NotFound() { export default function NotFound() {
return ( return (
@@ -9,11 +14,11 @@ export default function NotFound() {
<HttpStatusCode code={404} /> <HttpStatusCode code={404} />
<DefaultMain> <DefaultMain>
<LargeHeader>Not Found</LargeHeader> <LargeHeader>Not Found</LargeHeader>
<p> <p>This is probably Paul's fault.</p>
This is probably Paul's fault.
</p>
<div class="h-full" /> <div class="h-full" />
<ButtonLink href="/" intent="red">Dangit</ButtonLink> <ButtonLink href="/" intent="red">
Dangit
</ButtonLink>
</DefaultMain> </DefaultMain>
</SafeArea> </SafeArea>
); );

View File

@@ -1,4 +1,3 @@
import App from "~/components/App"; import App from "~/components/App";
import { Switch, Match } from "solid-js"; import { Switch, Match } from "solid-js";
import { WaitlistAlreadyIn } from "~/components/waitlist/WaitlistAlreadyIn"; import { WaitlistAlreadyIn } from "~/components/waitlist/WaitlistAlreadyIn";
@@ -11,7 +10,7 @@ export default function Home() {
return ( return (
<> <>
<Switch fallback={<FullscreenLoader />} > <Switch fallback={<FullscreenLoader />}>
{/* TODO: can you put a suspense around a match? */} {/* TODO: can you put a suspense around a match? */}
<Match when={state.user_status === "approved"}> <Match when={state.user_status === "approved"}>
<App /> <App />

View File

@@ -1,9 +1,19 @@
/* @refresh reload */ /* @refresh reload */
// Inspired by https://github.com/solidjs/solid-realworld/blob/main/src/store/index.js // Inspired by https://github.com/solidjs/solid-realworld/blob/main/src/store/index.js
import { ParentComponent, createContext, createEffect, onCleanup, onMount, useContext } from "solid-js"; import {
ParentComponent,
createContext,
createEffect,
onCleanup,
onMount,
useContext
} from "solid-js";
import { createStore, reconcile } from "solid-js/store"; import { createStore, reconcile } from "solid-js/store";
import { MutinyWalletSettingStrings, setupMutinyWallet } from "~/logic/mutinyWalletSetup"; import {
MutinyWalletSettingStrings,
setupMutinyWallet
} from "~/logic/mutinyWalletSetup";
import { import {
MutinyBalance, MutinyBalance,
MutinyWallet, MutinyWallet,
@@ -64,7 +74,8 @@ export const Provider: ParentComponent = (props) => {
balance: undefined as MutinyBalance | undefined, balance: undefined as MutinyBalance | undefined,
last_sync: undefined as number | undefined, last_sync: undefined as number | undefined,
is_syncing: false, is_syncing: false,
dismissed_restore_prompt: localStorage.getItem("dismissed_restore_prompt") === "true", dismissed_restore_prompt:
localStorage.getItem("dismissed_restore_prompt") === "true",
wallet_loading: true, wallet_loading: true,
nwc_enabled: localStorage.getItem("nwc_enabled") === "true", nwc_enabled: localStorage.getItem("nwc_enabled") === "true",
activity: [] as MutinyActivity[] activity: [] as MutinyActivity[]
@@ -98,7 +109,9 @@ export const Provider: ParentComponent = (props) => {
return "new_here"; return "new_here";
} }
}, },
async setupMutinyWallet(settings?: MutinyWalletSettingStrings): Promise<void> { async setupMutinyWallet(
settings?: MutinyWalletSettingStrings
): Promise<void> {
try { try {
setState({ wallet_loading: true }); setState({ wallet_loading: true });
const mutinyWallet = await setupMutinyWallet(settings); const mutinyWallet = await setupMutinyWallet(settings);
@@ -110,7 +123,11 @@ export const Provider: ParentComponent = (props) => {
const firstNode = (nodes[0] as string) || ""; const firstNode = (nodes[0] as string) || "";
await mutinyWallet.start_nostr_wallet_connect(firstNode); await mutinyWallet.start_nostr_wallet_connect(firstNode);
} }
setState({ mutiny_wallet: mutinyWallet, wallet_loading: false, balance }); setState({
mutiny_wallet: mutinyWallet,
wallet_loading: false,
balance
});
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} }
@@ -133,7 +150,8 @@ export const Provider: ParentComponent = (props) => {
if (state.mutiny_wallet && !state.is_syncing) { if (state.mutiny_wallet && !state.is_syncing) {
setState({ is_syncing: true }); setState({ is_syncing: true });
const newBalance = await state.mutiny_wallet?.get_balance(); const newBalance = await state.mutiny_wallet?.get_balance();
const price = await state.mutiny_wallet?.get_bitcoin_price(); const price =
await state.mutiny_wallet?.get_bitcoin_price();
setState({ setState({
balance: newBalance, balance: newBalance,
last_sync: Date.now(), last_sync: Date.now(),
@@ -181,9 +199,15 @@ export const Provider: ParentComponent = (props) => {
setState({ user_status: status }); setState({ user_status: status });
// Only load node manager when status is approved // Only load node manager when status is approved
if (state.user_status === "approved" && !state.mutiny_wallet && !state.deleting) { if (
state.user_status === "approved" &&
!state.mutiny_wallet &&
!state.deleting
) {
console.log("running setup node manager..."); console.log("running setup node manager...");
actions.setupMutinyWallet().then(() => console.log("node manager setup done")); actions
.setupMutinyWallet()
.then(() => console.log("node manager setup done"));
// Setup an event listener to stop the mutiny wallet when the page unloads // Setup an event listener to stop the mutiny wallet when the page unloads
window.onunload = async (_e) => { window.onunload = async (_e) => {
@@ -214,14 +238,18 @@ export const Provider: ParentComponent = (props) => {
const store = [state, actions] as MegaStore; const store = [state, actions] as MegaStore;
return <MegaStoreContext.Provider value={store}>{props.children}</MegaStoreContext.Provider>; return (
<MegaStoreContext.Provider value={store}>
{props.children}
</MegaStoreContext.Provider>
);
}; };
export function useMegaStore() { export function useMegaStore() {
// This is a trick to narrow the typescript types: https://docs.solidjs.com/references/api-reference/component-apis/createContext // This is a trick to narrow the typescript types: https://docs.solidjs.com/references/api-reference/component-apis/createContext
const context = useContext(MegaStoreContext); const context = useContext(MegaStoreContext);
if (!context) { if (!context) {
throw new Error("useMegaStore: cannot find a MegaStoreContext") throw new Error("useMegaStore: cannot find a MegaStoreContext");
} }
return context; return context;
} }

View File

@@ -1,2 +1,3 @@
export const DIALOG_POSITIONER = "fixed inset-0 h-[100dvh] z-50" export const DIALOG_POSITIONER = "fixed inset-0 h-[100dvh] z-50";
export const DIALOG_CONTENT = "h-[100dvh] flex flex-col justify-between px-4 pt-4 pb-8 bg-neutral-800/80 backdrop-blur-xl touch-manipulation select-none" export const DIALOG_CONTENT =
"h-[100dvh] flex flex-col justify-between px-4 pt-4 pb-8 bg-neutral-800/80 backdrop-blur-xl touch-manipulation select-none";

View File

@@ -1,25 +0,0 @@
// Take in a string that looks like this:
// bitcoin:tb1pdh43en28jmhnsrhxkusja46aufdlae5qnfrhucw5jvefw9flce3sdxfcwe?amount=0.00001&label=heyo&lightning=lntbs10u1pjrwrdedq8dpjhjmcnp4qd60w268ve0jencwzhz048ruprkxefhj0va2uspgj4q42azdg89uupp5gngy2pqte5q5uvnwcxwl2t8fsdlla5s6xl8aar4xcsvxeus2w2pqsp5n5jp3pz3vpu92p3uswttxmw79a5lc566herwh3f2amwz2sp6f9tq9qyysgqcqpcxqrpwugv5m534ww5ukcf6sdw2m75f2ntjfh3gzeqay649256yvtecgnhjyugf74zakaf56sdh66ec9fqep2kvu6xv09gcwkv36rrkm38ylqsgpw3yfjl
// and return an object with this shape: { address: string, amount: number, label: string, lightning: string }
// using typescript type annotations
export function bip21decode(bip21: string): { address?: string, amount?: number, label?: string, lightning?: string } {
const [scheme, data] = bip21.split(':')
if (scheme !== 'bitcoin') {
// TODO: this is a WAILA job I just want to debug more of the send flow
if (bip21.startsWith('lnt')) {
return { lightning: bip21 }
} else if (bip21.startsWith('tb1')) {
return { address: bip21 }
} else {
throw new Error('Not a bitcoin URI')
}
}
const [address, query] = data.split('?')
const params = new URLSearchParams(query)
return {
address,
amount: Number(params.get('amount')) || undefined,
label: params.get('label') || undefined,
lightning: params.get('lightning') || undefined
}
}

View File

@@ -1,15 +1,24 @@
import { MutinyWallet } from "@mutinywallet/mutiny-wasm"; import { MutinyWallet } from "@mutinywallet/mutiny-wasm";
export function satsToUsd(amount: number | undefined, price: number, formatted: boolean): string { export function satsToUsd(
amount: number | undefined,
price: number,
formatted: boolean
): string {
if (typeof amount !== "number" || isNaN(amount)) { if (typeof amount !== "number" || isNaN(amount)) {
return ""; return "";
} }
try { try {
const btc = MutinyWallet.convert_sats_to_btc(BigInt(Math.floor(amount))); const btc = MutinyWallet.convert_sats_to_btc(
BigInt(Math.floor(amount))
);
const usd = btc * price; const usd = btc * price;
if (formatted) { if (formatted) {
return usd.toLocaleString("en-US", { style: "currency", currency: "USD" }); return usd.toLocaleString("en-US", {
style: "currency",
currency: "USD"
});
} else { } else {
// Some float fighting shenaningans // Some float fighting shenaningans
const roundedUsd = Math.round(usd); const roundedUsd = Math.round(usd);
@@ -25,7 +34,11 @@ export function satsToUsd(amount: number | undefined, price: number, formatted:
} }
} }
export function usdToSats(amount: number | undefined, price: number, formatted: boolean): string { export function usdToSats(
amount: number | undefined,
price: number,
formatted: boolean
): string {
if (typeof amount !== "number" || isNaN(amount)) { if (typeof amount !== "number" || isNaN(amount)) {
return ""; return "";
} }

View File

@@ -1,6 +1,10 @@
// https://stackoverflow.com/questions/34156282/how-do-i-save-json-to-local-text-file // https://stackoverflow.com/questions/34156282/how-do-i-save-json-to-local-text-file
export function downloadTextFile(content: string, fileName: string, type?: string) { export function downloadTextFile(
content: string,
fileName: string,
type?: string
) {
const contentType = type ? type : "application/json"; const contentType = type ? type : "application/json";
const a = document.createElement("a"); const a = document.createElement("a");
const file = new Blob([content], { type: contentType }); const file = new Blob([content], { type: contentType });

View File

@@ -2,9 +2,9 @@
export default function eify(e: unknown): Error { export default function eify(e: unknown): Error {
if (e instanceof Error) { if (e instanceof Error) {
return e; return e;
} else if (typeof e === 'string') { } else if (typeof e === "string") {
return new Error(e); return new Error(e);
} else { } else {
return new Error('Unknown error'); return new Error("Unknown error");
} }
} }

View File

@@ -1,19 +1,20 @@
// Simple storage for fake labels // Simple storage for fake labels
// For each outpoint string, we can store a boolean whether it's redshifted or not // For each outpoint string, we can store a boolean whether it's redshifted or not
function setRedshifted(redshifted: boolean, outpoint?: string,) { function setRedshifted(redshifted: boolean, outpoint?: string) {
if (outpoint === undefined) return; if (outpoint === undefined) return;
localStorage.setItem(outpoint, redshifted.toString()) localStorage.setItem(outpoint, redshifted.toString());
} }
function getRedshifted(outpoint: string): boolean { function getRedshifted(outpoint: string): boolean {
const redshifted = localStorage.getItem(outpoint) const redshifted = localStorage.getItem(outpoint);
if (redshifted === null) { if (redshifted === null) {
return false return false;
} }
return redshifted === 'true' return redshifted === "true";
} }
const TEST_UTXO = "47651763fbd74488a478aad80e4205c3e34bbadcfc42b5cd9557ef12a15ab00c:1" const TEST_UTXO =
"47651763fbd74488a478aad80e4205c3e34bbadcfc42b5cd9557ef12a15ab00c:1";
export { setRedshifted, getRedshifted, TEST_UTXO } export { setRedshifted, getRedshifted, TEST_UTXO };

View File

@@ -3,7 +3,7 @@ import { Contact } from "@mutinywallet/mutiny-wasm";
export async function generateGradient(str: string) { export async function generateGradient(str: string) {
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const data = encoder.encode(str); const data = encoder.encode(str);
const digestBuffer = await crypto.subtle.digest('SHA-256', data); const digestBuffer = await crypto.subtle.digest("SHA-256", data);
const digestArray = new Uint8Array(digestBuffer); const digestArray = new Uint8Array(digestBuffer);
const h1 = digestArray[0] % 360; const h1 = digestArray[0] % 360;
const h2 = (h1 + 180) % 360; const h2 = (h1 + 180) % 360;

View File

@@ -1,23 +1,23 @@
import { Network } from "~/logic/mutinyWalletSetup" import { Network } from "~/logic/mutinyWalletSetup";
export default function mempoolTxUrl(txid?: string, network?: Network) { export default function mempoolTxUrl(txid?: string, network?: Network) {
if (!txid || !network) { if (!txid || !network) {
console.error("Problem creating the mempool url") console.error("Problem creating the mempool url");
return "#" return "#";
} }
if (network) { if (network) {
switch (network) { switch (network) {
case "bitcoin": case "bitcoin":
return `https://mempool.space/tx/${txid}` return `https://mempool.space/tx/${txid}`;
case "testnet": case "testnet":
return `https://mempool.space/testnet/tx/${txid}` return `https://mempool.space/testnet/tx/${txid}`;
case "signet": case "signet":
return `https://mutinynet.com/tx/${txid}` return `https://mutinynet.com/tx/${txid}`;
default: default:
return `https://mempool.space/tx/${txid}` return `https://mempool.space/tx/${txid}`;
} }
} }
return `https://mempool.space/tx/${txid}` return `https://mempool.space/tx/${txid}`;
} }

View File

@@ -1,7 +1,15 @@
export function objectToSearchParams<T extends Record<string, string | undefined>>(obj: T): string { export function objectToSearchParams<
return Object.entries(obj) T extends Record<string, string | undefined>
>(obj: T): string {
return (
Object.entries(obj)
.filter(([_, value]) => value !== undefined) .filter(([_, value]) => value !== undefined)
// Value shouldn't be null we just filtered it out but typescript is dumb // Value shouldn't be null we just filtered it out but typescript is dumb
.map(([key, value]) => value ? `${encodeURIComponent(key)}=${encodeURIComponent(value)}` : "") .map(([key, value]) =>
.join("&"); value
? `${encodeURIComponent(key)}=${encodeURIComponent(value)}`
: ""
)
.join("&")
);
} }

View File

@@ -1,13 +1,13 @@
export function prettyPrintTime(ts: number) { export function prettyPrintTime(ts: number) {
const options: Intl.DateTimeFormatOptions = { const options: Intl.DateTimeFormatOptions = {
year: 'numeric', year: "numeric",
month: 'short', month: "short",
day: 'numeric', day: "numeric",
hour: 'numeric', hour: "numeric",
minute: 'numeric' minute: "numeric"
}; };
return new Date(ts * 1000).toLocaleString('en-US', options); return new Date(ts * 1000).toLocaleString("en-US", options);
} }
export function timeAgo(ts?: number | bigint): string { export function timeAgo(ts?: number | bigint): string {
@@ -23,15 +23,15 @@ export function timeAgo(ts?: number | bigint): string {
if (elapsedSeconds < 60) { if (elapsedSeconds < 60) {
return "Just now"; return "Just now";
} else if (elapsedMinutes < 60) { } else if (elapsedMinutes < 60) {
return `${elapsedMinutes} minute${elapsedMinutes > 1 ? 's' : ''} ago`; return `${elapsedMinutes} minute${elapsedMinutes > 1 ? "s" : ""} ago`;
} else if (elapsedHours < 24) { } else if (elapsedHours < 24) {
return `${elapsedHours} hour${elapsedHours > 1 ? 's' : ''} ago`; return `${elapsedHours} hour${elapsedHours > 1 ? "s" : ""} ago`;
} else if (elapsedDays < 7) { } else if (elapsedDays < 7) {
return `${elapsedDays} day${elapsedDays > 1 ? 's' : ''} ago`; return `${elapsedDays} day${elapsedDays > 1 ? "s" : ""} ago`;
} else { } else {
const date = new Date(timestamp); const date = new Date(timestamp);
const day = String(date.getDate()).padStart(2, '0'); const day = String(date.getDate()).padStart(2, "0");
const month = String(date.getMonth() + 1).padStart(2, '0'); const month = String(date.getMonth() + 1).padStart(2, "0");
const year = date.getFullYear(); const year = date.getFullYear();
return `${month}/${day}/${year}`; return `${month}/${day}/${year}`;
} }

View File

@@ -1,27 +1,32 @@
import { TagItem } from "@mutinywallet/mutiny-wasm" import { TagItem } from "@mutinywallet/mutiny-wasm";
export type MutinyTagItem = { export type MutinyTagItem = {
id: string, id: string;
kind: "Label" | "Contact" kind: "Label" | "Contact";
name: string, name: string;
last_used_time: bigint, last_used_time: bigint;
npub?: string, npub?: string;
ln_address?: string, ln_address?: string;
lnurl?: string, lnurl?: string;
} };
export const UNKNOWN_TAG: MutinyTagItem = { id: "Unknown", kind: "Label", name: "Unknown", last_used_time: 0n } export const UNKNOWN_TAG: MutinyTagItem = {
id: "Unknown",
kind: "Label",
name: "Unknown",
last_used_time: 0n
};
export function tagsToIds(tags?: MutinyTagItem[]): string[] { export function tagsToIds(tags?: MutinyTagItem[]): string[] {
if (!tags) { if (!tags) {
return [] return [];
} }
return tags.filter((tag) => tag.id !== "Unknown").map((tag) => tag.id) return tags.filter((tag) => tag.id !== "Unknown").map((tag) => tag.id);
} }
export function tagToMutinyTag(tag: TagItem): MutinyTagItem { export function tagToMutinyTag(tag: TagItem): MutinyTagItem {
// @ts-expect-error: FIXME: make typescript less mad about this // @ts-expect-error: FIXME: make typescript less mad about this
return tag as MutinyTagItem return tag as MutinyTagItem;
} }
export function sortByLastUsed(a: MutinyTagItem, b: MutinyTagItem) { export function sortByLastUsed(a: MutinyTagItem, b: MutinyTagItem) {

View File

@@ -1,21 +1,21 @@
// Thanks you https://soorria.com/snippets/use-copy-solidjs // Thanks you https://soorria.com/snippets/use-copy-solidjs
import type { Accessor } from 'solid-js' import type { Accessor } from "solid-js";
import { createSignal } from 'solid-js' import { createSignal } from "solid-js";
export type UseCopyProps = { export type UseCopyProps = {
copiedTimeout?: number copiedTimeout?: number;
} };
type CopyFn = (text: string) => Promise<void> type CopyFn = (text: string) => Promise<void>;
export const useCopy = ({ copiedTimeout = 2000 }: UseCopyProps = {}): [ export const useCopy = ({ copiedTimeout = 2000 }: UseCopyProps = {}): [
copy: CopyFn, copy: CopyFn,
copied: Accessor<boolean> copied: Accessor<boolean>
] => { ] => {
const [copied, setCopied] = createSignal(false) const [copied, setCopied] = createSignal(false);
let timeout: NodeJS.Timeout let timeout: NodeJS.Timeout;
const copy: CopyFn = async text => { const copy: CopyFn = async (text) => {
await navigator.clipboard.writeText(text) await navigator.clipboard.writeText(text);
setCopied(true) setCopied(true);
if (timeout) clearTimeout(timeout) if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => setCopied(false), copiedTimeout) timeout = setTimeout(() => setCopied(false), copiedTimeout);
} };
return [copy, copied] return [copy, copied];
} };

View File

@@ -56,50 +56,41 @@ module.exports = {
require("@kobalte/tailwindcss"), require("@kobalte/tailwindcss"),
plugin(function ({ addUtilities }) { plugin(function ({ addUtilities }) {
const newUtilities = { const newUtilities = {
'.safe-top': { ".safe-top": {
paddingTop: 'constant(safe-area-inset-top)', paddingTop: "constant(safe-area-inset-top)",
paddingTop: 'env(safe-area-inset-top)' paddingTop: "env(safe-area-inset-top)"
}, },
'.safe-left': { ".safe-left": {
paddingLeft: 'constant(safe-area-inset-left)', paddingLeft: "constant(safe-area-inset-left)",
paddingLeft: 'env(safe-area-inset-left)' paddingLeft: "env(safe-area-inset-left)"
}, },
'.safe-right': { ".safe-right": {
paddingRight: 'constant(safe-area-inset-right)', paddingRight: "constant(safe-area-inset-right)",
paddingRight: 'env(safe-area-inset-right)' paddingRight: "env(safe-area-inset-right)"
}, },
'.safe-bottom': { ".safe-bottom": {
paddingBottom: 'constant(safe-area-inset-bottom)', paddingBottom: "constant(safe-area-inset-bottom)",
paddingBottom: 'env(safe-area-inset-bottom)' paddingBottom: "env(safe-area-inset-bottom)"
}, },
'.h-screen-safe': { ".disable-scrollbars": {
height: 'calc(100vh - (env(safe-area-inset-top) + env(safe-area-inset-bottom)))' scrollbarWidth: "none",
"-ms-overflow-style": "none",
"&::-webkit-scrollbar": {
width: "0px",
background: "transparent",
display: "none"
}, },
'.min-h-screen-safe': { "& *::-webkit-scrollbar": {
minHeight: 'calc(100vh - (env(safe-area-inset-top) + env(safe-area-inset-bottom)))' width: "0px",
background: "transparent",
display: "none"
}, },
'.max-h-screen-safe': { "& *": {
maxHeight: 'calc(100vh - (env(safe-area-inset-top) + env(safe-area-inset-bottom)))' scrollbarWidth: "none",
}, "-ms-overflow-style": "none"
'.disable-scrollbars': {
scrollbarWidth: 'none',
'-ms-overflow-style': 'none',
'&::-webkit-scrollbar': {
width: '0px',
background: 'transparent',
display: 'none'
},
'& *::-webkit-scrollbar': {
width: '0px',
background: 'transparent',
display: 'none'
},
'& *': {
scrollbarWidth: 'none',
'-ms-overflow-style': 'none'
}
} }
} }
};
addUtilities(newUtilities); addUtilities(newUtilities);
}), }),
// Text shadow! // Text shadow!

View File

@@ -1,5 +1,10 @@
{ {
"include": ["global.d.ts", "src/**/*"], "include": [
"global.d.ts",
"src/**/*",
"tailwind.config.cjs",
".eslintrc.cjs"
],
"compilerOptions": { "compilerOptions": {
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"esModuleInterop": true, "esModuleInterop": true,