Merge branch 'main' of github.com:v0l/snort into svg-proposal
This commit is contained in:
commit
8fa2d4c29f
@ -13,6 +13,11 @@ declare module "*.webp" {
|
||||
export default value;
|
||||
}
|
||||
|
||||
declare module "*.png" {
|
||||
const value: string;
|
||||
export default value;
|
||||
}
|
||||
|
||||
declare module "translations/*.json" {
|
||||
const value: Record<string, string>;
|
||||
export default value;
|
||||
|
@ -7,6 +7,7 @@
|
||||
"@fortawesome/free-solid-svg-icons": "^6.2.1",
|
||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||
"@jukben/emoji-search": "^2.0.1",
|
||||
"@lightninglabs/lnc-web": "^0.2.3-alpha",
|
||||
"@noble/hashes": "^1.2.0",
|
||||
"@noble/secp256k1": "^1.7.0",
|
||||
"@protobufjs/base64": "^1.1.2",
|
||||
|
@ -8,7 +8,7 @@
|
||||
<meta name="description" content="Fast nostr web ui" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; child-src 'none'; worker-src 'self'; frame-src youtube.com www.youtube.com https://platform.twitter.com https://embed.tidal.com https://w.soundcloud.com https://www.mixcloud.com https://open.spotify.com https://player.twitch.tv https://embed.music.apple.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; connect-src *; img-src * data:; font-src https://fonts.gstatic.com; media-src *; script-src 'self' https://static.cloudflareinsights.com https://platform.twitter.com https://embed.tidal.com;" />
|
||||
content="default-src 'self'; child-src 'none'; worker-src 'self'; frame-src youtube.com www.youtube.com https://platform.twitter.com https://embed.tidal.com https://w.soundcloud.com https://www.mixcloud.com https://open.spotify.com https://player.twitch.tv https://embed.music.apple.com'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; connect-src *; img-src * data:; font-src https://fonts.gstatic.com; media-src *; script-src 'self' 'wasm-unsafe-eval' https://static.cloudflareinsights.com https://platform.twitter.com https://embed.tidal.com;" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
|
@ -1,11 +1,12 @@
|
||||
import "./Invoice.css";
|
||||
import { useState } from "react";
|
||||
import { useIntl, FormattedMessage } from "react-intl";
|
||||
import { decode as invoiceDecode } from "light-bolt11-decoder";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import SendSats from "Element/SendSats";
|
||||
import Icon from "Icons/Icon";
|
||||
import useWebln from "Hooks/useWebln";
|
||||
import { useWallet } from "Wallet";
|
||||
import { decodeInvoice } from "Util";
|
||||
|
||||
import messages from "./messages";
|
||||
|
||||
@ -15,38 +16,12 @@ export interface InvoiceProps {
|
||||
|
||||
export default function Invoice(props: InvoiceProps) {
|
||||
const invoice = props.invoice;
|
||||
const webln = useWebln();
|
||||
const [showInvoice, setShowInvoice] = useState(false);
|
||||
const { formatMessage } = useIntl();
|
||||
const [showInvoice, setShowInvoice] = useState(false);
|
||||
const walletState = useWallet();
|
||||
const wallet = walletState.wallet;
|
||||
|
||||
const info = useMemo(() => {
|
||||
try {
|
||||
const parsed = invoiceDecode(invoice);
|
||||
|
||||
const amountSection = parsed.sections.find(a => a.name === "amount");
|
||||
const amount = amountSection ? (amountSection.value as number) : NaN;
|
||||
|
||||
const timestampSection = parsed.sections.find(a => a.name === "timestamp");
|
||||
const timestamp = timestampSection ? (timestampSection.value as number) : NaN;
|
||||
|
||||
const expirySection = parsed.sections.find(a => a.name === "expiry");
|
||||
const expire = expirySection ? (expirySection.value as number) : NaN;
|
||||
const descriptionSection = parsed.sections.find(a => a.name === "description")?.value;
|
||||
const ret = {
|
||||
amount: !isNaN(amount) ? amount / 1000 : 0,
|
||||
expire: !isNaN(timestamp) && !isNaN(expire) ? timestamp + expire : null,
|
||||
description: descriptionSection as string | undefined,
|
||||
expired: false,
|
||||
};
|
||||
if (ret.expire) {
|
||||
ret.expired = ret.expire < new Date().getTime() / 1000;
|
||||
}
|
||||
return ret;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}, [invoice]);
|
||||
|
||||
const info = useMemo(() => decodeInvoice(invoice), [invoice]);
|
||||
const [isPaid, setIsPaid] = useState(false);
|
||||
const isExpired = info?.expired;
|
||||
const amount = info?.amount ?? 0;
|
||||
@ -71,9 +46,9 @@ export default function Invoice(props: InvoiceProps) {
|
||||
|
||||
async function payInvoice(e: React.MouseEvent<HTMLButtonElement>) {
|
||||
e.stopPropagation();
|
||||
if (webln?.enabled) {
|
||||
if (wallet?.isReady) {
|
||||
try {
|
||||
await webln.sendPayment(invoice);
|
||||
await wallet.payInvoice(invoice);
|
||||
setIsPaid(true);
|
||||
} catch (error) {
|
||||
setShowInvoice(true);
|
||||
|
@ -3,8 +3,10 @@ import { useSelector, useDispatch } from "react-redux";
|
||||
import { useIntl, FormattedMessage } from "react-intl";
|
||||
import { Menu, MenuItem } from "@szhsin/react-menu";
|
||||
import { useLongPress } from "use-long-press";
|
||||
import { Event as NEvent, TaggedRawEvent, HexKey } from "@snort/nostr";
|
||||
|
||||
import Icon from "Icons/Icon";
|
||||
import Spinner from "Icons/Spinner";
|
||||
|
||||
import { formatShort } from "Number";
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
@ -14,14 +16,13 @@ import Reactions from "Element/Reactions";
|
||||
import SendSats from "Element/SendSats";
|
||||
import { ParsedZap, ZapsSummary } from "Element/Zap";
|
||||
import { useUserProfile } from "Feed/ProfileFeed";
|
||||
import { Event as NEvent, TaggedRawEvent, HexKey } from "@snort/nostr";
|
||||
import { RootState } from "State/Store";
|
||||
import { UserPreferences, setPinned, setBookmarked } from "State/Login";
|
||||
import useModeration from "Hooks/useModeration";
|
||||
import { TranslateHost } from "Const";
|
||||
import useWebln from "Hooks/useWebln";
|
||||
import { LNURL } from "LNURL";
|
||||
import Spinner from "Icons/Spinner";
|
||||
|
||||
import { useWallet } from "Wallet";
|
||||
|
||||
import messages from "./messages";
|
||||
|
||||
@ -55,7 +56,9 @@ export default function NoteFooter(props: NoteFooterProps) {
|
||||
const [reply, setReply] = useState(false);
|
||||
const [tip, setTip] = useState(false);
|
||||
const [zapping, setZapping] = useState(false);
|
||||
const webln = useWebln();
|
||||
const walletState = useWallet();
|
||||
const wallet = walletState.wallet;
|
||||
|
||||
const isMine = ev.RootPubKey === login;
|
||||
const lang = window.navigator.language;
|
||||
const langNames = new Intl.DisplayNames([...window.navigator.languages], {
|
||||
@ -108,14 +111,14 @@ export default function NoteFooter(props: NoteFooterProps) {
|
||||
if (zapping || e.isPropagationStopped()) return;
|
||||
|
||||
const lnurl = author?.lud16 || author?.lud06;
|
||||
if (webln?.enabled && lnurl) {
|
||||
if (wallet?.isReady() && lnurl) {
|
||||
setZapping(true);
|
||||
try {
|
||||
const handler = new LNURL(lnurl);
|
||||
await handler.load();
|
||||
const zap = handler.canZap ? await publisher.zap(prefs.defaultZapAmount * 1000, ev.PubKey, ev.Id) : undefined;
|
||||
const invoice = await handler.getInvoice(prefs.defaultZapAmount, undefined, zap);
|
||||
await await webln.sendPayment(unwrap(invoice.pr));
|
||||
await wallet.payInvoice(unwrap(invoice.pr));
|
||||
} catch (e) {
|
||||
console.warn("Fast zap failed", e);
|
||||
if (!(e instanceof Error) || e.message !== "User rejected") {
|
||||
@ -135,7 +138,7 @@ export default function NoteFooter(props: NoteFooterProps) {
|
||||
return (
|
||||
<>
|
||||
<div className={`reaction-pill ${didZap ? "reacted" : ""}`} {...longPress()} onClick={e => fastZap(e)}>
|
||||
{zapping ? <Spinner /> : webln?.enabled ? <Icon name="zapFast" /> : <Icon name="zap" />}
|
||||
{zapping ? <Spinner /> : wallet?.isReady ? <Icon name="zapFast" /> : <Icon name="zap" />}
|
||||
{zapTotal > 0 && <div className="reaction-pill-number">{formatShort(zapTotal)}</div>}
|
||||
</div>
|
||||
</>
|
||||
|
@ -12,11 +12,11 @@ import ProfileImage from "Element/ProfileImage";
|
||||
import Modal from "Element/Modal";
|
||||
import QrCode from "Element/QrCode";
|
||||
import Copy from "Element/Copy";
|
||||
import useWebln from "Hooks/useWebln";
|
||||
import { LNURL, LNURLError, LNURLErrorCode, LNURLInvoice, LNURLSuccessAction } from "LNURL";
|
||||
import { debounce } from "Util";
|
||||
|
||||
import messages from "./messages";
|
||||
import { useWallet } from "Wallet";
|
||||
|
||||
enum ZapType {
|
||||
PublicZap = 1,
|
||||
@ -74,10 +74,11 @@ export default function SendSats(props: SendSatsProps) {
|
||||
const [zapType, setZapType] = useState(ZapType.PublicZap);
|
||||
const [paying, setPaying] = useState<boolean>(false);
|
||||
|
||||
const webln = useWebln(props.show);
|
||||
const { formatMessage } = useIntl();
|
||||
const publisher = useEventPublisher();
|
||||
const canComment = handler ? (handler.canZap && zapType !== ZapType.NonZap) || handler.maxCommentLength > 0 : false;
|
||||
const walletState = useWallet();
|
||||
const wallet = walletState.wallet;
|
||||
|
||||
useEffect(() => {
|
||||
if (props.show) {
|
||||
@ -152,7 +153,7 @@ export default function SendSats(props: SendSatsProps) {
|
||||
const rsp = await handler.getInvoice(amount, comment, zap);
|
||||
if (rsp.pr) {
|
||||
setInvoice(rsp.pr);
|
||||
await payWebLNIfEnabled(rsp);
|
||||
await payWithWallet(rsp);
|
||||
}
|
||||
} catch (e) {
|
||||
handleLNURLError(e, formatMessage(messages.InvoiceFail));
|
||||
@ -202,11 +203,11 @@ export default function SendSats(props: SendSatsProps) {
|
||||
);
|
||||
}
|
||||
|
||||
async function payWebLNIfEnabled(invoice: LNURLInvoice) {
|
||||
async function payWithWallet(invoice: LNURLInvoice) {
|
||||
try {
|
||||
if (webln?.enabled) {
|
||||
if (wallet?.isReady) {
|
||||
setPaying(true);
|
||||
const res = await webln.sendPayment(invoice?.pr ?? "");
|
||||
const res = await wallet.payInvoice(invoice?.pr ?? "");
|
||||
console.log(res);
|
||||
setSuccess(invoice?.successAction ?? {});
|
||||
}
|
||||
@ -304,7 +305,12 @@ export default function SendSats(props: SendSatsProps) {
|
||||
{props.notice && <b className="error">{props.notice}</b>}
|
||||
{paying ? (
|
||||
<h4>
|
||||
<FormattedMessage defaultMessage="Paying with WebLN" />
|
||||
<FormattedMessage
|
||||
defaultMessage="Paying with {wallet}"
|
||||
values={{
|
||||
wallet: walletState.config?.info.alias,
|
||||
}}
|
||||
/>
|
||||
...
|
||||
</h4>
|
||||
) : (
|
||||
|
@ -2,12 +2,10 @@ import "./Zap.css";
|
||||
import { useMemo } from "react";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { useSelector } from "react-redux";
|
||||
import { decode as invoiceDecode } from "light-bolt11-decoder";
|
||||
import { bytesToHex } from "@noble/hashes/utils";
|
||||
import { sha256, unwrap } from "Util";
|
||||
import { Event, HexKey, TaggedRawEvent } from "@snort/nostr";
|
||||
|
||||
import { decodeInvoice, sha256, unwrap } from "Util";
|
||||
import { formatShort } from "Number";
|
||||
import { HexKey, TaggedRawEvent } from "@snort/nostr";
|
||||
import { Event } from "@snort/nostr";
|
||||
import Text from "Element/Text";
|
||||
import ProfileImage from "Element/ProfileImage";
|
||||
import { RootState } from "State/Store";
|
||||
@ -27,15 +25,9 @@ function getInvoice(zap: TaggedRawEvent) {
|
||||
console.debug("Invalid zap: ", zap);
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const decoded = invoiceDecode(bolt11);
|
||||
|
||||
const amount = decoded.sections.find(section => section.name === "amount")?.value;
|
||||
const hash = decoded.sections.find(section => section.name === "description_hash")?.value;
|
||||
|
||||
return { amount, hash: hash ? bytesToHex(hash as Uint8Array) : undefined };
|
||||
} catch {
|
||||
// ignore
|
||||
const decoded = decodeInvoice(bolt11);
|
||||
if (decoded) {
|
||||
return { amount: decoded?.amount, hash: decoded?.descriptionHash };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
@ -8,6 +8,7 @@ import { HexKey, RawEvent, u256, UserMetadata, Lists } from "@snort/nostr";
|
||||
import { bech32ToHex, delay, unwrap } from "Util";
|
||||
import { DefaultRelays, HashtagRegex } from "Const";
|
||||
import { System } from "System";
|
||||
import { useMemo } from "react";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@ -23,6 +24,8 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
export type EventPublisher = ReturnType<typeof useEventPublisher>;
|
||||
|
||||
export default function useEventPublisher() {
|
||||
const pubKey = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
|
||||
const privKey = useSelector<RootState, HexKey | undefined>(s => s.login.privateKey);
|
||||
@ -78,7 +81,7 @@ export default function useEventPublisher() {
|
||||
ev.Content = content;
|
||||
}
|
||||
|
||||
return {
|
||||
const ret = {
|
||||
nip42Auth: async (challenge: string, relay: string) => {
|
||||
if (pubKey) {
|
||||
const ev = NEvent.ForPubKey(pubKey);
|
||||
@ -393,7 +396,17 @@ export default function useEventPublisher() {
|
||||
publicKey: pubKey,
|
||||
};
|
||||
},
|
||||
generic: async (content: string, kind: EventKind) => {
|
||||
if (pubKey) {
|
||||
const ev = NEvent.ForPubKey(pubKey);
|
||||
ev.Kind = kind;
|
||||
ev.Content = content;
|
||||
return await signEvent(ev);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return useMemo(() => ret, [pubKey, relays, follows]);
|
||||
}
|
||||
|
||||
let isNip07Busy = false;
|
||||
|
@ -1,90 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { delay, unwrap } from "Util";
|
||||
|
||||
let isWebLnBusy = false;
|
||||
export const barrierWebLn = async <T>(then: () => Promise<T>): Promise<T> => {
|
||||
while (isWebLnBusy) {
|
||||
await delay(10);
|
||||
}
|
||||
isWebLnBusy = true;
|
||||
try {
|
||||
return await then();
|
||||
} finally {
|
||||
isWebLnBusy = false;
|
||||
}
|
||||
};
|
||||
|
||||
interface SendPaymentResponse {
|
||||
paymentHash?: string;
|
||||
preimage: string;
|
||||
route?: {
|
||||
total_amt: number;
|
||||
total_fees: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface RequestInvoiceArgs {
|
||||
amount?: string | number;
|
||||
defaultAmount?: string | number;
|
||||
minimumAmount?: string | number;
|
||||
maximumAmount?: string | number;
|
||||
defaultMemo?: string;
|
||||
}
|
||||
|
||||
interface RequestInvoiceResponse {
|
||||
paymentRequest: string;
|
||||
}
|
||||
|
||||
interface GetInfoResponse {
|
||||
node: {
|
||||
alias: string;
|
||||
pubkey: string;
|
||||
color?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface SignMessageResponse {
|
||||
message: string;
|
||||
signature: string;
|
||||
}
|
||||
|
||||
interface WebLN {
|
||||
enabled: boolean;
|
||||
getInfo(): Promise<GetInfoResponse>;
|
||||
enable: () => Promise<void>;
|
||||
makeInvoice(args: RequestInvoiceArgs): Promise<RequestInvoiceResponse>;
|
||||
signMessage(message: string): Promise<SignMessageResponse>;
|
||||
verifyMessage(signature: string, message: string): Promise<void>;
|
||||
sendPayment: (pr: string) => Promise<SendPaymentResponse>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
webln?: WebLN;
|
||||
}
|
||||
}
|
||||
|
||||
export default function useWebln(enable = true) {
|
||||
const maybeWebLn =
|
||||
"webln" in window && window.webln
|
||||
? ({
|
||||
enabled: unwrap(window.webln).enabled,
|
||||
getInfo: () => barrierWebLn(() => unwrap(window.webln).getInfo()),
|
||||
enable: () => barrierWebLn(() => unwrap(window.webln).enable()),
|
||||
makeInvoice: args => barrierWebLn(() => unwrap(window.webln).makeInvoice(args)),
|
||||
signMessage: msg => barrierWebLn(() => unwrap(window.webln).signMessage(msg)),
|
||||
verifyMessage: (sig, msg) => barrierWebLn(() => unwrap(window.webln).verifyMessage(sig, msg)),
|
||||
sendPayment: pr => barrierWebLn(() => unwrap(window.webln).sendPayment(pr)),
|
||||
} as WebLN)
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (maybeWebLn && !maybeWebLn.enabled && enable) {
|
||||
maybeWebLn.enable().catch(() => {
|
||||
console.debug("Couldn't enable WebLN");
|
||||
});
|
||||
}
|
||||
}, [maybeWebLn, enable]);
|
||||
|
||||
return maybeWebLn;
|
||||
}
|
15
packages/app/src/Icons/Bitcoin.tsx
Normal file
15
packages/app/src/Icons/Bitcoin.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
const Bitcoin = () => {
|
||||
return (
|
||||
<svg width="16" height="22" viewBox="0 0 16 22" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M5.5 1V3M5.5 19V21M9.5 1V3M9.5 19V21M3.5 3H10C12.2091 3 14 4.79086 14 7C14 9.20914 12.2091 11 10 11H3.5H11C13.2091 11 15 12.7909 15 15C15 17.2091 13.2091 19 11 19H3.5M3.5 3H1.5M3.5 3V19M3.5 19H1.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default Bitcoin;
|
61
packages/app/src/Icons/BlueWallet.tsx
Normal file
61
packages/app/src/Icons/BlueWallet.tsx
Normal file
File diff suppressed because one or more lines are too long
@ -20,6 +20,7 @@ import { NoteCreator } from "Element/NoteCreator";
|
||||
import { RelaySettings } from "@snort/nostr";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import messages from "./messages";
|
||||
import Bitcoin from "Icons/Bitcoin";
|
||||
|
||||
export default function Layout() {
|
||||
const location = useLocation();
|
||||
@ -194,6 +195,9 @@ export default function Layout() {
|
||||
function accountHeader() {
|
||||
return (
|
||||
<div className="header-actions">
|
||||
<div className="btn btn-rnd" onClick={() => navigate("/wallet")}>
|
||||
<Bitcoin />
|
||||
</div>
|
||||
<div className="btn btn-rnd" onClick={() => navigate("/search")}>
|
||||
<Icon name="search" size={20} />
|
||||
</div>
|
||||
|
@ -5,6 +5,7 @@ import Profile from "Pages/settings/Profile";
|
||||
import Relay from "Pages/settings/Relays";
|
||||
import Preferences from "Pages/settings/Preferences";
|
||||
import RelayInfo from "Pages/settings/RelayInfo";
|
||||
import { WalletSettingsRoutes } from "Pages/settings/WalletSettings";
|
||||
|
||||
import messages from "./messages";
|
||||
|
||||
@ -42,4 +43,5 @@ export const SettingsRoutes: RouteObject[] = [
|
||||
path: "preferences",
|
||||
element: <Preferences />,
|
||||
},
|
||||
...WalletSettingsRoutes,
|
||||
];
|
||||
|
16
packages/app/src/Pages/WalletPage.css
Normal file
16
packages/app/src/Pages/WalletPage.css
Normal file
@ -0,0 +1,16 @@
|
||||
.wallet-history-item {
|
||||
}
|
||||
|
||||
.wallet-history-item time {
|
||||
font-size: small;
|
||||
color: var(--font-tertiary-color);
|
||||
line-height: 1.5em;
|
||||
}
|
||||
|
||||
.pending {
|
||||
color: var(--font-tertiary-color);
|
||||
}
|
||||
|
||||
.wallet-buttons > button {
|
||||
margin: 10px;
|
||||
}
|
216
packages/app/src/Pages/WalletPage.tsx
Normal file
216
packages/app/src/Pages/WalletPage.tsx
Normal file
@ -0,0 +1,216 @@
|
||||
import "./WalletPage.css";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { RouteObject, useNavigate } from "react-router-dom";
|
||||
import { FormattedMessage, FormattedNumber, useIntl } from "react-intl";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faCheck, faClock, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
import NoteTime from "Element/NoteTime";
|
||||
import { WalletInvoice, Sats, WalletInfo, WalletInvoiceState, useWallet, LNWallet, Wallets, WalletKind } from "Wallet";
|
||||
import AsyncButton from "Element/AsyncButton";
|
||||
import { unwrap } from "Util";
|
||||
import { WebLNWallet } from "Wallet/WebLN";
|
||||
|
||||
export const WalletRoutes: RouteObject[] = [
|
||||
{
|
||||
path: "/wallet",
|
||||
element: <WalletPage />,
|
||||
},
|
||||
];
|
||||
|
||||
export default function WalletPage() {
|
||||
const navigate = useNavigate();
|
||||
const { formatMessage } = useIntl();
|
||||
const [info, setInfo] = useState<WalletInfo>();
|
||||
const [balance, setBalance] = useState<Sats>();
|
||||
const [history, setHistory] = useState<WalletInvoice[]>();
|
||||
const [walletPassword, setWalletPassword] = useState<string>();
|
||||
const [error, setError] = useState<string>();
|
||||
const walletState = useWallet();
|
||||
const wallet = walletState.wallet;
|
||||
|
||||
async function loadWallet(wallet: LNWallet) {
|
||||
try {
|
||||
const i = await wallet.getInfo();
|
||||
setInfo(i);
|
||||
const b = await wallet.getBalance();
|
||||
setBalance(b as Sats);
|
||||
const h = await wallet.getInvoices();
|
||||
setHistory((h as WalletInvoice[]).sort((a, b) => b.timestamp - a.timestamp));
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
setError((e as Error).message);
|
||||
} else {
|
||||
setError(formatMessage({ defaultMessage: "Unknown error" }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (wallet) {
|
||||
if (wallet.isReady()) {
|
||||
loadWallet(wallet).catch(console.warn);
|
||||
} else if (walletState.config?.kind !== WalletKind.LNC) {
|
||||
wallet
|
||||
.login()
|
||||
.then(async () => await loadWallet(wallet))
|
||||
.catch(console.warn);
|
||||
}
|
||||
}
|
||||
}, [wallet]);
|
||||
|
||||
function stateIcon(s: WalletInvoiceState) {
|
||||
switch (s) {
|
||||
case WalletInvoiceState.Pending:
|
||||
return <FontAwesomeIcon icon={faClock} className="mr5" />;
|
||||
case WalletInvoiceState.Paid:
|
||||
return <FontAwesomeIcon icon={faCheck} className="mr5" />;
|
||||
case WalletInvoiceState.Expired:
|
||||
return <FontAwesomeIcon icon={faXmark} className="mr5" />;
|
||||
}
|
||||
}
|
||||
|
||||
async function loginWallet(pw: string) {
|
||||
if (wallet) {
|
||||
await wallet.login(pw);
|
||||
await loadWallet(wallet);
|
||||
setWalletPassword(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function unlockWallet() {
|
||||
if (!wallet || wallet.isReady()) return null;
|
||||
return (
|
||||
<>
|
||||
<h3>
|
||||
<FormattedMessage defaultMessage="Enter wallet password" />
|
||||
</h3>
|
||||
<div className="flex w-max">
|
||||
<div className="f-grow mr10">
|
||||
<input
|
||||
type="password"
|
||||
placeholder={formatMessage({
|
||||
defaultMessage: "Wallet password",
|
||||
description: "Wallet password input placeholder",
|
||||
})}
|
||||
className="w-max"
|
||||
value={walletPassword}
|
||||
onChange={e => setWalletPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<AsyncButton onClick={() => loginWallet(unwrap(walletPassword))} disabled={(walletPassword?.length ?? 0) < 8}>
|
||||
<FormattedMessage defaultMessage="Unlock" description="Unlock wallet" />
|
||||
</AsyncButton>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function walletList() {
|
||||
if (walletState.configs.length === 0) {
|
||||
return (
|
||||
<button onClick={() => navigate("/settings/wallet")}>
|
||||
<FormattedMessage defaultMessage="Connect Wallet" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex w-max">
|
||||
<h4 className="f-1">
|
||||
<FormattedMessage defaultMessage="Select Wallet" />
|
||||
</h4>
|
||||
<div className="f-1">
|
||||
<select className="w-max" onChange={e => Wallets.switch(e.target.value)} value={walletState.config?.id}>
|
||||
{Wallets.list().map(a => {
|
||||
return <option value={a.id}>{a.info.alias}</option>;
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function walletHistory() {
|
||||
if (wallet instanceof WebLNWallet) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3>
|
||||
<FormattedMessage defaultMessage="History" description="Wallet transation history" />
|
||||
</h3>
|
||||
{history?.map(a => (
|
||||
<div className="card flex wallet-history-item" key={a.timestamp}>
|
||||
<div className="f-grow f-col">
|
||||
<NoteTime from={a.timestamp * 1000} fallback={formatMessage({ defaultMessage: "now" })} />
|
||||
<div>{(a.memo ?? "").length === 0 ? <> </> : a.memo}</div>
|
||||
</div>
|
||||
<div
|
||||
className={(() => {
|
||||
switch (a.state) {
|
||||
case WalletInvoiceState.Paid:
|
||||
return "success";
|
||||
case WalletInvoiceState.Expired:
|
||||
return "expired";
|
||||
case WalletInvoiceState.Failed:
|
||||
return "failed";
|
||||
default:
|
||||
return "pending";
|
||||
}
|
||||
})()}>
|
||||
{stateIcon(a.state)}
|
||||
<FormattedMessage
|
||||
defaultMessage="{amount} sats"
|
||||
values={{
|
||||
amount: <FormattedNumber value={a.amount / 1e3} />,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function walletBalance() {
|
||||
if (wallet instanceof WebLNWallet) return null;
|
||||
return (
|
||||
<b>
|
||||
<FormattedMessage
|
||||
defaultMessage="Balance: {amount} sats"
|
||||
values={{
|
||||
amount: <FormattedNumber value={balance ?? 0} />,
|
||||
}}
|
||||
/>
|
||||
</b>
|
||||
);
|
||||
}
|
||||
|
||||
function walletInfo() {
|
||||
if (!wallet?.isReady()) return null;
|
||||
return (
|
||||
<>
|
||||
<h3>{info?.alias}</h3>
|
||||
{walletBalance()}
|
||||
{/*<div className="flex wallet-buttons">
|
||||
<AsyncButton onClick={createInvoice}>
|
||||
<FormattedMessage defaultMessage="Receive" description="Receive sats by generating LN invoice" />
|
||||
</AsyncButton>
|
||||
</div>*/}
|
||||
{walletHistory()}
|
||||
<button onClick={() => Wallets.remove(unwrap(walletState.config).id)}>
|
||||
<FormattedMessage defaultMessage="Delete Account" />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="main-content">
|
||||
{error && <b className="error">{error}</b>}
|
||||
{walletList()}
|
||||
{unlockWallet()}
|
||||
{walletInfo()}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -34,6 +34,11 @@ const SettingsIndex = () => {
|
||||
<FormattedMessage {...messages.Preferences} />
|
||||
<Icon name="arrowFront" />
|
||||
</div>
|
||||
<div className="settings-row" onClick={() => navigate("wallet")}>
|
||||
<Icon name="bitcoin" />
|
||||
<FormattedMessage defaultMessage="Wallet" />
|
||||
<Icon name="arrowFront" />
|
||||
</div>
|
||||
<div className="settings-row" onClick={() => navigate("/donate")}>
|
||||
<Icon name="heart" />
|
||||
<FormattedMessage {...messages.Donate} />
|
||||
|
@ -68,11 +68,12 @@ export default function ProfileSettings(props: ProfileSettingsProps) {
|
||||
website,
|
||||
nip05,
|
||||
lud16,
|
||||
};
|
||||
} as Record<string, string | number | undefined>;
|
||||
delete userCopy["loaded"];
|
||||
delete userCopy["created"];
|
||||
delete userCopy["pubkey"];
|
||||
delete userCopy["npub"];
|
||||
delete userCopy["deleted"];
|
||||
console.debug(userCopy);
|
||||
|
||||
const ev = await publisher.metadata(userCopy);
|
||||
|
14
packages/app/src/Pages/settings/WalletSettings.css
Normal file
14
packages/app/src/Pages/settings/WalletSettings.css
Normal file
@ -0,0 +1,14 @@
|
||||
.wallet-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
text-align: center;
|
||||
grid-gap: 10px;
|
||||
}
|
||||
|
||||
.wallet-grid .card {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
48
packages/app/src/Pages/settings/WalletSettings.tsx
Normal file
48
packages/app/src/Pages/settings/WalletSettings.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import "./WalletSettings.css";
|
||||
import LndLogo from "lnd-logo.png";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { RouteObject, useNavigate } from "react-router-dom";
|
||||
|
||||
import BlueWallet from "Icons/BlueWallet";
|
||||
import ConnectLNC from "Pages/settings/wallet/LNC";
|
||||
import ConnectLNDHub from "./wallet/LNDHub";
|
||||
|
||||
const WalletSettings = () => {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<>
|
||||
<h3>
|
||||
<FormattedMessage defaultMessage="Connect Wallet" />
|
||||
</h3>
|
||||
<div className="wallet-grid">
|
||||
<div className="card" onClick={() => navigate("/settings/wallet/lnc")}>
|
||||
<img src={LndLogo} width={100} />
|
||||
<h3 className="f-end">LND with LNC</h3>
|
||||
</div>
|
||||
{
|
||||
<div className="card" onClick={() => navigate("/settings/wallet/lndhub")}>
|
||||
<BlueWallet width={100} height={100} />
|
||||
<h3 className="f-end">LNDHub</h3>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default WalletSettings;
|
||||
|
||||
export const WalletSettingsRoutes = [
|
||||
{
|
||||
path: "/settings/wallet",
|
||||
element: <WalletSettings />,
|
||||
},
|
||||
{
|
||||
path: "/settings/wallet/lnc",
|
||||
element: <ConnectLNC />,
|
||||
},
|
||||
{
|
||||
path: "/settings/wallet/lndhub",
|
||||
element: <ConnectLNDHub />,
|
||||
},
|
||||
] as Array<RouteObject>;
|
121
packages/app/src/Pages/settings/wallet/LNC.tsx
Normal file
121
packages/app/src/Pages/settings/wallet/LNC.tsx
Normal file
@ -0,0 +1,121 @@
|
||||
import { useState } from "react";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { v4 as uuid } from "uuid";
|
||||
|
||||
import AsyncButton from "Element/AsyncButton";
|
||||
import { LNCWallet } from "Wallet/LNCWallet";
|
||||
import { WalletInfo, WalletKind, Wallets } from "Wallet";
|
||||
import { unwrap } from "Util";
|
||||
|
||||
const ConnectLNC = () => {
|
||||
const { formatMessage } = useIntl();
|
||||
const navigate = useNavigate();
|
||||
const [pairingPhrase, setPairingPhrase] = useState<string>();
|
||||
const [error, setError] = useState<string>();
|
||||
const [connectedLNC, setConnectedLNC] = useState<LNCWallet>();
|
||||
const [walletInfo, setWalletInfo] = useState<WalletInfo>();
|
||||
const [walletPassword, setWalletPassword] = useState<string>();
|
||||
|
||||
async function tryConnect(cfg: string) {
|
||||
try {
|
||||
const lnc = await LNCWallet.Initialize(cfg);
|
||||
const info = await lnc.getInfo();
|
||||
|
||||
// prompt password
|
||||
setConnectedLNC(lnc);
|
||||
setWalletInfo(info as WalletInfo);
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
setError(e.message);
|
||||
} else {
|
||||
setError(
|
||||
formatMessage({
|
||||
defaultMessage: "Unknown error",
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setLNCPassword(pw: string) {
|
||||
connectedLNC?.setPassword(pw);
|
||||
Wallets.add({
|
||||
id: uuid(),
|
||||
kind: WalletKind.LNC,
|
||||
active: true,
|
||||
info: unwrap(walletInfo),
|
||||
});
|
||||
navigate("/wallet");
|
||||
}
|
||||
|
||||
function flowConnect() {
|
||||
if (connectedLNC) return null;
|
||||
return (
|
||||
<>
|
||||
<h4>
|
||||
<FormattedMessage defaultMessage="Enter pairing phrase" />
|
||||
</h4>
|
||||
<div className="flex">
|
||||
<div className="f-grow mr10">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={formatMessage({ defaultMessage: "Pairing phrase" })}
|
||||
className="w-max"
|
||||
value={pairingPhrase}
|
||||
onChange={e => setPairingPhrase(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<AsyncButton onClick={() => tryConnect(unwrap(pairingPhrase))} disabled={!pairingPhrase}>
|
||||
<FormattedMessage defaultMessage="Connect" />
|
||||
</AsyncButton>
|
||||
</div>
|
||||
{error && <b className="error p10">{error}</b>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function flowSetPassword() {
|
||||
if (!connectedLNC) return null;
|
||||
return (
|
||||
<div className="flex f-col">
|
||||
<h3>
|
||||
<FormattedMessage
|
||||
defaultMessage="Connected to: {node} 🎉"
|
||||
values={{
|
||||
node: walletInfo?.alias,
|
||||
}}
|
||||
/>
|
||||
</h3>
|
||||
<h4>
|
||||
<FormattedMessage defaultMessage="Enter password" />
|
||||
</h4>
|
||||
<div className="flex w-max">
|
||||
<div className="f-grow mr10">
|
||||
<input
|
||||
type="password"
|
||||
placeholder={formatMessage({ defaultMessage: "Wallet password" })}
|
||||
className="w-max"
|
||||
value={walletPassword}
|
||||
onChange={e => setWalletPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<AsyncButton
|
||||
onClick={() => setLNCPassword(unwrap(walletPassword))}
|
||||
disabled={(walletPassword?.length ?? 0) < 8}>
|
||||
<FormattedMessage defaultMessage="Save" />
|
||||
</AsyncButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{flowConnect()}
|
||||
{flowSetPassword()}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConnectLNC;
|
70
packages/app/src/Pages/settings/wallet/LNDHub.tsx
Normal file
70
packages/app/src/Pages/settings/wallet/LNDHub.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
import { useState } from "react";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { v4 as uuid } from "uuid";
|
||||
|
||||
import AsyncButton from "Element/AsyncButton";
|
||||
import { unwrap } from "Util";
|
||||
import LNDHubWallet from "Wallet/LNDHub";
|
||||
import { WalletConfig, WalletKind, Wallets } from "Wallet";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
const ConnectLNDHub = () => {
|
||||
const navigate = useNavigate();
|
||||
const { formatMessage } = useIntl();
|
||||
const [config, setConfig] = useState<string>();
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
async function tryConnect(config: string) {
|
||||
try {
|
||||
const connection = new LNDHubWallet(config);
|
||||
await connection.login();
|
||||
const info = await connection.getInfo();
|
||||
|
||||
const newWallet = {
|
||||
id: uuid(),
|
||||
kind: WalletKind.LNDHub,
|
||||
active: true,
|
||||
info,
|
||||
data: config,
|
||||
} as WalletConfig;
|
||||
Wallets.add(newWallet);
|
||||
|
||||
navigate("/wallet");
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
setError((e as Error).message);
|
||||
} else {
|
||||
setError(
|
||||
formatMessage({
|
||||
defaultMessage: "Unknown error",
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h4>
|
||||
<FormattedMessage defaultMessage="Enter LNDHub config" />
|
||||
</h4>
|
||||
<div className="flex">
|
||||
<div className="f-grow mr10">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="lndhub://username:password@lndhub.io"
|
||||
className="w-max"
|
||||
value={config}
|
||||
onChange={e => setConfig(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<AsyncButton onClick={() => tryConnect(unwrap(config))} disabled={!config}>
|
||||
<FormattedMessage defaultMessage="Connect" />
|
||||
</AsyncButton>
|
||||
</div>
|
||||
{error && <b className="error p10">{error}</b>}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConnectLNDHub;
|
@ -1,7 +1,10 @@
|
||||
import * as secp from "@noble/secp256k1";
|
||||
import { sha256 as hash } from "@noble/hashes/sha256";
|
||||
import { bytesToHex } from "@noble/hashes/utils";
|
||||
import { decode as invoiceDecode } from "light-bolt11-decoder";
|
||||
import { bech32 } from "bech32";
|
||||
import { HexKey, TaggedRawEvent, u256, EventKind, encodeTLV, NostrPrefix } from "@snort/nostr";
|
||||
|
||||
import { MetadataCache } from "State/Users";
|
||||
|
||||
export const sha256 = (str: string) => {
|
||||
@ -235,3 +238,36 @@ export const delay = (t: number) => {
|
||||
setTimeout(resolve, t);
|
||||
});
|
||||
};
|
||||
|
||||
export function decodeInvoice(pr: string) {
|
||||
try {
|
||||
const parsed = invoiceDecode(pr);
|
||||
|
||||
const amountSection = parsed.sections.find(a => a.name === "amount");
|
||||
const amount = amountSection ? (amountSection.value as number) : NaN;
|
||||
|
||||
const timestampSection = parsed.sections.find(a => a.name === "timestamp");
|
||||
const timestamp = timestampSection ? (timestampSection.value as number) : NaN;
|
||||
|
||||
const expirySection = parsed.sections.find(a => a.name === "expiry");
|
||||
const expire = expirySection ? (expirySection.value as number) : NaN;
|
||||
const descriptionSection = parsed.sections.find(a => a.name === "description")?.value;
|
||||
const descriptionHashSection = parsed.sections.find(a => a.name === "description_hash")?.value;
|
||||
const paymentHashSection = parsed.sections.find(a => a.name === "payment_hash")?.value;
|
||||
const ret = {
|
||||
amount: !isNaN(amount) ? amount : undefined,
|
||||
expire: !isNaN(timestamp) && !isNaN(expire) ? timestamp + expire : undefined,
|
||||
timestamp: !isNaN(timestamp) ? timestamp : undefined,
|
||||
description: descriptionSection as string | undefined,
|
||||
descriptionHash: descriptionHashSection ? bytesToHex(descriptionHashSection as Uint8Array) : undefined,
|
||||
paymentHash: paymentHashSection ? bytesToHex(paymentHashSection as Uint8Array) : undefined,
|
||||
expired: false,
|
||||
};
|
||||
if (ret.expire) {
|
||||
ret.expired = ret.expire < new Date().getTime() / 1000;
|
||||
}
|
||||
return ret;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
152
packages/app/src/Wallet/LNCWallet.ts
Normal file
152
packages/app/src/Wallet/LNCWallet.ts
Normal file
@ -0,0 +1,152 @@
|
||||
import LNC from "@lightninglabs/lnc-web";
|
||||
import { unwrap } from "Util";
|
||||
import {
|
||||
InvoiceRequest,
|
||||
LNWallet,
|
||||
Login,
|
||||
prToWalletInvoice,
|
||||
WalletError,
|
||||
WalletErrorCode,
|
||||
WalletInfo,
|
||||
WalletInvoice,
|
||||
WalletInvoiceState,
|
||||
} from "Wallet";
|
||||
|
||||
enum Payment_PaymentStatus {
|
||||
UNKNOWN = "UNKNOWN",
|
||||
IN_FLIGHT = "IN_FLIGHT",
|
||||
SUCCEEDED = "SUCCEEDED",
|
||||
FAILED = "FAILED",
|
||||
UNRECOGNIZED = "UNRECOGNIZED",
|
||||
}
|
||||
|
||||
export class LNCWallet implements LNWallet {
|
||||
#lnc: LNC;
|
||||
|
||||
private constructor(pairingPhrase?: string, password?: string) {
|
||||
this.#lnc = new LNC({
|
||||
pairingPhrase,
|
||||
password,
|
||||
});
|
||||
}
|
||||
|
||||
isReady(): boolean {
|
||||
return this.#lnc.isReady;
|
||||
}
|
||||
|
||||
static async Initialize(pairingPhrase: string) {
|
||||
const lnc = new LNCWallet(pairingPhrase);
|
||||
await lnc.login();
|
||||
return lnc;
|
||||
}
|
||||
|
||||
static Empty() {
|
||||
return new LNCWallet();
|
||||
}
|
||||
|
||||
setPassword(pw: string) {
|
||||
if (this.#lnc.credentials.password && pw !== this.#lnc.credentials.password) {
|
||||
throw new WalletError(WalletErrorCode.GeneralError, "Password is already set, cannot update password");
|
||||
}
|
||||
this.#lnc.credentials.password = pw;
|
||||
}
|
||||
|
||||
createAccount(): Promise<WalletError | Login> {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
async getInfo(): Promise<WalletInfo> {
|
||||
const nodeInfo = await this.#lnc.lnd.lightning.getInfo();
|
||||
return {
|
||||
nodePubKey: nodeInfo.identityPubkey,
|
||||
alias: nodeInfo.alias,
|
||||
} as WalletInfo;
|
||||
}
|
||||
|
||||
close(): Promise<boolean> {
|
||||
if (this.#lnc.isConnected) {
|
||||
this.#lnc.disconnect();
|
||||
}
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
async login(password?: string): Promise<boolean> {
|
||||
if (password) {
|
||||
this.setPassword(password);
|
||||
this.#lnc.run();
|
||||
}
|
||||
await this.#lnc.connect();
|
||||
while (!this.#lnc.isConnected) {
|
||||
await new Promise(resolve => {
|
||||
setTimeout(resolve, 100);
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async getBalance(): Promise<number> {
|
||||
const rsp = await this.#lnc.lnd.lightning.channelBalance();
|
||||
console.debug(rsp);
|
||||
return parseInt(rsp.localBalance?.sat ?? "0");
|
||||
}
|
||||
|
||||
async createInvoice(req: InvoiceRequest): Promise<WalletInvoice> {
|
||||
const rsp = await this.#lnc.lnd.lightning.addInvoice({
|
||||
memo: req.memo,
|
||||
value: req.amount.toString(),
|
||||
expiry: req.expiry?.toString(),
|
||||
});
|
||||
return unwrap(prToWalletInvoice(rsp.paymentRequest));
|
||||
}
|
||||
|
||||
async payInvoice(pr: string): Promise<WalletInvoice> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.#lnc.lnd.router.sendPaymentV2(
|
||||
{
|
||||
paymentRequest: pr,
|
||||
timeoutSeconds: 60,
|
||||
feeLimitSat: "100",
|
||||
},
|
||||
msg => {
|
||||
console.debug(msg);
|
||||
if (msg.status === Payment_PaymentStatus.SUCCEEDED) {
|
||||
resolve({
|
||||
preimage: msg.paymentPreimage,
|
||||
state: WalletInvoiceState.Paid,
|
||||
timestamp: parseInt(msg.creationTimeNs) / 1e9,
|
||||
} as WalletInvoice);
|
||||
}
|
||||
},
|
||||
err => {
|
||||
console.debug(err);
|
||||
reject(err);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async getInvoices(): Promise<WalletInvoice[]> {
|
||||
const invoices = await this.#lnc.lnd.lightning.listPayments({
|
||||
maxPayments: "10",
|
||||
reversed: true,
|
||||
});
|
||||
|
||||
return invoices.payments.map(a => {
|
||||
const parsedInvoice = prToWalletInvoice(a.paymentRequest);
|
||||
return {
|
||||
...parsedInvoice,
|
||||
state: (() => {
|
||||
switch (a.status) {
|
||||
case Payment_PaymentStatus.SUCCEEDED:
|
||||
return;
|
||||
case Payment_PaymentStatus.FAILED:
|
||||
return WalletInvoiceState.Failed;
|
||||
default:
|
||||
return WalletInvoiceState.Pending;
|
||||
}
|
||||
})(),
|
||||
preimage: a.paymentPreimage,
|
||||
} as WalletInvoice;
|
||||
});
|
||||
}
|
||||
}
|
182
packages/app/src/Wallet/LNDHub.ts
Normal file
182
packages/app/src/Wallet/LNDHub.ts
Normal file
@ -0,0 +1,182 @@
|
||||
import { EventPublisher } from "Feed/EventPublisher";
|
||||
import {
|
||||
InvoiceRequest,
|
||||
LNWallet,
|
||||
prToWalletInvoice,
|
||||
Sats,
|
||||
WalletError,
|
||||
WalletErrorCode,
|
||||
WalletInfo,
|
||||
WalletInvoice,
|
||||
WalletInvoiceState,
|
||||
} from "Wallet";
|
||||
|
||||
const defaultHeaders = {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
export default class LNDHubWallet implements LNWallet {
|
||||
type: "lndhub" | "snort";
|
||||
url: string;
|
||||
user: string;
|
||||
password: string;
|
||||
auth?: AuthResponse;
|
||||
publisher?: EventPublisher;
|
||||
|
||||
constructor(url: string, publisher?: EventPublisher) {
|
||||
if (url.startsWith("lndhub://")) {
|
||||
const regex = /^lndhub:\/\/([\S-]+):([\S-]+)@(.*)$/i;
|
||||
const parsedUrl = url.match(regex);
|
||||
console.debug(parsedUrl);
|
||||
if (!parsedUrl || parsedUrl.length !== 4) {
|
||||
throw new Error("Invalid LNDHUB config");
|
||||
}
|
||||
this.url = new URL(parsedUrl[3]).toString();
|
||||
this.user = parsedUrl[1];
|
||||
this.password = parsedUrl[2];
|
||||
this.type = "lndhub";
|
||||
} else if (url.startsWith("snort://")) {
|
||||
const u = new URL(url);
|
||||
this.url = `https://${u.host}${u.pathname}`;
|
||||
this.user = "";
|
||||
this.password = "";
|
||||
this.type = "snort";
|
||||
this.publisher = publisher;
|
||||
} else {
|
||||
throw new Error("Invalid config");
|
||||
}
|
||||
}
|
||||
|
||||
isReady(): boolean {
|
||||
return this.auth !== undefined;
|
||||
}
|
||||
|
||||
close(): Promise<boolean> {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
async getInfo() {
|
||||
return await this.getJson<WalletInfo>("GET", "/getinfo");
|
||||
}
|
||||
|
||||
async login() {
|
||||
if (this.type === "snort") return true;
|
||||
|
||||
const rsp = await this.getJson<AuthResponse>("POST", "/auth?type=auth", {
|
||||
login: this.user,
|
||||
password: this.password,
|
||||
});
|
||||
this.auth = rsp as AuthResponse;
|
||||
return true;
|
||||
}
|
||||
|
||||
async getBalance(): Promise<Sats> {
|
||||
const rsp = await this.getJson<GetBalanceResponse>("GET", "/balance");
|
||||
const bal = Math.floor((rsp as GetBalanceResponse).BTC.AvailableBalance);
|
||||
return bal as Sats;
|
||||
}
|
||||
|
||||
async createInvoice(req: InvoiceRequest) {
|
||||
const rsp = await this.getJson<UserInvoicesResponse>("POST", "/addinvoice", {
|
||||
amt: req.amount,
|
||||
memo: req.memo,
|
||||
});
|
||||
|
||||
const pRsp = rsp as UserInvoicesResponse;
|
||||
return {
|
||||
pr: pRsp.payment_request,
|
||||
memo: req.memo,
|
||||
amount: req.amount,
|
||||
paymentHash: pRsp.payment_hash,
|
||||
timestamp: pRsp.timestamp,
|
||||
} as WalletInvoice;
|
||||
}
|
||||
|
||||
async payInvoice(pr: string) {
|
||||
const rsp = await this.getJson<PayInvoiceResponse>("POST", "/payinvoice", {
|
||||
invoice: pr,
|
||||
});
|
||||
|
||||
const pRsp = rsp as PayInvoiceResponse;
|
||||
return {
|
||||
pr: pr,
|
||||
paymentHash: pRsp.payment_hash,
|
||||
state: pRsp.payment_error === undefined ? WalletInvoiceState.Paid : WalletInvoiceState.Pending,
|
||||
} as WalletInvoice;
|
||||
}
|
||||
|
||||
async getInvoices(): Promise<WalletInvoice[]> {
|
||||
const rsp = await this.getJson<UserInvoicesResponse[]>("GET", "/getuserinvoices");
|
||||
return (rsp as UserInvoicesResponse[]).map(a => {
|
||||
const decodedInvoice = prToWalletInvoice(a.payment_request);
|
||||
if (!decodedInvoice) {
|
||||
throw new WalletError(WalletErrorCode.InvalidInvoice, "Failed to parse invoice");
|
||||
}
|
||||
return {
|
||||
...decodedInvoice,
|
||||
state: a.ispaid ? WalletInvoiceState.Paid : decodedInvoice.state,
|
||||
paymentHash: a.payment_hash,
|
||||
memo: a.description,
|
||||
} as WalletInvoice;
|
||||
});
|
||||
}
|
||||
|
||||
private async getJson<T>(method: "GET" | "POST", path: string, body?: unknown): Promise<T> {
|
||||
let auth = `Bearer ${this.auth?.access_token}`;
|
||||
if (this.type === "snort") {
|
||||
const ev = await this.publisher?.generic(`${new URL(this.url).pathname}${path}`, 30_000);
|
||||
auth = JSON.stringify(ev?.ToObject());
|
||||
}
|
||||
const rsp = await fetch(`${this.url}${path}`, {
|
||||
method: method,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
headers: {
|
||||
...defaultHeaders,
|
||||
Authorization: auth,
|
||||
},
|
||||
});
|
||||
const json = await rsp.json();
|
||||
if ("error" in json) {
|
||||
const err = json as ErrorResponse;
|
||||
throw new WalletError(err.code, err.message);
|
||||
}
|
||||
return json as T;
|
||||
}
|
||||
}
|
||||
|
||||
interface AuthResponse {
|
||||
refresh_token?: string;
|
||||
access_token?: string;
|
||||
token_type?: string;
|
||||
}
|
||||
|
||||
interface GetBalanceResponse {
|
||||
BTC: {
|
||||
AvailableBalance: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface UserInvoicesResponse {
|
||||
amt: number;
|
||||
description: string;
|
||||
ispaid: boolean;
|
||||
type: string;
|
||||
timestamp: number;
|
||||
pay_req: string;
|
||||
payment_hash: string;
|
||||
payment_request: string;
|
||||
r_hash: string;
|
||||
}
|
||||
|
||||
interface PayInvoiceResponse {
|
||||
payment_error?: string;
|
||||
payment_hash: string;
|
||||
payment_preimage: string;
|
||||
payment_route?: { total_amt: number; total_fees: number };
|
||||
}
|
||||
|
||||
interface ErrorResponse {
|
||||
code: number;
|
||||
message: string;
|
||||
}
|
181
packages/app/src/Wallet/WebLN.ts
Normal file
181
packages/app/src/Wallet/WebLN.ts
Normal file
@ -0,0 +1,181 @@
|
||||
import {
|
||||
InvoiceRequest,
|
||||
LNWallet,
|
||||
prToWalletInvoice,
|
||||
Sats,
|
||||
WalletConfig,
|
||||
WalletError,
|
||||
WalletErrorCode,
|
||||
WalletInfo,
|
||||
WalletInvoice,
|
||||
WalletInvoiceState,
|
||||
WalletKind,
|
||||
WalletStore,
|
||||
} from "Wallet";
|
||||
import { delay } from "Util";
|
||||
|
||||
let isWebLnBusy = false;
|
||||
export const barrierWebLn = async <T>(then: () => Promise<T>): Promise<T> => {
|
||||
while (isWebLnBusy) {
|
||||
await delay(10);
|
||||
}
|
||||
isWebLnBusy = true;
|
||||
try {
|
||||
return await then();
|
||||
} finally {
|
||||
isWebLnBusy = false;
|
||||
}
|
||||
};
|
||||
|
||||
interface SendPaymentResponse {
|
||||
paymentHash?: string;
|
||||
preimage: string;
|
||||
route?: {
|
||||
total_amt: number;
|
||||
total_fees: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface RequestInvoiceArgs {
|
||||
amount?: string | number;
|
||||
defaultAmount?: string | number;
|
||||
minimumAmount?: string | number;
|
||||
maximumAmount?: string | number;
|
||||
defaultMemo?: string;
|
||||
}
|
||||
|
||||
interface RequestInvoiceResponse {
|
||||
paymentRequest: string;
|
||||
}
|
||||
|
||||
interface GetInfoResponse {
|
||||
node: {
|
||||
alias: string;
|
||||
pubkey: string;
|
||||
color?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface SignMessageResponse {
|
||||
message: string;
|
||||
signature: string;
|
||||
}
|
||||
|
||||
interface WebLN {
|
||||
enabled: boolean;
|
||||
getInfo(): Promise<GetInfoResponse>;
|
||||
enable(): Promise<void>;
|
||||
makeInvoice(args: RequestInvoiceArgs): Promise<RequestInvoiceResponse>;
|
||||
signMessage(message: string): Promise<SignMessageResponse>;
|
||||
verifyMessage(signature: string, message: string): Promise<void>;
|
||||
sendPayment: (pr: string) => Promise<SendPaymentResponse>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
webln?: WebLN;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a wallet config for WebLN if detected
|
||||
*/
|
||||
export function setupWebLNWalletConfig(store: WalletStore) {
|
||||
const wallets = store.list();
|
||||
if (window.webln && !wallets.some(a => a.kind === WalletKind.WebLN)) {
|
||||
const newConfig = {
|
||||
id: "webln",
|
||||
kind: WalletKind.WebLN,
|
||||
active: wallets.length === 0,
|
||||
info: {
|
||||
alias: "WebLN",
|
||||
},
|
||||
} as WalletConfig;
|
||||
store.add(newConfig);
|
||||
}
|
||||
}
|
||||
|
||||
export class WebLNWallet implements LNWallet {
|
||||
isReady(): boolean {
|
||||
if (window.webln) {
|
||||
return true;
|
||||
}
|
||||
throw new WalletError(WalletErrorCode.GeneralError, "WebLN not available");
|
||||
}
|
||||
|
||||
async getInfo(): Promise<WalletInfo> {
|
||||
await this.login();
|
||||
if (this.isReady()) {
|
||||
const rsp = await barrierWebLn(async () => await window.webln?.getInfo());
|
||||
if (rsp) {
|
||||
return {
|
||||
nodePubKey: rsp.node.pubkey,
|
||||
alias: rsp.node.alias,
|
||||
} as WalletInfo;
|
||||
} else {
|
||||
throw new WalletError(WalletErrorCode.GeneralError, "Could not load wallet info");
|
||||
}
|
||||
}
|
||||
throw new WalletError(WalletErrorCode.GeneralError, "WebLN not available");
|
||||
}
|
||||
|
||||
async login(): Promise<boolean> {
|
||||
if (window.webln && !window.webln.enabled) {
|
||||
await window.webln.enable();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
close(): Promise<boolean> {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
getBalance(): Promise<Sats> {
|
||||
return Promise.resolve(0);
|
||||
}
|
||||
|
||||
async createInvoice(req: InvoiceRequest): Promise<WalletInvoice> {
|
||||
await this.login();
|
||||
if (this.isReady()) {
|
||||
const rsp = await barrierWebLn(
|
||||
async () =>
|
||||
await window.webln?.makeInvoice({
|
||||
amount: req.amount,
|
||||
defaultMemo: req.memo,
|
||||
})
|
||||
);
|
||||
if (rsp) {
|
||||
const invoice = prToWalletInvoice(rsp.paymentRequest);
|
||||
if (!invoice) {
|
||||
throw new WalletError(WalletErrorCode.InvalidInvoice, "Could not parse invoice");
|
||||
}
|
||||
return invoice;
|
||||
}
|
||||
}
|
||||
throw new WalletError(WalletErrorCode.GeneralError, "WebLN not available");
|
||||
}
|
||||
|
||||
async payInvoice(pr: string): Promise<WalletInvoice> {
|
||||
await this.login();
|
||||
if (this.isReady()) {
|
||||
const invoice = prToWalletInvoice(pr);
|
||||
if (!invoice) {
|
||||
throw new WalletError(WalletErrorCode.InvalidInvoice, "Could not parse invoice");
|
||||
}
|
||||
const rsp = await barrierWebLn(async () => await window.webln?.sendPayment(pr));
|
||||
if (rsp) {
|
||||
invoice.state = WalletInvoiceState.Paid;
|
||||
invoice.preimage = rsp.preimage;
|
||||
return invoice;
|
||||
} else {
|
||||
invoice.state = WalletInvoiceState.Failed;
|
||||
return invoice;
|
||||
}
|
||||
}
|
||||
throw new WalletError(WalletErrorCode.GeneralError, "WebLN not available");
|
||||
}
|
||||
|
||||
getInvoices(): Promise<WalletInvoice[]> {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
}
|
263
packages/app/src/Wallet/index.ts
Normal file
263
packages/app/src/Wallet/index.ts
Normal file
@ -0,0 +1,263 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
import { decodeInvoice, unwrap } from "Util";
|
||||
import { LNCWallet } from "./LNCWallet";
|
||||
import LNDHubWallet from "./LNDHub";
|
||||
import { setupWebLNWalletConfig, WebLNWallet } from "./WebLN";
|
||||
|
||||
export enum WalletKind {
|
||||
LNDHub = 1,
|
||||
LNC = 2,
|
||||
WebLN = 3,
|
||||
}
|
||||
|
||||
export enum WalletErrorCode {
|
||||
BadAuth = 1,
|
||||
NotEnoughBalance = 2,
|
||||
BadPartner = 3,
|
||||
InvalidInvoice = 4,
|
||||
RouteNotFound = 5,
|
||||
GeneralError = 6,
|
||||
NodeFailure = 7,
|
||||
}
|
||||
|
||||
export class WalletError extends Error {
|
||||
code: WalletErrorCode;
|
||||
|
||||
constructor(c: WalletErrorCode, msg: string) {
|
||||
super(msg);
|
||||
this.code = c;
|
||||
}
|
||||
}
|
||||
|
||||
export const UnknownWalletError = {
|
||||
code: WalletErrorCode.GeneralError,
|
||||
message: "Unknown error",
|
||||
} as WalletError;
|
||||
|
||||
export interface WalletInfo {
|
||||
fee: number;
|
||||
nodePubKey: string;
|
||||
alias: string;
|
||||
pendingChannels: number;
|
||||
activeChannels: number;
|
||||
peers: number;
|
||||
blockHeight: number;
|
||||
blockHash: string;
|
||||
synced: boolean;
|
||||
chains: string[];
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface Login {
|
||||
service: string;
|
||||
save: () => Promise<void>;
|
||||
load: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface InvoiceRequest {
|
||||
amount: Sats;
|
||||
memo?: string;
|
||||
expiry?: number;
|
||||
}
|
||||
|
||||
export enum WalletInvoiceState {
|
||||
Pending = 0,
|
||||
Paid = 1,
|
||||
Expired = 2,
|
||||
Failed = 3,
|
||||
}
|
||||
|
||||
export interface WalletInvoice {
|
||||
pr: string;
|
||||
paymentHash: string;
|
||||
memo: string;
|
||||
amount: MilliSats;
|
||||
fees: number;
|
||||
timestamp: number;
|
||||
preimage?: string;
|
||||
state: WalletInvoiceState;
|
||||
}
|
||||
|
||||
export function prToWalletInvoice(pr: string) {
|
||||
const parsedInvoice = decodeInvoice(pr);
|
||||
if (parsedInvoice) {
|
||||
return {
|
||||
amount: parsedInvoice.amount ?? 0,
|
||||
memo: parsedInvoice.description,
|
||||
paymentHash: parsedInvoice.paymentHash,
|
||||
timestamp: parsedInvoice.timestamp,
|
||||
state: parsedInvoice.expired ? WalletInvoiceState.Expired : WalletInvoiceState.Pending,
|
||||
pr,
|
||||
} as WalletInvoice;
|
||||
}
|
||||
}
|
||||
|
||||
export type Sats = number;
|
||||
export type MilliSats = number;
|
||||
|
||||
export interface LNWallet {
|
||||
isReady(): boolean;
|
||||
getInfo: () => Promise<WalletInfo>;
|
||||
login: (password?: string) => Promise<boolean>;
|
||||
close: () => Promise<boolean>;
|
||||
getBalance: () => Promise<Sats>;
|
||||
createInvoice: (req: InvoiceRequest) => Promise<WalletInvoice>;
|
||||
payInvoice: (pr: string) => Promise<WalletInvoice>;
|
||||
getInvoices: () => Promise<WalletInvoice[]>;
|
||||
}
|
||||
|
||||
export interface WalletConfig {
|
||||
id: string;
|
||||
kind: WalletKind;
|
||||
active: boolean;
|
||||
info: WalletInfo;
|
||||
data?: string;
|
||||
}
|
||||
|
||||
export interface WalletStoreSnapshot {
|
||||
configs: Array<WalletConfig>;
|
||||
config?: WalletConfig;
|
||||
wallet?: LNWallet;
|
||||
}
|
||||
|
||||
type WalletStateHook = (state: WalletStoreSnapshot) => void;
|
||||
|
||||
export class WalletStore {
|
||||
#configs: Array<WalletConfig>;
|
||||
#instance: Map<string, LNWallet>;
|
||||
|
||||
#hooks: Array<WalletStateHook>;
|
||||
#snapshot: Readonly<WalletStoreSnapshot>;
|
||||
|
||||
constructor() {
|
||||
this.#configs = [];
|
||||
this.#instance = new Map();
|
||||
this.#hooks = [];
|
||||
this.#snapshot = Object.freeze({
|
||||
configs: [],
|
||||
});
|
||||
this.load(false);
|
||||
setupWebLNWalletConfig(this);
|
||||
this.snapshotState();
|
||||
}
|
||||
|
||||
hook(fn: WalletStateHook) {
|
||||
this.#hooks.push(fn);
|
||||
return () => {
|
||||
const idx = this.#hooks.findIndex(a => a === fn);
|
||||
this.#hooks = this.#hooks.splice(idx, 1);
|
||||
};
|
||||
}
|
||||
|
||||
list() {
|
||||
return Object.freeze([...this.#configs]);
|
||||
}
|
||||
|
||||
get() {
|
||||
const activeConfig = this.#configs.find(a => a.active);
|
||||
if (!activeConfig) {
|
||||
if (this.#configs.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
throw new Error("No active wallet config");
|
||||
}
|
||||
if (this.#instance.has(activeConfig.id)) {
|
||||
return unwrap(this.#instance.get(activeConfig.id));
|
||||
} else {
|
||||
const w = this.#activateWallet(activeConfig);
|
||||
if (w) {
|
||||
this.#instance.set(activeConfig.id, w);
|
||||
return w;
|
||||
} else {
|
||||
throw new Error("Unable to activate wallet config");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
add(cfg: WalletConfig) {
|
||||
this.#configs.push(cfg);
|
||||
this.save();
|
||||
}
|
||||
|
||||
remove(id: string) {
|
||||
const idx = this.#configs.findIndex(a => a.id === id);
|
||||
if (idx === -1) {
|
||||
throw new Error("Wallet not found");
|
||||
}
|
||||
const [removed] = this.#configs.splice(idx, 1);
|
||||
if (removed.active && this.#configs.length > 0) {
|
||||
this.#configs[0].active = true;
|
||||
}
|
||||
this.save();
|
||||
}
|
||||
|
||||
switch(id: string) {
|
||||
this.#configs.forEach(a => (a.active = a.id === id));
|
||||
this.save();
|
||||
}
|
||||
|
||||
save() {
|
||||
const json = JSON.stringify(this.#configs);
|
||||
window.localStorage.setItem("wallet-config", json);
|
||||
this.snapshotState();
|
||||
}
|
||||
|
||||
load(snapshot = true) {
|
||||
const cfg = window.localStorage.getItem("wallet-config");
|
||||
if (cfg) {
|
||||
this.#configs = JSON.parse(cfg);
|
||||
}
|
||||
if (snapshot) {
|
||||
this.snapshotState();
|
||||
}
|
||||
}
|
||||
|
||||
free() {
|
||||
this.#instance.forEach(w => w.close());
|
||||
}
|
||||
|
||||
getSnapshot() {
|
||||
return this.#snapshot;
|
||||
}
|
||||
|
||||
snapshotState() {
|
||||
const newState = {
|
||||
configs: [...this.#configs],
|
||||
config: this.#configs.find(a => a.active),
|
||||
wallet: this.get(),
|
||||
} as WalletStoreSnapshot;
|
||||
this.#snapshot = Object.freeze(newState);
|
||||
for (const hook of this.#hooks) {
|
||||
console.debug(this.#snapshot);
|
||||
hook(this.#snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
#activateWallet(cfg: WalletConfig): LNWallet | undefined {
|
||||
switch (cfg.kind) {
|
||||
case WalletKind.LNC: {
|
||||
const w = LNCWallet.Empty();
|
||||
return w;
|
||||
}
|
||||
case WalletKind.WebLN: {
|
||||
return new WebLNWallet();
|
||||
}
|
||||
case WalletKind.LNDHub: {
|
||||
return new LNDHubWallet(unwrap(cfg.data));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const Wallets = new WalletStore();
|
||||
window.document.addEventListener("close", () => {
|
||||
Wallets.free();
|
||||
});
|
||||
|
||||
export function useWallet() {
|
||||
return useSyncExternalStore<WalletStoreSnapshot>(
|
||||
h => Wallets.hook(h),
|
||||
() => Wallets.getSnapshot()
|
||||
);
|
||||
}
|
@ -8,6 +8,7 @@ import { Provider } from "react-redux";
|
||||
import { createBrowserRouter, RouterProvider } from "react-router-dom";
|
||||
|
||||
import * as serviceWorkerRegistration from "serviceWorkerRegistration";
|
||||
import { IntlProvider } from "IntlProvider";
|
||||
import Store from "State/Store";
|
||||
import EventPage from "Pages/EventPage";
|
||||
import Layout from "Pages/Layout";
|
||||
@ -25,8 +26,8 @@ import HashTagsPage from "Pages/HashTagsPage";
|
||||
import SearchPage from "Pages/SearchPage";
|
||||
import HelpPage from "Pages/HelpPage";
|
||||
import { NewUserRoutes } from "Pages/new";
|
||||
import { WalletRoutes } from "Pages/WalletPage";
|
||||
import NostrLinkHandler from "Pages/NostrLinkHandler";
|
||||
import { IntlProvider } from "./IntlProvider";
|
||||
import { unwrap } from "Util";
|
||||
|
||||
/**
|
||||
@ -99,6 +100,7 @@ export const router = createBrowserRouter([
|
||||
element: <NostrLinkHandler />,
|
||||
},
|
||||
...NewUserRoutes,
|
||||
...WalletRoutes,
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
@ -8,6 +8,9 @@
|
||||
"+vIQlC": {
|
||||
"defaultMessage": "Please make sure to save the following password in order to manage your handle in the future"
|
||||
},
|
||||
"+vVZ/G": {
|
||||
"defaultMessage": "Connect"
|
||||
},
|
||||
"/4tOwT": {
|
||||
"defaultMessage": "Skip"
|
||||
},
|
||||
@ -45,6 +48,9 @@
|
||||
"1Mo59U": {
|
||||
"defaultMessage": "Are you sure you want to remove this note from bookmarks?"
|
||||
},
|
||||
"1c4YST": {
|
||||
"defaultMessage": "Connected to: {node} 🎉"
|
||||
},
|
||||
"1nYUGC": {
|
||||
"defaultMessage": "{n} Following"
|
||||
},
|
||||
@ -60,6 +66,9 @@
|
||||
"2IFGap": {
|
||||
"defaultMessage": "Donate"
|
||||
},
|
||||
"2LbrkB": {
|
||||
"defaultMessage": "Enter password"
|
||||
},
|
||||
"2a2YiP": {
|
||||
"defaultMessage": "{n} Bookmarks"
|
||||
},
|
||||
@ -82,6 +91,9 @@
|
||||
"defaultMessage": "OR",
|
||||
"description": "Seperator text for Login / Generate Key"
|
||||
},
|
||||
"3yk8fB": {
|
||||
"defaultMessage": "Wallet"
|
||||
},
|
||||
"450Fty": {
|
||||
"defaultMessage": "None"
|
||||
},
|
||||
@ -140,6 +152,9 @@
|
||||
"8g2vyB": {
|
||||
"defaultMessage": "name too long"
|
||||
},
|
||||
"8v1NN+": {
|
||||
"defaultMessage": "Pairing phrase"
|
||||
},
|
||||
"9+Ddtu": {
|
||||
"defaultMessage": "Next"
|
||||
},
|
||||
@ -257,6 +272,9 @@
|
||||
"G/yZLu": {
|
||||
"defaultMessage": "Remove"
|
||||
},
|
||||
"G1BGCg": {
|
||||
"defaultMessage": "Select Wallet"
|
||||
},
|
||||
"GFOoEE": {
|
||||
"defaultMessage": "Salt"
|
||||
},
|
||||
@ -312,6 +330,9 @@
|
||||
"K7AkdL": {
|
||||
"defaultMessage": "Show"
|
||||
},
|
||||
"KAhAcM": {
|
||||
"defaultMessage": "Enter LNDHub config"
|
||||
},
|
||||
"KQvWvD": {
|
||||
"defaultMessage": "Deleted"
|
||||
},
|
||||
@ -340,6 +361,10 @@
|
||||
"MI2jkA": {
|
||||
"defaultMessage": "Not available:"
|
||||
},
|
||||
"MP54GY": {
|
||||
"defaultMessage": "Wallet password",
|
||||
"description": "Wallet password input placeholder"
|
||||
},
|
||||
"MRp6Ly": {
|
||||
"defaultMessage": "Twitter username"
|
||||
},
|
||||
@ -399,6 +424,9 @@
|
||||
"defaultMessage": "Art by {name}",
|
||||
"description": "Artwork attribution label"
|
||||
},
|
||||
"R2OqnW": {
|
||||
"defaultMessage": "Delete Account"
|
||||
},
|
||||
"RDZVQL": {
|
||||
"defaultMessage": "Check"
|
||||
},
|
||||
@ -427,6 +455,9 @@
|
||||
"Up5U7K": {
|
||||
"defaultMessage": "Block"
|
||||
},
|
||||
"VN0+Fz": {
|
||||
"defaultMessage": "Balance: {amount} sats"
|
||||
},
|
||||
"VOjC1i": {
|
||||
"defaultMessage": "Pick which upload service you want to upload attachments to"
|
||||
},
|
||||
@ -467,6 +498,9 @@
|
||||
"YXA3AH": {
|
||||
"defaultMessage": "Enable reactions"
|
||||
},
|
||||
"Z4BMCZ": {
|
||||
"defaultMessage": "Enter pairing phrase"
|
||||
},
|
||||
"ZKORll": {
|
||||
"defaultMessage": "Activate Now"
|
||||
},
|
||||
@ -501,12 +535,19 @@
|
||||
"cWx9t8": {
|
||||
"defaultMessage": "Mute all"
|
||||
},
|
||||
"cg1VJ2": {
|
||||
"defaultMessage": "Connect Wallet"
|
||||
},
|
||||
"cuV2gK": {
|
||||
"defaultMessage": "name is registered"
|
||||
},
|
||||
"cyR7Kh": {
|
||||
"defaultMessage": "Back"
|
||||
},
|
||||
"d6CyG5": {
|
||||
"defaultMessage": "History",
|
||||
"description": "Wallet transation history"
|
||||
},
|
||||
"d7d0/x": {
|
||||
"defaultMessage": "LN Address"
|
||||
},
|
||||
@ -613,14 +654,17 @@
|
||||
"k7sKNy": {
|
||||
"defaultMessage": "Our very own NIP-05 verification service, help support the development of this site and get a shiny special badge on our site!"
|
||||
},
|
||||
"kaaf1E": {
|
||||
"defaultMessage": "now"
|
||||
},
|
||||
"lCILNz": {
|
||||
"defaultMessage": "Buy Now"
|
||||
},
|
||||
"lD3+8a": {
|
||||
"defaultMessage": "Pay"
|
||||
},
|
||||
"lSpQet": {
|
||||
"defaultMessage": "Paying with WebLN"
|
||||
"lTbT3s": {
|
||||
"defaultMessage": "Wallet password"
|
||||
},
|
||||
"lgg1KN": {
|
||||
"defaultMessage": "account page"
|
||||
@ -693,6 +737,9 @@
|
||||
"pzTOmv": {
|
||||
"defaultMessage": "Followers"
|
||||
},
|
||||
"qDwvZ4": {
|
||||
"defaultMessage": "Unknown error"
|
||||
},
|
||||
"qMx1sA": {
|
||||
"defaultMessage": "Default Zap amount"
|
||||
},
|
||||
@ -711,6 +758,9 @@
|
||||
"r3C4x/": {
|
||||
"defaultMessage": "Software"
|
||||
},
|
||||
"r5srDR": {
|
||||
"defaultMessage": "Enter wallet password"
|
||||
},
|
||||
"rT14Ow": {
|
||||
"defaultMessage": "Add Relays"
|
||||
},
|
||||
@ -759,9 +809,15 @@
|
||||
"vOKedj": {
|
||||
"defaultMessage": "{n,plural,=1{& {n} other} other{& {n} others}}"
|
||||
},
|
||||
"vU71Ez": {
|
||||
"defaultMessage": "Paying with {wallet}"
|
||||
},
|
||||
"vZ4quW": {
|
||||
"defaultMessage": "NIP-05 is a DNS based verification spec which helps to validate you as a real user."
|
||||
},
|
||||
"vrTOHJ": {
|
||||
"defaultMessage": "{amount} sats"
|
||||
},
|
||||
"wEQDC6": {
|
||||
"defaultMessage": "Edit"
|
||||
},
|
||||
@ -798,6 +854,10 @@
|
||||
"xKflGN": {
|
||||
"defaultMessage": "{username}''s Follows on Nostr"
|
||||
},
|
||||
"xQtL3v": {
|
||||
"defaultMessage": "Unlock",
|
||||
"description": "Unlock wallet"
|
||||
},
|
||||
"xbVgIm": {
|
||||
"defaultMessage": "Automatically load media"
|
||||
},
|
||||
|
BIN
packages/app/src/lnd-logo.png
Normal file
BIN
packages/app/src/lnd-logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.4 KiB |
@ -2,6 +2,7 @@
|
||||
"+D82kt": "Are you sure you want to repost: {id}",
|
||||
"+aZY2h": "Zap Type",
|
||||
"+vIQlC": "Please make sure to save the following password in order to manage your handle in the future",
|
||||
"+vVZ/G": "Connect",
|
||||
"/4tOwT": "Skip",
|
||||
"/JE/X+": "Account Support",
|
||||
"/PCavi": "Public",
|
||||
@ -14,11 +15,13 @@
|
||||
"0yO7wF": "{n} secs",
|
||||
"1A7TZk": "What is Snort and how does it work?",
|
||||
"1Mo59U": "Are you sure you want to remove this note from bookmarks?",
|
||||
"1c4YST": "Connected to: {node} 🎉",
|
||||
"1nYUGC": "{n} Following",
|
||||
"1udzha": "Conversations",
|
||||
"2/2yg+": "Add",
|
||||
"25V4l1": "Banner",
|
||||
"2IFGap": "Donate",
|
||||
"2LbrkB": "Enter password",
|
||||
"2a2YiP": "{n} Bookmarks",
|
||||
"2k0Cv+": "Dislikes ({n})",
|
||||
"3cc4Ct": "Light",
|
||||
@ -26,6 +29,7 @@
|
||||
"3t3kok": "{n,plural,=1{{n} new note} other{{n} new notes}}",
|
||||
"3tVy+Z": "{n} Followers",
|
||||
"3xCwbZ": "OR",
|
||||
"3yk8fB": "Wallet",
|
||||
"450Fty": "None",
|
||||
"47FYwb": "Cancel",
|
||||
"4IPzdn": "Primary Developers",
|
||||
@ -45,6 +49,7 @@
|
||||
"8E9muH": "Import Twitter Follows (optional)",
|
||||
"8QDesP": "Zap {n} sats",
|
||||
"8g2vyB": "name too long",
|
||||
"8v1NN+": "Pairing phrase",
|
||||
"9+Ddtu": "Next",
|
||||
"9HU8vw": "Reply",
|
||||
"9SvQep": "Follows {n}",
|
||||
@ -83,6 +88,7 @@
|
||||
"FfYsOb": "An error has occured!",
|
||||
"FmXUJg": "follows you",
|
||||
"G/yZLu": "Remove",
|
||||
"G1BGCg": "Select Wallet",
|
||||
"GFOoEE": "Salt",
|
||||
"GL8aXW": "Bookmarks ({n})",
|
||||
"GspYR7": "{n} Dislike",
|
||||
@ -101,6 +107,7 @@
|
||||
"JkLHGw": "Website",
|
||||
"K3r6DQ": "Delete",
|
||||
"K7AkdL": "Show",
|
||||
"KAhAcM": "Enter LNDHub config",
|
||||
"KQvWvD": "Deleted",
|
||||
"KWuDfz": "I have saved my keys, continue",
|
||||
"KahimY": "Unknown event kind: {kind}",
|
||||
@ -110,6 +117,7 @@
|
||||
"M3Oirc": "Debug Menus",
|
||||
"MBAYRO": "Shows \"Copy ID\" and \"Copy Event JSON\" in the context menu on each message",
|
||||
"MI2jkA": "Not available:",
|
||||
"MP54GY": "Wallet password",
|
||||
"MRp6Ly": "Twitter username",
|
||||
"MWTx65": "Default Page",
|
||||
"MzRYWH": "Buying {item}",
|
||||
@ -129,6 +137,7 @@
|
||||
"QTdJfH": "Create an Account",
|
||||
"QawghE": "You can change your username at any point.",
|
||||
"QxCuTo": "Art by {name}",
|
||||
"R2OqnW": "Delete Account",
|
||||
"RDZVQL": "Check",
|
||||
"RahCRH": "Expired",
|
||||
"RhDAoS": "Are you sure you want to delete {id}",
|
||||
@ -138,6 +147,7 @@
|
||||
"TpgeGw": "Hex Salt..",
|
||||
"UQ3pOC": "On Nostr, many people have the same username. User names and identity are separate things. You can get a unique identifier in the next step.",
|
||||
"Up5U7K": "Block",
|
||||
"VN0+Fz": "Balance: {amount} sats",
|
||||
"VOjC1i": "Pick which upload service you want to upload attachments to",
|
||||
"VlJkSk": "{n} muted",
|
||||
"VnXp8Z": "Avatar",
|
||||
@ -151,6 +161,7 @@
|
||||
"Y31HTH": "Help fund the development of Snort",
|
||||
"YDURw6": "Service URL",
|
||||
"YXA3AH": "Enable reactions",
|
||||
"Z4BMCZ": "Enter pairing phrase",
|
||||
"ZKORll": "Activate Now",
|
||||
"ZLmyG9": "Contributors",
|
||||
"ZUZedV": "Lightning Donation:",
|
||||
@ -162,8 +173,10 @@
|
||||
"cPIKU2": "Following",
|
||||
"cQfLWb": "URL..",
|
||||
"cWx9t8": "Mute all",
|
||||
"cg1VJ2": "Connect Wallet",
|
||||
"cuV2gK": "name is registered",
|
||||
"cyR7Kh": "Back",
|
||||
"d6CyG5": "History",
|
||||
"d7d0/x": "LN Address",
|
||||
"dOQCL8": "Display name",
|
||||
"e7qqly": "Mark All Read",
|
||||
@ -199,9 +212,10 @@
|
||||
"jzgQ2z": "{n} Reactions",
|
||||
"k2veDA": "Write",
|
||||
"k7sKNy": "Our very own NIP-05 verification service, help support the development of this site and get a shiny special badge on our site!",
|
||||
"kaaf1E": "now",
|
||||
"lCILNz": "Buy Now",
|
||||
"lD3+8a": "Pay",
|
||||
"lSpQet": "Paying with WebLN",
|
||||
"lTbT3s": "Wallet password",
|
||||
"lgg1KN": "account page",
|
||||
"ll3xBp": "Image proxy service",
|
||||
"lnaT9F": "Following {n}",
|
||||
@ -225,12 +239,14 @@
|
||||
"oxCa4R": "Getting an identifier helps confirm the real you to people who know you. Many people can have a username @jack, but there is only one jack@cash.app.",
|
||||
"puLNUJ": "Pin",
|
||||
"pzTOmv": "Followers",
|
||||
"qDwvZ4": "Unknown error",
|
||||
"qMx1sA": "Default Zap amount",
|
||||
"qUJTsT": "Blocked",
|
||||
"qdGuQo": "Your Private Key Is (do not share this with anyone)",
|
||||
"qkvYUb": "Add to Profile",
|
||||
"qmJ8kD": "Translation failed",
|
||||
"r3C4x/": "Software",
|
||||
"r5srDR": "Enter wallet password",
|
||||
"rT14Ow": "Add Relays",
|
||||
"reJ6SM": "It is recommended to use one of the following browser extensions if you are on a desktop computer to secure your key:",
|
||||
"rfuMjE": "(Default)",
|
||||
@ -247,7 +263,9 @@
|
||||
"usAvMr": "Edit Profile",
|
||||
"ut+2Cd": "Get a partner identifier",
|
||||
"vOKedj": "{n,plural,=1{& {n} other} other{& {n} others}}",
|
||||
"vU71Ez": "Paying with {wallet}",
|
||||
"vZ4quW": "NIP-05 is a DNS based verification spec which helps to validate you as a real user.",
|
||||
"vrTOHJ": "{amount} sats",
|
||||
"wEQDC6": "Edit",
|
||||
"wLtRCF": "Your key",
|
||||
"wWLwvh": "Anon",
|
||||
@ -259,6 +277,7 @@
|
||||
"xJ9n2N": "Your public key",
|
||||
"xKdNPm": "Send",
|
||||
"xKflGN": "{username}''s Follows on Nostr",
|
||||
"xQtL3v": "Unlock",
|
||||
"xbVgIm": "Automatically load media",
|
||||
"xmcVZ0": "Search",
|
||||
"y1Z3or": "Language",
|
||||
|
@ -9,6 +9,7 @@ enum EventKind {
|
||||
Repost = 6, // NIP-18
|
||||
Reaction = 7, // NIP-25
|
||||
Relays = 10002, // NIP-65
|
||||
Ephemeral = 20_000,
|
||||
Auth = 22242, // NIP-42
|
||||
PubkeyLists = 30000, // NIP-51a
|
||||
NoteLists = 30001, // NIP-51b
|
||||
|
Loading…
x
Reference in New Issue
Block a user