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 = {
"env": {
"browser": true,
"es2021": true
env: {
browser: true,
es2021: true
},
"extends": [
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:solid/typescript",
"plugin:import/typescript",
"plugin:import/recommended"
],
"overrides": [
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"tsconfigRootDir": "./",
"project": ["./tsconfig.json"],
"ecmaVersion": "latest",
"sourceType": "module",
"ecmaFeatures": {
"jsx": true
overrides: [],
parser: "@typescript-eslint/parser",
parserOptions: {
tsconfigRootDir: "./",
project: ["./tsconfig.json"],
ecmaVersion: "latest",
sourceType: "module",
ecmaFeatures: {
jsx: true
}
},
"plugins": [
"@typescript-eslint",
"solid",
"import"
],
"rules": {
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_", destructuredArrayIgnorePattern: "^_", varsIgnorePattern: "^_" }],
plugins: ["@typescript-eslint", "solid", "import"],
rules: {
"@typescript-eslint/no-unused-vars": [
"warn",
{
argsIgnorePattern: "^_",
destructuredArrayIgnorePattern: "^_",
varsIgnorePattern: "^_"
}
],
"solid/reactivity": "warn",
"solid/no-destructure": "warn",
"solid/jsx-no-undef": "error",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-non-null-assertion": "off"
},
"settings": {
settings: {
"import/parsers": {
"@typescript-eslint/parser": [".ts", ".tsx"]
},
"import/resolver": {
"typescript": {
"project": ["./tsconfig.json"],
"alwaysTryTypes": true
typescript: {
project: ["./tsconfig.json"],
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",
"tabWidth": 2,
"tabWidth": 4,
"semi": true,
"singleQuote": false,
"arrowParens": "always",
"printWidth": 100,
"printWidth": 80,
"useTabs": false
}

View File

@@ -9,9 +9,31 @@ pnpm install
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
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`.

View File

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

View File

@@ -1,56 +1,58 @@
{
"name": "mws",
"version": "0.3.7",
"license": "MIT",
"packageManager": "pnpm@8.3.1",
"scripts": {
"dev": "solid-start dev",
"host": "solid-start dev --host",
"build": "solid-start build",
"start": "solid-start start",
"lint": "eslint src --ext .ts,.tsx,.js"
},
"type": "module",
"devDependencies": {
"@playwright/test": "^1.34.3",
"@types/node": "^18.16.15",
"@typescript-eslint/eslint-plugin": "^5.59.7",
"@typescript-eslint/parser": "^5.59.7",
"autoprefixer": "^10.4.14",
"esbuild": "^0.14.54",
"eslint": "^8.41.0",
"eslint-import-resolver-typescript": "2.7.1",
"eslint-plugin-import": "2.27.5",
"eslint-plugin-prettier": "4.2.1",
"eslint-plugin-solid": "0.11.0",
"postcss": "^8.4.23",
"solid-start-node": "^0.2.26",
"tailwindcss": "^3.3.2",
"typescript": "^4.9.5",
"vite": "^4.3.9",
"vite-plugin-pwa": "^0.14.7",
"vite-plugin-wasm": "^3.2.2",
"workbox-window": "^6.5.4"
},
"dependencies": {
"@kobalte/core": "^0.9.6",
"@kobalte/tailwindcss": "^0.5.0",
"@modular-forms/solid": "^0.13.2",
"@mutinywallet/mutiny-wasm": "0.3.7",
"@mutinywallet/waila-wasm": "^0.2.0",
"@solid-primitives/upload": "^0.0.111",
"@solidjs/meta": "^0.28.5",
"@solidjs/router": "^0.8.2",
"@thisbeyond/solid-select": "^0.14.0",
"class-variance-authority": "^0.4.0",
"nostr-tools": "^1.11.1",
"qr-scanner": "^1.4.2",
"solid-js": "^1.7.5",
"solid-qr-code": "^0.0.8",
"solid-start": "^0.2.26",
"undici": "^5.22.1"
},
"engines": {
"node": ">=16.8"
}
"name": "mws",
"version": "0.3.7",
"license": "MIT",
"packageManager": "pnpm@8.3.1",
"scripts": {
"dev": "solid-start dev",
"host": "solid-start dev --host",
"build": "solid-start build",
"start": "solid-start start",
"lint": "eslint src --ext .ts,.tsx,.js",
"format": "npx prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,scss,md}\""
},
"type": "module",
"devDependencies": {
"@playwright/test": "^1.34.3",
"@types/node": "^18.16.15",
"@typescript-eslint/eslint-plugin": "^5.59.7",
"@typescript-eslint/parser": "^5.59.7",
"autoprefixer": "^10.4.14",
"esbuild": "^0.14.54",
"eslint": "^8.41.0",
"eslint-import-resolver-typescript": "2.7.1",
"eslint-plugin-import": "2.27.5",
"eslint-plugin-prettier": "4.2.1",
"eslint-plugin-solid": "0.11.0",
"postcss": "^8.4.23",
"prettier": "^2.8.8",
"solid-start-node": "^0.2.26",
"tailwindcss": "^3.3.2",
"typescript": "^4.9.5",
"vite": "^4.3.9",
"vite-plugin-pwa": "^0.14.7",
"vite-plugin-wasm": "^3.2.2",
"workbox-window": "^6.5.4"
},
"dependencies": {
"@kobalte/core": "^0.9.6",
"@kobalte/tailwindcss": "^0.5.0",
"@modular-forms/solid": "^0.13.2",
"@mutinywallet/mutiny-wasm": "0.3.7",
"@mutinywallet/waila-wasm": "^0.2.0",
"@solid-primitives/upload": "^0.0.111",
"@solidjs/meta": "^0.28.5",
"@solidjs/router": "^0.8.2",
"@thisbeyond/solid-select": "^0.14.0",
"class-variance-authority": "^0.4.0",
"nostr-tools": "^1.11.1",
"qr-scanner": "^1.4.2",
"solid-js": "^1.7.5",
"solid-qr-code": "^0.0.8",
"solid-start": "^0.2.26",
"undici": "^5.22.1"
},
"engines": {
"node": ">=16.8"
}
}

3
pnpm-lock.yaml generated
View File

@@ -87,6 +87,9 @@ devDependencies:
postcss:
specifier: ^8.4.23
version: 8.4.23
prettier:
specifier: ^2.8.8
version: 2.8.8
solid-start-node:
specifier: ^0.2.26
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() {
return (<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>
)
}
return (
<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>
);
}

View File

@@ -1,8 +1,24 @@
export function Paste() {
return (<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 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>)
return (
<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
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() {
return (<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>)
return (
<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

@@ -6,112 +6,125 @@ import { ActivityItem, HackActivityType } from "./ActivityItem";
import { DetailsIdModal } from "./DetailsModal";
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 MISSING_LABEL = "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 MISSING_LABEL =
"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 type OnChainTx = {
txid: string;
received: number;
sent: number;
fee?: number;
confirmation_time?: {
Confirmed?: {
height: number;
time: number;
txid: string;
received: number;
sent: number;
fee?: number;
confirmation_time?: {
Confirmed?: {
height: number;
time: number;
};
};
};
labels: string[];
labels: string[];
};
export type UtxoItem = {
outpoint: string;
txout: {
value: number;
script_pubkey: string;
};
keychain: string;
is_spent: boolean;
redshifted?: boolean;
outpoint: string;
txout: {
value: number;
script_pubkey: string;
};
keychain: string;
is_spent: boolean;
redshifted?: boolean;
};
function UnifiedActivityItem(props: {
item: MutinyActivity;
onClick: (id: string, kind: HackActivityType) => void;
item: MutinyActivity;
onClick: (id: string, kind: HackActivityType) => void;
}) {
const click = () => {
props.onClick(props.item.id, props.item.kind as unknown as HackActivityType);
};
const click = () => {
props.onClick(
props.item.id,
props.item.kind as unknown as HackActivityType
);
};
return (
<ActivityItem
// This is actually the ActivityType enum but wasm is hard
kind={props.item.kind as unknown as HackActivityType}
labels={props.item.labels}
contacts={props.item.contacts}
// FIXME: is this something we can put into node logic?
amount={props.item.amount_sats || 0}
date={props.item.last_updated}
positive={props.item.inbound}
onClick={click}
/>
);
return (
<ActivityItem
// This is actually the ActivityType enum but wasm is hard
kind={props.item.kind as unknown as HackActivityType}
labels={props.item.labels}
contacts={props.item.contacts}
// FIXME: is this something we can put into node logic?
amount={props.item.amount_sats || 0}
date={props.item.last_updated}
positive={props.item.inbound}
onClick={click}
/>
);
}
export function CombinedActivity(props: { limit?: number }) {
const [state, actions] = useMegaStore();
const [state, actions] = useMegaStore();
const [detailsOpen, setDetailsOpen] = createSignal(false);
const [detailsKind, setDetailsKind] = createSignal<HackActivityType>();
const [detailsId, setDetailsId] = createSignal("");
const [detailsOpen, setDetailsOpen] = createSignal(false);
const [detailsKind, setDetailsKind] = createSignal<HackActivityType>();
const [detailsId, setDetailsId] = createSignal("");
function openDetailsModal(id: string, kind: HackActivityType) {
console.log("Opening details modal: ", id, kind);
function openDetailsModal(id: string, kind: HackActivityType) {
console.log("Opening details modal: ", id, kind);
setDetailsId(id);
setDetailsKind(kind);
setDetailsOpen(true);
}
createEffect(() => {
if (!state.wallet_loading && !state.is_syncing) {
actions.syncActivity();
setDetailsId(id);
setDetailsKind(kind);
setDetailsOpen(true);
}
});
return (
<>
<Show when={detailsId() && detailsKind()}>
<DetailsIdModal
open={detailsOpen()}
kind={detailsKind()}
id={detailsId()}
setOpen={setDetailsOpen}
/>
</Show>
<Switch>
<Match when={state.activity.length === 0}>
<div class="w-full text-center pb-4">
<NiceP>Receive some sats to get started</NiceP>
</div>
</Match>
<Match when={props.limit && state.activity.length > props.limit}>
<For each={state.activity.slice(0, props.limit)}>
{(activityItem) => (
<UnifiedActivityItem item={activityItem} onClick={openDetailsModal} />
)}
</For>
</Match>
<Match when={state.activity.length >= 0}>
<For each={state.activity}>
{(activityItem) => (
<UnifiedActivityItem item={activityItem} onClick={openDetailsModal} />
)}
</For>
</Match>
</Switch>
</>
);
createEffect(() => {
if (!state.wallet_loading && !state.is_syncing) {
actions.syncActivity();
}
});
return (
<>
<Show when={detailsId() && detailsKind()}>
<DetailsIdModal
open={detailsOpen()}
kind={detailsKind()}
id={detailsId()}
setOpen={setDetailsOpen}
/>
</Show>
<Switch>
<Match when={state.activity.length === 0}>
<div class="w-full text-center pb-4">
<NiceP>Receive some sats to get started</NiceP>
</div>
</Match>
<Match
when={props.limit && state.activity.length > props.limit}
>
<For each={state.activity.slice(0, props.limit)}>
{(activityItem) => (
<UnifiedActivityItem
item={activityItem}
onClick={openDetailsModal}
/>
)}
</For>
</Match>
<Match when={state.activity.length >= 0}>
<For each={state.activity}>
{(activityItem) => (
<UnifiedActivityItem
item={activityItem}
onClick={openDetailsModal}
/>
)}
</For>
</Match>
</Switch>
</>
);
}

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 bolt from "~/assets/icons/bolt.svg";
import chain from "~/assets/icons/chain.svg";
@@ -9,137 +15,161 @@ import { useMegaStore } from "~/state/megaStore";
import { Contact } from "@mutinywallet/mutiny-wasm";
export const ActivityAmount: ParentComponent<{
amount: string;
price: number;
positive?: boolean;
center?: boolean;
amount: string;
price: number;
positive?: boolean;
center?: boolean;
}> = (props) => {
const amountInUsd = createMemo(() => {
const parsed = Number(props.amount);
if (isNaN(parsed)) {
return props.amount;
} else {
return satsToUsd(props.price, parsed, true);
}
});
const amountInUsd = createMemo(() => {
const parsed = Number(props.amount);
if (isNaN(parsed)) {
return props.amount;
} else {
return satsToUsd(props.price, parsed, true);
}
});
const prettyPrint = createMemo(() => {
const parsed = Number(props.amount);
if (isNaN(parsed)) {
return props.amount;
} else {
return parsed.toLocaleString();
}
});
const prettyPrint = createMemo(() => {
const parsed = Number(props.amount);
if (isNaN(parsed)) {
return props.amount;
} else {
return parsed.toLocaleString();
}
});
return (
<div
class="flex flex-col"
classList={{ "items-end": !props.center, "items-center": props.center }}
>
<div class="text-base" classList={{ "text-m-green": props.positive }}>
{props.positive && "+ "}
{prettyPrint()}&nbsp;<span class="text-sm">SATS</span>
</div>
<div class="text-sm text-neutral-500">
&#8776;&nbsp;{amountInUsd()}&nbsp;<span class="text-sm">USD</span>
</div>
</div>
);
return (
<div
class="flex flex-col"
classList={{
"items-end": !props.center,
"items-center": props.center
}}
>
<div
class="text-base"
classList={{ "text-m-green": props.positive }}
>
{props.positive && "+ "}
{prettyPrint()}&nbsp;<span class="text-sm">SATS</span>
</div>
<div class="text-sm text-neutral-500">
&#8776;&nbsp;{amountInUsd()}&nbsp;
<span class="text-sm">USD</span>
</div>
</div>
);
};
function LabelCircle(props: { name?: string; contact: boolean; label: boolean }) {
// TODO: don't need to run this if it's not a contact
const [gradient] = createResource(async () => {
return generateGradient(props.name || "?");
});
function LabelCircle(props: {
name?: string;
contact: boolean;
label: boolean;
}) {
// TODO: don't need to run this if it's not a contact
const [gradient] = createResource(async () => {
return generateGradient(props.name || "?");
});
const text = () =>
props.contact && props.name && props.name.length ? props.name[0] : props.label ? "≡" : "?";
const bg = () => (props.name && props.contact ? gradient() : "gray");
const text = () =>
props.contact && props.name && props.name.length
? props.name[0]
: props.label
? "≡"
: "?";
const bg = () => (props.name && props.contact ? gradient() : "gray");
return (
<div
class="flex-none h-[3rem] w-[3rem] rounded-full flex items-center justify-center text-3xl uppercase border-t border-b border-t-white/50 border-b-white/10"
style={{ background: bg() }}
>
{text()}
</div>
);
return (
<div
class="flex-none h-[3rem] w-[3rem] rounded-full flex items-center justify-center text-3xl uppercase border-t border-b border-t-white/50 border-b-white/10"
style={{ background: bg() }}
>
{text()}
</div>
);
}
export type HackActivityType = "Lightning" | "OnChain" | "ChannelOpen";
export function ActivityItem(props: {
// This is actually the ActivityType enum but wasm is hard
kind: HackActivityType;
contacts: Contact[];
labels: string[];
amount: number | bigint;
date?: number | bigint;
positive?: boolean;
onClick?: () => void;
// This is actually the ActivityType enum but wasm is hard
kind: HackActivityType;
contacts: Contact[];
labels: string[];
amount: number | bigint;
date?: number | bigint;
positive?: boolean;
onClick?: () => void;
}) {
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 (
<div
onClick={() => props.onClick && props.onClick()}
class="grid grid-cols-[auto_minmax(0,_1fr)_minmax(0,_max-content)] pb-4 gap-4 border-b border-neutral-800 last:border-b-0"
classList={{ "cursor-pointer": !!props.onClick }}
>
<div class="flex gap-2 md:gap-4 items-center">
<div class="">
<Switch>
<Match when={props.kind === "Lightning"}>
<img class="w-[1rem]" src={bolt} alt="lightning" />
</Match>
<Match when={props.kind === "OnChain"}>
<img class="w-[1rem]" src={chain} alt="onchain" />
</Match>
<Match when={props.kind === "ChannelOpen"}>
<img class="w-[1rem]" src={shuffle} alt="swap" />
</Match>
</Switch>
return (
<div
onClick={() => props.onClick && props.onClick()}
class="grid grid-cols-[auto_minmax(0,_1fr)_minmax(0,_max-content)] pb-4 gap-4 border-b border-neutral-800 last:border-b-0"
classList={{ "cursor-pointer": !!props.onClick }}
>
<div class="flex gap-2 md:gap-4 items-center">
<div class="">
<Switch>
<Match when={props.kind === "Lightning"}>
<img class="w-[1rem]" src={bolt} alt="lightning" />
</Match>
<Match when={props.kind === "OnChain"}>
<img class="w-[1rem]" src={chain} alt="onchain" />
</Match>
<Match when={props.kind === "ChannelOpen"}>
<img class="w-[1rem]" src={shuffle} alt="swap" />
</Match>
</Switch>
</div>
<div class="">
<LabelCircle
name={firstContact()?.name}
contact={props.contacts?.length > 0}
label={props.labels?.length > 0}
/>
</div>
</div>
<div class="flex flex-col">
<Switch>
<Match when={firstContact()?.name}>
<span class="text-base font-semibold truncate">
{firstContact()?.name}
</span>
</Match>
<Match when={props.labels.length > 0}>
<span class="text-base font-semibold truncate">
{props.labels[0]}
</span>
</Match>
<Match when={true}>
<span class="text-base font-semibold text-neutral-500">
Unknown
</span>
</Match>
</Switch>
<Switch>
<Match when={props.date && props.date > 2147483647}>
<time class="text-sm text-neutral-500">Pending</time>
</Match>
<Match when={true}>
<time class="text-sm text-neutral-500">
{timeAgo(props.date)}
</time>
</Match>
</Switch>
</div>
<div class="">
<ActivityAmount
amount={props.amount.toString()}
price={state.price}
positive={props.positive}
/>
</div>
</div>
<div class="">
<LabelCircle
name={firstContact()?.name}
contact={props.contacts?.length > 0}
label={props.labels?.length > 0}
/>
</div>
</div>
<div class="flex flex-col">
<Switch>
<Match when={firstContact()?.name}>
<span class="text-base font-semibold truncate">{firstContact()?.name}</span>
</Match>
<Match when={props.labels.length > 0}>
<span class="text-base font-semibold truncate">{props.labels[0]}</span>
</Match>
<Match when={true}>
<span class="text-base font-semibold text-neutral-500">Unknown</span>
</Match>
</Switch>
<Switch>
<Match when={props.date && props.date > 2147483647}>
<time class="text-sm text-neutral-500">Pending</time>
</Match>
<Match when={true}>
<time class="text-sm text-neutral-500">{timeAgo(props.date)}</time>
</Match>
</Switch>
</div>
<div class="">
<ActivityAmount
amount={props.amount.toString()}
price={state.price}
positive={props.positive}
/>
</div>
</div>
);
);
}

View File

@@ -1,49 +1,56 @@
import { Show } from "solid-js"
import { useMegaStore } from "~/state/megaStore"
import { satsToUsd } from "~/utils/conversions"
import { Show } from "solid-js";
import { useMegaStore } from "~/state/megaStore";
import { satsToUsd } from "~/utils/conversions";
function prettyPrintAmount(n?: number | bigint): string {
if (!n || n.valueOf() === 0) {
return "0"
}
return n.toLocaleString()
if (!n || n.valueOf() === 0) {
return "0";
}
return n.toLocaleString();
}
export function Amount(props: {
amountSats: bigint | number | undefined;
showFiat?: boolean;
loading?: boolean;
centered?: boolean;
amountSats: bigint | number | undefined;
showFiat?: boolean;
loading?: boolean;
centered?: boolean;
}) {
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 (
<div class="flex flex-col gap-2" classList={{ "items-center": props.centered }}>
<h1 class="text-4xl font-light">
{props.loading ? "..." : prettyPrintAmount(props.amountSats)}&nbsp;
<span class="text-xl">SATS</span>
</h1>
<Show when={props.showFiat}>
<h2 class="text-xl font-light text-white/70">
&#8776; {props.loading ? "..." : amountInUsd()}&nbsp;
<span class="text-sm">USD</span>
</h2>
</Show>
</div>
);
return (
<div
class="flex flex-col gap-2"
classList={{ "items-center": props.centered }}
>
<h1 class="text-4xl font-light">
{props.loading ? "..." : prettyPrintAmount(props.amountSats)}
&nbsp;
<span class="text-xl">SATS</span>
</h1>
<Show when={props.showFiat}>
<h2 class="text-xl font-light text-white/70">
&#8776; {props.loading ? "..." : amountInUsd()}&nbsp;
<span class="text-sm">USD</span>
</h2>
</Show>
</div>
);
}
export function AmountSmall(props: {
amountSats: bigint | number | undefined
amountSats: bigint | number | undefined;
}) {
return (
<span class="font-light">
{prettyPrintAmount(props.amountSats)}&nbsp;
<span class="text-sm">
{props.amountSats === 1 || props.amountSats === 1n ? "SAT" : "SATS"}
</span>
</span>
);
return (
<span class="font-light">
{prettyPrintAmount(props.amountSats)}&nbsp;
<span class="text-sm">
{props.amountSats === 1 || props.amountSats === 1n
? "SAT"
: "SATS"}
</span>
</span>
);
}

View File

@@ -5,132 +5,172 @@ import { satsToUsd } from "~/utils/conversions";
import { AmountEditable } from "./AmountEditable";
const noop = () => {
// do nothing
// do nothing
};
const KeyValue: ParentComponent<{ key: string; gray?: boolean }> = (props) => {
return (
<div class="flex justify-between items-center" classList={{ "text-neutral-400": props.gray }}>
<div class="font-semibold uppercase">{props.key}</div>
<div class="font-light">{props.children}</div>
</div>
);
return (
<div
class="flex justify-between items-center"
classList={{ "text-neutral-400": props.gray }}
>
<div class="font-semibold uppercase">{props.key}</div>
<div class="font-light">{props.children}</div>
</div>
);
};
export const InlineAmount: ParentComponent<{ amount: string; sign?: string; fiat?: boolean }> = (
props
) => {
const prettyPrint = createMemo(() => {
const parsed = Number(props.amount);
if (isNaN(parsed)) {
return props.amount;
} else {
return parsed.toLocaleString();
}
});
export const InlineAmount: ParentComponent<{
amount: string;
sign?: string;
fiat?: boolean;
}> = (props) => {
const prettyPrint = createMemo(() => {
const parsed = Number(props.amount);
if (isNaN(parsed)) {
return props.amount;
} else {
return parsed.toLocaleString();
}
});
return (
<div class="inline-block text-lg">
{props.sign ? `${props.sign} ` : ""}
{props.fiat ? "$" : ""}
{prettyPrint()} <span class="text-sm">{props.fiat ? "USD" : "SATS"}</span>
</div>
);
return (
<div class="inline-block text-lg">
{props.sign ? `${props.sign} ` : ""}
{props.fiat ? "$" : ""}
{prettyPrint()}{" "}
<span class="text-sm">{props.fiat ? "USD" : "SATS"}</span>
</div>
);
};
function USDShower(props: { amountSats: string; fee?: string }) {
const [state, _] = useMegaStore();
const amountInUsd = () => satsToUsd(state.price, add(props.amountSats, props.fee), true);
const [state, _] = useMegaStore();
const amountInUsd = () =>
satsToUsd(state.price, add(props.amountSats, props.fee), true);
return (
<Show when={!(props.amountSats === "0")}>
<KeyValue gray key="">
<div class="self-end">
&#8776; {amountInUsd()}&nbsp;<span class="text-sm">USD</span>
</div>
</KeyValue>
</Show>
);
return (
<Show when={!(props.amountSats === "0")}>
<KeyValue gray key="">
<div class="self-end">
&#8776; {amountInUsd()}&nbsp;
<span class="text-sm">USD</span>
</div>
</KeyValue>
</Show>
);
}
function add(a: string, b?: string) {
return Number(a || 0) + Number(b || 0);
return Number(a || 0) + Number(b || 0);
}
export function AmountCard(props: {
amountSats: string;
fee?: string;
reserve?: string;
initialOpen?: boolean;
isAmountEditable?: boolean;
setAmountSats?: (amount: bigint) => void;
amountSats: string;
fee?: string;
reserve?: string;
initialOpen?: boolean;
isAmountEditable?: boolean;
setAmountSats?: (amount: bigint) => void;
}) {
return (
<Card>
<VStack>
<Switch>
<Match when={props.fee}>
<div class="flex flex-col gap-1">
<KeyValue key="Amount">
<Show
when={props.isAmountEditable}
fallback={<InlineAmount amount={props.amountSats} />}
>
<AmountEditable
initialOpen={props.initialOpen ?? false}
initialAmountSats={props.amountSats.toString()}
setAmountSats={props.setAmountSats ? props.setAmountSats : noop}
/>
</Show>
</KeyValue>
<KeyValue gray key="+ Fee">
<InlineAmount amount={props.fee || "0"} />
</KeyValue>
</div>
<hr class="border-white/20" />
<div class="flex flex-col gap-1">
<KeyValue key="Total">
<InlineAmount amount={add(props.amountSats, props.fee).toString()} />
</KeyValue>
<USDShower amountSats={props.amountSats} fee={props.fee} />
</div>
</Match>
<Match when={props.reserve}>
<div class="flex flex-col gap-1">
<KeyValue key="Channel size">
<InlineAmount amount={add(props.amountSats, props.reserve).toString()} />
</KeyValue>
<KeyValue gray key="- Channel Reserve">
<InlineAmount amount={props.reserve || "0"} />
</KeyValue>
</div>
<hr class="border-white/20" />
<div class="flex flex-col gap-1">
<KeyValue key="Spendable">
<InlineAmount amount={props.amountSats} />
</KeyValue>
<USDShower amountSats={props.amountSats} fee={props.reserve} />
</div>
</Match>
<Match when={!props.fee && !props.reserve}>
<div class="flex flex-col gap-1">
<KeyValue key="Amount">
<Show
when={props.isAmountEditable}
fallback={<InlineAmount amount={props.amountSats} />}
>
<AmountEditable
initialOpen={props.initialOpen ?? false}
initialAmountSats={props.amountSats.toString()}
setAmountSats={props.setAmountSats ? props.setAmountSats : noop}
/>
</Show>
</KeyValue>
<USDShower amountSats={props.amountSats} />
</div>
</Match>
</Switch>
</VStack>
</Card>
);
return (
<Card>
<VStack>
<Switch>
<Match when={props.fee}>
<div class="flex flex-col gap-1">
<KeyValue key="Amount">
<Show
when={props.isAmountEditable}
fallback={
<InlineAmount
amount={props.amountSats}
/>
}
>
<AmountEditable
initialOpen={props.initialOpen ?? false}
initialAmountSats={props.amountSats.toString()}
setAmountSats={
props.setAmountSats
? props.setAmountSats
: noop
}
/>
</Show>
</KeyValue>
<KeyValue gray key="+ Fee">
<InlineAmount amount={props.fee || "0"} />
</KeyValue>
</div>
<hr class="border-white/20" />
<div class="flex flex-col gap-1">
<KeyValue key="Total">
<InlineAmount
amount={add(
props.amountSats,
props.fee
).toString()}
/>
</KeyValue>
<USDShower
amountSats={props.amountSats}
fee={props.fee}
/>
</div>
</Match>
<Match when={props.reserve}>
<div class="flex flex-col gap-1">
<KeyValue key="Channel size">
<InlineAmount
amount={add(
props.amountSats,
props.reserve
).toString()}
/>
</KeyValue>
<KeyValue gray key="- Channel Reserve">
<InlineAmount amount={props.reserve || "0"} />
</KeyValue>
</div>
<hr class="border-white/20" />
<div class="flex flex-col gap-1">
<KeyValue key="Spendable">
<InlineAmount amount={props.amountSats} />
</KeyValue>
<USDShower
amountSats={props.amountSats}
fee={props.reserve}
/>
</div>
</Match>
<Match when={!props.fee && !props.reserve}>
<div class="flex flex-col gap-1">
<KeyValue key="Amount">
<Show
when={props.isAmountEditable}
fallback={
<InlineAmount
amount={props.amountSats}
/>
}
>
<AmountEditable
initialOpen={props.initialOpen ?? false}
initialAmountSats={props.amountSats.toString()}
setAmountSats={
props.setAmountSats
? props.setAmountSats
: noop
}
/>
</Show>
</KeyValue>
<USDShower amountSats={props.amountSats} />
</div>
</Match>
</Switch>
</VStack>
</Card>
);
}

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 { useMegaStore } from "~/state/megaStore";
import { satsToUsd, usdToSats } from "~/utils/conversions";
@@ -10,328 +17,393 @@ import { DIALOG_CONTENT, DIALOG_POSITIONER } from "~/styles/dialogs";
import { InfoBox } from "./InfoBox";
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 = [
{ label: "10k", amount: "10000" },
{ label: "100k", amount: "100000" },
{ label: "1m", amount: "1000000" }
{ label: "10k", amount: "10000" },
{ label: "100k", amount: "100000" },
{ label: "1m", amount: "1000000" }
];
const FIXED_AMOUNTS_USD = [
{ label: "$1", amount: "1" },
{ label: "$10", amount: "10" },
{ label: "$100", amount: "100" }
{ label: "$1", amount: "1" },
{ label: "$10", amount: "10" },
{ label: "$100", amount: "100" }
];
function fiatInputSanitizer(input: string): string {
// Make sure only numbers and a single decimal point are allowed
const numeric = input.replace(/[^0-9.]/g, "").replace(/(\..*)\./g, "$1");
// Remove leading zeros if not a decimal, add 0 if starts with a decimal
const cleaned = numeric.replace(/^0([^.]|$)/g, "$1").replace(/^\./g, "0.");
// Make sure only numbers and a single decimal point are allowed
const numeric = input.replace(/[^0-9.]/g, "").replace(/(\..*)\./g, "$1");
// 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;
// Remove leading zeros if not a decimal, add 0 if starts with a decimal
const cleaned = numeric.replace(/^0([^.]|$)/g, "$1").replace(/^\./g, "0.");
// Truncate any numbers two past the decimal
const twoDecimals = shifted.replace(/(\.[0-9]{2}).*/g, "$1");
// 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;
return twoDecimals;
// Truncate any numbers two past the decimal
const twoDecimals = shifted.replace(/(\.[0-9]{2}).*/g, "$1");
return twoDecimals;
}
function satsInputSanitizer(input: string): string {
// Make sure only numbers are allowed
const numeric = input.replace(/[^0-9]/g, "");
// If it starts with a 0, remove the 0
const noLeadingZero = numeric.replace(/^0([^.]|$)/g, "$1");
// Make sure only numbers are allowed
const numeric = input.replace(/[^0-9]/g, "");
// If it starts with a 0, remove the 0
const noLeadingZero = numeric.replace(/^0([^.]|$)/g, "$1");
return noLeadingZero;
return noLeadingZero;
}
function SingleDigitButton(props: {
character: string;
onClick: (c: string) => void;
fiat: boolean;
character: string;
onClick: (c: string) => void;
fiat: boolean;
}) {
return (
// Skip the "." if it's fiat
<Show when={props.fiat || !(props.character === ".")} fallback={<div />}>
<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"
onClick={() => props.onClick(props.character)}
>
{props.character}
</button>
</Show>
);
return (
// Skip the "." if it's fiat
<Show
when={props.fiat || !(props.character === ".")}
fallback={<div />}
>
<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"
onClick={() => props.onClick(props.character)}
>
{props.character}
</button>
</Show>
);
}
function BigScalingText(props: { text: string; fiat: boolean }) {
const chars = () => props.text.length;
const chars = () => props.text.length;
return (
<h1
class="font-light text-center transition-transform ease-out duration-300 text-4xl"
classList={{
"scale-90": chars() > 9,
"scale-95": chars() > 8,
"scale-100": chars() > 7,
"scale-105": chars() > 6,
"scale-110": chars() > 5,
"scale-125": chars() > 4,
"scale-150": chars() <= 4
}}
>
{props.text}&nbsp;<span class="text-xl">{props.fiat ? "USD" : "SATS"}</span>
</h1>
);
return (
<h1
class="font-light text-center transition-transform ease-out duration-300 text-4xl"
classList={{
"scale-90": chars() > 9,
"scale-95": chars() > 8,
"scale-100": chars() > 7,
"scale-105": chars() > 6,
"scale-110": chars() > 5,
"scale-125": chars() > 4,
"scale-150": chars() <= 4
}}
>
{props.text}&nbsp;
<span class="text-xl">{props.fiat ? "USD" : "SATS"}</span>
</h1>
);
}
function SmallSubtleAmount(props: { text: string; fiat: boolean }) {
return (
<h2 class="text-xl font-light text-neutral-400">
&#8776;&nbsp;{props.text}&nbsp;<span class="text-sm">{props.fiat ? "USD" : "SATS"}</span>
</h2>
);
return (
<h2 class="text-xl font-light text-neutral-400">
&#8776;&nbsp;{props.text}&nbsp;
<span class="text-sm">{props.fiat ? "USD" : "SATS"}</span>
</h2>
);
}
function toDisplayHandleNaN(input: string, _fiat: boolean): string {
const parsed = Number(input);
if (isNaN(parsed)) {
return "0";
} else {
return parsed.toLocaleString();
}
const parsed = Number(input);
if (isNaN(parsed)) {
return "0";
} else {
return parsed.toLocaleString();
}
}
export const AmountEditable: ParentComponent<{
initialAmountSats: string;
initialOpen: boolean;
setAmountSats: (s: bigint) => void;
initialAmountSats: string;
initialOpen: boolean;
setAmountSats: (s: bigint) => void;
}> = (props) => {
const [isOpen, setIsOpen] = createSignal(props.initialOpen);
const [state, _actions] = useMegaStore();
const [mode, setMode] = createSignal<"fiat" | "sats">("sats");
const [localSats, setLocalSats] = createSignal(props.initialAmountSats || "0");
const [localFiat, setLocalFiat] = createSignal(
satsToUsd(state.price, parseInt(props.initialAmountSats || "0") || 0, false)
);
const [isOpen, setIsOpen] = createSignal(props.initialOpen);
const [state, _actions] = useMegaStore();
const [mode, setMode] = createSignal<"fiat" | "sats">("sats");
const [localSats, setLocalSats] = createSignal(
props.initialAmountSats || "0"
);
const [localFiat, setLocalFiat] = createSignal(
satsToUsd(
state.price,
parseInt(props.initialAmountSats || "0") || 0,
false
)
);
const displaySats = () => toDisplayHandleNaN(localSats(), false);
const displayFiat = () => `$${toDisplayHandleNaN(localFiat(), true)}`;
const displaySats = () => toDisplayHandleNaN(localSats(), false);
const displayFiat = () => `$${toDisplayHandleNaN(localFiat(), true)}`;
let satsInputRef!: HTMLInputElement;
let fiatInputRef!: HTMLInputElement;
let satsInputRef!: HTMLInputElement;
let fiatInputRef!: HTMLInputElement;
const [inboundCapacity] = createResource(async () => {
const channels = await state.mutiny_wallet?.list_channels();
let inbound = 0;
const [inboundCapacity] = createResource(async () => {
const channels = await state.mutiny_wallet?.list_channels();
let inbound = 0;
for (const channel of channels) {
inbound += channel.size - (channel.balance + channel.reserve);
for (const channel of channels) {
inbound += channel.size - (channel.balance + channel.reserve);
}
return inbound;
});
const warningText = () => {
if ((state.balance?.lightning || 0n) === 0n) {
const network = state.mutiny_wallet?.get_network() as Network;
if (network === "bitcoin") {
return "Your first lightning receive needs to be 50,000 sats or greater.";
} else {
return "Your first lightning receive needs to be 10,000 sats or greater.";
}
}
const parsed = Number(localSats());
if (isNaN(parsed)) {
return undefined;
}
if (parsed > (inboundCapacity() || 0)) {
return "A lightning setup fee will be charged if paid over lightning.";
}
return undefined;
};
function handleCharacterInput(character: string) {
const isFiatMode = mode() === "fiat";
const inputSanitizer = isFiatMode
? fiatInputSanitizer
: satsInputSanitizer;
const localValue = isFiatMode ? localFiat : localSats;
let sane;
if (character === "DEL") {
sane = inputSanitizer(localValue().slice(0, -1));
} else {
if (localValue() === "0") {
sane = inputSanitizer(character);
} else {
sane = inputSanitizer(localValue() + character);
}
}
if (isFiatMode) {
setLocalFiat(sane);
setLocalSats(
usdToSats(state.price, parseFloat(sane || "0") || 0, false)
);
} else {
setLocalSats(sane);
setLocalFiat(satsToUsd(state.price, Number(sane) || 0, false));
}
// After a button press make sure we re-focus the input
focus();
}
return inbound;
});
const warningText = () => {
if ((state.balance?.lightning || 0n) === 0n) {
const network = state.mutiny_wallet?.get_network() as Network;
if (network === "bitcoin") {
return "Your first lightning receive needs to be 50,000 sats or greater.";
} else {
return "Your first lightning receive needs to be 10,000 sats or greater.";
}
function setFixedAmount(amount: string) {
if (mode() === "fiat") {
setLocalFiat(amount);
setLocalSats(
usdToSats(state.price, parseFloat(amount || "0") || 0, false)
);
} else {
setLocalSats(amount);
setLocalFiat(satsToUsd(state.price, Number(amount) || 0, false));
}
}
const parsed = Number(localSats());
if (isNaN(parsed)) {
return undefined;
// What we're all here for in the first place: returning a value
function handleSubmit(e: SubmitEvent | MouseEvent) {
e.preventDefault();
props.setAmountSats(BigInt(localSats()));
setIsOpen(false);
}
if (parsed > (inboundCapacity() || 0)) {
return "A lightning setup fee will be charged if paid over lightning.";
function handleSatsInput(e: InputEvent) {
const { value } = e.target as HTMLInputElement;
const sane = satsInputSanitizer(value);
setLocalSats(sane);
setLocalFiat(satsToUsd(state.price, Number(sane) || 0, false));
}
return undefined;
};
function handleCharacterInput(character: string) {
const isFiatMode = mode() === "fiat";
const inputSanitizer = isFiatMode ? fiatInputSanitizer : satsInputSanitizer;
const localValue = isFiatMode ? localFiat : localSats;
let sane;
if (character === "DEL") {
sane = inputSanitizer(localValue().slice(0, -1));
} else {
if (localValue() === "0") {
sane = inputSanitizer(character);
} else {
sane = inputSanitizer(localValue() + character);
}
function handleFiatInput(e: InputEvent) {
const { value } = e.target as HTMLInputElement;
const sane = fiatInputSanitizer(value);
setLocalFiat(sane);
setLocalSats(
usdToSats(state.price, parseFloat(sane || "0") || 0, false)
);
}
if (isFiatMode) {
setLocalFiat(sane);
setLocalSats(usdToSats(state.price, parseFloat(sane || "0") || 0, false));
} else {
setLocalSats(sane);
setLocalFiat(satsToUsd(state.price, Number(sane) || 0, false));
function toggle() {
setMode((m) => (m === "sats" ? "fiat" : "sats"));
focus();
}
// After a button press make sure we re-focus the input
focus();
}
onMount(() => {
focus();
});
function setFixedAmount(amount: string) {
if (mode() === "fiat") {
setLocalFiat(amount);
setLocalSats(usdToSats(state.price, parseFloat(amount || "0") || 0, false));
} else {
setLocalSats(amount);
setLocalFiat(satsToUsd(state.price, Number(amount) || 0, false));
function focus() {
// Make sure we actually have the inputs mounted before we try to focus them
if (isOpen() && satsInputRef && fiatInputRef) {
if (mode() === "sats") {
satsInputRef.focus();
} else {
fiatInputRef.focus();
}
}
}
}
// What we're all here for in the first place: returning a value
function handleSubmit(e: SubmitEvent | MouseEvent) {
e.preventDefault();
props.setAmountSats(BigInt(localSats()));
setIsOpen(false);
}
function handleSatsInput(e: InputEvent) {
const { value } = e.target as HTMLInputElement;
const sane = satsInputSanitizer(value);
setLocalSats(sane);
setLocalFiat(satsToUsd(state.price, Number(sane) || 0, false));
}
function handleFiatInput(e: InputEvent) {
const { value } = e.target as HTMLInputElement;
const sane = fiatInputSanitizer(value);
setLocalFiat(sane);
setLocalSats(usdToSats(state.price, parseFloat(sane || "0") || 0, false));
}
function toggle() {
setMode((m) => (m === "sats" ? "fiat" : "sats"));
focus();
}
onMount(() => {
focus();
});
function focus() {
// Make sure we actually have the inputs mounted before we try to focus them
if (isOpen() && satsInputRef && fiatInputRef) {
if (mode() === "sats") {
satsInputRef.focus();
} else {
fiatInputRef.focus();
}
}
}
return (
<Dialog.Root open={isOpen()}>
<button
onClick={() => setIsOpen(true)}
class="px-4 py-2 rounded-xl border-2 border-m-blue flex gap-2 items-center"
>
<Show
when={localSats() !== "0"}
fallback={<div class="inline-block font-semibold">Set amount</div>}
>
<InlineAmount amount={localSats()} />
</Show>
<img src={pencil} alt="Edit" />
{/* {props.children} */}
</button>
<Dialog.Portal>
{/* <Dialog.Overlay class={OVERLAY} /> */}
<div class={DIALOG_POSITIONER}>
<Dialog.Content class={DIALOG_CONTENT} onEscapeKeyDown={() => setIsOpen(false)}>
{/* TODO: figure out how to submit on enter */}
<div class="w-full flex justify-end">
<button
onClick={() => setIsOpen(false)}
class="hover:bg-white/10 rounded-lg active:bg-m-blue w-8 h-8"
>
<img src={close} alt="Close" />
</button>
</div>
{/* <form onSubmit={handleSubmit} class="text-black"> */}
<form onSubmit={handleSubmit} class="opacity-0 absolute -z-10">
<input
ref={(el) => (satsInputRef = el)}
disabled={mode() === "fiat"}
type="text"
value={localSats()}
onInput={handleSatsInput}
inputMode="none"
/>
<input
ref={(el) => (fiatInputRef = el)}
disabled={mode() === "sats"}
type="text"
value={localFiat()}
onInput={handleFiatInput}
inputMode="none"
/>
</form>
<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}>
<BigScalingText
text={mode() === "fiat" ? displayFiat() : displaySats()}
fiat={mode() === "fiat"}
/>
<SmallSubtleAmount
text={mode() === "fiat" ? displaySats() : displayFiat()}
fiat={mode() !== "fiat"}
/>
</div>
<Show when={warningText()}>
<InfoBox accent="green">{warningText()}</InfoBox>
</Show>
<div class="flex justify-center gap-4 my-2">
<For each={mode() === "fiat" ? FIXED_AMOUNTS_USD : FIXED_AMOUNTS_SATS}>
{(amount) => (
<button
onClick={() => {
setFixedAmount(amount.amount);
focus();
}}
class="py-2 px-4 rounded-lg bg-white/10"
return (
<Dialog.Root open={isOpen()}>
<button
onClick={() => setIsOpen(true)}
class="px-4 py-2 rounded-xl border-2 border-m-blue flex gap-2 items-center"
>
<Show
when={localSats() !== "0"}
fallback={
<div class="inline-block font-semibold">Set amount</div>
}
>
<InlineAmount amount={localSats()} />
</Show>
<img src={pencil} alt="Edit" />
{/* {props.children} */}
</button>
<Dialog.Portal>
{/* <Dialog.Overlay class={OVERLAY} /> */}
<div class={DIALOG_POSITIONER}>
<Dialog.Content
class={DIALOG_CONTENT}
onEscapeKeyDown={() => setIsOpen(false)}
>
{amount.label}
</button>
)}
</For>
</div>
<div class="grid grid-cols-3 w-full flex-none">
<For each={CHARACTERS}>
{(character) => (
<SingleDigitButton
fiat={mode() === "fiat"}
character={character}
onClick={handleCharacterInput}
/>
)}
</For>
</div>
<Button intent="blue" class="w-full flex-none" onClick={handleSubmit}>
Set Amount
</Button>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog.Root>
);
{/* TODO: figure out how to submit on enter */}
<div class="w-full flex justify-end">
<button
onClick={() => setIsOpen(false)}
class="hover:bg-white/10 rounded-lg active:bg-m-blue w-8 h-8"
>
<img src={close} alt="Close" />
</button>
</div>
{/* <form onSubmit={handleSubmit} class="text-black"> */}
<form
onSubmit={handleSubmit}
class="opacity-0 absolute -z-10"
>
<input
ref={(el) => (satsInputRef = el)}
disabled={mode() === "fiat"}
type="text"
value={localSats()}
onInput={handleSatsInput}
inputMode="none"
/>
<input
ref={(el) => (fiatInputRef = el)}
disabled={mode() === "sats"}
type="text"
value={localFiat()}
onInput={handleFiatInput}
inputMode="none"
/>
</form>
<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}
>
<BigScalingText
text={
mode() === "fiat"
? displayFiat()
: displaySats()
}
fiat={mode() === "fiat"}
/>
<SmallSubtleAmount
text={
mode() === "fiat"
? displaySats()
: displayFiat()
}
fiat={mode() !== "fiat"}
/>
</div>
<Show when={warningText()}>
<InfoBox accent="green">
{warningText()}
</InfoBox>
</Show>
<div class="flex justify-center gap-4 my-2">
<For
each={
mode() === "fiat"
? FIXED_AMOUNTS_USD
: FIXED_AMOUNTS_SATS
}
>
{(amount) => (
<button
onClick={() => {
setFixedAmount(amount.amount);
focus();
}}
class="py-2 px-4 rounded-lg bg-white/10"
>
{amount.label}
</button>
)}
</For>
</div>
<div class="grid grid-cols-3 w-full flex-none">
<For each={CHARACTERS}>
{(character) => (
<SingleDigitButton
fiat={mode() === "fiat"}
character={character}
onClick={handleCharacterInput}
/>
)}
</For>
</div>
<Button
intent="blue"
class="w-full flex-none"
onClick={handleSubmit}
>
Set Amount
</Button>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog.Root>
);
};

View File

@@ -1,60 +1,66 @@
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 BalanceBox, { LoadingShimmer } from "~/components/BalanceBox";
import NavBar from "~/components/NavBar";
import ReloadPrompt from "~/components/Reload";
import { A } from 'solid-start';
import { OnboardWarning } from '~/components/OnboardWarning';
import { CombinedActivity } from './Activity';
import userClock from '~/assets/icons/user-clock.svg';
import { useMegaStore } from '~/state/megaStore';
import { Show } from 'solid-js';
import { A } from "solid-start";
import { OnboardWarning } from "~/components/OnboardWarning";
import { CombinedActivity } from "./Activity";
import userClock from "~/assets/icons/user-clock.svg";
import { useMegaStore } from "~/state/megaStore";
import { Show } from "solid-js";
import { ExternalLink } from "./layout/ExternalLink";
export default function App() {
const [state, _actions] = useMegaStore();
return (
<SafeArea>
<DefaultMain>
<header class="w-full flex justify-between items-center mt-4 mb-2">
<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">
<img src={userClock} alt="Activity" class="h-8 w-8" />
</A>
</header>
<Show when={!state.wallet_loading}>
<OnboardWarning />
<ReloadPrompt />
</Show>
<BalanceBox loading={state.wallet_loading} />
<Card title="Activity">
<div class="p-1" />
<VStack>
<Show when={!state.wallet_loading} fallback={<LoadingShimmer />}>
<CombinedActivity limit={3} />
</Show>
{/* <ButtonLink href="/activity">View All</ButtonLink> */}
</VStack>
<Show when={state.activity && state.activity.length > 0}>
<A
href="/activity"
class="text-m-red active:text-m-red/80 text-xl font-semibold no-underline self-center"
>
View All
</A>
</Show>
</Card>
<p class="self-center text-neutral-500 mt-4">
Bugs? Feedback?{" "}
<span class="text-neutral-400">
<ExternalLink href="https://github.com/MutinyWallet/mutiny-web/issues">
Create an issue
</ExternalLink>
</span>
</p>
</DefaultMain>
<NavBar activeTab="home" />
</SafeArea>
<SafeArea>
<DefaultMain>
<header class="w-full flex justify-between items-center mt-4 mb-2">
<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"
>
<img src={userClock} alt="Activity" class="h-8 w-8" />
</A>
</header>
<Show when={!state.wallet_loading}>
<OnboardWarning />
<ReloadPrompt />
</Show>
<BalanceBox loading={state.wallet_loading} />
<Card title="Activity">
<div class="p-1" />
<VStack>
<Show
when={!state.wallet_loading}
fallback={<LoadingShimmer />}
>
<CombinedActivity limit={3} />
</Show>
{/* <ButtonLink href="/activity">View All</ButtonLink> */}
</VStack>
<Show when={state.activity && state.activity.length > 0}>
<A
href="/activity"
class="text-m-red active:text-m-red/80 text-xl font-semibold no-underline self-center"
>
View All
</A>
</Show>
</Card>
<p class="self-center text-neutral-500 mt-4">
Bugs? Feedback?{" "}
<span class="text-neutral-400">
<ExternalLink href="https://github.com/MutinyWallet/mutiny-web/issues">
Create an issue
</ExternalLink>
</span>
</p>
</DefaultMain>
<NavBar activeTab="home" />
</SafeArea>
);
}

View File

@@ -6,71 +6,90 @@ import { A, useNavigate } from "solid-start";
import shuffle from "~/assets/icons/shuffle.svg";
export function LoadingShimmer() {
return (
<div class="flex flex-col gap-2 animate-pulse">
<h1 class="text-4xl font-light">
<div class="w-[12rem] rounded bg-neutral-700 h-[2.5rem]" />
</h1>
<h2 class="text-xl font-light text-white/70">
<div class="w-[8rem] rounded bg-neutral-700 h-[1.75rem]" />
</h2>
</div>
);
return (
<div class="flex flex-col gap-2 animate-pulse">
<h1 class="text-4xl font-light">
<div class="w-[12rem] rounded bg-neutral-700 h-[2.5rem]" />
</h1>
<h2 class="text-xl font-light text-white/70">
<div class="w-[8rem] rounded bg-neutral-700 h-[1.75rem]" />
</h2>
</div>
);
}
const STYLE =
"px-2 py-1 rounded-xl border border-neutral-400 text-sm flex gap-2 items-center font-semibold";
"px-2 py-1 rounded-xl border border-neutral-400 text-sm flex gap-2 items-center font-semibold";
export default function BalanceBox(props: { loading?: boolean }) {
const [state, _actions] = useMegaStore();
const [state, _actions] = useMegaStore();
const emptyBalance = () =>
(state.balance?.confirmed || 0n) === 0n &&
(state.balance?.lightning || 0n) === 0n &&
(state.balance?.force_close || 0n) === 0n &&
(state.balance?.unconfirmed || 0n) === 0n;
const emptyBalance = () =>
(state.balance?.confirmed || 0n) === 0n &&
(state.balance?.lightning || 0n) === 0n &&
(state.balance?.force_close || 0n) === 0n &&
(state.balance?.unconfirmed || 0n) === 0n;
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 (
<>
<FancyCard title="Lightning">
<Show when={!props.loading} fallback={<LoadingShimmer />}>
<Amount amountSats={state.balance?.lightning || 0} showFiat />
</Show>
</FancyCard>
return (
<>
<FancyCard title="Lightning">
<Show when={!props.loading} fallback={<LoadingShimmer />}>
<Amount
amountSats={state.balance?.lightning || 0}
showFiat
/>
</Show>
</FancyCard>
<FancyCard
title="On-Chain"
subtitle={((Number(state.balance?.unconfirmed) || 0) + (Number(state.balance?.force_close) || 0)) ? "Unconfirmed" : undefined}
>
<Show when={!props.loading} fallback={<LoadingShimmer />}>
<div class="flex justify-between">
<Amount amountSats={totalOnchain()} showFiat />
<Show when={!emptyBalance()}>
<div class="self-end justify-self-end">
<A href="/swap" class={STYLE}>
<img src={shuffle} alt="swap" class="h-8 w-8" />
</A>
</div>
</Show>
</div>
</Show>
</FancyCard>
<div class="flex gap-2 py-4">
<Button
onClick={() => navigate("/send")}
disabled={emptyBalance() || props.loading}
intent="green"
>
Send
</Button>
<Button onClick={() => navigate("/receive")} disabled={props.loading} intent="blue">
Receive
</Button>
</div>
</>
);
<FancyCard
title="On-Chain"
subtitle={
(Number(state.balance?.unconfirmed) || 0) +
(Number(state.balance?.force_close) || 0)
? "Unconfirmed"
: undefined
}
>
<Show when={!props.loading} fallback={<LoadingShimmer />}>
<div class="flex justify-between">
<Amount amountSats={totalOnchain()} showFiat />
<Show when={!emptyBalance()}>
<div class="self-end justify-self-end">
<A href="/swap" class={STYLE}>
<img
src={shuffle}
alt="swap"
class="h-8 w-8"
/>
</A>
</div>
</Show>
</div>
</Show>
</FancyCard>
<div class="flex gap-2 py-4">
<Button
onClick={() => navigate("/send")}
disabled={emptyBalance() || props.loading}
intent="green"
>
Send
</Button>
<Button
onClick={() => navigate("/receive")}
disabled={props.loading}
intent="blue"
>
Receive
</Button>
</div>
</>
);
}

View File

@@ -1,50 +1,69 @@
import { Match, Switch, createSignal } from 'solid-js';
import { SmallHeader, TinyButton } from '~/components/layout';
import { Dialog } from '@kobalte/core';
import { Match, Switch, createSignal } from "solid-js";
import { SmallHeader, TinyButton } from "~/components/layout";
import { Dialog } from "@kobalte/core";
import close from "~/assets/icons/close.svg";
import { SubmitHandler } from '@modular-forms/solid';
import { ContactForm } from './ContactForm';
import { ContactFormValues } from './ContactViewer';
import { DIALOG_CONTENT, DIALOG_POSITIONER } from '~/styles/dialogs';
import { SubmitHandler } from "@modular-forms/solid";
import { ContactForm } from "./ContactForm";
import { ContactFormValues } from "./ContactViewer";
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);
// What we're all here for in the first place: returning a value
const handleSubmit: SubmitHandler<ContactFormValues> = (c: ContactFormValues) => {
props.createContact(c)
const handleSubmit: SubmitHandler<ContactFormValues> = (
c: ContactFormValues
) => {
props.createContact(c);
setIsOpen(false);
}
};
return (
<Dialog.Root open={isOpen()}>
<Switch>
<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 ">
<span class="leading-[4rem]">+</span>
</div>
<SmallHeader class="overflow-ellipsis">
new
</SmallHeader>
<SmallHeader class="overflow-ellipsis">new</SmallHeader>
</button>
</Match>
<Match when={!props.list}>
<TinyButton onClick={() => setIsOpen(true)}>+ Add Contact</TinyButton>
<TinyButton onClick={() => setIsOpen(true)}>
+ Add Contact
</TinyButton>
</Match>
</Switch>
<Dialog.Portal>
<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">
<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" />
</button>
</div>
<ContactForm title="New contact" cta="Create contact" handleSubmit={handleSubmit} />
<ContactForm
title="New contact"
cta="Create contact"
handleSubmit={handleSubmit}
/>
</Dialog.Content>
</div>
</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 { ContactFormValues } from "./ContactViewer";
export function ContactForm(props: { handleSubmit: SubmitHandler<ContactFormValues>, initialValues?: ContactFormValues, title: string, cta: string }) {
const [_contactForm, { Form, Field }] = createForm<ContactFormValues>({ initialValues: props.initialValues });
export function ContactForm(props: {
handleSubmit: SubmitHandler<ContactFormValues>;
initialValues?: ContactFormValues;
title: string;
cta: string;
}) {
const [_contactForm, { Form, Field }] = createForm<ContactFormValues>({
initialValues: props.initialValues
});
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>
<LargeHeader>{props.title}</LargeHeader>
<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) => (
<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 name="npub" validate={[]}>
@@ -27,5 +46,5 @@ export function ContactForm(props: { handleSubmit: SubmitHandler<ContactFormValu
{props.cta}
</Button>
</Form>
)
}
);
}

View File

@@ -1,34 +1,44 @@
import { Match, Switch, createSignal } from 'solid-js';
import { Button, Card, NiceP, SmallHeader } from '~/components/layout';
import { Dialog } from '@kobalte/core';
import { Match, Switch, createSignal } from "solid-js";
import { Button, Card, NiceP, SmallHeader } from "~/components/layout";
import { Dialog } from "@kobalte/core";
import close from "~/assets/icons/close.svg";
import { SubmitHandler } from '@modular-forms/solid';
import { ContactForm } from './ContactForm';
import { showToast } from './Toaster';
import { Contact } from '@mutinywallet/mutiny-wasm';
import { DIALOG_CONTENT, DIALOG_POSITIONER } from '~/styles/dialogs';
import { SubmitHandler } from "@modular-forms/solid";
import { ContactForm } from "./ContactForm";
import { showToast } from "./Toaster";
import { Contact } from "@mutinywallet/mutiny-wasm";
import { DIALOG_CONTENT, DIALOG_POSITIONER } from "~/styles/dialogs";
export type ContactFormValues = {
name: string,
npub?: string,
}
name: 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 [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: npub not valid? other undefineds
const contact = new Contact(c.name, undefined, undefined, undefined)
props.saveContact(contact)
setIsEditing(false)
}
const contact = new Contact(c.name, undefined, undefined, undefined);
props.saveContact(contact);
setIsEditing(false);
};
return (
<Dialog.Root open={isOpen()}>
<button 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"
<button
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 }}
>
{props.contact.name[0]}
@@ -39,33 +49,72 @@ export function ContactViewer(props: { contact: Contact, gradient: string, saveC
</button>
<Dialog.Portal>
<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">
<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" />
</button>
</div>
<Switch>
<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 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 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"
style={{ background: props.gradient }}
<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"
style={{
background: props.gradient
}}
>
{props.contact.name[0]}
</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">
<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>
</div>
<div class="flex w-full gap-2">
<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>
<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>
</Match>
@@ -73,6 +122,6 @@ export function ContactViewer(props: { contact: Contact, gradient: string, saveC
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog.Root >
</Dialog.Root>
);
}

View File

@@ -3,15 +3,22 @@ import { QRCodeSVG } from "solid-qr-code";
import { useCopy } from "~/utils/useCopy";
export function CopyableQR(props: { value: string }) {
const [copy, copied] = useCopy({ copiedTimeout: 1000 });
return (
<div id="qr" class="w-full bg-white rounded-xl relative" onClick={() => copy(props.value)}>
<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">
<p class="text-xl font-bold">Copied</p>
const [copy, copied] = useCopy({ copiedTimeout: 1000 });
return (
<div
id="qr"
class="w-full bg-white rounded-xl relative"
onClick={() => copy(props.value)}
>
<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">
<p class="text-xl font-bold">Copied</p>
</div>
</Show>
<QRCodeSVG
value={props.value}
class="w-full h-full p-8 max-h-[400px]"
/>
</div>
</Show>
<QRCodeSVG value={props.value} class="w-full h-full p-8 max-h-[400px]" />
</div>
);
);
}

View File

@@ -15,19 +15,18 @@ export function DeleteEverything() {
const [confirmOpen, setConfirmOpen] = createSignal(false);
const [confirmLoading, setConfirmLoading] = createSignal(false);
async function resetNode() {
try {
setConfirmLoading(true);
await actions.deleteMutinyWallet();
showToast({ title: "Deleted", description: `Deleted all data` })
showToast({ title: "Deleted", description: `Deleted all data` });
setTimeout(() => {
window.location.href = "/";
}, 1000);
} catch (e) {
console.error(e)
showToast(eify(e))
console.error(e);
showToast(eify(e));
} finally {
setConfirmOpen(false);
setConfirmLoading(false);
@@ -37,9 +36,14 @@ export function DeleteEverything() {
return (
<>
<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!
</ConfirmDialog>
</>
)
);
}

View File

@@ -1,14 +1,14 @@
import { Dialog } from "@kobalte/core"
import { Dialog } from "@kobalte/core";
import {
For,
Match,
ParentComponent,
Show,
Suspense,
Switch,
createEffect,
createMemo,
createResource
For,
Match,
ParentComponent,
Show,
Suspense,
Switch,
createEffect,
createMemo,
createResource
} from "solid-js";
import { Hr, ModalCloseButton, TinyButton, VStack } from "~/components/layout";
import { MutinyInvoice } from "@mutinywallet/mutiny-wasm";
@@ -29,286 +29,320 @@ import { Network } from "~/logic/mutinyWalletSetup";
import { AmountSmall } from "./Amount";
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 =
"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";
function LightningHeader(props: { info: MutinyInvoice }) {
const [state, _actions] = useMegaStore();
const [state, _actions] = useMegaStore();
const tags = createMemo(() => {
if (props.info.labels.length) {
const contact = state.mutiny_wallet?.get_contact(props.info.labels[0]);
if (contact) {
return [tagToMutinyTag(contact)];
} else {
return [];
}
} else {
return [];
}
});
const tags = createMemo(() => {
if (props.info.labels.length) {
const contact = state.mutiny_wallet?.get_contact(
props.info.labels[0]
);
if (contact) {
return [tagToMutinyTag(contact)];
} else {
return [];
}
} else {
return [];
}
});
return (
<div class="flex flex-col items-center gap-4">
<div class="p-4 bg-neutral-100 rounded-full">
<img src={bolt} alt="lightning bolt" class="w-8 h-8" />
</div>
<h1 class="uppercase font-semibold">
{props.info.inbound ? "Lightning receive" : "Lightning send"}
</h1>
<ActivityAmount
center
amount={props.info.amount_sats?.toString() ?? "0"}
price={state.price}
positive={props.info.inbound}
/>
<For each={tags()}>
{(tag) => (
<TinyButton
tag={tag}
onClick={() => {
// noop
}}
>
{tag.name}
</TinyButton>
)}
</For>
</div>
);
return (
<div class="flex flex-col items-center gap-4">
<div class="p-4 bg-neutral-100 rounded-full">
<img src={bolt} alt="lightning bolt" class="w-8 h-8" />
</div>
<h1 class="uppercase font-semibold">
{props.info.inbound ? "Lightning receive" : "Lightning send"}
</h1>
<ActivityAmount
center
amount={props.info.amount_sats?.toString() ?? "0"}
price={state.price}
positive={props.info.inbound}
/>
<For each={tags()}>
{(tag) => (
<TinyButton
tag={tag}
onClick={() => {
// noop
}}
>
{tag.name}
</TinyButton>
)}
</For>
</div>
);
}
function OnchainHeader(props: { info: OnChainTx }) {
const [state, _actions] = useMegaStore();
const [state, _actions] = useMegaStore();
const tags = createMemo(() => {
if (props.info.labels.length) {
const contact = state.mutiny_wallet?.get_contact(props.info.labels[0]);
if (contact) {
return [tagToMutinyTag(contact)];
} else {
return [];
}
} else {
return [];
}
});
const tags = createMemo(() => {
if (props.info.labels.length) {
const contact = state.mutiny_wallet?.get_contact(
props.info.labels[0]
);
if (contact) {
return [tagToMutinyTag(contact)];
} else {
return [];
}
} else {
return [];
}
});
const isSend = () => {
return props.info.sent > props.info.received;
};
const isSend = () => {
return props.info.sent > props.info.received;
};
const amount = () => {
if (isSend()) {
return (props.info.sent - props.info.received).toString();
} else {
return (props.info.received - props.info.sent).toString();
}
};
const amount = () => {
if (isSend()) {
return (props.info.sent - props.info.received).toString();
} else {
return (props.info.received - props.info.sent).toString();
}
};
return (
<div class="flex flex-col items-center gap-4">
<div class="p-4 bg-neutral-100 rounded-full">
<img src={chain} alt="blockchain" class="w-8 h-8" />
</div>
<h1 class="uppercase font-semibold">{isSend() ? "On-chain send" : "On-chain receive"}</h1>
<ActivityAmount center amount={amount() ?? "0"} price={state.price} positive={!isSend()} />
<For each={tags()}>
{(tag) => (
<TinyButton
tag={tag}
onClick={() => {
// noop
}}
>
{tag.name}
</TinyButton>
)}
</For>
</div>
);
return (
<div class="flex flex-col items-center gap-4">
<div class="p-4 bg-neutral-100 rounded-full">
<img src={chain} alt="blockchain" class="w-8 h-8" />
</div>
<h1 class="uppercase font-semibold">
{isSend() ? "On-chain send" : "On-chain receive"}
</h1>
<ActivityAmount
center
amount={amount() ?? "0"}
price={state.price}
positive={!isSend()}
/>
<For each={tags()}>
{(tag) => (
<TinyButton
tag={tag}
onClick={() => {
// noop
}}
>
{tag.name}
</TinyButton>
)}
</For>
</div>
);
}
const KeyValue: ParentComponent<{ key: string }> = (props) => {
return (
<li class="flex justify-between items-center gap-4">
<span class="uppercase font-semibold whitespace-nowrap">{props.key}</span>
<span class="font-light">{props.children}</span>
</li>
);
return (
<li class="flex justify-between items-center gap-4">
<span class="uppercase font-semibold whitespace-nowrap">
{props.key}
</span>
<span class="font-light">{props.children}</span>
</li>
);
};
function MiniStringShower(props: { text: string }) {
const [copy, _copied] = useCopy({ copiedTimeout: 1000 });
const [copy, _copied] = useCopy({ copiedTimeout: 1000 });
return (
<div class="w-full grid gap-1 grid-cols-[minmax(0,_1fr)_auto]">
<pre class="truncate text-neutral-300 font-light">{props.text}</pre>
<button class="w-[1rem]" onClick={() => copy(props.text)}>
<img src={copyIcon} alt="copy" class="w-4 h-4" />
</button>
</div>
);
return (
<div class="w-full grid gap-1 grid-cols-[minmax(0,_1fr)_auto]">
<pre class="truncate text-neutral-300 font-light">{props.text}</pre>
<button class="w-[1rem]" onClick={() => copy(props.text)}>
<img src={copyIcon} alt="copy" class="w-4 h-4" />
</button>
</div>
);
}
function LightningDetails(props: { info: MutinyInvoice }) {
return (
<VStack>
<ul class="flex flex-col gap-4">
<KeyValue key="Status">
<span class="text-neutral-300">{props.info.paid ? "Paid" : "Unpaid"}</span>
</KeyValue>
<KeyValue key="When">
<span class="text-neutral-300">{prettyPrintTime(Number(props.info.last_updated))}</span>
</KeyValue>
<Show when={props.info.description}>
<KeyValue key="Description">
<span class="text-neutral-300 truncate">{props.info.description}</span>
</KeyValue>
</Show>
<KeyValue key="Fees">
<span class="text-neutral-300">
<AmountSmall amountSats={props.info.fees_paid} />
</span>
</KeyValue>
<KeyValue key="Bolt11">
<MiniStringShower text={props.info.bolt11 ?? ""} />
</KeyValue>
<KeyValue key="Payment Hash">
<MiniStringShower text={props.info.payment_hash ?? ""} />
</KeyValue>
<KeyValue key="Preimage">
<MiniStringShower text={props.info.preimage ?? ""} />
</KeyValue>
</ul>
</VStack>
);
return (
<VStack>
<ul class="flex flex-col gap-4">
<KeyValue key="Status">
<span class="text-neutral-300">
{props.info.paid ? "Paid" : "Unpaid"}
</span>
</KeyValue>
<KeyValue key="When">
<span class="text-neutral-300">
{prettyPrintTime(Number(props.info.last_updated))}
</span>
</KeyValue>
<Show when={props.info.description}>
<KeyValue key="Description">
<span class="text-neutral-300 truncate">
{props.info.description}
</span>
</KeyValue>
</Show>
<KeyValue key="Fees">
<span class="text-neutral-300">
<AmountSmall amountSats={props.info.fees_paid} />
</span>
</KeyValue>
<KeyValue key="Bolt11">
<MiniStringShower text={props.info.bolt11 ?? ""} />
</KeyValue>
<KeyValue key="Payment Hash">
<MiniStringShower text={props.info.payment_hash ?? ""} />
</KeyValue>
<KeyValue key="Preimage">
<MiniStringShower text={props.info.preimage ?? ""} />
</KeyValue>
</ul>
</VStack>
);
}
function OnchainDetails(props: { info: OnChainTx }) {
const [state, _actions] = useMegaStore();
const [state, _actions] = useMegaStore();
const confirmationTime = () => {
return props.info.confirmation_time?.Confirmed?.time;
};
const confirmationTime = () => {
return props.info.confirmation_time?.Confirmed?.time;
};
const network = state.mutiny_wallet?.get_network() as Network;
const network = state.mutiny_wallet?.get_network() as Network;
return (
<VStack>
{/* <pre>{JSON.stringify(props.info, null, 2)}</pre> */}
<ul class="flex flex-col gap-4">
<KeyValue key="Status">
<span class="text-neutral-300">{confirmationTime() ? "Confirmed" : "Unconfirmed"}</span>
</KeyValue>
<Show when={confirmationTime()}>
<KeyValue key="When">
<span class="text-neutral-300">
{confirmationTime() ? prettyPrintTime(Number(confirmationTime())) : "Pending"}
</span>
</KeyValue>
</Show>
<Show when={props.info.fee && props.info.fee > 0}>
<KeyValue key="Fee">
<span class="text-neutral-300">
<AmountSmall amountSats={props.info.fee} />
</span>
</KeyValue>
</Show>
<KeyValue key="Txid">
<MiniStringShower text={props.info.txid ?? ""} />
</KeyValue>
</ul>
<a
class="uppercase font-light text-center"
href={mempoolTxUrl(props.info.txid, network)}
target="_blank"
rel="noreferrer"
>
Mempool.space
</a>
</VStack>
);
return (
<VStack>
{/* <pre>{JSON.stringify(props.info, null, 2)}</pre> */}
<ul class="flex flex-col gap-4">
<KeyValue key="Status">
<span class="text-neutral-300">
{confirmationTime() ? "Confirmed" : "Unconfirmed"}
</span>
</KeyValue>
<Show when={confirmationTime()}>
<KeyValue key="When">
<span class="text-neutral-300">
{confirmationTime()
? prettyPrintTime(Number(confirmationTime()))
: "Pending"}
</span>
</KeyValue>
</Show>
<Show when={props.info.fee && props.info.fee > 0}>
<KeyValue key="Fee">
<span class="text-neutral-300">
<AmountSmall amountSats={props.info.fee} />
</span>
</KeyValue>
</Show>
<KeyValue key="Txid">
<MiniStringShower text={props.info.txid ?? ""} />
</KeyValue>
</ul>
<a
class="uppercase font-light text-center"
href={mempoolTxUrl(props.info.txid, network)}
target="_blank"
rel="noreferrer"
>
Mempool.space
</a>
</VStack>
);
}
export function DetailsIdModal(props: {
open: boolean;
kind?: HackActivityType;
id: string;
setOpen: (open: boolean) => void;
open: boolean;
kind?: HackActivityType;
id: string;
setOpen: (open: boolean) => void;
}) {
const [state, _actions] = useMegaStore();
const [state, _actions] = useMegaStore();
const id = () => props.id;
const kind = () => props.kind;
const id = () => props.id;
const kind = () => props.kind;
// TODO: is there a cleaner way to do refetch when id changes?
const [data, { refetch }] = createResource(async () => {
if (kind() === "Lightning") {
console.log("reading invoice: ", id());
const invoice = await state.mutiny_wallet?.get_invoice_by_hash(id());
return invoice;
} else {
console.log("reading tx: ", id());
const tx = await state.mutiny_wallet?.get_transaction(id());
return tx;
}
});
// TODO: is there a cleaner way to do refetch when id changes?
const [data, { refetch }] = createResource(async () => {
if (kind() === "Lightning") {
console.log("reading invoice: ", id());
const invoice = await state.mutiny_wallet?.get_invoice_by_hash(
id()
);
return invoice;
} else {
console.log("reading tx: ", id());
const tx = await state.mutiny_wallet?.get_transaction(id());
return tx;
}
});
createEffect(() => {
if (props.id && props.kind && props.open) {
refetch();
}
});
createEffect(() => {
if (props.id && props.kind && props.open) {
refetch();
}
});
const json = createMemo(() => JSON.stringify(data() || "", null, 2));
const json = createMemo(() => JSON.stringify(data() || "", null, 2));
const isInvoice = () => {
return props.kind === "Lightning";
};
const isInvoice = () => {
return props.kind === "Lightning";
};
return (
<Dialog.Root open={props.open} onOpenChange={props.setOpen}>
<Dialog.Portal>
<Dialog.Overlay class={OVERLAY} />
<div class={DIALOG_POSITIONER}>
<Dialog.Content class={DIALOG_CONTENT}>
<Suspense>
<div class="flex justify-between mb-2">
<div />
<Dialog.CloseButton>
<ModalCloseButton />
</Dialog.CloseButton>
</div>
<Dialog.Title>
<Switch>
<Match when={isInvoice()}>
<LightningHeader info={data() as MutinyInvoice} />
</Match>
<Match when={true}>
<OnchainHeader info={data() as OnChainTx} />
</Match>
</Switch>
</Dialog.Title>
<Hr />
<Dialog.Description class="flex flex-col gap-4">
<Switch>
<Match when={isInvoice()}>
<LightningDetails info={data() as MutinyInvoice} />
</Match>
<Match when={true}>
<OnchainDetails info={data() as OnChainTx} />
</Match>
</Switch>
<div class="flex justify-center">
<CopyButton title="Copy" text={json()} />
return (
<Dialog.Root open={props.open} onOpenChange={props.setOpen}>
<Dialog.Portal>
<Dialog.Overlay class={OVERLAY} />
<div class={DIALOG_POSITIONER}>
<Dialog.Content class={DIALOG_CONTENT}>
<Suspense>
<div class="flex justify-between mb-2">
<div />
<Dialog.CloseButton>
<ModalCloseButton />
</Dialog.CloseButton>
</div>
<Dialog.Title>
<Switch>
<Match when={isInvoice()}>
<LightningHeader
info={data() as MutinyInvoice}
/>
</Match>
<Match when={true}>
<OnchainHeader
info={data() as OnChainTx}
/>
</Match>
</Switch>
</Dialog.Title>
<Hr />
<Dialog.Description class="flex flex-col gap-4">
<Switch>
<Match when={isInvoice()}>
<LightningDetails
info={data() as MutinyInvoice}
/>
</Match>
<Match when={true}>
<OnchainDetails
info={data() as OnChainTx}
/>
</Match>
</Switch>
<div class="flex justify-center">
<CopyButton title="Copy" text={json()} />
</div>
</Dialog.Description>
</Suspense>
</Dialog.Content>
</div>
</Dialog.Description>
</Suspense>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog.Root>
);
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@@ -2,12 +2,18 @@ import { Dialog } from "@kobalte/core";
import { ParentComponent } from "solid-js";
import { Button, SmallHeader } from "./layout";
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_CONTENT = "w-[80vw] max-w-[400px] p-4 bg-gray/50 backdrop-blur-md shadow-xl rounded-xl border border-white/10"
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_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" })`
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 (
<Dialog.Root open={props.open} onOpenChange={props.onCancel}>
<Dialog.Portal>
@@ -15,18 +21,27 @@ export const ConfirmDialog: ParentComponent<{ open: boolean; loading: boolean; o
<div class={DIALOG_POSITIONER}>
<Dialog.Content class={DIALOG_CONTENT}>
<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>
<Dialog.Description class="flex flex-col gap-4">
{props.children}
<div class="flex gap-4 w-full justify-end">
<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>
</Dialog.Description>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog.Root >
)
}
</Dialog.Root>
);
};

View File

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

View File

@@ -4,16 +4,16 @@ import { createSignal } from "solid-js";
import eify from "~/utils/eify";
import { showToast } from "./Toaster";
import { downloadTextFile } from "~/utils/download";
import { createFileUploader } from "@solid-primitives/upload"
import { createFileUploader } from "@solid-primitives/upload";
import { ConfirmDialog } from "./Dialog";
import { MutinyWallet } from "@mutinywallet/mutiny-wasm";
export function ImportExport() {
const [state, _] = useMegaStore()
const [state, _] = useMegaStore();
async function handleSave() {
const json = await state.mutiny_wallet?.export_json()
downloadTextFile(json || "", "mutiny-state.json")
const json = await state.mutiny_wallet?.export_json();
downloadTextFile(json || "", "mutiny-state.json");
}
const { files, selectFiles } = createFileUploader();
@@ -26,7 +26,7 @@ export function ImportExport() {
const file: File = files()[0].file;
const text = await new Promise<string | null>((resolve, reject) => {
fileReader.onload = e => {
fileReader.onload = (e) => {
const result = e.target?.result?.toString();
if (result) {
resolve(result);
@@ -34,7 +34,8 @@ export function ImportExport() {
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");
});
@@ -49,7 +50,6 @@ export function ImportExport() {
}
window.location.href = "/";
} catch (e) {
showToast(eify(e));
} finally {
@@ -59,12 +59,12 @@ export function ImportExport() {
}
async function uploadFile() {
selectFiles(async files => {
selectFiles(async (files) => {
if (files.length) {
setConfirmOpen(true);
return;
}
})
});
}
const [confirmOpen, setConfirmOpen] = createSignal(false);
@@ -78,9 +78,14 @@ export function ImportExport() {
<Button onClick={uploadFile}>Upload Saved State</Button>
</VStack>
</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}?
</ConfirmDialog>
</>
)
);
}

View File

@@ -1,23 +1,25 @@
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) => {
return (
<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"
classList={{
"border-m-red": props.accent === "red",
"border-m-blue": props.accent === "blue",
"border-m-green": props.accent === "green",
"border-white": props.accent === "white"
}}
>
<div class="self-center">
<img src={info} alt="info" class="w-8 h-8" />
</div>
<div class="flex items-center">
<p class="text-base font-light">{props.children}</p>
</div>
</div>
);
}
export const InfoBox: ParentComponent<{
accent: "red" | "blue" | "green" | "white";
}> = (props) => {
return (
<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"
classList={{
"border-m-red": props.accent === "red",
"border-m-blue": props.accent === "blue",
"border-m-green": props.accent === "green",
"border-white": props.accent === "white"
}}
>
<div class="self-center">
<img src={info} alt="info" class="w-8 h-8" />
</div>
<div class="flex items-center">
<p class="text-base font-light">{props.children}</p>
</div>
</div>
);
};

View File

@@ -1,11 +1,24 @@
import { Dialog } from "@kobalte/core";
import { JSX, createMemo } from "solid-js";
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";
export function JsonModal(props: { 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));
export function JsonModal(props: {
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 (
<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}>
<div class="flex justify-between mb-2 items-center">
<Dialog.Title>
<SmallHeader>
{props.title}
</SmallHeader>
<SmallHeader>{props.title}</SmallHeader>
</Dialog.Title>
<Dialog.CloseButton>
<ModalCloseButton />
@@ -35,6 +46,6 @@ export function JsonModal(props: { title: string, open: boolean, plaintext?: str
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog.Root >
)
}
</Dialog.Root>
);
}

View File

@@ -1,8 +1,23 @@
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 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 { Collapsible, TextField } from "@kobalte/core";
import mempoolTxUrl from "~/utils/mempoolTxUrl";
@@ -15,190 +30,221 @@ import { ExternalLink } from "./layout/ExternalLink";
// TODO: hopefully I don't have to maintain this type forever but I don't know how to pass it around otherwise
type RefetchPeersType = (
info?: unknown
info?: unknown
) => MutinyPeer[] | Promise<MutinyPeer[] | undefined> | null | undefined;
function PeerItem(props: { peer: MutinyPeer }) {
const [state, _] = useMegaStore();
const [state, _] = useMegaStore();
const handleDisconnectPeer = async () => {
const nodes = await state.mutiny_wallet?.list_nodes();
const firstNode = (nodes[0] as string) || "";
const handleDisconnectPeer = async () => {
const nodes = await state.mutiny_wallet?.list_nodes();
const firstNode = (nodes[0] as string) || "";
if (props.peer.is_connected) {
await state.mutiny_wallet?.disconnect_peer(firstNode, props.peer.pubkey);
} else {
await state.mutiny_wallet?.delete_peer(firstNode, props.peer.pubkey);
}
};
if (props.peer.is_connected) {
await state.mutiny_wallet?.disconnect_peer(
firstNode,
props.peer.pubkey
);
} else {
await state.mutiny_wallet?.delete_peer(
firstNode,
props.peer.pubkey
);
}
};
return (
<Collapsible.Root>
<Collapsible.Trigger class="w-full">
<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}
</h2>
</Collapsible.Trigger>
<Collapsible.Content>
<VStack>
<pre class="overflow-x-auto whitespace-pre-wrap break-all">
{JSON.stringify(props.peer, null, 2)}
</pre>
<Button intent="glowy" layout="xs" onClick={handleDisconnectPeer}>
Disconnect
</Button>
</VStack>
</Collapsible.Content>
</Collapsible.Root>
);
return (
<Collapsible.Root>
<Collapsible.Trigger class="w-full">
<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}
</h2>
</Collapsible.Trigger>
<Collapsible.Content>
<VStack>
<pre class="overflow-x-auto whitespace-pre-wrap break-all">
{JSON.stringify(props.peer, null, 2)}
</pre>
<Button
intent="glowy"
layout="xs"
onClick={handleDisconnectPeer}
>
Disconnect
</Button>
</VStack>
</Collapsible.Content>
</Collapsible.Root>
);
}
function PeersList() {
const [state, _] = useMegaStore();
const [state, _] = useMegaStore();
const getPeers = async () => {
return (await state.mutiny_wallet?.list_peers()) as Promise<MutinyPeer[]>;
};
const getPeers = async () => {
return (await state.mutiny_wallet?.list_peers()) as Promise<
MutinyPeer[]
>;
};
const [peers, { refetch }] = createResource(getPeers);
const [peers, { refetch }] = createResource(getPeers);
createEffect(() => {
// refetch peers every 5 seconds
const interval = setTimeout(() => {
refetch();
}, 5000);
onCleanup(() => {
clearInterval(interval);
createEffect(() => {
// refetch peers every 5 seconds
const interval = setTimeout(() => {
refetch();
}, 5000);
onCleanup(() => {
clearInterval(interval);
});
});
});
return (
<>
<SmallHeader>Peers</SmallHeader>
{/* By wrapping this in a suspense I don't cause the page to jump to the top */}
<Suspense>
<VStack>
<For each={peers()} fallback={<code>No peers</code>}>
{(peer) => <PeerItem peer={peer} />}
</For>
</VStack>
</Suspense>
<Button layout="small" onClick={refetch}>
Refresh Peers
</Button>
<ConnectPeer refetchPeers={refetch} />
</>
);
return (
<>
<SmallHeader>Peers</SmallHeader>
{/* By wrapping this in a suspense I don't cause the page to jump to the top */}
<Suspense>
<VStack>
<For each={peers()} fallback={<code>No peers</code>}>
{(peer) => <PeerItem peer={peer} />}
</For>
</VStack>
</Suspense>
<Button layout="small" onClick={refetch}>
Refresh Peers
</Button>
<ConnectPeer refetchPeers={refetch} />
</>
);
}
function ConnectPeer(props: { refetchPeers: RefetchPeersType }) {
const [state, _] = useMegaStore();
const [state, _] = useMegaStore();
const [value, setValue] = createSignal("");
const [value, setValue] = createSignal("");
const onSubmit = async (e: SubmitEvent) => {
e.preventDefault();
const onSubmit = async (e: SubmitEvent) => {
e.preventDefault();
const peerConnectString = value().trim();
const nodes = await state.mutiny_wallet?.list_nodes();
const firstNode = (nodes[0] as string) || "";
const peerConnectString = value().trim();
const nodes = await state.mutiny_wallet?.list_nodes();
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();
setValue("");
};
setValue("");
};
return (
<InnerCard>
<form class="flex flex-col gap-4" onSubmit={onSubmit}>
<TextField.Root
value={value()}
onChange={setValue}
validationState={value() == "" ? "valid" : "invalid"}
class="flex flex-col gap-4"
>
<TextField.Label class="text-sm font-semibold uppercase">Connect Peer</TextField.Label>
<TextField.Input
class="w-full p-2 rounded-lg text-black"
placeholder="mutiny:028241..."
/>
<TextField.ErrorMessage class="text-red-500">
Expecting something like mutiny:abc123...
</TextField.ErrorMessage>
</TextField.Root>
<Button layout="small" type="submit">
Connect
</Button>
</form>
</InnerCard>
);
return (
<InnerCard>
<form class="flex flex-col gap-4" onSubmit={onSubmit}>
<TextField.Root
value={value()}
onChange={setValue}
validationState={value() == "" ? "valid" : "invalid"}
class="flex flex-col gap-4"
>
<TextField.Label class="text-sm font-semibold uppercase">
Connect Peer
</TextField.Label>
<TextField.Input
class="w-full p-2 rounded-lg text-black"
placeholder="mutiny:028241..."
/>
<TextField.ErrorMessage class="text-red-500">
Expecting something like mutiny:abc123...
</TextField.ErrorMessage>
</TextField.Root>
<Button layout="small" type="submit">
Connect
</Button>
</form>
</InnerCard>
);
}
type RefetchChannelsListType = (
info?: unknown
info?: unknown
) => MutinyChannel[] | Promise<MutinyChannel[] | undefined> | null | undefined;
function ChannelItem(props: { channel: MutinyChannel; network?: Network }) {
const [state, _] = useMegaStore();
const [state, _] = useMegaStore();
const [confirmOpen, setConfirmOpen] = createSignal(false);
const [confirmLoading, setConfirmLoading] = createSignal(false);
const [confirmOpen, setConfirmOpen] = createSignal(false);
const [confirmLoading, setConfirmLoading] = createSignal(false);
function handleCloseChannel() {
setConfirmOpen(true);
}
async function confirmCloseChannel() {
setConfirmLoading(true);
try {
await state.mutiny_wallet?.close_channel(props.channel.outpoint as string);
} catch (e) {
console.error(e);
showToast(eify(e));
function handleCloseChannel() {
setConfirmOpen(true);
}
setConfirmLoading(false);
setConfirmOpen(false);
}
return (
<Collapsible.Root>
<Collapsible.Trigger class="w-full">
<h2 class="truncate text-start text-lg font-mono bg-neutral-200 text-black rounded px-4 py-2">
{">"} {props.channel.peer}
</h2>
</Collapsible.Trigger>
<Collapsible.Content>
<VStack>
<pre class="overflow-x-auto whitespace-pre-wrap break-all">
{JSON.stringify(props.channel, null, 2)}
</pre>
<ExternalLink href={mempoolTxUrl(props.channel.outpoint?.split(":")[0], props.network)}>
View Transaction
</ExternalLink>
<Button intent="glowy" layout="xs" onClick={handleCloseChannel}>
Close Channel
</Button>
</VStack>
<ConfirmDialog
open={confirmOpen()}
onConfirm={confirmCloseChannel}
onCancel={() => setConfirmOpen(false)}
loading={confirmLoading()}
>
<p>Are you sure you want to close this channel?</p>
</ConfirmDialog>
</Collapsible.Content>
</Collapsible.Root>
);
async function confirmCloseChannel() {
setConfirmLoading(true);
try {
await state.mutiny_wallet?.close_channel(
props.channel.outpoint as string
);
} catch (e) {
console.error(e);
showToast(eify(e));
}
setConfirmLoading(false);
setConfirmOpen(false);
}
return (
<Collapsible.Root>
<Collapsible.Trigger class="w-full">
<h2 class="truncate text-start text-lg font-mono bg-neutral-200 text-black rounded px-4 py-2">
{">"} {props.channel.peer}
</h2>
</Collapsible.Trigger>
<Collapsible.Content>
<VStack>
<pre class="overflow-x-auto whitespace-pre-wrap break-all">
{JSON.stringify(props.channel, null, 2)}
</pre>
<ExternalLink
href={mempoolTxUrl(
props.channel.outpoint?.split(":")[0],
props.network
)}
>
View Transaction
</ExternalLink>
<Button
intent="glowy"
layout="xs"
onClick={handleCloseChannel}
>
Close Channel
</Button>
</VStack>
<ConfirmDialog
open={confirmOpen()}
onConfirm={confirmCloseChannel}
onCancel={() => setConfirmOpen(false)}
loading={confirmLoading()}
>
<p>Are you sure you want to close this channel?</p>
</ConfirmDialog>
</Collapsible.Content>
</Collapsible.Root>
);
}
function ChannelsList() {
const [state, _] = useMegaStore()
const [state, _] = useMegaStore();
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);
@@ -211,32 +257,38 @@ function ChannelsList() {
onCleanup(() => {
clearInterval(interval);
});
})
});
const network = state.mutiny_wallet?.get_network() as Network;
return (
<>
<SmallHeader>
Channels
</SmallHeader>
<SmallHeader>Channels</SmallHeader>
{/* By wrapping this in a suspense I don't cause the page to jump to the top */}
<Suspense>
<For each={channels()} fallback={<code>No channels</code>}>
{(channel) => (
<ChannelItem channel={channel} network={network} />
)}
</For>
</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} />
</>
)
);
}
function OpenChannel(props: { refetchChannels: RefetchChannelsListType }) {
const [state, _] = useMegaStore()
const [state, _] = useMegaStore();
const [creationError, setCreationError] = createSignal<Error>();
@@ -256,63 +308,82 @@ function OpenChannel(props: { refetchChannels: RefetchChannelsListType }) {
const bigAmount = BigInt(amount());
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("");
setPeerPubkey("");
} catch (e) {
setCreationError(eify(e))
setCreationError(eify(e));
}
};
const network = state.mutiny_wallet?.get_network() as Network;
return (
<>
<InnerCard>
<form class="flex flex-col gap-4" onSubmit={onSubmit}>
<TextField.Root
value={peerPubkey()}
onChange={setPeerPubkey}
class="flex flex-col gap-2"
>
<TextField.Label class="text-sm font-semibold uppercase">Pubkey</TextField.Label>
<TextField.Input class="w-full p-2 rounded-lg text-black" />
</TextField.Root>
<TextField.Root value={amount()} 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>
<Button layout="small" type="submit">
Open Channel
</Button>
</form>
</InnerCard>
<Show when={newChannel()}>
<pre class="overflow-x-auto whitespace-pre-wrap break-all">
{JSON.stringify(newChannel()?.outpoint, null, 2)}
</pre>
<pre>{newChannel()?.outpoint}</pre>
<ExternalLink href={mempoolTxUrl(newChannel()?.outpoint?.split(":")[0], network)}>
View Transaction
</ExternalLink>
</Show>
<Show when={creationError()}>
<pre>{creationError()?.message}</pre>
</Show>
</>
<>
<InnerCard>
<form class="flex flex-col gap-4" onSubmit={onSubmit}>
<TextField.Root
value={peerPubkey()}
onChange={setPeerPubkey}
class="flex flex-col gap-2"
>
<TextField.Label class="text-sm font-semibold uppercase">
Pubkey
</TextField.Label>
<TextField.Input class="w-full p-2 rounded-lg text-black" />
</TextField.Root>
<TextField.Root
value={amount()}
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>
<Button layout="small" type="submit">
Open Channel
</Button>
</form>
</InnerCard>
<Show when={newChannel()}>
<pre class="overflow-x-auto whitespace-pre-wrap break-all">
{JSON.stringify(newChannel()?.outpoint, null, 2)}
</pre>
<pre>{newChannel()?.outpoint}</pre>
<ExternalLink
href={mempoolTxUrl(
newChannel()?.outpoint?.split(":")[0],
network
)}
>
View Transaction
</ExternalLink>
</Show>
<Show when={creationError()}>
<pre>{creationError()?.message}</pre>
</Show>
</>
);
}
function LnUrlAuth() {
const [state, _] = useMegaStore()
const [state, _] = useMegaStore();
const [value, setValue] = createSignal("");
@@ -320,37 +391,50 @@ function LnUrlAuth() {
e.preventDefault();
const lnurl = value().trim();
await state.mutiny_wallet?.lnurl_auth(0, lnurl)
await state.mutiny_wallet?.lnurl_auth(0, lnurl);
setValue("");
};
return (
<InnerCard>
<form class="flex flex-col gap-4" onSubmit={onSubmit} >
<form class="flex flex-col gap-4" onSubmit={onSubmit}>
<TextField.Root
value={value()}
onChange={setValue}
validationState={(value() == "" || value().toLowerCase().startsWith("lnurl")) ? "valid" : "invalid"}
validationState={
value() == "" ||
value().toLowerCase().startsWith("lnurl")
? "valid"
: "invalid"
}
class="flex flex-col gap-4"
>
<TextField.Label class="text-sm font-semibold uppercase" >LNURL Auth</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.Label class="text-sm font-semibold uppercase">
LNURL Auth
</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>
<Button layout="small" type="submit">Auth</Button>
</form >
<Button layout="small" type="submit">
Auth
</Button>
</form>
</InnerCard>
)
);
}
function ListTags() {
const [_state, actions] = useMegaStore()
const [_state, actions] = useMegaStore();
const [tags] = createResource(actions.listTags)
const [tags] = createResource(actions.listTags);
return (
<Collapsible.Root>
<Collapsible.Trigger class="w-full">
<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>
</Collapsible.Content>
</Collapsible.Root>
)
);
}
export default function KitchenSink() {
return (
<Card title="Kitchen Sink">
@@ -390,5 +470,5 @@ export default function KitchenSink() {
<Hr />
<ImportExport />
</Card>
)
}
);
}

View File

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

View File

@@ -1,51 +1,91 @@
import mutiny_m from '~/assets/icons/m.svg';
import airplane from '~/assets/icons/airplane.svg';
import settings from '~/assets/icons/settings.svg';
import receive from '~/assets/icons/big-receive.svg';
import redshift from '~/assets/icons/rs.svg';
import userClock from '~/assets/icons/user-clock.svg';
import mutiny_m from "~/assets/icons/m.svg";
import airplane from "~/assets/icons/airplane.svg";
import settings from "~/assets/icons/settings.svg";
import receive from "~/assets/icons/big-receive.svg";
import redshift from "~/assets/icons/rs.svg";
import userClock from "~/assets/icons/user-clock.svg";
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 }) {
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 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";
return (
<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'>
<li class={props.activeTab === "home" ? activeStyle : inactiveStyle}>
<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">
<li
class={
props.activeTab === "home" ? activeStyle : inactiveStyle
}
>
<A href="/">
<img src={mutiny_m} alt="home" />
</A>
</li>
<li class={props.activeTab === "send" ? activeStyle : inactiveStyle}>
<li
class={
props.activeTab === "send" ? activeStyle : inactiveStyle
}
>
<A href="/send">
<img src={airplane} alt="send" />
</A>
</li>
<li class={props.activeTab === "receive" ? activeStyle : inactiveStyle}>
<li
class={
props.activeTab === "receive"
? activeStyle
: inactiveStyle
}
>
<A href="/receive">
<img src={receive} alt="receive" />
</A>
</li>
<li class={props.activeTab === "activity" ? activeStyle : inactiveStyle}>
<li
class={
props.activeTab === "activity"
? activeStyle
: inactiveStyle
}
>
<A href="/activity">
<img src={userClock} alt="activity" />
</A>
</li>
<li class={props.activeTab === "redshift" ? activeStyle : inactiveStyle}>
<li
class={
props.activeTab === "redshift"
? activeStyle
: inactiveStyle
}
>
<A href="/redshift">
<img src={redshift} alt="redshift" width={36} />
</A>
</li>
<li class={props.activeTab === "settings" ? activeStyle : inactiveStyle}>
<li
class={
props.activeTab === "settings"
? activeStyle
: inactiveStyle
}
>
<A href="/settings">
<img src={settings} alt="settings" />
</A>
</li>
</ul>
</nav >
)
}
</nav>
);
}

View File

@@ -1,22 +1,23 @@
import {QRCodeSVG} from "solid-qr-code";
import {As, Dialog} from "@kobalte/core";
import {Button, Card} from "~/components/layout";
import {useMegaStore} from "~/state/megaStore";
import {createResource, Show} from "solid-js";
import { QRCodeSVG } from "solid-qr-code";
import { As, Dialog } from "@kobalte/core";
import { Button, Card } from "~/components/layout";
import { useMegaStore } from "~/state/megaStore";
import { createResource, Show } from "solid-js";
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_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 SMALL_HEADER = "text-sm font-semibold uppercase"
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_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 SMALL_HEADER = "text-sm font-semibold uppercase";
export default function NostrWalletConnectModal() {
const [state, actions] = useMegaStore()
const [state, actions] = useMegaStore();
const getConnectionURI = () => {
if (state.mutiny_wallet) {
return state.mutiny_wallet.get_nwc_uri()
return state.mutiny_wallet.get_nwc_uri();
} else {
return undefined
return undefined;
}
};
@@ -24,15 +25,15 @@ export default function NostrWalletConnectModal() {
const toggleNwc = async () => {
if (state.nwc_enabled) {
actions.setNwc(false)
window.location.reload()
actions.setNwc(false);
window.location.reload();
} else {
actions.setNwc(true)
actions.setNwc(true);
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);
}
}
};
// TODO: a lot of this markup is probably reusable as a "Modal" component
return (
@@ -45,7 +46,9 @@ export default function NostrWalletConnectModal() {
<div class={DIALOG_POSITIONER}>
<Dialog.Content class={DIALOG_CONTENT}>
<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">
<code>X</code>
</Dialog.CloseButton>
@@ -53,17 +56,24 @@ export default function NostrWalletConnectModal() {
<Dialog.Description class="flex flex-col gap-4">
<Show when={connectionURI()}>
<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>
<Card>
<code class="break-all">{connectionURI() || ""}</code>
<code class="break-all">
{connectionURI() || ""}
</code>
</Card>
</Show>
<Button onClick={toggleNwc}>{state.nwc_enabled ? "Disable" : "Enable"}</Button>
<Button onClick={toggleNwc}>
{state.nwc_enabled ? "Disable" : "Enable"}
</Button>
</Dialog.Description>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog.Root >
)
}
</Dialog.Root>
);
}

View File

@@ -7,80 +7,97 @@ import close from "~/assets/icons/close.svg";
import restore from "~/assets/icons/upload.svg";
export function OnboardWarning() {
const [state, actions] = useMegaStore();
const [dismissedBackup, setDismissedBackup] = createSignal(false);
const [state, actions] = useMegaStore();
const [dismissedBackup, setDismissedBackup] = createSignal(false);
function hasMoney() {
return state.balance?.confirmed || state.balance?.lightning || state.balance?.unconfirmed || state.balance?.force_close;
}
function hasMoney() {
return (
state.balance?.confirmed ||
state.balance?.lightning ||
state.balance?.unconfirmed ||
state.balance?.force_close
);
}
return (
<>
{/* TODO: show this once we have a restore flow */}
<Show when={false}>
<div class="grid grid-cols-[auto_minmax(0,_1fr)_auto] rounded-xl p-4 gap-4 bg-neutral-950/50">
<div class="self-center">
<img src={restore} alt="backup" class="w-8 h-8" />
</div>
<div class="flex md:flex-row flex-col items-center gap-4">
<div class="flex flex-col">
<SmallHeader>Welcome!</SmallHeader>
<p class="text-base font-light">
If you've used Mutiny before you can restore from a backup. Otherwise you can skip
this and enjoy your new wallet!
</p>
</div>
<Button
intent="green"
layout="xs"
class="self-start md:self-auto"
onClick={() => {
showToast({ title: "Unimplemented", description: "We don't do that yet" });
}}
return (
<>
{/* TODO: show this once we have a restore flow */}
<Show when={false}>
<div class="grid grid-cols-[auto_minmax(0,_1fr)_auto] rounded-xl p-4 gap-4 bg-neutral-950/50">
<div class="self-center">
<img src={restore} alt="backup" class="w-8 h-8" />
</div>
<div class="flex md:flex-row flex-col items-center gap-4">
<div class="flex flex-col">
<SmallHeader>Welcome!</SmallHeader>
<p class="text-base font-light">
If you've used Mutiny before you can restore
from a backup. Otherwise you can skip this and
enjoy your new wallet!
</p>
</div>
<Button
intent="green"
layout="xs"
class="self-start md:self-auto"
onClick={() => {
showToast({
title: "Unimplemented",
description: "We don't do that yet"
});
}}
>
Restore
</Button>
</div>
<button
tabindex="-1"
onClick={() => {
actions.dismissRestorePrompt();
}}
class="self-center hover:bg-white/10 rounded-lg active:bg-m-blue w-8"
>
<img src={close} alt="Close" />
</button>
</div>
</Show>
<Show
when={!state.has_backed_up && hasMoney() && !dismissedBackup()}
>
Restore
</Button>
</div>
<button
tabindex="-1"
onClick={() => {
actions.dismissRestorePrompt();
}}
class="self-center hover:bg-white/10 rounded-lg active:bg-m-blue w-8"
>
<img src={close} alt="Close" />
</button>
</div>
</Show>
<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="self-center">
<img src={save} alt="backup" class="w-8 h-8" />
</div>
<div class="flex flex-row max-md:items-center justify-between gap-4">
<div class="flex flex-col">
<SmallHeader>Secure your funds</SmallHeader>
<p class="text-base font-light max-md:hidden">
You have money stored in this browser. Let's make sure you have a backup.
</p>
</div>
<div class="flex items-center">
<ButtonLink intent="blue" layout="xs" class="self-auto" href="/backup">
Backup
</ButtonLink>
</div>
</div>
<button
tabindex="-1"
onClick={() => {
setDismissedBackup(true);
}}
class="self-center hover:bg-white/10 rounded-lg active:bg-m-blue w-8"
>
<img src={close} alt="Close" />
</button>
</div>
</Show>
</>
);
<div class="grid grid-cols-[auto_minmax(0,_1fr)_auto] rounded-xl p-4 gap-4 bg-neutral-950/50">
<div class="self-center">
<img src={save} alt="backup" class="w-8 h-8" />
</div>
<div class="flex flex-row max-md:items-center justify-between gap-4">
<div class="flex flex-col">
<SmallHeader>Secure your funds</SmallHeader>
<p class="text-base font-light max-md:hidden">
You have money stored in this browser. Let's
make sure you have a backup.
</p>
</div>
<div class="flex items-center">
<ButtonLink
intent="blue"
layout="xs"
class="self-auto"
href="/backup"
>
Backup
</ButtonLink>
</div>
</div>
<button
tabindex="-1"
onClick={() => {
setDismissedBackup(true);
}}
class="self-center hover:bg-white/10 rounded-lg active:bg-m-blue w-8"
>
<img src={close} alt="Close" />
</button>
</div>
</Show>
</>
);
}

View File

@@ -6,30 +6,30 @@ import { Show, createResource } from "solid-js";
import { getExistingSettings } from "~/logic/mutinyWalletSetup";
import getHostname from "~/utils/getHostname";
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_CONTENT = "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"
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_CONTENT =
"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() {
const [state, _] = useMegaStore()
const [state, _] = useMegaStore();
const getPeerConnectString = async () => {
if (state.mutiny_wallet) {
const { proxy } = getExistingSettings();
const nodes = await state.mutiny_wallet.list_nodes();
const firstNode = nodes[0] as string || ""
const hostName = getHostname(proxy || "")
const connectString = `mutiny:${firstNode}@${hostName}`
return connectString
const firstNode = (nodes[0] as string) || "";
const hostName = getHostname(proxy || "");
const connectString = `mutiny:${firstNode}@${hostName}`;
return connectString;
} else {
return undefined
return undefined;
}
};
const [peerConnectString] = createResource(getPeerConnectString);
// TODO: a lot of this markup is probably reusable as a "Modal" component
return (
<Dialog.Root>
@@ -41,7 +41,9 @@ export default function PeerConnectModal() {
<div class={DIALOG_POSITIONER}>
<Dialog.Content class={DIALOG_CONTENT}>
<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">
<code>X</code>
</Dialog.CloseButton>
@@ -49,16 +51,21 @@ export default function PeerConnectModal() {
<Dialog.Description class="flex flex-col gap-4">
<Show when={peerConnectString()}>
<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>
<Card>
<code class="break-all">{peerConnectString() || ""}</code>
<code class="break-all">
{peerConnectString() || ""}
</code>
</Card>
</Show>
</Dialog.Description>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog.Root >
)
}
</Dialog.Root>
);
}

View File

@@ -1,5 +1,5 @@
import QrScanner from 'qr-scanner';
import { createSignal, onCleanup, onMount } from 'solid-js';
import QrScanner from "qr-scanner";
import { createSignal, onCleanup, onMount } from "solid-js";
export default function Scanner(props: { onResult: (result: string) => void }) {
let container: HTMLVideoElement | null;
@@ -9,19 +9,15 @@ export default function Scanner(props: { onResult: (result: string) => void }) {
const handleResult = (result: { data: string }) => {
props.onResult(result.data);
}
};
onMount(() => {
if (container) {
const newScanner = new QrScanner(
container,
handleResult,
{
returnDetailedScanResult: true,
}
);
const newScanner = new QrScanner(container, handleResult, {
returnDetailedScanResult: true
});
newScanner.start();
setScanner(newScanner)
setScanner(newScanner);
}
});
@@ -34,7 +30,10 @@ export default function Scanner(props: { onResult: (result: string) => void }) {
return (
<>
<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>
</>
);

View File

@@ -1,22 +1,22 @@
import type { Component } from 'solid-js'
import { Show } from 'solid-js'
import type { Component } from "solid-js";
import { Show } from "solid-js";
// 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 {
offlineReady: [offlineReady, _setOfflineReady],
needRefresh: [needRefresh, _setNeedRefresh],
updateServiceWorker: _update,
updateServiceWorker: _update
} = useRegisterSW({
immediate: true,
onRegisteredSW(swUrl, r) {
console.log('SW Registered: ' + r?.scope)
console.log("SW Registered: " + r?.scope);
},
onRegisterError(error: Error) {
console.log('SW registration error', error)
},
})
console.log("SW registration error", error);
}
});
// const close = () => {
// setOfflineReady(false)
@@ -40,7 +40,7 @@ const ReloadPrompt: Component = () => {
<Button onClick={() => close()}>Close</Button>
</Card> */}
</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";
export function Restart() {
const [state, _] = useMegaStore()
const [state, _] = useMegaStore();
async function handleStop() {
await state.mutiny_wallet?.stop()
await state.mutiny_wallet?.stop();
}
return (
<Card>
<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>
</VStack>
</Card>
)
);
}

View File

@@ -1,36 +1,42 @@
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 }) {
const [shouldShow, setShouldShow] = createSignal(false)
export function SeedWords(props: {
words: string;
setHasSeen?: (hasSeen: boolean) => void;
}) {
const [shouldShow, setShouldShow] = createSignal(false);
function toggleShow() {
setShouldShow(!shouldShow())
setShouldShow(!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}>
<Switch>
<Match when={!shouldShow()}>
<div class="cursor-pointer">
<code class="text-red">TAP TO REVEAL SEED WORDS</code>
</div>
</Match>
return (
<button
class="flex items-center gap-4 bg-m-red p-4 rounded-xl overflow-hidden"
onClick={toggleShow}
>
<Switch>
<Match when={!shouldShow()}>
<div class="cursor-pointer">
<code class="text-red">TAP TO REVEAL SEED WORDS</code>
</div>
</Match>
<Match when={shouldShow()}>
<ol class="cursor-pointer overflow-hidden grid grid-cols-2 w-full list-decimal list-inside">
<For each={splitWords()}>
{(word) => (
<li class="font-mono text-left">
{word}
</li>
)}
</For>
</ol>
</Match>
</Switch>
</button >)
}
<Match when={shouldShow()}>
<ol class="cursor-pointer overflow-hidden grid grid-cols-2 w-full list-decimal list-inside">
<For each={splitWords()}>
{(word) => (
<li class="font-mono text-left">{word}</li>
)}
</For>
</ol>
</Match>
</Switch>
</button>
);
}

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
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 { TinyButton } from "./layout";
import { MutinyTagItem, sortByLastUsed } from "~/utils/tags";
@@ -10,36 +10,43 @@ const createLabelValue = (label: string): Partial<MutinyTagItem> => {
};
export function TagEditor(props: {
selectedValues: Partial<MutinyTagItem>[],
setSelectedValues: (value: Partial<MutinyTagItem>[]) => void,
placeholder: string
selectedValues: Partial<MutinyTagItem>[];
setSelectedValues: (value: Partial<MutinyTagItem>[]) => void;
placeholder: string;
}) {
const [_state, actions] = useMegaStore();
const [availableTags, setAvailableTags] = createSignal<MutinyTagItem[]>([]);
onMount(async () => {
const tags = await actions.listTags()
const tags = await actions.listTags();
if (tags) {
setAvailableTags(tags.filter((tag) => tag.kind === "Contact").sort(sortByLastUsed))
setAvailableTags(
tags
.filter((tag) => tag.kind === "Contact")
.sort(sortByLastUsed)
);
}
})
});
const selectProps = createMemo(() => {
return createOptions(availableTags() || [], {
key: "name",
filterable: true, // Default
createable: createLabelValue,
createable: createLabelValue
});
})
});
const onChange = (selected: MutinyTagItem[]) => {
props.setSelectedValues(selected);
console.log(selected)
console.log(selected);
const lastValue = selected[selected.length - 1];
if (lastValue && availableTags() && !availableTags()!.includes(lastValue)) {
if (
lastValue &&
availableTags() &&
!availableTags()!.includes(lastValue)
) {
setAvailableTags([...availableTags(), lastValue]);
}
};
@@ -50,7 +57,7 @@ export function TagEditor(props: {
};
return (
<div class="flex flex-col gap-2 flex-shrink flex-1" >
<div class="flex flex-col gap-2 flex-shrink flex-1">
<Select
multiple
initialValue={props.selectedValues}
@@ -70,6 +77,6 @@ export function TagEditor(props: {
</For>
</Show>
</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.Region>
</Portal>
)
);
}
type ToastArg = { title: string, description: string } | Error
type ToastArg = { title: string; description: string } | Error;
export function showToast(arg: ToastArg) {
if (arg instanceof Error) {
return toaster.show(props => (
<ToastItem title="Error" description={arg.message} isError {...props} />
))
return toaster.show((props) => (
<ToastItem
title="Error"
description={arg.message}
isError
{...props}
/>
));
} else {
return toaster.show(props => (
<ToastItem title={arg.title} description={arg.description} {...props} />
))
return toaster.show((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 (
<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-1">
<Toast.Title>
<SmallHeader>
{props.title}
</SmallHeader>
<SmallHeader>{props.title}</SmallHeader>
</Toast.Title>
<Toast.Description>
<p>
{props.description}
</p>
<p>{props.description}</p>
</Toast.Description>
</div>
<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>
</div>
</Toast.Root>
)
}
);
}

View File

@@ -1,18 +1,18 @@
import { Back } from "~/assets/svg/Back";
export function BackButton(props: {
onClick: () => void;
title?: string;
showOnDesktop?: boolean;
onClick: () => void;
title?: string;
showOnDesktop?: boolean;
}) {
return (
<button
onClick={() => props.onClick()}
class="text-m-red active:text-m-red/80 text-xl font-semibold no-underline md:hidden flex items-center"
classList={{ "md:!flex": props.showOnDesktop }}
>
<Back />
{props.title ? props.title : "Home"}
</button>
);
}
return (
<button
onClick={() => props.onClick()}
class="text-m-red active:text-m-red/80 text-xl font-semibold no-underline md:hidden flex items-center"
classList={{ "md:!flex": props.showOnDesktop }}
>
<Back />
{props.title ? props.title : "Home"}
</button>
);
}

View File

@@ -1,6 +1,14 @@
import { A } from "solid-start";
import { Back } from "~/assets/svg/Back";
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>)
}
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>
);
}

View File

@@ -5,78 +5,95 @@ import { A } from "solid-start";
import { LoadingSpinner } from ".";
const button = cva(
"p-3 rounded-xl font-semibold disabled:opacity-20 disabled:grayscale transition",
{
variants: {
// TODO: button hover has to work different than buttonlinks (like disabled state)
intent: {
active: "bg-white text-black border border-white hover:text-[#3B6CCC]",
inactive: "bg-black text-white border border-white hover:text-[#3B6CCC]",
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",
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"
},
layout: {
flex: "flex-1 text-xl",
pad: "px-8 text-xl",
small: "px-4 py-2 w-auto text-lg",
xs: "px-4 py-2 w-auto rounded-lg text-base"
}
},
defaultVariants: {
intent: "inactive",
layout: "flex"
"p-3 rounded-xl font-semibold disabled:opacity-20 disabled:grayscale transition",
{
variants: {
// TODO: button hover has to work different than buttonlinks (like disabled state)
intent: {
active: "bg-white text-black border border-white hover:text-[#3B6CCC]",
inactive:
"bg-black text-white border border-white hover:text-[#3B6CCC]",
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",
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"
},
layout: {
flex: "flex-1 text-xl",
pad: "px-8 text-xl",
small: "px-4 py-2 w-auto text-lg",
xs: "px-4 py-2 w-auto rounded-lg text-base"
}
},
defaultVariants: {
intent: "inactive",
layout: "flex"
}
}
}
);
// Help from https://github.com/arpadgabor/credee/blob/main/packages/www/src/components/ui/button.tsx
type StyleProps = VariantProps<typeof button>
interface ButtonProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement>, StyleProps {
loading?: boolean,
disabled?: boolean,
type StyleProps = VariantProps<typeof button>;
interface ButtonProps
extends JSX.ButtonHTMLAttributes<HTMLButtonElement>,
StyleProps {
loading?: boolean;
disabled?: boolean;
}
export const Button: ParentComponent<ButtonProps> = props => {
const slot = children(() => props.children)
const [local, attrs] = splitProps(props, ['children', 'intent', 'layout', 'class'])
export const Button: ParentComponent<ButtonProps> = (props) => {
const slot = children(() => props.children);
const [local, attrs] = splitProps(props, [
"children",
"intent",
"layout",
"class"
]);
return (
<button
{...attrs}
disabled={props.disabled || props.loading}
class={button({
class: local.class || "",
intent: local.intent,
layout: local.layout
})}
>
<Show when={props.loading} fallback={slot()}>
<div class="flex justify-center">
{/* TODO: constrain this to the exact height of the button */}
<LoadingSpinner wide />
</div>
</Show>
</button>
<button
{...attrs}
disabled={props.disabled || props.loading}
class={button({
class: local.class || "",
intent: local.intent,
layout: local.layout
})}
>
<Show when={props.loading} fallback={slot()}>
<div class="flex justify-center">
{/* TODO: constrain this to the exact height of the button */}
<LoadingSpinner wide />
</div>
</Show>
</button>
);
};
interface ButtonLinkProps
extends JSX.ButtonHTMLAttributes<HTMLAnchorElement>,
StyleProps {
href: string;
target?: string;
rel?: string;
}
interface ButtonLinkProps extends JSX.ButtonHTMLAttributes<HTMLAnchorElement>, StyleProps {
href: string
target?: string
rel?: string
}
export const ButtonLink: ParentComponent<ButtonLinkProps> = props => {
const slot = children(() => props.children)
const [local, attrs] = splitProps(props, ['children', 'intent', 'layout', 'class', 'href', 'target', 'rel'])
export const ButtonLink: ParentComponent<ButtonLinkProps> = (props) => {
const slot = children(() => props.children);
const [local, attrs] = splitProps(props, [
"children",
"intent",
"layout",
"class",
"href",
"target",
"rel"
]);
return (
<Dynamic
component={local.href?.includes('://') ? 'a' : A}
component={local.href?.includes("://") ? "a" : A}
href={local.href}
target={local.target}
rel={local.rel}
@@ -84,10 +101,10 @@ export const ButtonLink: ParentComponent<ButtonLinkProps> = props => {
class={button({
class: `flex justify-center no-underline ${local.class || ""}`,
intent: local.intent,
layout: local.layout,
layout: local.layout
})}
>
{slot()}
</Dynamic>
)
}
);
};

View File

@@ -1,21 +1,21 @@
import { ParentComponent } from "solid-js";
export const ExternalLink: ParentComponent<{ href: string }> = (props) => {
return (
<a target="_blank" rel="noopener noreferrer" href={props.href}>
{props.children}{" "}
<svg
class="inline-block"
width="16"
height="16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.00002 3.33337v1.33334H10.39L2.66669 12.39l.94333.9434 7.72338-7.72336V10h1.3333V3.33337H6.00002Z"
fill="currentColor"
/>
</svg>
</a>
);
return (
<a target="_blank" rel="noopener noreferrer" href={props.href}>
{props.children}{" "}
<svg
class="inline-block"
width="16"
height="16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.00002 3.33337v1.33334H10.39L2.66669 12.39l.94333.9434 7.72338-7.72336V10h1.3333V3.33337H6.00002Z"
fill="currentColor"
/>
</svg>
</a>
);
};

View File

@@ -1,4 +1,4 @@
import { JSX } from 'solid-js';
import { JSX } from "solid-js";
interface LinkifyProps {
initialText: string;
@@ -15,7 +15,7 @@ export default function Linkify(props: LinkifyProps): JSX.Element {
while ((match = pattern.exec(text)) !== null) {
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);
lastIndex = pattern.lastIndex;
@@ -23,7 +23,16 @@ export default function Linkify(props: LinkifyProps): JSX.Element {
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);
@@ -32,4 +41,4 @@ export default function Linkify(props: LinkifyProps): JSX.Element {
}
return <>{links}</>;
}
}

View File

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

View File

@@ -1,56 +1,73 @@
import { RadioGroup } from "@kobalte/core";
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?
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 (
// TODO: rewrite this with CVA, props are bad for tailwind
<RadioGroup.Root
value={props.value}
onChange={props.onValueChange}
class={"grid w-full gap-4"}
classList={{
"grid-cols-2": props.choices.length === 2,
"grid-cols-3": props.choices.length === 3,
"gap-2": props.small
}}
>
<For each={props.choices}>
{(choice) => (
<RadioGroup.Item
value={choice.value}
class={`ui-checked:bg-neutral-950 bg-white/10 rounded outline outline-black/50 ui-checked:outline-m-blue ui-checked:outline-2`}
classList={{
"ui-checked:outline-m-red": props.accent === "red",
"ui-checked:outline-white": props.accent === "white",
"ui-disabled:opacity-50": choice.disabled
}}
disabled={choice.disabled}
>
<div class={props.small ? "py-2 px-2" : "py-3 px-4"}>
<RadioGroup.ItemInput />
<RadioGroup.ItemControl>
<RadioGroup.ItemIndicator />
</RadioGroup.ItemControl>
<RadioGroup.ItemLabel class="ui-checked:text-white text-neutral-400">
<div class="block">
<div
classList={{ "text-base": props.small, "text-lg": !props.small }}
class={`font-semibold max-sm:text-sm`}
// TODO: rewrite this with CVA, props are bad for tailwind
<RadioGroup.Root
value={props.value}
onChange={props.onValueChange}
class={"grid w-full gap-4"}
classList={{
"grid-cols-2": props.choices.length === 2,
"grid-cols-3": props.choices.length === 3,
"gap-2": props.small
}}
>
<For each={props.choices}>
{(choice) => (
<RadioGroup.Item
value={choice.value}
class={`ui-checked:bg-neutral-950 bg-white/10 rounded outline outline-black/50 ui-checked:outline-m-blue ui-checked:outline-2`}
classList={{
"ui-checked:outline-m-red": props.accent === "red",
"ui-checked:outline-white":
props.accent === "white",
"ui-disabled:opacity-50": choice.disabled
}}
disabled={choice.disabled}
>
{choice.label}
</div>
<Show when={!props.small}>
<div class="text-sm font-light">{choice.caption}</div>
</Show>
</div>
</RadioGroup.ItemLabel>
</div>
</RadioGroup.Item>
)}
</For>
</RadioGroup.Root>
<div class={props.small ? "py-2 px-2" : "py-3 px-4"}>
<RadioGroup.ItemInput />
<RadioGroup.ItemControl>
<RadioGroup.ItemIndicator />
</RadioGroup.ItemControl>
<RadioGroup.ItemLabel class="ui-checked:text-white text-neutral-400">
<div class="block">
<div
classList={{
"text-base": props.small,
"text-lg": !props.small
}}
class={`font-semibold max-sm:text-sm`}
>
{choice.label}
</div>
<Show when={!props.small}>
<div class="text-sm font-light">
{choice.caption}
</div>
</Show>
</div>
</RadioGroup.ItemLabel>
</div>
</RadioGroup.Item>
)}
</For>
</RadioGroup.Root>
);
}
}

View File

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

View File

@@ -1,59 +1,66 @@
import { JSX, ParentComponent, Show, Suspense, createResource } from "solid-js"
import Linkify from "./Linkify"
import { Button, ButtonLink } from "./Button"
import { Checkbox as KCheckbox, Separator } from "@kobalte/core"
import { useMegaStore } from "~/state/megaStore"
import check from "~/assets/icons/check.svg"
import { MutinyTagItem } from "~/utils/tags"
import { generateGradient } from "~/utils/gradientHash"
import close from "~/assets/icons/close.svg"
import { JSX, ParentComponent, Show, Suspense, createResource } from "solid-js";
import Linkify from "./Linkify";
import { Button, ButtonLink } from "./Button";
import { Checkbox as KCheckbox, Separator } from "@kobalte/core";
import { useMegaStore } from "~/state/megaStore";
import check from "~/assets/icons/check.svg";
import { MutinyTagItem } from "~/utils/tags";
import { generateGradient } from "~/utils/gradientHash";
import close from "~/assets/icons/close.svg";
export {
Button,
ButtonLink,
Linkify,
}
export { Button, ButtonLink, Linkify };
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 (
<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.titleElement && props.titleElement}
{props.children}
</div>
)
}
);
};
export const InnerCard: ParentComponent<{ title?: string }> = (props) => {
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.children}
</div>
)
}
);
};
export const FancyCard: ParentComponent<{
title?: string;
subtitle?: string;
tag?: JSX.Element;
title?: string;
subtitle?: string;
tag?: JSX.Element;
}> = (props) => {
return (
<div class="border border-black/50 rounded-xl border-b-4 p-4 flex flex-col gap-2 bg-neutral-800/50 shadow-fancy-card">
<div class="w-full flex justify-between items-center">
<div class="flex gap-2">
{props.title && <SmallHeader>{props.title}</SmallHeader>}
{props.subtitle && <SmallHeader class="text-neutral-500">{props.subtitle}</SmallHeader>}
return (
<div class="border border-black/50 rounded-xl border-b-4 p-4 flex flex-col gap-2 bg-neutral-800/50 shadow-fancy-card">
<div class="w-full flex justify-between items-center">
<div class="flex gap-2">
{props.title && <SmallHeader>{props.title}</SmallHeader>}
{props.subtitle && (
<SmallHeader class="text-neutral-500">
{props.subtitle}
</SmallHeader>
)}
</div>
{props.tag && props.tag}
</div>
{props.children}
</div>
{props.tag && props.tag}
</div>
{props.children}
</div>
);
);
};
export const SafeArea: ParentComponent = (props) => {
@@ -62,9 +69,9 @@ export const SafeArea: ParentComponent = (props) => {
{/* <div class="flex-1 disable-scrollbars overflow-y-scroll md:pl-[8rem] md:pr-[6rem]"> */}
{props.children}
{/* </div> */}
</div >
)
}
</div>
);
};
export const DefaultMain: ParentComponent = (props) => {
return (
@@ -73,124 +80,165 @@ export const DefaultMain: ParentComponent = (props) => {
{/* CSS is hard sometimes */}
<div class="py-4" />
</main>
)
}
);
};
export const FullscreenLoader = () => {
return (
<div class="w-full h-[100dvh] flex justify-center items-center">
<LoadingSpinner wide />
</div>
<div class="w-full h-[100dvh] flex justify-center items-center">
<LoadingSpinner wide />
</div>
);
}
};
export const MutinyWalletGuard: ParentComponent = (props) => {
const [state, _] = useMegaStore();
return (
<Suspense fallback={<FullscreenLoader />}>
<Show when={state.mutiny_wallet && !state.wallet_loading}>{props.children}</Show>
</Suspense>
<Suspense fallback={<FullscreenLoader />}>
<Show when={state.mutiny_wallet && !state.wallet_loading}>
{props.children}
</Show>
</Suspense>
);
}
};
export const LoadingSpinner = (props: { big?: boolean, wide?: boolean }) => {
export const LoadingSpinner = (props: { big?: boolean; wide?: boolean }) => {
return (
<div
role="status"
class="w-full"
classList={{ "flex justify-center": props.wide, "h-full grid": props.big }}
>
<svg
aria-hidden="true"
class="w-8 h-8 mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-m-red place-self-center"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
<div
role="status"
class="w-full"
classList={{
"flex justify-center": props.wide,
"h-full grid": props.big
}}
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
<span class="sr-only">Loading...</span>
</div>
<svg
aria-hidden="true"
class="w-8 h-8 mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-m-red place-self-center"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
<span class="sr-only">Loading...</span>
</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 (
<header class="w-full flex justify-between items-center mt-4 mb-2">
<h1 class="text-3xl font-semibold">{props.children}</h1>
<Show when={props.action}>
{props.action}
</Show>
<Show when={props.action}>{props.action}</Show>
</header>
)
}
);
};
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) => {
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) => {
return (<h2 class="font-light text-lg">{props.sign ? `${props.sign} ` : ""}{props.amount.toLocaleString()} <span class="text-sm">SATS</span></h2>)
}
export const SmallAmount: ParentComponent<{
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) => {
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
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 (
<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() }}
>
{props.children}
</button>
)
}
);
};
export const Indicator: ParentComponent = (props) => {
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 (
<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.Control class="flex-0 w-8 h-8 rounded-lg border-2 border-white bg-neutral-800 ui-checked:bg-m-red">
<KCheckbox.Indicator>
<img src={check} class="w-8 h-8" alt="check" />
</KCheckbox.Indicator>
</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>
)
);
}
export function ModalCloseButton() {
return (<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" />
</button>)
}
return (
<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" />
</button>
);
}

View File

@@ -1,5 +1,11 @@
import megacheck from "~/assets/icons/megacheck.png";
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";
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

@@ -4,40 +4,42 @@ import { Button, LargeHeader } from "~/components/layout";
import { DIALOG_CONTENT, DIALOG_POSITIONER } from "~/styles/dialogs";
type SuccessModalProps = {
title: string;
open: boolean;
setOpen: (open: boolean) => void;
children?: JSX.Element;
onConfirm?: () => void;
confirmText?: string;
title: string;
open: boolean;
setOpen: (open: boolean) => void;
children?: JSX.Element;
onConfirm?: () => void;
confirmText?: string;
};
export function SuccessModal(props: SuccessModalProps) {
const onNice = () => {
props.onConfirm ? props.onConfirm() : props.setOpen(false);
};
const onNice = () => {
props.onConfirm ? props.onConfirm() : props.setOpen(false);
};
// <div class="flex flex-col items-center gap-8 h-full max-w-[400px]">
return (
<Dialog.Root open={props.open} onOpenChange={props.setOpen}>
<Dialog.Portal>
<div class={DIALOG_POSITIONER}>
<Dialog.Content class={DIALOG_CONTENT}>
<div class="flex justify-between items-center mb-2">
<Dialog.Title>
<LargeHeader>{props.title}</LargeHeader>
</Dialog.Title>
<div />
</div>
<Dialog.Description class="flex flex-col items-center justify-center gap-8 pb-4 h-full w-full max-w-[400px] mx-auto">
{props.children}
</Dialog.Description>
<div class="w-full flex max-w-[300px] mx-auto">
<Button onClick={onNice}>{props.confirmText ?? "Nice"}</Button>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog.Root>
);
// <div class="flex flex-col items-center gap-8 h-full max-w-[400px]">
return (
<Dialog.Root open={props.open} onOpenChange={props.setOpen}>
<Dialog.Portal>
<div class={DIALOG_POSITIONER}>
<Dialog.Content class={DIALOG_CONTENT}>
<div class="flex justify-between items-center mb-2">
<Dialog.Title>
<LargeHeader>{props.title}</LargeHeader>
</Dialog.Title>
<div />
</div>
<Dialog.Description class="flex flex-col items-center justify-center gap-8 pb-4 h-full w-full max-w-[400px] mx-auto">
{props.children}
</Dialog.Description>
<div class="w-full flex max-w-[300px] mx-auto">
<Button onClick={onNice}>
{props.confirmText ?? "Nice"}
</Button>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@@ -1,11 +1,13 @@
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";
type NostrEvent = {
"content": string, "created_at": number, id?: string
}
content: string;
created_at: number;
id?: string;
};
const Note: Component<{ e: NostrEvent }> = (props) => {
const linkRoot = "https://snort.social/e/";
@@ -14,46 +16,62 @@ const Note: Component<{ e: NostrEvent }> = (props) => {
createEffect(() => {
if (props.e.id) {
setNoteId(nip19.noteEncode(props.e.id))
setNoteId(nip19.noteEncode(props.e.id));
}
})
});
return (
<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">
<p class="break-words">
{/* {props.e.content} */}
<Linkify initialText={props.e.content} />
</p>
<a 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
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>
</div>
</div>
)
}
);
};
function filterReplies(event: Event) {
// If there's a "p" tag or an "e" tag we want to return false, otherwise true
for (const tag of event.tags) {
if (tag[0] === "p" || tag[0] === "e") {
return false
return false;
}
}
return true
return true;
}
const Notes: Component<{ notes: Event[] }> = (props) => {
return (<ul class="flex flex-col">
<For 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>
</ul>)
return (
<ul class="flex flex-col">
<For
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>
</ul>
);
};
}
export default Notes
export default Notes;

View File

@@ -7,17 +7,16 @@ const relayUrls = [
"wss://nostr.fmt.wiz.biz",
"wss://relay.damus.io",
"wss://eden.nostr.land"
]
];
import { SimplePool } from 'nostr-tools'
import { SimplePool } from "nostr-tools";
import { LoadingSpinner } from "~/components/layout";
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 filter = {
authors: [
"df173277182f3155d37b330211ba1de4a81500c02d195e964f91be774ec96708"
@@ -26,34 +25,36 @@ const postsFetcher = async () => {
kinds: [1]
};
const events = await pool.list(relayUrls, [filter])
const events = await pool.list(relayUrls, [filter]);
return events;
}
};
export function WaitlistAlreadyIn() {
const [posts] = createResource("", postsFetcher);
return (
<main class="flex flex-col gap-4 sm:gap-4 py-8 px-4 max-w-xl mx-auto items-start drop-shadow-blue-glow">
<a href="https://mutinywallet.com">
<img src={logo} class="h-10" alt="logo" />
</a>
<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>
<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>
<Show
when={!posts.loading}
fallback={
<div class="h-[10rem]">
<LoadingSpinner big wide />
</div>
}
>
<Notes notes={(posts() && posts()) || []} />
</Show>
</div>
</main>
<main class="flex flex-col gap-4 sm:gap-4 py-8 px-4 max-w-xl mx-auto items-start drop-shadow-blue-glow">
<a href="https://mutinywallet.com">
<img src={logo} class="h-10" alt="logo" />
</a>
<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>
<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>
<Show
when={!posts.loading}
fallback={
<div class="h-[10rem]">
<LoadingSpinner big wide />
</div>
}
>
<Notes notes={(posts() && posts()) || []} />
</Show>
</div>
</main>
);
}

View File

@@ -2,109 +2,190 @@ import { Match, Switch, createSignal } from "solid-js";
import { Button } from "~/components/layout";
import { StyledRadioGroup } from "../layout/Radio";
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 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 = {
user_type: "nostr" | "email",
id: string
comment?: string
}
user_type: "nostr" | "email";
id: string;
comment?: string;
};
const initialValues: WaitlistForm = { user_type: "nostr", id: "", comment: "" };
export default function WaitlistForm() {
const [waitlistForm, { Form, Field }] = createForm<WaitlistForm>({ initialValues });
const [waitlistForm, { Form, Field }] = createForm<WaitlistForm>({
initialValues
});
const [loading, setLoading] = createSignal(false);
const newHandleSubmit: SubmitHandler<WaitlistForm> = async (f: WaitlistForm) => {
const newHandleSubmit: SubmitHandler<WaitlistForm> = async (
f: WaitlistForm
) => {
console.log(f);
// TODO: not sure why waitlistForm.submitting doesn't work for me
// https://modularforms.dev/solid/guides/handle-submission
setLoading(true)
setLoading(true);
try {
const res = await fetch(WAITLIST_ENDPOINT, {
method: 'POST',
method: "POST",
headers: {
'Content-Type': 'application/json'
"Content-Type": "application/json"
},
body: JSON.stringify(f)
})
});
if (res.status !== 200) {
throw new Error("nope");
} else {
// 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();
}
} catch (e) {
if (f.user_type === "nostr") {
const error = new Error("Something went wrong. Are you sure that's a valid npub?")
showToast(eify(error))
const error = new Error(
"Something went wrong. Are you sure that's a valid npub?"
);
showToast(eify(error));
} else {
const error = new Error("Something went wrong. Not sure what.")
showToast(eify(error))
const error = new Error("Something went wrong. Not sure what.");
showToast(eify(error));
}
return
return;
} finally {
setLoading(false)
setLoading(false);
}
}
};
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">
<img src={logo} class="h-10" alt="logo" />
</a>
<h1 class='text-4xl font-bold'>Join Waitlist</h1>
<h1 class="text-4xl font-bold">Join Waitlist</h1>
<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>
<Form onSubmit={newHandleSubmit} class="flex flex-col gap-8">
<Field name="user_type">
{(field, _props) => (
// 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>
<Switch>
<Match when={getValue(waitlistForm, 'user_type', { shouldActive: false }) === 'nostr'}>
<Field name="id"
validate={[required("We need some way to contact you")]}
<Match
when={
getValue(waitlistForm, "user_type", {
shouldActive: false
}) === "nostr"
}
>
<Field
name="id"
validate={[
required("We need some way to contact you")
]}
>
{(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>
</Match>
<Match when={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")]}
<Match
when={
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) => (
<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>
</Match>
</Switch>
<Field name="comment">
{(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>
<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>
</main>
)
}
);
}

View File

@@ -1,9 +1,9 @@
import {
createHandler,
renderAsync,
StartServer,
createHandler,
renderAsync,
StartServer
} from "solid-start/entry-server";
export default createHandler(
renderAsync((event) => <StartServer event={event} />)
renderAsync((event) => <StartServer event={event} />)
);

View File

@@ -1,6 +1,5 @@
import initMutinyWallet, { MutinyWallet } from '@mutinywallet/mutiny-wasm';
import initWaila from '@mutinywallet/waila-wasm'
import initMutinyWallet, { MutinyWallet } from "@mutinywallet/mutiny-wasm";
import initWaila from "@mutinywallet/waila-wasm";
// export type MutinyWalletSettingStrings = {
// 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 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 {
const network = localStorage.getItem('MUTINY_SETTINGS_network') || import.meta.env.VITE_NETWORK;
const proxy = 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;
const network =
localStorage.getItem("MUTINY_SETTINGS_network") ||
import.meta.env.VITE_NETWORK;
const proxy =
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 || {};
const existingSettings = getExistingSettings();
@@ -33,66 +46,84 @@ export async function setAndGetMutinySettings(settings?: MutinyWalletSettingStri
lsp = lsp || existingSettings.lsp;
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_proxy', proxy);
localStorage.setItem('MUTINY_SETTINGS_esplora', esplora);
localStorage.setItem("MUTINY_SETTINGS_network", network);
localStorage.setItem("MUTINY_SETTINGS_proxy", proxy);
localStorage.setItem("MUTINY_SETTINGS_esplora", esplora);
if (!rgs || !lsp) {
console.warn("RGS or LSP not set")
console.warn("RGS or LSP not set");
}
rgs && localStorage.setItem('MUTINY_SETTINGS_rgs', rgs);
lsp && localStorage.setItem('MUTINY_SETTINGS_lsp', lsp);
rgs && localStorage.setItem("MUTINY_SETTINGS_rgs", rgs);
lsp && localStorage.setItem("MUTINY_SETTINGS_lsp", lsp);
return { network, proxy, esplora, rgs, lsp }
return { network, proxy, esplora, rgs, lsp };
} catch (error) {
console.error(error)
throw error
console.error(error);
throw error;
}
}
export async function checkForWasm() {
try {
if (typeof WebAssembly === "object"
&& typeof WebAssembly.instantiate === "function") {
const module = new WebAssembly.Module(Uint8Array.of(0x0, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00));
if (
typeof WebAssembly === "object" &&
typeof WebAssembly.instantiate === "function"
) {
const module = new WebAssembly.Module(
Uint8Array.of(0x0, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00)
);
if (!(module instanceof WebAssembly.Module)) {
throw new Error("Couldn't instantiate WASM Module")
throw new Error("Couldn't instantiate WASM Module");
}
} else {
throw new Error("No WebAssembly global object found")
throw new Error("No WebAssembly global object found");
}
} 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();
// Might as well init waila while we're at it
await initWaila();
console.time("Setup");
console.log("Starting setup...")
const { network, proxy, esplora, rgs, lsp } = await setAndGetMutinySettings(settings)
console.log("Initializing Mutiny Manager")
console.log("Starting setup...");
const { network, proxy, esplora, rgs, lsp } = await setAndGetMutinySettings(
settings
);
console.log("Initializing Mutiny Manager");
console.log("Using network", network);
console.log("Using proxy", proxy);
console.log("Using esplora address", esplora);
console.log("Using rgs address", rgs);
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();
// If we don't have any nodes yet, create one
if (!nodes.length) {
await mutinyWallet?.new_node()
await mutinyWallet?.new_node();
}
return mutinyWallet
}
return mutinyWallet;
}

View File

@@ -3,51 +3,51 @@
@tailwind utilities;
body {
@apply text-white bg-neutral-900;
overscroll-behavior-y: none;
min-height: 100.3%;
@apply text-white bg-neutral-900;
overscroll-behavior-y: none;
min-height: 100.3%;
}
.bg-gradient {
@apply bg-fixed bg-no-repeat bg-gradient-to-b from-black to-[#0b215b];
@apply bg-fixed bg-no-repeat bg-gradient-to-b from-black to-[#0b215b];
}
.bg-gray {
@apply bg-fixed bg-no-repeat bg-gradient-to-b from-[hsl(224,5%,5%)] to-[hsl(224,5%,20%)];
@apply bg-fixed bg-no-repeat bg-gradient-to-b from-[hsl(224,5%,5%)] to-[hsl(224,5%,20%)];
}
.react-modal-sheet-container {
@apply !bg-[#262626];
@apply !bg-[#262626];
}
a {
@apply underline decoration-light-text hover:decoration-white;
@apply underline decoration-light-text hover:decoration-white;
}
#video-container {
position: relative;
width: max-content;
height: max-content;
overflow: hidden;
position: relative;
width: max-content;
height: max-content;
overflow: hidden;
}
#video-container .scan-region-highlight {
border-radius: 30px;
outline: rgba(0, 0, 0, 0.25) solid 50vmax;
border-radius: 30px;
outline: rgba(0, 0, 0, 0.25) solid 50vmax;
}
#video-container .scan-region-highlight-svg {
display: none;
display: none;
}
select {
@apply appearance-none;
@apply block;
@apply border-[2px] focus:outline-none focus:ring-2 focus:ring-offset-2 ring-offset-black;
@apply font-light text-lg;
@apply py-4 pl-4 pr-8;
background-image: url("data:image/svg+xml,%3Csvg aria-hidden='true' class='w-4 h-4 ml-1' fill='white' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' d='M5.293 7.293a1 1 0 0 1 1.414 0L10 10.586l3.293-3.293a1 1 0 1 1 1.414 1.414l-4 4a1 1 0 0 1-1.414 0l-4-4a1 1 0 0 1 0-1.414z' clip-rule='evenodd'/%3E%3C/svg%3E");
background-position: right 0.75rem center;
background-size: 20px 20px;
background-repeat: no-repeat;
}
@apply appearance-none;
@apply block;
@apply border-[2px] focus:outline-none focus:ring-2 focus:ring-offset-2 ring-offset-black;
@apply font-light text-lg;
@apply py-4 pl-4 pr-8;
background-image: url("data:image/svg+xml,%3Csvg aria-hidden='true' class='w-4 h-4 ml-1' fill='white' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' d='M5.293 7.293a1 1 0 0 1 1.414 0L10 10.586l3.293-3.293a1 1 0 1 1 1.414 1.414l-4 4a1 1 0 0 1-1.414 0l-4-4a1 1 0 0 1 0-1.414z' clip-rule='evenodd'/%3E%3C/svg%3E");
background-position: right 0.75rem center;
background-size: 20px 20px;
background-repeat: no-repeat;
}

View File

@@ -1,16 +1,16 @@
// @refresh reload
import { Suspense } from "solid-js";
import {
Body,
ErrorBoundary,
FileRoutes,
Head,
Html,
Link,
Meta,
Routes,
Scripts,
Title,
Body,
ErrorBoundary,
FileRoutes,
Head,
Html,
Link,
Meta,
Routes,
Scripts,
Title
} from "solid-start";
import "./root.css";
import { Provider as MegaStoreProvider } from "~/state/megaStore";
@@ -18,35 +18,46 @@ import { Toaster } from "~/components/Toaster";
import ErrorDisplay from "./components/ErrorDisplay";
export default function Root() {
return (
<Html lang="en">
<Head>
<Title>Mutiny Wallet</Title>
<Meta charset="utf-8" />
<Meta
name="viewport"
content="width=device-width, initial-scale=1.0 height=device-height viewport-fit=cover user-scalable=no"
/>
<Link rel="manifest" href="/manifest.webmanifest" />
<Meta name="theme-color" content="rgb(23,23,23)" />
<Meta name="description" content="Lightning wallet for the web" />
<Link rel="icon" href="/favicon.ico" />
<Link rel="apple-touch-icon" href="/images/icon.png" sizes="512x512" />
<Link rel="mask-icon" href="/mutiny_logo_mask.svg" color="#000" />
</Head>
<Body>
<Suspense>
<ErrorBoundary fallback={(e) => <ErrorDisplay error={e} />}>
<MegaStoreProvider>
<Routes>
<FileRoutes />
</Routes>
<Toaster />
</MegaStoreProvider>
</ErrorBoundary>
</Suspense>
<Scripts />
</Body>
</Html>
);
return (
<Html lang="en">
<Head>
<Title>Mutiny Wallet</Title>
<Meta charset="utf-8" />
<Meta
name="viewport"
content="width=device-width, initial-scale=1.0 height=device-height viewport-fit=cover user-scalable=no"
/>
<Link rel="manifest" href="/manifest.webmanifest" />
<Meta name="theme-color" content="rgb(23,23,23)" />
<Meta
name="description"
content="Lightning wallet for the web"
/>
<Link rel="icon" href="/favicon.ico" />
<Link
rel="apple-touch-icon"
href="/images/icon.png"
sizes="512x512"
/>
<Link
rel="mask-icon"
href="/mutiny_logo_mask.svg"
color="#000"
/>
</Head>
<Body>
<Suspense>
<ErrorBoundary fallback={(e) => <ErrorDisplay error={e} />}>
<MegaStoreProvider>
<Routes>
<FileRoutes />
</Routes>
<Toaster />
</MegaStoreProvider>
</ErrorBoundary>
</Suspense>
<Scripts />
</Body>
</Html>
);
}

View File

@@ -1,14 +1,14 @@
import { For, Show, createResource } from "solid-js";
import NavBar from "~/components/NavBar";
import {
Button,
Card,
DefaultMain,
LargeHeader,
NiceP,
MutinyWalletGuard,
SafeArea,
VStack
Button,
Card,
DefaultMain,
LargeHeader,
NiceP,
MutinyWalletGuard,
SafeArea,
VStack
} from "~/components/layout";
import { BackLink } from "~/components/layout/BackLink";
import { CombinedActivity } from "~/components/Activity";
@@ -24,114 +24,120 @@ import { showToast } from "~/components/Toaster";
import { LoadingShimmer } from "~/components/BalanceBox";
function ContactRow() {
const [state, _actions] = useMegaStore();
const [contacts, { refetch }] = createResource(async () => {
const contacts = state.mutiny_wallet?.get_contacts();
console.log(contacts);
const [state, _actions] = useMegaStore();
const [contacts, { refetch }] = createResource(async () => {
const contacts = state.mutiny_wallet?.get_contacts();
console.log(contacts);
// FIXME: this is just types shenanigans I believe
const c: Contact[] = [];
if (contacts) {
for (const contact in contacts) {
c.push(contacts[contact]);
}
// FIXME: this is just types shenanigans I believe
const c: Contact[] = [];
if (contacts) {
for (const contact in contacts) {
c.push(contacts[contact]);
}
}
return c || [];
});
const [gradients] = createResource(contacts, gradientsPerContact);
async function createContact(contact: ContactFormValues) {
// FIXME: npub not valid? other undefineds
const c = new Contact(contact.name, undefined, undefined, undefined);
await state.mutiny_wallet?.create_new_contact(c);
refetch();
}
return c || [];
});
const [gradients] = createResource(contacts, gradientsPerContact);
async function createContact(contact: ContactFormValues) {
// FIXME: npub not valid? other undefineds
const c = new Contact(contact.name, undefined, undefined, undefined);
await state.mutiny_wallet?.create_new_contact(c);
refetch();
}
//
async function saveContact(_contact: ContactFormValues) {
showToast(new Error("Unimplemented"));
// await editContact(contact)
refetch();
}
//
async function saveContact(_contact: ContactFormValues) {
showToast(new Error("Unimplemented"));
// await editContact(contact)
refetch();
}
return (
<div class="flex gap-4">
<ContactEditor list createContact={createContact} />
<Show when={contacts()}>
<div class="flex gap-4 flex-1 overflow-x-scroll disable-scrollbars">
<For each={contacts()}>
{(contact) => (
<ContactViewer
contact={contact}
gradient={gradients()?.get(contact.name)}
saveContact={saveContact}
/>
)}
</For>
return (
<div class="flex gap-4">
<ContactEditor list createContact={createContact} />
<Show when={contacts()}>
<div class="flex gap-4 flex-1 overflow-x-scroll disable-scrollbars">
<For each={contacts()}>
{(contact) => (
<ContactViewer
contact={contact}
gradient={gradients()?.get(contact.name)}
saveContact={saveContact}
/>
)}
</For>
</div>
</Show>
</div>
</Show>
</div>
);
);
}
const TAB =
"flex-1 inline-block px-8 py-4 text-lg font-semibold rounded-lg ui-selected:bg-white/10 bg-neutral-950 hover:bg-white/10";
"flex-1 inline-block px-8 py-4 text-lg font-semibold rounded-lg ui-selected:bg-white/10 bg-neutral-950 hover:bg-white/10";
export default function Activity() {
const [state, _actions] = useMegaStore();
return (
<MutinyWalletGuard>
<SafeArea>
<DefaultMain>
<BackLink />
<LargeHeader
action={
<A
class="md:hidden p-2 hover:bg-white/5 rounded-lg active:bg-m-blue"
href="/settings"
>
<img src={settings} alt="Settings" />
</A>
}
>
Activity
</LargeHeader>
<ContactRow />
<Tabs.Root defaultValue="mutiny">
<Tabs.List class="relative flex justify-around mt-4 mb-8 gap-1 bg-neutral-950 p-1 rounded-xl">
<Tabs.Trigger value="mutiny" class={TAB}>
Mutiny
</Tabs.Trigger>
<Tabs.Trigger value="nostr" class={TAB}>
Nostr
</Tabs.Trigger>
{/* <Tabs.Indicator class="absolute bg-m-blue transition-all bottom-[-1px] h-[2px]" /> */}
</Tabs.List>
<Tabs.Content value="mutiny">
{/* <MutinyActivity /> */}
<Card title="Activity">
<div class="p-1" />
<VStack>
<Show when={!state.wallet_loading} fallback={<LoadingShimmer />}>
<CombinedActivity />
</Show>
</VStack>
</Card>
</Tabs.Content>
<Tabs.Content value="nostr">
<VStack>
<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>
<Button disabled intent="blue">
Coming soon
</Button>
</div>
</VStack>
</Tabs.Content>
</Tabs.Root>
</DefaultMain>
<NavBar activeTab="activity" />
</SafeArea>
</MutinyWalletGuard>
);
const [state, _actions] = useMegaStore();
return (
<MutinyWalletGuard>
<SafeArea>
<DefaultMain>
<BackLink />
<LargeHeader
action={
<A
class="md:hidden p-2 hover:bg-white/5 rounded-lg active:bg-m-blue"
href="/settings"
>
<img src={settings} alt="Settings" />
</A>
}
>
Activity
</LargeHeader>
<ContactRow />
<Tabs.Root defaultValue="mutiny">
<Tabs.List class="relative flex justify-around mt-4 mb-8 gap-1 bg-neutral-950 p-1 rounded-xl">
<Tabs.Trigger value="mutiny" class={TAB}>
Mutiny
</Tabs.Trigger>
<Tabs.Trigger value="nostr" class={TAB}>
Nostr
</Tabs.Trigger>
{/* <Tabs.Indicator class="absolute bg-m-blue transition-all bottom-[-1px] h-[2px]" /> */}
</Tabs.List>
<Tabs.Content value="mutiny">
{/* <MutinyActivity /> */}
<Card title="Activity">
<div class="p-1" />
<VStack>
<Show
when={!state.wallet_loading}
fallback={<LoadingShimmer />}
>
<CombinedActivity />
</Show>
</VStack>
</Card>
</Tabs.Content>
<Tabs.Content value="nostr">
<VStack>
<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>
<Button disabled intent="blue">
Coming soon
</Button>
</div>
</VStack>
</Tabs.Content>
</Tabs.Root>
</DefaultMain>
<NavBar activeTab="activity" />
</SafeArea>
</MutinyWalletGuard>
);
}

View File

@@ -1,7 +1,15 @@
import { DeleteEverything } from "~/components/DeleteEverything";
import KitchenSink from "~/components/KitchenSink";
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";
export default function Admin() {
@@ -12,9 +20,14 @@ export default function Admin() {
<BackLink href="/settings" title="Settings" />
<LargeHeader>Admin</LargeHeader>
<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 />
<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>
<DeleteEverything />
</div>
@@ -23,5 +36,5 @@ export default function Admin() {
<NavBar activeTab="none" />
</SafeArea>
</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 { useNavigate } from 'solid-start';
import { SeedWords } from '~/components/SeedWords';
import { useMegaStore } from '~/state/megaStore';
import { Show, createEffect, createSignal } from 'solid-js';
import { useNavigate } from "solid-start";
import { SeedWords } from "~/components/SeedWords";
import { useMegaStore } from "~/state/megaStore";
import { Show, createEffect, createSignal } from "solid-js";
import { BackLink } from "~/components/layout/BackLink";
function Quiz(props: { setHasCheckedAll: (hasChecked: boolean) => void }) {
@@ -13,19 +22,31 @@ function Quiz(props: { setHasCheckedAll: (hasChecked: boolean) => void }) {
createEffect(() => {
if (one() && two() && three()) {
props.setHasCheckedAll(true)
props.setHasCheckedAll(true);
} else {
props.setHasCheckedAll(false)
props.setHasCheckedAll(false);
}
})
});
return (
<VStack>
<Checkbox checked={one()} 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" />
<Checkbox
checked={one()}
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>
)
);
}
export default function App() {
@@ -36,8 +57,8 @@ export default function App() {
const [hasCheckedAll, setHasCheckedAll] = createSignal(false);
function wroteDownTheWords() {
actions.setHasBackedUp()
navigate("/")
actions.setHasBackedUp();
navigate("/");
}
return (
@@ -49,16 +70,32 @@ export default function App() {
<VStack>
<NiceP>Let's get these funds secured.</NiceP>
<NiceP>We'll show you 12 words. You write down the 12 words.</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>Mutiny is self-custodial. It's all up to you...</NiceP>
<SeedWords words={store.mutiny_wallet?.show_seed() || ""} setHasSeen={setHasSeenBackup} />
<NiceP>
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()}>
<Quiz setHasCheckedAll={setHasCheckedAll} />
</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>
</DefaultMain>
<NavBar activeTab="none" />

View File

@@ -1,22 +1,26 @@
import { Contact, MutinyBip21RawMaterials, MutinyInvoice } from "@mutinywallet/mutiny-wasm";
import {
createEffect,
createMemo,
createResource,
createSignal,
Match,
onCleanup,
Show,
Switch
Contact,
MutinyBip21RawMaterials,
MutinyInvoice
} from "@mutinywallet/mutiny-wasm";
import {
createEffect,
createMemo,
createResource,
createSignal,
Match,
onCleanup,
Show,
Switch
} from "solid-js";
import {
Button,
Card,
DefaultMain,
Indicator,
LargeHeader,
MutinyWalletGuard,
SafeArea
Button,
Card,
DefaultMain,
Indicator,
LargeHeader,
MutinyWalletGuard,
SafeArea
} from "~/components/layout";
import NavBar from "~/components/NavBar";
import { useMegaStore } from "~/state/megaStore";
@@ -40,33 +44,33 @@ import { CopyableQR } from "~/components/CopyableQR";
import { InfoBox } from "~/components/InfoBox";
type OnChainTx = {
transaction: {
version: number;
lock_time: number;
input: Array<{
previous_output: string;
script_sig: string;
sequence: number;
witness: Array<string>;
}>;
output: Array<{
value: number;
script_pubkey: string;
}>;
};
txid: string;
received: number;
sent: number;
confirmation_time: {
height: number;
timestamp: number;
};
transaction: {
version: number;
lock_time: number;
input: Array<{
previous_output: string;
script_sig: string;
sequence: number;
witness: Array<string>;
}>;
output: Array<{
value: number;
script_pubkey: string;
}>;
};
txid: string;
received: number;
sent: number;
confirmation_time: {
height: number;
timestamp: number;
};
};
const RECEIVE_FLAVORS = [
{ value: "unified", label: "Unified", caption: "Sender decides" },
{ value: "lightning", label: "Lightning", caption: "Fast and cool" },
{ value: "onchain", label: "On-chain", caption: "Just like Satoshi did it" }
{ value: "unified", label: "Unified", caption: "Sender decides" },
{ value: "lightning", label: "Lightning", caption: "Fast and cool" },
{ value: "onchain", label: "On-chain", caption: "Just like Satoshi did it" }
];
type ReceiveFlavor = "unified" | "lightning" | "onchain";
@@ -74,297 +78,364 @@ type ReceiveState = "edit" | "show" | "paid";
type PaidState = "lightning_paid" | "onchain_paid";
function FeeWarning(props: { fee: bigint; flavor: ReceiveFlavor }) {
return (
// TODO: probably won't always be fixed 2500?
<Show when={props.fee > 1000n}>
<Switch>
<Match when={props.flavor === "unified"}>
<InfoBox accent="green">
A lightning setup fee of <AmountSmall amountSats={props.fee} /> will be charged if paid
over lightning.
</InfoBox>
</Match>
<Match when={props.flavor === "lightning"}>
<InfoBox accent="green">
A lightning setup fee of <AmountSmall amountSats={props.fee} /> will be charged for this
receive.
</InfoBox>
</Match>
</Switch>
</Show>
);
return (
// TODO: probably won't always be fixed 2500?
<Show when={props.fee > 1000n}>
<Switch>
<Match when={props.flavor === "unified"}>
<InfoBox accent="green">
A lightning setup fee of{" "}
<AmountSmall amountSats={props.fee} /> will be charged
if paid over lightning.
</InfoBox>
</Match>
<Match when={props.flavor === "lightning"}>
<InfoBox accent="green">
A lightning setup fee of{" "}
<AmountSmall amountSats={props.fee} /> will be charged
for this receive.
</InfoBox>
</Match>
</Switch>
</Show>
);
}
function FeeExplanation(props: { fee: bigint }) {
return (
// TODO: probably won't always be a fixed 2500?
<Switch>
<Match when={props.fee > 1000n}>
<InfoBox accent="green">
A lightning setup fee of <AmountSmall amountSats={props.fee} /> was charged for this
receive.
</InfoBox>
</Match>
<Match when={props.fee > 0n}>
<InfoBox accent="green">
A lightning service fee of <AmountSmall amountSats={props.fee} /> was charged for this
receive.
</InfoBox>
</Match>
</Switch>
);
return (
// TODO: probably won't always be a fixed 2500?
<Switch>
<Match when={props.fee > 1000n}>
<InfoBox accent="green">
A lightning setup fee of{" "}
<AmountSmall amountSats={props.fee} /> was charged for this
receive.
</InfoBox>
</Match>
<Match when={props.fee > 0n}>
<InfoBox accent="green">
A lightning service fee of{" "}
<AmountSmall amountSats={props.fee} /> was charged for this
receive.
</InfoBox>
</Match>
</Switch>
);
}
export default function Receive() {
const [state, _actions] = useMegaStore();
const navigate = useNavigate();
const [state, _actions] = useMegaStore();
const navigate = useNavigate();
const [amount, setAmount] = createSignal("");
const [receiveState, setReceiveState] = createSignal<ReceiveState>("edit");
const [bip21Raw, setBip21Raw] = createSignal<MutinyBip21RawMaterials>();
const [unified, setUnified] = createSignal("");
const [shouldShowAmountEditor, setShouldShowAmountEditor] = createSignal(true);
const [amount, setAmount] = createSignal("");
const [receiveState, setReceiveState] = createSignal<ReceiveState>("edit");
const [bip21Raw, setBip21Raw] = createSignal<MutinyBip21RawMaterials>();
const [unified, setUnified] = createSignal("");
const [shouldShowAmountEditor, setShouldShowAmountEditor] =
createSignal(true);
const [lspFee, setLspFee] = createSignal(0n);
const [lspFee, setLspFee] = createSignal(0n);
// Tagging stuff
const [selectedValues, setSelectedValues] = createSignal<MutinyTagItem[]>([]);
// Tagging stuff
const [selectedValues, setSelectedValues] = createSignal<MutinyTagItem[]>(
[]
);
// The data we get after a payment
const [paymentTx, setPaymentTx] = createSignal<OnChainTx>();
const [paymentInvoice, setPaymentInvoice] = createSignal<MutinyInvoice>();
// The data we get after a payment
const [paymentTx, setPaymentTx] = createSignal<OnChainTx>();
const [paymentInvoice, setPaymentInvoice] = createSignal<MutinyInvoice>();
// The flavor of the receive
const [flavor, setFlavor] = createSignal<ReceiveFlavor>("unified");
// The flavor of the receive
const [flavor, setFlavor] = createSignal<ReceiveFlavor>("unified");
const receiveString = createMemo(() => {
if (unified() && receiveState() === "show") {
if (flavor() === "unified") {
return unified();
} else if (flavor() === "lightning") {
return bip21Raw()?.invoice ?? "";
} else if (flavor() === "onchain") {
return bip21Raw()?.address ?? "";
}
}
});
function clearAll() {
setAmount("");
setReceiveState("edit");
setBip21Raw(undefined);
setUnified("");
setPaymentTx(undefined);
setPaymentInvoice(undefined);
setSelectedValues([]);
}
async function processContacts(contacts: Partial<MutinyTagItem>[]): Promise<string[]> {
console.log("Processing contacts", contacts);
if (contacts.length) {
const first = contacts![0];
if (!first.name) {
console.error("Something went wrong with contact creation, proceeding anyway");
return [];
}
if (!first.id && first.name) {
console.error("Creating new contact", first.name);
const c = new Contact(first.name, undefined, undefined, undefined);
const newContactId = await state.mutiny_wallet?.create_new_contact(c);
if (newContactId) {
return [newContactId];
const receiveString = createMemo(() => {
if (unified() && receiveState() === "show") {
if (flavor() === "unified") {
return unified();
} else if (flavor() === "lightning") {
return bip21Raw()?.invoice ?? "";
} else if (flavor() === "onchain") {
return bip21Raw()?.address ?? "";
}
}
}
if (first.id) {
console.error("Using existing contact", first.name, first.id);
return [first.id];
}
}
console.error("Something went wrong with contact creation, proceeding anyway");
return [];
}
async function getUnifiedQr(amount: string) {
const bigAmount = BigInt(amount);
try {
const tags = await processContacts(selectedValues());
const raw = await state.mutiny_wallet?.create_bip21(bigAmount, tags);
// Save the raw info so we can watch the address and invoice
setBip21Raw(raw);
const params = objectToSearchParams({
amount: raw?.btc_amount,
lightning: raw?.invoice
});
return `bitcoin:${raw?.address}?${params}`;
} catch (e) {
showToast(new Error("Couldn't create invoice. Are you asking for enough?"));
console.error(e);
}
}
async function onSubmit(e: Event) {
e.preventDefault();
const unifiedQr = await getUnifiedQr(amount());
setUnified(unifiedQr || "");
setReceiveState("show");
setShouldShowAmountEditor(false);
}
async function checkIfPaid(bip21?: MutinyBip21RawMaterials): Promise<PaidState | undefined> {
if (bip21) {
console.debug("checking if paid...");
const lightning = bip21.invoice;
const address = bip21.address;
const invoice = await state.mutiny_wallet?.get_invoice(lightning);
// If the invoice has a fees amount that's probably the LSP fee
if (invoice?.fees_paid) {
setLspFee(invoice.fees_paid);
}
if (invoice && invoice.paid) {
setReceiveState("paid");
setPaymentInvoice(invoice);
return "lightning_paid";
}
const tx = (await state.mutiny_wallet?.check_address(address)) as OnChainTx | undefined;
if (tx) {
setReceiveState("paid");
setPaymentTx(tx);
return "onchain_paid";
}
}
}
const [paidState, { refetch }] = createResource(bip21Raw, checkIfPaid);
const network = state.mutiny_wallet?.get_network() as Network;
createEffect(() => {
const interval = setInterval(() => {
if (receiveState() === "show") refetch();
}, 1000); // Poll every second
onCleanup(() => {
clearInterval(interval);
});
});
return (
<MutinyWalletGuard>
<SafeArea>
<DefaultMain>
<Show when={receiveState() === "show"} fallback={<BackLink />}>
<BackButton onClick={() => setReceiveState("edit")} title="Edit" showOnDesktop />
</Show>
<LargeHeader action={receiveState() === "show" && <Indicator>Checking</Indicator>}>
Receive Bitcoin
</LargeHeader>
<Switch>
<Match when={!unified() || receiveState() === "edit"}>
<div class="flex flex-col flex-1 gap-8">
<AmountCard
initialOpen={shouldShowAmountEditor()}
amountSats={amount() || "0"}
setAmountSats={setAmount}
isAmountEditable
/>
function clearAll() {
setAmount("");
setReceiveState("edit");
setBip21Raw(undefined);
setUnified("");
setPaymentTx(undefined);
setPaymentInvoice(undefined);
setSelectedValues([]);
}
<Card title="Private tags">
<TagEditor
selectedValues={selectedValues()}
setSelectedValues={setSelectedValues}
placeholder="Add the sender for your records"
/>
</Card>
async function processContacts(
contacts: Partial<MutinyTagItem>[]
): Promise<string[]> {
console.log("Processing contacts", contacts);
<div class="flex-1" />
<Button
class="w-full flex-grow-0"
disabled={!amount()}
intent="green"
onClick={onSubmit}
>
Continue
</Button>
</div>
</Match>
<Match when={unified() && receiveState() === "show"}>
<FeeWarning fee={lspFee()} flavor={flavor()} />
<CopyableQR value={receiveString() ?? ""} />
<p class="text-neutral-400 text-center">
<Switch>
<Match when={flavor() === "lightning"}>
Show or share this invoice with the sender.
</Match>
<Match when={flavor() === "onchain"}>
Show or share this address with the sender.
</Match>
<Match when={flavor() === "unified"}>
Show or share this code with the sender. Sender decides method of payment.
</Match>
</Switch>
</p>
<StyledRadioGroup
small
value={flavor()}
onValueChange={setFlavor}
choices={RECEIVE_FLAVORS}
accent="white"
/>{" "}
<ShareCard text={receiveString() ?? ""} />
</Match>
<Match when={receiveState() === "paid" && paidState() === "lightning_paid"}>
<SuccessModal
title="Payment Received"
open={!!paidState()}
setOpen={(open: boolean) => {
if (!open) clearAll();
}}
onConfirm={() => {
clearAll();
navigate("/");
}}
>
<MegaCheck />
<FeeExplanation fee={lspFee()} />
<Amount amountSats={paymentInvoice()?.amount_sats} showFiat centered />
</SuccessModal>
</Match>
<Match when={receiveState() === "paid" && paidState() === "onchain_paid"}>
<SuccessModal
title="Payment Received"
open={!!paidState()}
setOpen={(open: boolean) => {
if (!open) clearAll();
}}
onConfirm={() => {
clearAll();
navigate("/");
}}
>
<MegaCheck />
<Amount amountSats={paymentTx()?.received} showFiat centered />
<ExternalLink href={mempoolTxUrl(paymentTx()?.txid, network)}>
View Transaction
</ExternalLink>
</SuccessModal>
</Match>
</Switch>
</DefaultMain>
<NavBar activeTab="receive" />
</SafeArea>
</MutinyWalletGuard>
);
}
if (contacts.length) {
const first = contacts![0];
if (!first.name) {
console.error(
"Something went wrong with contact creation, proceeding anyway"
);
return [];
}
if (!first.id && first.name) {
console.error("Creating new contact", first.name);
const c = new Contact(
first.name,
undefined,
undefined,
undefined
);
const newContactId =
await state.mutiny_wallet?.create_new_contact(c);
if (newContactId) {
return [newContactId];
}
}
if (first.id) {
console.error("Using existing contact", first.name, first.id);
return [first.id];
}
}
console.error(
"Something went wrong with contact creation, proceeding anyway"
);
return [];
}
async function getUnifiedQr(amount: string) {
const bigAmount = BigInt(amount);
try {
const tags = await processContacts(selectedValues());
const raw = await state.mutiny_wallet?.create_bip21(
bigAmount,
tags
);
// Save the raw info so we can watch the address and invoice
setBip21Raw(raw);
const params = objectToSearchParams({
amount: raw?.btc_amount,
lightning: raw?.invoice
});
return `bitcoin:${raw?.address}?${params}`;
} catch (e) {
showToast(
new Error("Couldn't create invoice. Are you asking for enough?")
);
console.error(e);
}
}
async function onSubmit(e: Event) {
e.preventDefault();
const unifiedQr = await getUnifiedQr(amount());
setUnified(unifiedQr || "");
setReceiveState("show");
setShouldShowAmountEditor(false);
}
async function checkIfPaid(
bip21?: MutinyBip21RawMaterials
): Promise<PaidState | undefined> {
if (bip21) {
console.debug("checking if paid...");
const lightning = bip21.invoice;
const address = bip21.address;
const invoice = await state.mutiny_wallet?.get_invoice(lightning);
// If the invoice has a fees amount that's probably the LSP fee
if (invoice?.fees_paid) {
setLspFee(invoice.fees_paid);
}
if (invoice && invoice.paid) {
setReceiveState("paid");
setPaymentInvoice(invoice);
return "lightning_paid";
}
const tx = (await state.mutiny_wallet?.check_address(address)) as
| OnChainTx
| undefined;
if (tx) {
setReceiveState("paid");
setPaymentTx(tx);
return "onchain_paid";
}
}
}
const [paidState, { refetch }] = createResource(bip21Raw, checkIfPaid);
const network = state.mutiny_wallet?.get_network() as Network;
createEffect(() => {
const interval = setInterval(() => {
if (receiveState() === "show") refetch();
}, 1000); // Poll every second
onCleanup(() => {
clearInterval(interval);
});
});
return (
<MutinyWalletGuard>
<SafeArea>
<DefaultMain>
<Show
when={receiveState() === "show"}
fallback={<BackLink />}
>
<BackButton
onClick={() => setReceiveState("edit")}
title="Edit"
showOnDesktop
/>
</Show>
<LargeHeader
action={
receiveState() === "show" && (
<Indicator>Checking</Indicator>
)
}
>
Receive Bitcoin
</LargeHeader>
<Switch>
<Match when={!unified() || receiveState() === "edit"}>
<div class="flex flex-col flex-1 gap-8">
<AmountCard
initialOpen={shouldShowAmountEditor()}
amountSats={amount() || "0"}
setAmountSats={setAmount}
isAmountEditable
/>
<Card title="Private tags">
<TagEditor
selectedValues={selectedValues()}
setSelectedValues={setSelectedValues}
placeholder="Add the sender for your records"
/>
</Card>
<div class="flex-1" />
<Button
class="w-full flex-grow-0"
disabled={!amount()}
intent="green"
onClick={onSubmit}
>
Continue
</Button>
</div>
</Match>
<Match when={unified() && receiveState() === "show"}>
<FeeWarning fee={lspFee()} flavor={flavor()} />
<CopyableQR value={receiveString() ?? ""} />
<p class="text-neutral-400 text-center">
<Switch>
<Match when={flavor() === "lightning"}>
Show or share this invoice with the
sender.
</Match>
<Match when={flavor() === "onchain"}>
Show or share this address with the
sender.
</Match>
<Match when={flavor() === "unified"}>
Show or share this code with the sender.
Sender decides method of payment.
</Match>
</Switch>
</p>
<StyledRadioGroup
small
value={flavor()}
onValueChange={setFlavor}
choices={RECEIVE_FLAVORS}
accent="white"
/>{" "}
<ShareCard text={receiveString() ?? ""} />
</Match>
<Match
when={
receiveState() === "paid" &&
paidState() === "lightning_paid"
}
>
<SuccessModal
title="Payment Received"
open={!!paidState()}
setOpen={(open: boolean) => {
if (!open) clearAll();
}}
onConfirm={() => {
clearAll();
navigate("/");
}}
>
<MegaCheck />
<FeeExplanation fee={lspFee()} />
<Amount
amountSats={paymentInvoice()?.amount_sats}
showFiat
centered
/>
</SuccessModal>
</Match>
<Match
when={
receiveState() === "paid" &&
paidState() === "onchain_paid"
}
>
<SuccessModal
title="Payment Received"
open={!!paidState()}
setOpen={(open: boolean) => {
if (!open) clearAll();
}}
onConfirm={() => {
clearAll();
navigate("/");
}}
>
<MegaCheck />
<Amount
amountSats={paymentTx()?.received}
showFiat
centered
/>
<ExternalLink
href={mempoolTxUrl(
paymentTx()?.txid,
network
)}
>
View Transaction
</ExternalLink>
</SuccessModal>
</Match>
</Switch>
</DefaultMain>
<NavBar activeTab="receive" />
</SafeArea>
</MutinyWalletGuard>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -15,27 +15,40 @@ export type ParsedParams = {
memo?: string;
node_pubkey?: string;
lnurl?: string;
}
};
export function toParsedParams(str: string, ourNetwork: string): Result<ParsedParams> {
export function toParsedParams(
str: string,
ourNetwork: string
): Result<ParsedParams> {
let params;
try {
params = new PaymentParams(str || "")
params = new PaymentParams(str || "");
} catch (e) {
console.error(e)
return { ok: false, error: new Error("Invalid payment request") }
console.error(e);
return { ok: false, error: new Error("Invalid payment request") };
}
// 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
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) {
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 {
ok: true, value: {
ok: true,
value: {
address: params.address,
invoice: params.invoice,
amount_sats: params.amount_sats,
@@ -44,7 +57,7 @@ export function toParsedParams(str: string, ourNetwork: string): Result<ParsedPa
node_pubkey: params.node_pubkey,
lnurl: params.lnurl
}
}
};
}
export default function Scanner() {
@@ -62,14 +75,14 @@ export default function Scanner() {
}
function handlePaste() {
navigator.clipboard.readText().then(text => {
navigator.clipboard.readText().then((text) => {
setScanResult(text);
});
}
onMount(async () => {
await init()
})
await init();
});
// When we have a nice result we can head over to the send screen
createEffect(() => {
@@ -80,23 +93,30 @@ export default function Scanner() {
showToast(result.error);
return;
} 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);
navigate("/send")
navigate("/send");
}
}
}
})
});
return (
<div class="safe-top safe-left safe-right safe-bottom h-full">
<Reader onResult={onResult} />
<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">
<Button intent="blue" onClick={handlePaste}>Paste Something</Button>
<Button intent="blue" onClick={handlePaste}>
Paste Something
</Button>
<Button onClick={exit}>Cancel</Button>
</div>
</div>
</div>
);
}
}

File diff suppressed because it is too large Load Diff

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

View File

@@ -2,22 +2,27 @@ import { AmountCard } from "~/components/AmountCard";
import NavBar from "~/components/NavBar";
import { OnboardWarning } from "~/components/OnboardWarning";
import { ShareCard } from "~/components/ShareCard";
import { DefaultMain, LargeHeader, SafeArea, VStack } from "~/components/layout";
import {
DefaultMain,
LargeHeader,
SafeArea,
VStack
} from "~/components/layout";
const SAMPLE =
"bitcoin:tb1prqm8xtlgme0vmw5s30lgf0a4f5g4mkgsqundwmpu6thrg8zr6uvq2qrhzq?amount=0.001&lightning=lntbs1m1pj9n9xjsp5xgdrmvprtm67p7nq4neparalexlhlmtxx87zx6xeqthsplu842zspp546d6zd2seyaxpapaxx62m88yz3xueqtjmn9v6wj8y56np8weqsxqdqqnp4qdn2hj8tfknpuvdg6tz9yrf3e27ltrx9y58c24jh89lnm43yjwfc5xqrpwjcqpj9qrsgq5sdgh0m3ur5mu5hrmmag4mx9yvy86f83pd0x9ww80kgck6tac3thuzkj0mrtltaxwnlfea95h2re7tj4qsnwzxlvrdmyq2h9mgapnycpppz6k6";
"bitcoin:tb1prqm8xtlgme0vmw5s30lgf0a4f5g4mkgsqundwmpu6thrg8zr6uvq2qrhzq?amount=0.001&lightning=lntbs1m1pj9n9xjsp5xgdrmvprtm67p7nq4neparalexlhlmtxx87zx6xeqthsplu842zspp546d6zd2seyaxpapaxx62m88yz3xueqtjmn9v6wj8y56np8weqsxqdqqnp4qdn2hj8tfknpuvdg6tz9yrf3e27ltrx9y58c24jh89lnm43yjwfc5xqrpwjcqpj9qrsgq5sdgh0m3ur5mu5hrmmag4mx9yvy86f83pd0x9ww80kgck6tac3thuzkj0mrtltaxwnlfea95h2re7tj4qsnwzxlvrdmyq2h9mgapnycpppz6k6";
export default function Admin() {
return (
<SafeArea>
<DefaultMain>
<LargeHeader>Storybook</LargeHeader>
<OnboardWarning />
<VStack>
<AmountCard amountSats={"100000"} fee={"69"} />
<ShareCard text={SAMPLE} />
</VStack>
</DefaultMain>
<NavBar activeTab="none" />
</SafeArea>
)
}
return (
<SafeArea>
<DefaultMain>
<LargeHeader>Storybook</LargeHeader>
<OnboardWarning />
<VStack>
<AmountCard amountSats={"100000"} fee={"69"} />
<ShareCard text={SAMPLE} />
</VStack>
</DefaultMain>
<NavBar activeTab="none" />
</SafeArea>
);
}

View File

@@ -1,17 +1,24 @@
import { createForm, required } from "@modular-forms/solid";
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 NavBar from "~/components/NavBar";
import { showToast } from "~/components/Toaster";
import {
Button,
Card,
DefaultMain,
LargeHeader,
MutinyWalletGuard,
SafeArea,
VStack
Button,
Card,
DefaultMain,
LargeHeader,
MutinyWalletGuard,
SafeArea,
VStack
} from "~/components/layout";
import { BackLink } from "~/components/layout/BackLink";
import { TextField } from "~/components/layout/TextField";
@@ -28,274 +35,354 @@ import { ExternalLink } from "~/components/layout/ExternalLink";
import { Network } from "~/logic/mutinyWalletSetup";
const CHANNEL_FEE_ESTIMATE_ADDRESS =
"bc1qf7546vg73ddsjznzq57z3e8jdn6gtw6au576j07kt6d9j7nz8mzsyn6lgf";
"bc1qf7546vg73ddsjznzq57z3e8jdn6gtw6au576j07kt6d9j7nz8mzsyn6lgf";
type PeerConnectForm = {
peer: string;
peer: string;
};
type ChannelOpenDetails = {
channel?: MutinyChannel;
failure_reason?: Error;
channel?: MutinyChannel;
failure_reason?: Error;
};
export default function Swap() {
const [state, _actions] = useMegaStore();
const navigate = useNavigate();
const [state, _actions] = useMegaStore();
const navigate = useNavigate();
const [source, setSource] = createSignal<SendSource>("onchain");
const [amountSats, setAmountSats] = createSignal(0n);
const [isConnecting, setIsConnecting] = createSignal(false);
const [source, setSource] = createSignal<SendSource>("onchain");
const [amountSats, setAmountSats] = createSignal(0n);
const [isConnecting, setIsConnecting] = createSignal(false);
const [selectedPeer, setSelectedPeer] = createSignal<string>("");
const [selectedPeer, setSelectedPeer] = createSignal<string>("");
const [channelOpenResult, setChannelOpenResult] = createSignal<ChannelOpenDetails>();
const [channelOpenResult, setChannelOpenResult] =
createSignal<ChannelOpenDetails>();
const feeEstimate = () => {
if (amountSats()) {
try {
return state.mutiny_wallet?.estimate_tx_fee(
CHANNEL_FEE_ESTIMATE_ADDRESS,
amountSats(),
undefined
);
} catch (e) {
console.error(e);
// showToast(eify(new Error("Unsufficient funds")))
return undefined;
}
}
return undefined;
};
const hasLsp = () => {
return !!localStorage.getItem("MUTINY_SETTINGS_lsp") || !!import.meta.env.VITE_LSP;
};
const getPeers = async () => {
return (await state.mutiny_wallet?.list_peers()) as Promise<MutinyPeer[]>;
};
const [peers, { refetch }] = createResource(getPeers);
const [_peerForm, { Form, Field }] = createForm<PeerConnectForm>();
const onSubmit = async (values: PeerConnectForm) => {
setIsConnecting(true);
try {
const peerConnectString = values.peer.trim();
const nodes = await state.mutiny_wallet?.list_nodes();
const firstNode = (nodes[0] as string) || "";
await state.mutiny_wallet?.connect_to_peer(firstNode, peerConnectString);
await refetch();
// If peers list contains the peer we just connected to, select it
const peer = peers()?.find((p) => p.pubkey === peerConnectString.split("@")[0]);
if (peer) {
setSelectedPeer(peer.pubkey);
} else {
showToast(new Error("Peer not found"));
}
} catch (e) {
showToast(eify(e));
} finally {
setIsConnecting(false);
}
};
const handlePeerSelect = (
e: Event & {
currentTarget: HTMLSelectElement;
target: HTMLSelectElement;
}
) => {
setSelectedPeer(e.currentTarget.value);
};
const handleSwap = async () => {
if (canSwap()) {
try {
const nodes = await state.mutiny_wallet?.list_nodes();
const firstNode = (nodes[0] as string) || "";
if (hasLsp()) {
const new_channel = await state.mutiny_wallet?.open_channel(
firstNode,
undefined,
amountSats()
);
setChannelOpenResult({ channel: new_channel });
} else {
const new_channel = await state.mutiny_wallet?.open_channel(
firstNode,
selectedPeer(),
amountSats()
);
setChannelOpenResult({ channel: new_channel });
const feeEstimate = () => {
if (amountSats()) {
try {
return state.mutiny_wallet?.estimate_tx_fee(
CHANNEL_FEE_ESTIMATE_ADDRESS,
amountSats(),
undefined
);
} catch (e) {
console.error(e);
// showToast(eify(new Error("Unsufficient funds")))
return undefined;
}
}
} catch (e) {
setChannelOpenResult({ failure_reason: eify(e) });
// showToast(eify(e))
}
}
};
return undefined;
};
const hasLsp = () => {
return (
!!localStorage.getItem("MUTINY_SETTINGS_lsp") ||
!!import.meta.env.VITE_LSP
);
};
const getPeers = async () => {
return (await state.mutiny_wallet?.list_peers()) as Promise<
MutinyPeer[]
>;
};
const [peers, { refetch }] = createResource(getPeers);
const [_peerForm, { Form, Field }] = createForm<PeerConnectForm>();
const onSubmit = async (values: PeerConnectForm) => {
setIsConnecting(true);
try {
const peerConnectString = values.peer.trim();
const nodes = await state.mutiny_wallet?.list_nodes();
const firstNode = (nodes[0] as string) || "";
await state.mutiny_wallet?.connect_to_peer(
firstNode,
peerConnectString
);
await refetch();
// If peers list contains the peer we just connected to, select it
const peer = peers()?.find(
(p) => p.pubkey === peerConnectString.split("@")[0]
);
if (peer) {
setSelectedPeer(peer.pubkey);
} else {
showToast(new Error("Peer not found"));
}
} catch (e) {
showToast(eify(e));
} finally {
setIsConnecting(false);
}
};
const handlePeerSelect = (
e: Event & {
currentTarget: HTMLSelectElement;
target: HTMLSelectElement;
}
) => {
setSelectedPeer(e.currentTarget.value);
};
const handleSwap = async () => {
if (canSwap()) {
try {
const nodes = await state.mutiny_wallet?.list_nodes();
const firstNode = (nodes[0] as string) || "";
if (hasLsp()) {
const new_channel = await state.mutiny_wallet?.open_channel(
firstNode,
undefined,
amountSats()
);
setChannelOpenResult({ channel: new_channel });
} else {
const new_channel = await state.mutiny_wallet?.open_channel(
firstNode,
selectedPeer(),
amountSats()
);
setChannelOpenResult({ channel: new_channel });
}
} catch (e) {
setChannelOpenResult({ failure_reason: eify(e) });
// showToast(eify(e))
}
}
};
const canSwap = () => {
const balance =
(state.balance?.confirmed || 0n) +
(state.balance?.unconfirmed || 0n);
const network = state.mutiny_wallet?.get_network() as Network;
if (network === "bitcoin") {
return (
(!!selectedPeer() || !!hasLsp()) &&
amountSats() >= 50000n &&
amountSats() <= balance
);
} else {
return (
(!!selectedPeer() || !!hasLsp()) &&
amountSats() >= 10000n &&
amountSats() <= balance
);
}
};
const amountWarning = () => {
const network = state.mutiny_wallet?.get_network() as Network;
if (network === "bitcoin" && amountSats() < 50000n) {
return "It's just silly to make a channel smaller than 50,000 sats";
}
if (amountSats() < 10000n) {
return "It's just silly to make a channel smaller than 10,000 sats";
}
if (
amountSats() >
(state.balance?.confirmed || 0n) +
(state.balance?.unconfirmed || 0n) ||
!feeEstimate()
) {
return "You don't have enough funds to make this channel";
}
return undefined;
};
const canSwap = () => {
const balance = (state.balance?.confirmed || 0n) + (state.balance?.unconfirmed || 0n);
const network = state.mutiny_wallet?.get_network() as Network;
if (network === "bitcoin") {
return (!!selectedPeer() || !!hasLsp()) && amountSats() >= 50000n && amountSats() <= balance;
} else {
return (!!selectedPeer() || !!hasLsp()) && amountSats() >= 10000n && amountSats() <= balance;
}
};
return (
<MutinyWalletGuard>
<SafeArea>
<DefaultMain>
<BackLink />
<LargeHeader>Swap to Lightning</LargeHeader>
<SuccessModal
title={
channelOpenResult()?.channel
? "Swap Success"
: "Swap Failed"
}
confirmText={
channelOpenResult()?.channel ? "Nice" : "Too Bad"
}
open={!!channelOpenResult()}
setOpen={(open: boolean) => {
if (!open) setChannelOpenResult(undefined);
}}
onConfirm={() => {
setChannelOpenResult(undefined);
navigate("/");
}}
>
<Switch>
<Match when={channelOpenResult()?.failure_reason}>
<img
src={megaex}
alt="fail"
class="w-1/2 mx-auto max-w-[30vh] flex-shrink"
/>
const amountWarning = () => {
const network = state.mutiny_wallet?.get_network() as Network;
if (network === "bitcoin" && amountSats() < 50000n) {
return "It's just silly to make a channel smaller than 50,000 sats";
}
if (amountSats() < 10000n) {
return "It's just silly to make a channel smaller than 10,000 sats";
}
if (
amountSats() > (state.balance?.confirmed || 0n) + (state.balance?.unconfirmed || 0n) ||
!feeEstimate()
) {
return "You don't have enough funds to make this channel";
}
return undefined;
};
const network = state.mutiny_wallet?.get_network() as Network;
return (
<MutinyWalletGuard>
<SafeArea>
<DefaultMain>
<BackLink />
<LargeHeader>Swap to Lightning</LargeHeader>
<SuccessModal
title={channelOpenResult()?.channel ? "Swap Success" : "Swap Failed"}
confirmText={channelOpenResult()?.channel ? "Nice" : "Too Bad"}
open={!!channelOpenResult()}
setOpen={(open: boolean) => {
if (!open) setChannelOpenResult(undefined);
}}
onConfirm={() => {
setChannelOpenResult(undefined);
navigate("/");
}}
>
<Switch>
<Match when={channelOpenResult()?.failure_reason}>
<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">
{channelOpenResult()?.failure_reason?.message}
</p>
</Match>
<Match when={true}>
<img src={megacheck} alt="success" class="w-1/2 mx-auto max-w-[30vh] flex-shrink" />
<AmountCard
amountSats={channelOpenResult()?.channel?.balance?.toString() || ""}
reserve={channelOpenResult()?.channel?.reserve?.toString() || ""}
/>
<Show when={channelOpenResult()?.channel?.outpoint}>
<ExternalLink
href={mempoolTxUrl(
channelOpenResult()?.channel?.outpoint?.split(":")[0],
network
)}
>
View Transaction
</ExternalLink>
</Show>
{/* <pre>{JSON.stringify(channelOpenResult()?.channel?.value, null, 2)}</pre> */}
</Match>
</Switch>
</SuccessModal>
<VStack biggap>
<MethodChooser source={source()} setSource={setSource} both={false} />
<VStack>
<Show when={!hasLsp()}>
<Card>
<VStack>
<div class="w-full flex flex-col gap-2">
<label for="peerselect" class="uppercase font-semibold text-sm">
Use existing peer
</label>
<select
name="peerselect"
class="bg-black px-4 py-2 rounded truncate w-full"
onChange={handlePeerSelect}
value={selectedPeer()}
>
<option value="" class="" selected>
Choose a peer
</option>
<For each={peers()}>
{(peer) => (
<option value={peer.pubkey}>{peer.alias ?? peer.pubkey}</option>
)}
</For>
</select>
</div>
<Show when={!selectedPeer()}>
<Form onSubmit={onSubmit} class="flex flex-col gap-4">
<Field name="peer" validate={[required("")]}>
{(field, props) => (
<TextField
{...props}
value={field.value}
error={field.error}
label="Connect to new peer"
placeholder="Peer connect string"
/>
)}
</Field>
<Button layout="small" type="submit" disabled={isConnecting()}>
{isConnecting() ? "Connecting..." : "Connect"}
</Button>
</Form>
</Show>
</VStack>
</Card>
</Show>
</VStack>
<AmountCard
amountSats={amountSats().toString()}
setAmountSats={setAmountSats}
fee={feeEstimate()?.toString()}
isAmountEditable={true}
/>
<Show when={amountWarning() && amountSats() > 0n}>
<InfoBox accent={"red"}>{amountWarning()}</InfoBox>
</Show>
</VStack>
<div class="flex-1" />
<Button
class="w-full flex-grow-0"
disabled={!canSwap()}
intent="blue"
onClick={handleSwap}
loading={false}
>
{"Confirm Swap"}
</Button>
</DefaultMain>
<NavBar activeTab="none" />
</SafeArea>
</MutinyWalletGuard>
);
<p class="text-xl font-light py-2 px-4 rounded-xl bg-white/10">
{
channelOpenResult()?.failure_reason
?.message
}
</p>
</Match>
<Match when={true}>
<img
src={megacheck}
alt="success"
class="w-1/2 mx-auto max-w-[30vh] flex-shrink"
/>
<AmountCard
amountSats={
channelOpenResult()?.channel?.balance?.toString() ||
""
}
reserve={
channelOpenResult()?.channel?.reserve?.toString() ||
""
}
/>
<Show
when={
channelOpenResult()?.channel?.outpoint
}
>
<ExternalLink
href={mempoolTxUrl(
channelOpenResult()?.channel?.outpoint?.split(
":"
)[0],
network
)}
>
View Transaction
</ExternalLink>
</Show>
{/* <pre>{JSON.stringify(channelOpenResult()?.channel?.value, null, 2)}</pre> */}
</Match>
</Switch>
</SuccessModal>
<VStack biggap>
<MethodChooser
source={source()}
setSource={setSource}
both={false}
/>
<VStack>
<Show when={!hasLsp()}>
<Card>
<VStack>
<div class="w-full flex flex-col gap-2">
<label
for="peerselect"
class="uppercase font-semibold text-sm"
>
Use existing peer
</label>
<select
name="peerselect"
class="bg-black px-4 py-2 rounded truncate w-full"
onChange={handlePeerSelect}
value={selectedPeer()}
>
<option
value=""
class=""
selected
>
Choose a peer
</option>
<For each={peers()}>
{(peer) => (
<option
value={peer.pubkey}
>
{peer.alias ??
peer.pubkey}
</option>
)}
</For>
</select>
</div>
<Show when={!selectedPeer()}>
<Form
onSubmit={onSubmit}
class="flex flex-col gap-4"
>
<Field
name="peer"
validate={[required("")]}
>
{(field, props) => (
<TextField
{...props}
value={field.value}
error={field.error}
label="Connect to new peer"
placeholder="Peer connect string"
/>
)}
</Field>
<Button
layout="small"
type="submit"
disabled={isConnecting()}
>
{isConnecting()
? "Connecting..."
: "Connect"}
</Button>
</Form>
</Show>
</VStack>
</Card>
</Show>
</VStack>
<AmountCard
amountSats={amountSats().toString()}
setAmountSats={setAmountSats}
fee={feeEstimate()?.toString()}
isAmountEditable={true}
/>
<Show when={amountWarning() && amountSats() > 0n}>
<InfoBox accent={"red"}>{amountWarning()}</InfoBox>
</Show>
</VStack>
<div class="flex-1" />
<Button
class="w-full flex-grow-0"
disabled={!canSwap()}
intent="blue"
onClick={handleSwap}
loading={false}
>
{"Confirm Swap"}
</Button>
</DefaultMain>
<NavBar activeTab="none" />
</SafeArea>
</MutinyWalletGuard>
);
}

View File

@@ -1,20 +1,25 @@
import { Title } from "solid-start";
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() {
return (
<SafeArea>
<Title>Not Found</Title>
<HttpStatusCode code={404} />
<DefaultMain>
<LargeHeader>Not Found</LargeHeader>
<p>
This is probably Paul's fault.
</p>
<div class="h-full" />
<ButtonLink href="/" intent="red">Dangit</ButtonLink>
</DefaultMain>
</SafeArea>
);
return (
<SafeArea>
<Title>Not Found</Title>
<HttpStatusCode code={404} />
<DefaultMain>
<LargeHeader>Not Found</LargeHeader>
<p>This is probably Paul's fault.</p>
<div class="h-full" />
<ButtonLink href="/" intent="red">
Dangit
</ButtonLink>
</DefaultMain>
</SafeArea>
);
}

View File

@@ -1,4 +1,3 @@
import App from "~/components/App";
import { Switch, Match } from "solid-js";
import { WaitlistAlreadyIn } from "~/components/waitlist/WaitlistAlreadyIn";
@@ -7,22 +6,22 @@ import { useMegaStore } from "~/state/megaStore";
import { FullscreenLoader } from "~/components/layout";
export default function Home() {
const [state, _] = useMegaStore();
const [state, _] = useMegaStore();
return (
<>
<Switch fallback={<FullscreenLoader />} >
{/* TODO: can you put a suspense around a match? */}
<Match when={state.user_status === "approved"}>
<App />
</Match>
<Match when={state.user_status === "waitlisted"}>
<WaitlistAlreadyIn />
</Match>
<Match when={state.user_status === "new_here"}>
<WaitlistForm />
</Match>
</Switch>
</>
);
return (
<>
<Switch fallback={<FullscreenLoader />}>
{/* TODO: can you put a suspense around a match? */}
<Match when={state.user_status === "approved"}>
<App />
</Match>
<Match when={state.user_status === "waitlisted"}>
<WaitlistAlreadyIn />
</Match>
<Match when={state.user_status === "new_here"}>
<WaitlistForm />
</Match>
</Switch>
</>
);
}

View File

@@ -1,13 +1,23 @@
/* @refresh reload */
// 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 { createStore, reconcile } from "solid-js/store";
import { MutinyWalletSettingStrings, setupMutinyWallet } from "~/logic/mutinyWalletSetup";
import {
MutinyBalance,
MutinyWallet,
ActivityItem as MutinyActivity
ParentComponent,
createContext,
createEffect,
onCleanup,
onMount,
useContext
} from "solid-js";
import { createStore, reconcile } from "solid-js/store";
import {
MutinyWalletSettingStrings,
setupMutinyWallet
} from "~/logic/mutinyWalletSetup";
import {
MutinyBalance,
MutinyWallet,
ActivityItem as MutinyActivity
} from "@mutinywallet/mutiny-wasm";
import { ParsedParams } from "~/routes/Scanner";
import { MutinyTagItem } from "~/utils/tags";
@@ -17,211 +27,229 @@ const MegaStoreContext = createContext<MegaStore>();
type UserStatus = undefined | "new_here" | "waitlisted" | "approved" | "paid";
export type MegaStore = [
{
already_approved?: boolean;
waitlist_id?: string;
mutiny_wallet?: MutinyWallet;
deleting: boolean;
user_status: UserStatus;
scan_result?: ParsedParams;
balance?: MutinyBalance;
is_syncing?: boolean;
last_sync?: number;
price: number;
has_backed_up: boolean;
dismissed_restore_prompt: boolean;
wallet_loading: boolean;
nwc_enabled: boolean;
activity: MutinyActivity[];
},
{
fetchUserStatus(): Promise<UserStatus>;
setupMutinyWallet(settings?: MutinyWalletSettingStrings): Promise<void>;
deleteMutinyWallet(): Promise<void>;
setWaitlistId(waitlist_id: string): void;
setScanResult(scan_result: ParsedParams | undefined): void;
sync(): Promise<void>;
dismissRestorePrompt(): void;
setHasBackedUp(): void;
listTags(): Promise<MutinyTagItem[]>;
setNwc(enabled: boolean): void;
syncActivity(): Promise<void>;
}
{
already_approved?: boolean;
waitlist_id?: string;
mutiny_wallet?: MutinyWallet;
deleting: boolean;
user_status: UserStatus;
scan_result?: ParsedParams;
balance?: MutinyBalance;
is_syncing?: boolean;
last_sync?: number;
price: number;
has_backed_up: boolean;
dismissed_restore_prompt: boolean;
wallet_loading: boolean;
nwc_enabled: boolean;
activity: MutinyActivity[];
},
{
fetchUserStatus(): Promise<UserStatus>;
setupMutinyWallet(settings?: MutinyWalletSettingStrings): Promise<void>;
deleteMutinyWallet(): Promise<void>;
setWaitlistId(waitlist_id: string): void;
setScanResult(scan_result: ParsedParams | undefined): void;
sync(): Promise<void>;
dismissRestorePrompt(): void;
setHasBackedUp(): void;
listTags(): Promise<MutinyTagItem[]>;
setNwc(enabled: boolean): void;
syncActivity(): Promise<void>;
}
];
export const Provider: ParentComponent = (props) => {
const [state, setState] = createStore({
already_approved:
import.meta.env.VITE_SELFHOSTED === "true" ||
localStorage.getItem("already_approved") === "true",
waitlist_id: localStorage.getItem("waitlist_id"),
mutiny_wallet: undefined as MutinyWallet | undefined,
deleting: false,
user_status: undefined as UserStatus,
scan_result: undefined as ParsedParams | undefined,
price: 0,
has_backed_up: localStorage.getItem("has_backed_up") === "true",
balance: undefined as MutinyBalance | undefined,
last_sync: undefined as number | undefined,
is_syncing: false,
dismissed_restore_prompt: localStorage.getItem("dismissed_restore_prompt") === "true",
wallet_loading: true,
nwc_enabled: localStorage.getItem("nwc_enabled") === "true",
activity: [] as MutinyActivity[]
});
const actions = {
async fetchUserStatus(): Promise<UserStatus> {
if (state.already_approved) {
console.log("welcome back!");
return "approved";
}
if (!state.waitlist_id) {
return "new_here";
}
try {
const res = await fetch(
`https://waitlist.mutiny-waitlist.workers.dev/waitlist/${state.waitlist_id}`
);
const data = await res.json();
if (data.approval_date) {
// Remember them so we don't have to check every time
localStorage.setItem("already_approved", "true");
return "approved";
} else {
return "waitlisted";
}
} catch (e) {
return "new_here";
}
},
async setupMutinyWallet(settings?: MutinyWalletSettingStrings): Promise<void> {
try {
setState({ wallet_loading: true });
const mutinyWallet = await setupMutinyWallet(settings);
// Get balance optimistically
const balance = await mutinyWallet.get_balance();
// start nwc if enabled
if (state.nwc_enabled) {
const nodes = await mutinyWallet.list_nodes();
const firstNode = (nodes[0] as string) || "";
await mutinyWallet.start_nostr_wallet_connect(firstNode);
}
setState({ mutiny_wallet: mutinyWallet, wallet_loading: false, balance });
} catch (e) {
console.error(e);
}
},
async deleteMutinyWallet(): Promise<void> {
await state.mutiny_wallet?.stop();
setState((prevState) => ({
...prevState,
mutiny_wallet: undefined,
deleting: true
}));
MutinyWallet.import_json("{}");
localStorage.clear();
},
setWaitlistId(waitlist_id: string) {
setState({ waitlist_id });
},
async sync(): Promise<void> {
try {
if (state.mutiny_wallet && !state.is_syncing) {
setState({ is_syncing: true });
const newBalance = await state.mutiny_wallet?.get_balance();
const price = await state.mutiny_wallet?.get_bitcoin_price();
setState({
balance: newBalance,
last_sync: Date.now(),
price: price || 0
});
}
} catch (e) {
console.error(e);
} finally {
setState({ is_syncing: false });
}
},
async syncActivity(): Promise<void> {
try {
const activity = await state.mutiny_wallet?.get_activity();
setState("activity", reconcile(activity, { merge: true }));
} catch (e) {
console.error(e);
}
},
setScanResult(scan_result: ParsedParams) {
setState({ scan_result });
},
setHasBackedUp() {
localStorage.setItem("has_backed_up", "true");
setState({ has_backed_up: true });
},
dismissRestorePrompt() {
localStorage.setItem("dismissed_restore_prompt", "true");
setState({ dismissed_restore_prompt: true });
},
async listTags(): Promise<MutinyTagItem[]> {
return state.mutiny_wallet?.get_tag_items() as MutinyTagItem[];
},
setNwc(enabled: boolean) {
localStorage.setItem("nwc_enabled", enabled.toString());
setState({ nwc_enabled: enabled });
}
};
// Fetch status from remote on load
onMount(() => {
// eslint-disable-next-line
actions.fetchUserStatus().then((status) => {
setState({ user_status: status });
// Only load node manager when status is approved
if (state.user_status === "approved" && !state.mutiny_wallet && !state.deleting) {
console.log("running setup node manager...");
actions.setupMutinyWallet().then(() => console.log("node manager setup done"));
// Setup an event listener to stop the mutiny wallet when the page unloads
window.onunload = async (_e) => {
console.log("stopping mutiny_wallet");
await state.mutiny_wallet?.stop();
console.log("mutiny_wallet stopped");
};
}
const [state, setState] = createStore({
already_approved:
import.meta.env.VITE_SELFHOSTED === "true" ||
localStorage.getItem("already_approved") === "true",
waitlist_id: localStorage.getItem("waitlist_id"),
mutiny_wallet: undefined as MutinyWallet | undefined,
deleting: false,
user_status: undefined as UserStatus,
scan_result: undefined as ParsedParams | undefined,
price: 0,
has_backed_up: localStorage.getItem("has_backed_up") === "true",
balance: undefined as MutinyBalance | undefined,
last_sync: undefined as number | undefined,
is_syncing: false,
dismissed_restore_prompt:
localStorage.getItem("dismissed_restore_prompt") === "true",
wallet_loading: true,
nwc_enabled: localStorage.getItem("nwc_enabled") === "true",
activity: [] as MutinyActivity[]
});
});
// Be reactive to changes in waitlist_id
createEffect(() => {
state.waitlist_id
? localStorage.setItem("waitlist_id", state.waitlist_id)
: localStorage.removeItem("waitlist_id");
});
const actions = {
async fetchUserStatus(): Promise<UserStatus> {
if (state.already_approved) {
console.log("welcome back!");
return "approved";
}
createEffect(() => {
const interval = setInterval(async () => {
await actions.sync();
}, 3 * 1000); // Poll every 3 seconds
if (!state.waitlist_id) {
return "new_here";
}
onCleanup(() => {
clearInterval(interval);
try {
const res = await fetch(
`https://waitlist.mutiny-waitlist.workers.dev/waitlist/${state.waitlist_id}`
);
const data = await res.json();
if (data.approval_date) {
// Remember them so we don't have to check every time
localStorage.setItem("already_approved", "true");
return "approved";
} else {
return "waitlisted";
}
} catch (e) {
return "new_here";
}
},
async setupMutinyWallet(
settings?: MutinyWalletSettingStrings
): Promise<void> {
try {
setState({ wallet_loading: true });
const mutinyWallet = await setupMutinyWallet(settings);
// Get balance optimistically
const balance = await mutinyWallet.get_balance();
// start nwc if enabled
if (state.nwc_enabled) {
const nodes = await mutinyWallet.list_nodes();
const firstNode = (nodes[0] as string) || "";
await mutinyWallet.start_nostr_wallet_connect(firstNode);
}
setState({
mutiny_wallet: mutinyWallet,
wallet_loading: false,
balance
});
} catch (e) {
console.error(e);
}
},
async deleteMutinyWallet(): Promise<void> {
await state.mutiny_wallet?.stop();
setState((prevState) => ({
...prevState,
mutiny_wallet: undefined,
deleting: true
}));
MutinyWallet.import_json("{}");
localStorage.clear();
},
setWaitlistId(waitlist_id: string) {
setState({ waitlist_id });
},
async sync(): Promise<void> {
try {
if (state.mutiny_wallet && !state.is_syncing) {
setState({ is_syncing: true });
const newBalance = await state.mutiny_wallet?.get_balance();
const price =
await state.mutiny_wallet?.get_bitcoin_price();
setState({
balance: newBalance,
last_sync: Date.now(),
price: price || 0
});
}
} catch (e) {
console.error(e);
} finally {
setState({ is_syncing: false });
}
},
async syncActivity(): Promise<void> {
try {
const activity = await state.mutiny_wallet?.get_activity();
setState("activity", reconcile(activity, { merge: true }));
} catch (e) {
console.error(e);
}
},
setScanResult(scan_result: ParsedParams) {
setState({ scan_result });
},
setHasBackedUp() {
localStorage.setItem("has_backed_up", "true");
setState({ has_backed_up: true });
},
dismissRestorePrompt() {
localStorage.setItem("dismissed_restore_prompt", "true");
setState({ dismissed_restore_prompt: true });
},
async listTags(): Promise<MutinyTagItem[]> {
return state.mutiny_wallet?.get_tag_items() as MutinyTagItem[];
},
setNwc(enabled: boolean) {
localStorage.setItem("nwc_enabled", enabled.toString());
setState({ nwc_enabled: enabled });
}
};
// Fetch status from remote on load
onMount(() => {
// eslint-disable-next-line
actions.fetchUserStatus().then((status) => {
setState({ user_status: status });
// Only load node manager when status is approved
if (
state.user_status === "approved" &&
!state.mutiny_wallet &&
!state.deleting
) {
console.log("running setup node manager...");
actions
.setupMutinyWallet()
.then(() => console.log("node manager setup done"));
// Setup an event listener to stop the mutiny wallet when the page unloads
window.onunload = async (_e) => {
console.log("stopping mutiny_wallet");
await state.mutiny_wallet?.stop();
console.log("mutiny_wallet stopped");
};
}
});
});
});
const store = [state, actions] as MegaStore;
// Be reactive to changes in waitlist_id
createEffect(() => {
state.waitlist_id
? localStorage.setItem("waitlist_id", state.waitlist_id)
: localStorage.removeItem("waitlist_id");
});
return <MegaStoreContext.Provider value={store}>{props.children}</MegaStoreContext.Provider>;
createEffect(() => {
const interval = setInterval(async () => {
await actions.sync();
}, 3 * 1000); // Poll every 3 seconds
onCleanup(() => {
clearInterval(interval);
});
});
const store = [state, actions] as MegaStore;
return (
<MegaStoreContext.Provider value={store}>
{props.children}
</MegaStoreContext.Provider>
);
};
export function useMegaStore() {
// This is a trick to narrow the typescript types: https://docs.solidjs.com/references/api-reference/component-apis/createContext
const context = useContext(MegaStoreContext);
if (!context) {
throw new Error("useMegaStore: cannot find a MegaStoreContext")
throw new Error("useMegaStore: cannot find a MegaStoreContext");
}
return context;
}

View File

@@ -1,2 +1,3 @@
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_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";

View File

@@ -1,67 +1,67 @@
.solid-select-container[data-disabled="true"] {
@apply pointer-events-none;
@apply pointer-events-none;
}
.solid-select-container {
@apply relative;
@apply relative;
}
.solid-select-control[data-disabled="true"] {
}
.solid-select-control {
@apply w-full p-2 rounded-lg bg-white/10 placeholder-neutral-400;
@apply grid leading-6;
grid-template-columns: repeat(1, minmax(0, 1fr));
@apply w-full p-2 rounded-lg bg-white/10 placeholder-neutral-400;
@apply grid leading-6;
grid-template-columns: repeat(1, minmax(0, 1fr));
}
.solid-select-control[data-multiple="true"][data-has-value="true"] {
@apply flex items-stretch gap-1 flex-wrap;
@apply flex items-stretch gap-1 flex-wrap;
}
.solid-select-placeholder {
@apply text-neutral-400;
@apply col-start-1 row-start-1;
@apply text-neutral-400;
@apply col-start-1 row-start-1;
}
.solid-select-single-value {
@apply col-start-1 row-start-1;
@apply col-start-1 row-start-1;
}
.solid-select-multi-value {
@apply flex bg-white/20 rounded items-center px-1;
@apply flex bg-white/20 rounded items-center px-1;
}
.solid-select-multi-value-remove {
/* TODO: there's gotta be a better way to vertically center this */
@apply pl-2 pr-1 leading-3 -mt-2 text-2xl;
/* TODO: there's gotta be a better way to vertically center this */
@apply pl-2 pr-1 leading-3 -mt-2 text-2xl;
}
.solid-select-input {
@apply bg-transparent caret-transparent flex-grow flex-shrink;
outline: 2px solid transparent;
@apply col-start-1 row-start-1;
@apply bg-transparent caret-transparent flex-grow flex-shrink;
outline: 2px solid transparent;
@apply col-start-1 row-start-1;
}
.solid-select-input:read-only {
@apply cursor-default;
@apply cursor-default;
}
.solid-select-input[data-multiple="true"] {
@apply caret-current;
@apply caret-current;
}
.solid-select-input[data-is-active="true"] {
@apply caret-current;
@apply caret-current;
}
.solid-select-list {
@apply max-h-[50vh] min-w-full overflow-y-auto absolute whitespace-nowrap z-10 bg-neutral-950 p-2 rounded-lg;
@apply max-h-[50vh] min-w-full overflow-y-auto absolute whitespace-nowrap z-10 bg-neutral-950 p-2 rounded-lg;
}
.solid-select-option[data-focused="true"] {
}
.solid-select-option > mark {
@apply underline bg-white/10 text-white;
@apply underline bg-white/10 text-white;
}
.solid-select-option {
@apply cursor-default select-none p-1 hover:bg-neutral-800 rounded;
@apply cursor-default select-none p-1 hover:bg-neutral-800 rounded;
}
.solid-select-option[data-disabled="true"] {
@apply pointer-events-none opacity-50;
@apply pointer-events-none opacity-50;
}
.solid-select-list-placeholder {
@apply cursor-default select-none;
@apply cursor-default 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,44 +1,57 @@
import { MutinyWallet } from "@mutinywallet/mutiny-wasm";
export function satsToUsd(amount: number | undefined, price: number, formatted: boolean): string {
if (typeof amount !== "number" || isNaN(amount)) {
return "";
}
try {
const btc = MutinyWallet.convert_sats_to_btc(BigInt(Math.floor(amount)));
const usd = btc * price;
if (formatted) {
return usd.toLocaleString("en-US", { style: "currency", currency: "USD" });
} else {
// Some float fighting shenaningans
const roundedUsd = Math.round(usd);
if (roundedUsd * 100 === Math.round(usd * 100)) {
return usd.toFixed(0);
} else {
return usd.toFixed(2);
}
export function satsToUsd(
amount: number | undefined,
price: number,
formatted: boolean
): string {
if (typeof amount !== "number" || isNaN(amount)) {
return "";
}
try {
const btc = MutinyWallet.convert_sats_to_btc(
BigInt(Math.floor(amount))
);
const usd = btc * price;
if (formatted) {
return usd.toLocaleString("en-US", {
style: "currency",
currency: "USD"
});
} else {
// Some float fighting shenaningans
const roundedUsd = Math.round(usd);
if (roundedUsd * 100 === Math.round(usd * 100)) {
return usd.toFixed(0);
} else {
return usd.toFixed(2);
}
}
} catch (e) {
console.error(e);
return "";
}
} catch (e) {
console.error(e);
return "";
}
}
export function usdToSats(amount: number | undefined, price: number, formatted: boolean): string {
if (typeof amount !== "number" || isNaN(amount)) {
return "";
}
try {
const btc = price / amount;
const sats = MutinyWallet.convert_btc_to_sats(btc);
if (formatted) {
return parseInt(sats.toString()).toLocaleString();
} else {
return sats.toString();
export function usdToSats(
amount: number | undefined,
price: number,
formatted: boolean
): string {
if (typeof amount !== "number" || isNaN(amount)) {
return "";
}
try {
const btc = price / amount;
const sats = MutinyWallet.convert_btc_to_sats(btc);
if (formatted) {
return parseInt(sats.toString()).toLocaleString();
} else {
return sats.toString();
}
} catch (e) {
console.error(e);
return "";
}
} catch (e) {
console.error(e);
return "";
}
}

View File

@@ -1,10 +1,14 @@
// 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 a = document.createElement("a");
const file = new Blob([content], { type: contentType });
a.href = URL.createObjectURL(file);
a.download = fileName;
a.click();
}
}

View File

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

View File

@@ -1,19 +1,20 @@
// Simple storage for fake labels
// 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;
localStorage.setItem(outpoint, redshifted.toString())
localStorage.setItem(outpoint, redshifted.toString());
}
function getRedshifted(outpoint: string): boolean {
const redshifted = localStorage.getItem(outpoint)
const redshifted = localStorage.getItem(outpoint);
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) {
const encoder = new TextEncoder();
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 h1 = digestArray[0] % 360;
const h2 = (h1 + 180) % 360;
@@ -22,4 +22,4 @@ export async function gradientsPerContact(contacts: Contact[]) {
}
return gradients;
}
}

View File

@@ -1,23 +1,23 @@
import { Network } from "~/logic/mutinyWalletSetup"
import { Network } from "~/logic/mutinyWalletSetup";
export default function mempoolTxUrl(txid?: string, network?: Network) {
if (!txid || !network) {
console.error("Problem creating the mempool url")
return "#"
console.error("Problem creating the mempool url");
return "#";
}
if (network) {
switch (network) {
case "bitcoin":
return `https://mempool.space/tx/${txid}`
return `https://mempool.space/tx/${txid}`;
case "testnet":
return `https://mempool.space/testnet/tx/${txid}`
return `https://mempool.space/testnet/tx/${txid}`;
case "signet":
return `https://mutinynet.com/tx/${txid}`
return `https://mutinynet.com/tx/${txid}`;
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 {
return Object.entries(obj)
.filter(([_, value]) => value !== undefined)
// Value shouldn't be null we just filtered it out but typescript is dumb
.map(([key, value]) => value ? `${encodeURIComponent(key)}=${encodeURIComponent(value)}` : "")
.join("&");
}
export function objectToSearchParams<
T extends Record<string, string | undefined>
>(obj: T): string {
return (
Object.entries(obj)
.filter(([_, value]) => value !== undefined)
// Value shouldn't be null we just filtered it out but typescript is dumb
.map(([key, value]) =>
value
? `${encodeURIComponent(key)}=${encodeURIComponent(value)}`
: ""
)
.join("&")
);
}

View File

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

View File

@@ -1,29 +1,34 @@
import { TagItem } from "@mutinywallet/mutiny-wasm"
import { TagItem } from "@mutinywallet/mutiny-wasm";
export type MutinyTagItem = {
id: string,
kind: "Label" | "Contact"
name: string,
last_used_time: bigint,
npub?: string,
ln_address?: string,
lnurl?: string,
}
id: string;
kind: "Label" | "Contact";
name: string;
last_used_time: bigint;
npub?: string;
ln_address?: 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[] {
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 {
// @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) {
return Number(b.last_used_time - a.last_used_time);
}
}

View File

@@ -1,3 +1,3 @@
export type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
| { ok: false; error: E };

View File

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

View File

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

View File

@@ -1,18 +1,23 @@
{
"include": ["global.d.ts", "src/**/*"],
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "node",
"jsxImportSource": "solid-js",
"jsx": "preserve",
"strict": true,
"types": ["solid-start/env"],
"baseUrl": "./",
"paths": {
"~/*": ["./src/*"]
"include": [
"global.d.ts",
"src/**/*",
"tailwind.config.cjs",
".eslintrc.cjs"
],
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "node",
"jsxImportSource": "solid-js",
"jsx": "preserve",
"strict": true,
"types": ["solid-start/env"],
"baseUrl": "./",
"paths": {
"~/*": ["./src/*"]
}
}
}
}