add more wallet features

This commit is contained in:
hzrd149 2025-03-10 16:36:38 +00:00
parent dc138f6037
commit 5fb58609e5
15 changed files with 473 additions and 158 deletions

View File

@ -19,7 +19,7 @@
"cap-sync-version": "pnpm dlx capacitor-set-version . -v $(jq -r .version package.json) -b 1"
},
"dependencies": {
"@cashu/cashu-ts": "^2.2.1",
"@cashu/cashu-ts": "^2.2.2",
"@chakra-ui/anatomy": "^2.3.4",
"@chakra-ui/breakpoint-utils": "^2.0.8",
"@chakra-ui/icons": "^2.2.4",

108
pnpm-lock.yaml generated
View File

@ -13,8 +13,8 @@ importers:
.:
dependencies:
'@cashu/cashu-ts':
specifier: ^2.2.1
version: 2.2.1
specifier: ^2.2.2
version: 2.2.2
'@chakra-ui/anatomy':
specifier: ^2.3.4
version: 2.3.4
@ -104,31 +104,31 @@ importers:
version: 0.7.2
applesauce-accounts:
specifier: next
version: 0.0.0-next-20250310121307(typescript@5.8.2)
version: 0.0.0-next-20250310162525(typescript@5.8.2)
applesauce-content:
specifier: next
version: 0.0.0-next-20250310121307(typescript@5.8.2)
version: 0.0.0-next-20250310162525(typescript@5.8.2)
applesauce-core:
specifier: next
version: 0.0.0-next-20250310121307(typescript@5.8.2)
version: 0.0.0-next-20250310162525(typescript@5.8.2)
applesauce-factory:
specifier: next
version: 0.0.0-next-20250310121307(typescript@5.8.2)
version: 0.0.0-next-20250310162525(typescript@5.8.2)
applesauce-loaders:
specifier: next
version: 0.0.0-next-20250310121307(typescript@5.8.2)
version: 0.0.0-next-20250310162525(typescript@5.8.2)
applesauce-react:
specifier: next
version: 0.0.0-next-20250310121307(react-dom@19.0.0(react@19.0.0))(typescript@5.8.2)
version: 0.0.0-next-20250310162525(react-dom@19.0.0(react@19.0.0))(typescript@5.8.2)
applesauce-relay:
specifier: next
version: 0.0.0-next-20250310121307(typescript@5.8.2)
version: 0.0.0-next-20250310162525(typescript@5.8.2)
applesauce-signers:
specifier: next
version: 0.0.0-next-20250310121307(typescript@5.8.2)
version: 0.0.0-next-20250310162525(typescript@5.8.2)
applesauce-wallet:
specifier: next
version: 0.0.0-next-20250310121307(typescript@5.8.2)
version: 0.0.0-next-20250310162525(typescript@5.8.2)
bech32:
specifier: ^2.0.0
version: 2.0.0
@ -1038,8 +1038,8 @@ packages:
'@cashu/cashu-ts@2.0.0-rc1':
resolution: {integrity: sha512-39459l7x/fUMEgOsCdGLLl6rMekO4nbv+wEuavmyElh8hgN8t66wcb29AJvdFTb6K3lPACKF2rs/jAlPYrN7Ng==}
'@cashu/cashu-ts@2.2.1':
resolution: {integrity: sha512-/A8Lfkf7nexldcAcTbqrITXxwgiCYTTnrthB8DoipLVeDfyUXer48FJdUmXpRp87Aijn2BNklo8qA0yO0kHXaA==}
'@cashu/cashu-ts@2.2.2':
resolution: {integrity: sha512-s4DRaIZOh2MC0qi0G1Te4KfOzzw91EZiIVupssKDmaPSUbT1ggLonj5qyzB4OCpI7uZ4lDpnxh43xkRZyAqotw==}
'@cashu/crypto@0.2.7':
resolution: {integrity: sha512-1aaDfUjiHNXoJqg8nW+341TLWV9W28DsVNXJUKcHL0yAmwLs5+56SSnb8LLDJzPamLVoYL0U0bda91klAzptig==}
@ -2195,32 +2195,32 @@ packages:
engines: {node: '>=8.0.0'}
hasBin: true
applesauce-accounts@0.0.0-next-20250310121307:
resolution: {integrity: sha512-Q1vEjYznvkxRDM2C/gN+UJpjva2QvFLrIlfMTyrfm7s1nDEIKP6wJ4Oj72PsnyZREP0/UnH7fP5olvJMakBOBg==}
applesauce-accounts@0.0.0-next-20250310162525:
resolution: {integrity: sha512-Gn+PJ3RgSOJGqKLcC9LRm7KgrwL64D0ZQBIZ7XORk/dF61kaF88gW3TnL72aUByjp8jJHxOTFxaR+HTR22aHZA==}
applesauce-content@0.0.0-next-20250310121307:
resolution: {integrity: sha512-oxMgVy1hS0ZWGL+0Skrt25vcMUwY8T3A1p45vc1q17oXWbIKBqZPtenoHarluEBdY9iUt0QBVVIFWSQtphudxw==}
applesauce-content@0.0.0-next-20250310162525:
resolution: {integrity: sha512-Bc5GS+taB29zewyH/t8Cfq3CdCF47xE20sVurNplrUyJ6jS99im+BFsgKQY1OX41xSsK6h3TDVjBYRUSvSwRtA==}
applesauce-core@0.0.0-next-20250310121307:
resolution: {integrity: sha512-u6YNDyPy2v+B8Iv6WFkTTnilLJfNgFVeDs+aGy4KpItq0tdEQcnwTIFRcTovSaCNn2YYNJ1WHw30/a622SmshA==}
applesauce-core@0.0.0-next-20250310162525:
resolution: {integrity: sha512-8E1PAfH4UPbS/GMNrKyrWO2DDtx+PlzMVnL5UskUX5YcEy3ziPCjXY10U4V32p+DKH+aGzFsWkaSVN4OR/TXaw==}
applesauce-factory@0.0.0-next-20250310121307:
resolution: {integrity: sha512-rJNnwwxIdX1s4vdmAmIDvappQT/0J0XH+xIpMDnuf1yvMmwK/lH+DvGWPdC2h/TNI4Lbd3c3774jUkgBZoKMrQ==}
applesauce-factory@0.0.0-next-20250310162525:
resolution: {integrity: sha512-avUTztNHvuZJe44OCoeI5aA8vcrokuqXPjsux+8xkrFOpLJ3LWcXOWQEhQJJ0sj9idiWoHa7P6kZVk7mQ+lhTw==}
applesauce-loaders@0.0.0-next-20250310121307:
resolution: {integrity: sha512-KiqVGqzIEOuGfLU6TGGjRFru7/0e1HATSyWsXyvyPJd0Pt5Ud9tkJidI+bAY+jZthDcBlFdEPT4HTPjKJ63AOg==}
applesauce-loaders@0.0.0-next-20250310162525:
resolution: {integrity: sha512-lwJvrruMeeNo25WK87GSDHb3QkxEmgM90rLrLSAdNVxhNW99ZhnHWmJr3bwMnRgRQoOTqNHAHhBV8Uk2TGsWCA==}
applesauce-react@0.0.0-next-20250310121307:
resolution: {integrity: sha512-MrWVYJUuKGAuW0ZjUTBgNP/o4AVth1vPlmAra0xYsH5y8puQvU0APqXpH60yrOGqMvtWiZbpMpnhPKjmjytxfw==}
applesauce-react@0.0.0-next-20250310162525:
resolution: {integrity: sha512-ZOewc/bvC2EiO5lOhNyiOBPzh1NsNA1fEDdHLI5oyUs76n4Pl/2sIz2Rhm9i+wMyxvrpqd26itglILibnMh+tw==}
applesauce-relay@0.0.0-next-20250310121307:
resolution: {integrity: sha512-5QyyRQc5vEHGUWJgHRw6hM5zSkvkqt+UYrwkL9yGcP1rQ/IyM0kcoog1VobKrSGjlQOyXxhw0cShSKoTzGWreA==}
applesauce-relay@0.0.0-next-20250310162525:
resolution: {integrity: sha512-JOq2SktlRGr6EIBJS3aZXxo+ySDcdySFdjlrNtSAMRfH6jGKHEkjbqgaMPdNQ0QGAkIYnnLTWwFSQJEYpH4H0w==}
applesauce-signers@0.0.0-next-20250310121307:
resolution: {integrity: sha512-dfppzzFIT3FtLF9JmBNAQToD8euOsWqt940itYMvesP5gV7BEHCSPiBzHdaXPNfK5/ay/kN9WHleBdwVcKTW5A==}
applesauce-signers@0.0.0-next-20250310162525:
resolution: {integrity: sha512-phmk143NUPEwu7h4bEL9Fpofx12C9lawhoC7+47CGYjgOdfAT2xjH+Xicot7qqUYjlE0QzYikS5rLzUXA/m93w==}
applesauce-wallet@0.0.0-next-20250310121307:
resolution: {integrity: sha512-9rwmR4RsJ1hv5nM7uOo/HWKHzhkhuueEtL99ZJleOGnQWm6fTz0LlMRDCT8OLlX7ZCC9Oho7p38YBqISQzJHew==}
applesauce-wallet@0.0.0-next-20250310162525:
resolution: {integrity: sha512-Bcp/HUyGyW3057wtK0y/eWLeEmV2WfuwpMNFWAHjs/Ro50FFNLt9YA7tEjiMQe42KQTwn+YjBXOpm2FFxDxAfg==}
arg@4.1.3:
resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==}
@ -6961,7 +6961,7 @@ snapshots:
'@scure/bip32': 1.6.2
buffer: 6.0.3
'@cashu/cashu-ts@2.2.1':
'@cashu/cashu-ts@2.2.2':
dependencies:
'@cashu/crypto': 0.3.4
'@noble/curves': 1.8.1
@ -8435,10 +8435,10 @@ snapshots:
dependencies:
entities: 2.2.0
applesauce-accounts@0.0.0-next-20250310121307(typescript@5.8.2):
applesauce-accounts@0.0.0-next-20250310162525(typescript@5.8.2):
dependencies:
'@noble/hashes': 1.7.1
applesauce-signers: 0.0.0-next-20250310121307(typescript@5.8.2)
applesauce-signers: 0.0.0-next-20250310162525(typescript@5.8.2)
nanoid: 5.1.3
nostr-tools: 2.10.4(typescript@5.8.2)
rxjs: 7.8.2
@ -8446,13 +8446,13 @@ snapshots:
- supports-color
- typescript
applesauce-content@0.0.0-next-20250310121307(typescript@5.8.2):
applesauce-content@0.0.0-next-20250310162525(typescript@5.8.2):
dependencies:
'@cashu/cashu-ts': 2.0.0-rc1
'@types/hast': 3.0.4
'@types/mdast': 4.0.4
'@types/unist': 3.0.3
applesauce-core: 0.0.0-next-20250310121307(typescript@5.8.2)
applesauce-core: 0.0.0-next-20250310162525(typescript@5.8.2)
mdast-util-find-and-replace: 3.0.2
nostr-tools: 2.10.4(typescript@5.8.2)
remark: 15.0.1
@ -8463,7 +8463,7 @@ snapshots:
- supports-color
- typescript
applesauce-core@0.0.0-next-20250310121307(typescript@5.8.2):
applesauce-core@0.0.0-next-20250310162525(typescript@5.8.2):
dependencies:
'@noble/hashes': 1.7.1
'@scure/base': 1.2.4
@ -8478,19 +8478,19 @@ snapshots:
- supports-color
- typescript
applesauce-factory@0.0.0-next-20250310121307(typescript@5.8.2):
applesauce-factory@0.0.0-next-20250310162525(typescript@5.8.2):
dependencies:
applesauce-content: 0.0.0-next-20250310121307(typescript@5.8.2)
applesauce-core: 0.0.0-next-20250310121307(typescript@5.8.2)
applesauce-content: 0.0.0-next-20250310162525(typescript@5.8.2)
applesauce-core: 0.0.0-next-20250310162525(typescript@5.8.2)
nanoid: 5.1.3
nostr-tools: 2.10.4(typescript@5.8.2)
transitivePeerDependencies:
- supports-color
- typescript
applesauce-loaders@0.0.0-next-20250310121307(typescript@5.8.2):
applesauce-loaders@0.0.0-next-20250310162525(typescript@5.8.2):
dependencies:
applesauce-core: 0.0.0-next-20250310121307(typescript@5.8.2)
applesauce-core: 0.0.0-next-20250310162525(typescript@5.8.2)
nanoid: 5.1.3
nostr-tools: 2.10.4(typescript@5.8.2)
rx-nostr: 3.5.0
@ -8499,12 +8499,12 @@ snapshots:
- supports-color
- typescript
applesauce-react@0.0.0-next-20250310121307(react-dom@19.0.0(react@19.0.0))(typescript@5.8.2):
applesauce-react@0.0.0-next-20250310162525(react-dom@19.0.0(react@19.0.0))(typescript@5.8.2):
dependencies:
applesauce-accounts: 0.0.0-next-20250310121307(typescript@5.8.2)
applesauce-content: 0.0.0-next-20250310121307(typescript@5.8.2)
applesauce-core: 0.0.0-next-20250310121307(typescript@5.8.2)
applesauce-factory: 0.0.0-next-20250310121307(typescript@5.8.2)
applesauce-accounts: 0.0.0-next-20250310162525(typescript@5.8.2)
applesauce-content: 0.0.0-next-20250310162525(typescript@5.8.2)
applesauce-core: 0.0.0-next-20250310162525(typescript@5.8.2)
applesauce-factory: 0.0.0-next-20250310162525(typescript@5.8.2)
nostr-tools: 2.10.4(typescript@5.8.2)
observable-hooks: 4.2.4(react-dom@19.0.0(react@19.0.0))(react@18.3.1)(rxjs@7.8.2)
react: 18.3.1
@ -8514,9 +8514,9 @@ snapshots:
- supports-color
- typescript
applesauce-relay@0.0.0-next-20250310121307(typescript@5.8.2):
applesauce-relay@0.0.0-next-20250310162525(typescript@5.8.2):
dependencies:
applesauce-core: 0.0.0-next-20250310121307(typescript@5.8.2)
applesauce-core: 0.0.0-next-20250310162525(typescript@5.8.2)
nanoid: 5.1.3
nostr-tools: 2.10.4(typescript@5.8.2)
rxjs: 7.8.2
@ -8524,12 +8524,12 @@ snapshots:
- supports-color
- typescript
applesauce-signers@0.0.0-next-20250310121307(typescript@5.8.2):
applesauce-signers@0.0.0-next-20250310162525(typescript@5.8.2):
dependencies:
'@noble/hashes': 1.7.1
'@noble/secp256k1': 1.7.1
'@scure/base': 1.2.4
applesauce-core: 0.0.0-next-20250310121307(typescript@5.8.2)
applesauce-core: 0.0.0-next-20250310162525(typescript@5.8.2)
debug: 4.4.0
nanoid: 5.1.3
nostr-tools: 2.10.4(typescript@5.8.2)
@ -8537,9 +8537,9 @@ snapshots:
- supports-color
- typescript
applesauce-wallet@0.0.0-next-20250310121307(typescript@5.8.2):
applesauce-wallet@0.0.0-next-20250310162525(typescript@5.8.2):
dependencies:
applesauce-core: 0.0.0-next-20250310121307(typescript@5.8.2)
applesauce-core: 0.0.0-next-20250310162525(typescript@5.8.2)
nostr-tools: 2.10.4(typescript@5.8.2)
rxjs: 7.8.2
transitivePeerDependencies:
@ -8700,7 +8700,7 @@ snapshots:
blossom-client-sdk@3.0.1:
dependencies:
'@cashu/cashu-ts': 2.2.1
'@cashu/cashu-ts': 2.2.2
'@noble/hashes': 1.7.1
blossom-server-sdk@0.4.0:

View File

@ -0,0 +1,20 @@
import { useMemo } from "react";
import { useAsync } from "react-use";
import { Avatar, AvatarProps } from "@chakra-ui/react";
import { MediaServerIcon } from "../icons";
import { getCashuMint } from "../../services/cashu-mints";
export default function CashuMintFavicon({ mint, ...props }: { mint: string } & Omit<AvatarProps, "src">) {
const { value: cashuMint } = useAsync(() => getCashuMint(mint), [mint]);
const { value: info } = useAsync(async () => cashuMint?.getInfo(), [cashuMint]);
const url = useMemo(() => {
const url = new URL(mint);
url.protocol = "https:";
url.pathname = "/favicon.ico";
return url.toString();
}, [mint]);
return <Avatar src={url} icon={<MediaServerIcon />} overflow="hidden" {...props} />;
}

View File

@ -0,0 +1,14 @@
import { AvatarProps, Text } from "@chakra-ui/react";
import { useObservable } from "applesauce-react/hooks";
import { cashuMintInfo } from "../../services/cashu-mints";
export default function CashuMintName({ mint, ...props }: { mint: string } & Omit<AvatarProps, "src">) {
const info = useObservable(cashuMintInfo(mint));
return (
<Text as="span" {...props}>
{info?.name || mint}
</Text>
);
}

View File

@ -10,7 +10,7 @@ import CurrencyEuro from "../icons/currency-euro";
import CurrencyYen from "../icons/currency-yen";
import CurrencyPound from "../icons/currency-pound";
import CurrencyBitcoin from "../icons/currency-bitcoin";
import { getMintWallet } from "../../services/cashu-mints";
import { getCashuWallet } from "../../services/cashu-mints";
export default function InlineCachuCard({
token,
@ -20,7 +20,7 @@ export default function InlineCachuCard({
encoded = encoded || getEncodedToken(token);
const { value: spendable, loading } = useAsync(async () => {
if (!token) return;
const wallet = await getMintWallet(token.mint);
const wallet = await getCashuWallet(token.mint);
const status = await wallet.checkProofsStates(token.proofs);
return status.some((s) => s.state !== CheckStateEnum.UNSPENT);
}, [token]);

View File

@ -1,5 +1,5 @@
import { Suspense, lazy, useCallback } from "react";
import { IconButton, useDisclosure, useToast } from "@chakra-ui/react";
import { IconButton, IconButtonProps, useDisclosure, useToast } from "@chakra-ui/react";
import { type QrScannerModalProps } from "./qr-scanner-modal";
import { CAP_IS_NATIVE } from "../../env";
@ -71,7 +71,10 @@ async function scanWithNative() {
}
}
export default function QRCodeScannerButton({ onData }: { onData: QrScannerModalProps["onData"] }) {
export default function QRCodeScannerButton({
onData,
...props
}: { onData: QrScannerModalProps["onData"] } & Omit<IconButtonProps, "icon" | "aria-label">) {
const toast = useToast();
const modal = useDisclosure();
@ -91,7 +94,7 @@ export default function QRCodeScannerButton({ onData }: { onData: QrScannerModal
return (
<>
<IconButton onClick={handleClick} icon={<QrCodeIcon boxSize={6} />} aria-label="Qr Scanner" />
<IconButton onClick={handleClick} icon={<QrCodeIcon boxSize={6} />} aria-label="Qr Scanner" {...props} />
{modal.isOpen && (
<Suspense fallback={null}>
<QrScannerModal isOpen={modal.isOpen} onClose={modal.onClose} onData={onData} />

View File

@ -1,13 +1,46 @@
import { CashuMint, CashuWallet } from "@cashu/cashu-ts";
import { CashuMint, CashuWallet, GetInfoResponse } from "@cashu/cashu-ts";
import { normalizeURL } from "applesauce-core/helpers";
import { from, Observable, ReplaySubject, share, switchMap } from "rxjs";
const mints = new Map<string, CashuMint>();
const wallets = new Map<string, CashuWallet>();
export async function getMintWallet(url: string) {
export async function getCashuMint(url: string) {
const formatted = new URL(url).toString();
if (!mints.has(formatted)) {
const mint = new CashuMint(formatted);
mints.set(formatted, mint);
}
return mints.get(formatted)!;
}
export async function getCashuWallet(url: string) {
const formatted = new URL(url).toString();
if (!wallets.has(formatted)) {
const mint = new CashuMint(formatted);
const mint = await getCashuMint(url);
const wallet = new CashuWallet(mint);
wallets.set(formatted, wallet);
}
return wallets.get(formatted)!;
}
const mintInfo = new Map<string, Observable<GetInfoResponse>>();
export function cashuMintInfo(mint: string): Observable<GetInfoResponse> {
mint = normalizeURL(mint);
const existing = mintInfo.get(mint);
if (existing) return existing;
const observable = from(getCashuMint(mint)).pipe(
// fetch mint info
switchMap((m) => from(m.getInfo())),
// share value and keep warm for 2 minutes
share({
connector: () => new ReplaySubject(1),
resetOnRefCountZero: false,
resetOnComplete: false,
resetOnError: false,
}),
);
mintInfo.set(mint, observable);
return observable;
}

View File

@ -8,7 +8,7 @@ import RequireActiveAccount from "../../../components/router/require-active-acco
import useUserMailboxes from "../../../hooks/use-user-mailboxes";
import { useActiveAccount } from "applesauce-react/hooks";
import { InboxIcon, OutboxIcon } from "../../../components/icons";
import MediaServerFavicon from "../../../components/media-server/media-server-favicon";
import MediaServerFavicon from "../../../components/favicon/media-server-favicon";
import { NostrEvent } from "../../../types/nostr-event";
import useAsyncErrorHandler from "../../../hooks/use-async-error-handler";
import { usePublishEvent } from "../../../providers/global/publish-provider";

View File

@ -19,7 +19,7 @@ import { useForm } from "react-hook-form";
import RequireActiveAccount from "../../../components/router/require-active-account";
import { useActiveAccount } from "applesauce-react/hooks";
import MediaServerFavicon from "../../../components/media-server/media-server-favicon";
import MediaServerFavicon from "../../../components/favicon/media-server-favicon";
import { usePublishEvent } from "../../../providers/global/publish-provider";
import useUsersMediaServers from "../../../hooks/use-user-media-servers";
import DebugEventButton from "../../../components/debug-modal/debug-event-button";

View File

@ -1,44 +1,37 @@
import { Button, Card, CardBody, CardFooter, CardHeader, CardProps, Text } from "@chakra-ui/react";
import { useActiveAccount, useEventStore, useStoreQuery } from "applesauce-react/hooks";
import { Button, Card, CardBody, CardHeader, CardProps, Flex, Text } from "@chakra-ui/react";
import { useStoreQuery } from "applesauce-react/hooks";
import { WalletBalanceQuery } from "applesauce-wallet/queries";
import { ECashIcon } from "../../components/icons";
import useReplaceableEvent from "../../hooks/use-replaceable-event";
import { isWalletLocked, unlockWallet, WALLET_KIND } from "applesauce-wallet/helpers";
import useAsyncErrorHandler from "../../hooks/use-async-error-handler";
import { WALLET_KIND } from "applesauce-wallet/helpers";
import useEventUpdate from "../../hooks/use-event-update";
import QRCodeScannerButton from "../../components/qr-code/qr-code-scanner-button";
export default function WalletBalanceCard({ pubkey, ...props }: { pubkey: string } & Omit<CardProps, "children">) {
const account = useActiveAccount();
const eventStore = useEventStore();
const wallet = useReplaceableEvent({ kind: WALLET_KIND, pubkey });
useEventUpdate(wallet?.id);
const locked = !wallet || isWalletLocked(wallet);
const balance = useStoreQuery(WalletBalanceQuery, [pubkey]);
const unlock = useAsyncErrorHandler(async () => {
if (!account) throw new Error("Missing account");
if (!wallet) throw new Error("Missing wallet");
await unlockWallet(wallet, account);
eventStore.update(wallet);
}, [wallet, account]);
return (
<Card {...props}>
<CardHeader gap="2" display="flex" justifyContent="center" alignItems="center" pt="10">
<ECashIcon color="green.400" boxSize={6} />
<Text fontWeight="bold" fontSize="lg">
<CardHeader gap="4" display="flex" justifyContent="center" alignItems="center" pt="10">
<ECashIcon color="green.400" boxSize={12} />
<Text fontWeight="bold" fontSize="4xl">
{balance ? Object.values(balance).reduce((t, v) => t + v, 0) : "--Locked--"}
</Text>
</CardHeader>
<CardBody></CardBody>
{locked && (
<CardFooter display="flex">
<Button colorScheme="primary" onClick={unlock} mx="auto">
Unlock
<CardBody>
<Flex gap="2" w="full">
<Button isDisabled w="full" size="lg">
Send
</Button>
</CardFooter>
)}
<QRCodeScannerButton onData={() => {}} isDisabled size="lg" />
<Button isDisabled w="full" size="lg">
Receive
</Button>
</Flex>
</CardBody>
</Card>
);
}

View File

@ -1,16 +1,18 @@
import { Button, ButtonGroup, Card, CardBody, CardFooter, Flex, Text } from "@chakra-ui/react";
import { kinds, NostrEvent } from "nostr-tools";
import { WalletQuery } from "applesauce-wallet/queries";
import { Button, Card, Tab, TabList, TabPanel, TabPanels, Tabs } from "@chakra-ui/react";
import { kinds } from "nostr-tools";
import { WalletBalanceQuery, WalletQuery } from "applesauce-wallet/queries";
import {
getTokenDetails,
isHistoryDetailsLocked,
isTokenDetailsLocked,
unlockHistoryDetails,
unlockTokenDetails,
unlockWallet,
WALLET_HISTORY_KIND,
WALLET_KIND,
WALLET_TOKEN_KIND,
} from "applesauce-wallet/helpers";
import { useActiveAccount, useEventStore, useStoreQuery } from "applesauce-react/hooks";
import { useActiveAccount, useStoreQuery } from "applesauce-react/hooks";
import useAsyncErrorHandler from "../../hooks/use-async-error-handler";
import { eventStore } from "../../services/event-store";
import useReplaceableEvent from "../../hooks/use-replaceable-event";
@ -20,53 +22,10 @@ import useUserMailboxes from "../../hooks/use-user-mailboxes";
import { useReadRelays } from "../../hooks/use-client-relays";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import IntersectionObserverProvider from "../../providers/local/intersection-observer";
import useEventIntersectionRef from "../../hooks/use-event-intersection-ref";
import useEventUpdate from "../../hooks/use-event-update";
import DebugEventButton from "../../components/debug-modal/debug-event-button";
import { ECashIcon } from "../../components/icons";
import WalletBalanceCard from "./balance-card";
import { useMemo } from "react";
function TokenEvent({ token }: { token: NostrEvent }) {
const account = useActiveAccount();
const eventStore = useEventStore();
useEventUpdate(token.id);
const ref = useEventIntersectionRef(token);
const locked = isTokenDetailsLocked(token);
const details = !locked ? getTokenDetails(token) : undefined;
const amount = details?.proofs.reduce((t, p) => t + p.amount, 0);
const unlock = useAsyncErrorHandler(async () => {
if (!account) return;
await unlockTokenDetails(token, account);
eventStore.update(token);
}, [token, account, eventStore]);
return (
<Card ref={ref} w="full">
<CardBody p="2" alignItems="center" flexDirection="row" display="flex" gap="2">
<ECashIcon color="green.400" boxSize={6} />
{amount && <Text>{amount}</Text>}
<ButtonGroup size="sm" ms="auto">
{locked && (
<Button onClick={unlock} variant="link" p="2">
Unlock
</Button>
)}
<DebugEventButton variant="ghost" event={token} />
</ButtonGroup>
</CardBody>
{details && (
<CardFooter px="2" pt="0" pb="0">
<Text fontSize="sm" fontStyle="italic">
{details.mint}
</Text>
</CardFooter>
)}
</Card>
);
}
import WalletTokensTab from "./tabs/tokens";
import WalletHistoryTab from "./tabs/history";
import WalletMintsTab from "./tabs/mints";
export default function WalletHomeView() {
const account = useActiveAccount()!;
@ -76,13 +35,12 @@ export default function WalletHomeView() {
const readRelays = useReadRelays(mailboxes?.outboxes);
const { timeline: events, loader } = useTimelineLoader(`${account.pubkey}-wallet-tokens`, readRelays, [
{
kinds: [WALLET_TOKEN_KIND],
kinds: [WALLET_TOKEN_KIND, WALLET_HISTORY_KIND],
authors: [account.pubkey],
},
{ kinds: [kinds.EventDeletion], "#k": [String(WALLET_TOKEN_KIND)], authors: [account.pubkey] },
]);
const tokens = useMemo(() => events.filter((e) => e.kind === WALLET_TOKEN_KIND), [events]);
const balance = useStoreQuery(WalletBalanceQuery, [account.pubkey]);
const unlock = useAsyncErrorHandler(async () => {
if (!wallet) throw new Error("Missing wallet");
@ -90,11 +48,18 @@ export default function WalletHomeView() {
eventStore.update(wallet);
// attempt to unlock all tokens
for (const token of tokens) {
await unlockTokenDetails(token, account);
eventStore.update(token);
for (const event of events) {
if (event.kind === WALLET_TOKEN_KIND) {
if (!isTokenDetailsLocked(event)) continue;
await unlockTokenDetails(event, account);
eventStore.update(event);
} else if (event.kind === WALLET_HISTORY_KIND) {
if (!isHistoryDetailsLocked(event)) continue;
await unlockHistoryDetails(event, account);
eventStore.update(event);
}
}
}, [wallet, account, tokens]);
}, [wallet, account, events]);
const walletInfo = useStoreQuery(WalletQuery, [account.pubkey]);
@ -113,19 +78,30 @@ export default function WalletHomeView() {
}
>
<WalletBalanceCard pubkey={account.pubkey} w="full" maxW="2xl" mx="auto" />
{walletInfo?.locked === false && (
<Card p="2" whiteSpace="pre-line">
Key: {walletInfo.privateKey}
<br />
Mints: {walletInfo.mints.join(", ")}
</Card>
{walletInfo?.locked && (
<Button onClick={unlock} colorScheme="primary" mx="auto" size="lg" w="sm">
Unlock
</Button>
)}
<Flex direction="column" gap="2" w="full" maxW="lg" mx="auto">
{tokens.map((token) => (
<TokenEvent key={token.id} token={token} />
))}
</Flex>
<Tabs isFitted maxW="2xl" mx="auto" w="full" isLazy>
<TabList mb="1em">
<Tab>History ({events.filter((e) => e.kind === WALLET_HISTORY_KIND).length})</Tab>
<Tab>Tokens ({events.filter((e) => e.kind === WALLET_TOKEN_KIND).length})</Tab>
<Tab>Mints ({balance ? Object.keys(balance).length : 0})</Tab>
</TabList>
<TabPanels>
<TabPanel p="0">
<WalletHistoryTab />
</TabPanel>
<TabPanel p="0">
<WalletTokensTab />
</TabPanel>
<TabPanel p="0">
<WalletMintsTab />
</TabPanel>
</TabPanels>
</Tabs>
</SimpleView>
</IntersectionObserverProvider>
);

View File

@ -0,0 +1,122 @@
import {
AvatarGroup,
Button,
ButtonGroup,
Card,
CardBody,
CardFooter,
Flex,
IconButton,
Spacer,
Text,
} from "@chakra-ui/react";
import { useActiveAccount, useEventStore, useStoreQuery } from "applesauce-react/hooks";
import {
getHistoryDetails,
getHistoryRedeemed,
isHistoryDetailsLocked,
unlockHistoryDetails,
} from "applesauce-wallet/helpers";
import { WalletHistoryQuery } from "applesauce-wallet/queries";
import { NostrEvent } from "nostr-tools";
import Lock01 from "../../../components/icons/lock-01";
import DebugEventButton from "../../../components/debug-modal/debug-event-button";
import ArrowBlockUp from "../../../components/icons/arrow-block-up";
import ArrowBlockDown from "../../../components/icons/arrow-block-down";
import useEventIntersectionRef from "../../../hooks/use-event-intersection-ref";
import useAsyncErrorHandler from "../../../hooks/use-async-error-handler";
import { useDeleteEventContext } from "../../../providers/route/delete-event-provider";
import { TrashIcon } from "../../../components/icons";
import useEventUpdate from "../../../hooks/use-event-update";
import Timestamp from "../../../components/timestamp";
import useSingleEvents from "../../../hooks/use-single-events";
import UserAvatarLink from "../../../components/user/user-avatar-link";
function HistoryEntry({ entry }: { entry: NostrEvent }) {
const account = useActiveAccount()!;
const eventStore = useEventStore();
const locked = isHistoryDetailsLocked(entry);
const details = !locked ? getHistoryDetails(entry) : undefined;
useEventUpdate(entry.id);
const ref = useEventIntersectionRef(entry);
const { deleteEvent } = useDeleteEventContext();
const redeemedIds = getHistoryRedeemed(entry);
const redeemed = useSingleEvents(redeemedIds);
const unlock = useAsyncErrorHandler(async () => {
await unlockHistoryDetails(entry, account);
eventStore.update(entry);
}, [entry, account, eventStore]);
return (
<Card ref={ref}>
<CardBody p="2" display="flex" flexDirection="row" gap="2">
{locked ? (
<Lock01 boxSize={8} />
) : details?.direction === "in" ? (
<ArrowBlockDown boxSize={8} color="green.500" />
) : (
<ArrowBlockUp boxSize={8} color="orange.500" />
)}
<Text fontSize="xl">{details?.amount}</Text>
<Spacer />
<ButtonGroup size="sm" alignItems="center">
{locked && (
<Button onClick={unlock} variant="link" p="2">
Unlock
</Button>
)}
<Timestamp timestamp={entry.created_at} />
<DebugEventButton variant="ghost" event={entry} />
<IconButton
icon={<TrashIcon boxSize={5} />}
aria-label="Delete entry"
onClick={() => deleteEvent(entry)}
colorScheme="red"
variant="ghost"
/>
</ButtonGroup>
</CardBody>
{redeemed.length > 0 && (
<CardFooter p="2">
<Text mr="2">Redeemed zaps from:</Text>
<AvatarGroup size="sm">
{redeemed.map((event) => (
<UserAvatarLink pubkey={event.pubkey} />
))}
</AvatarGroup>
</CardFooter>
)}
</Card>
);
}
export default function WalletHistoryTab() {
const account = useActiveAccount()!;
const eventStore = useEventStore();
const history = useStoreQuery(WalletHistoryQuery, [account.pubkey]) ?? [];
const locked = useStoreQuery(WalletHistoryQuery, [account.pubkey, true]) ?? [];
const unlock = useAsyncErrorHandler(async () => {
for (const entry of locked) {
if (!isHistoryDetailsLocked(entry)) continue;
await unlockHistoryDetails(entry, account);
eventStore.update(entry);
}
}, [locked, account, eventStore]);
return (
<Flex direction="column" gap="2" w="full">
{locked && locked.length > 0 && (
<Button onClick={unlock} size="sm" variant="link" p="2" ms="auto">
Unlock all ({locked?.length})
</Button>
)}
{history?.map((entry) => <HistoryEntry key={entry.id} entry={entry} />)}
</Flex>
);
}

View File

@ -0,0 +1,30 @@
import { Box, Card, Flex, Link, Text } from "@chakra-ui/react";
import { useActiveAccount, useStoreQuery } from "applesauce-react/hooks";
import { WalletBalanceQuery } from "applesauce-wallet/queries";
import CashuMintFavicon from "../../../components/cashu/cashu-mint-favicon";
import CashuMintName from "../../../components/cashu/cashu-mint-name";
export default function WalletMintsTab() {
const account = useActiveAccount()!;
const balance = useStoreQuery(WalletBalanceQuery, [account.pubkey]);
return (
<Flex direction="column" gap="2">
{balance &&
Object.entries(balance).map(([mint, total]) => (
<Card key={mint} gap="2" p="2" display="flex" direction="row">
<CashuMintFavicon mint={mint} size="sm" />
<Flex direction="column" w="full">
<Flex w="full" justifyContent="space-between">
<CashuMintName mint={mint} fontWeight="bold" />
<Link href={mint} isExternal fontStyle="italic">
{mint}
</Link>
</Flex>
<Text>Amount: {total}</Text>
</Flex>
</Card>
))}
</Flex>
);
}

View File

@ -0,0 +1,124 @@
import {
Button,
ButtonGroup,
Card,
CardFooter,
CardHeader,
Flex,
FlexProps,
IconButton,
Spacer,
Text,
} from "@chakra-ui/react";
import { useActiveAccount, useEventStore, useStoreQuery } from "applesauce-react/hooks";
import { WalletTokensQuery } from "applesauce-wallet/queries";
import { getTokenDetails, isTokenDetailsLocked, unlockTokenDetails } from "applesauce-wallet/helpers";
import { NostrEvent } from "nostr-tools";
import useAsyncErrorHandler from "../../../hooks/use-async-error-handler";
import useEventUpdate from "../../../hooks/use-event-update";
import useEventIntersectionRef from "../../../hooks/use-event-intersection-ref";
import { ECashIcon, TrashIcon } from "../../../components/icons";
import DebugEventButton from "../../../components/debug-modal/debug-event-button";
import { useDeleteEventContext } from "../../../providers/route/delete-event-provider";
import Timestamp from "../../../components/timestamp";
import { useState } from "react";
import { getCashuWallet } from "../../../services/cashu-mints";
function TokenEvent({ token }: { token: NostrEvent }) {
const account = useActiveAccount();
const eventStore = useEventStore();
useEventUpdate(token.id);
const ref = useEventIntersectionRef(token);
const locked = isTokenDetailsLocked(token);
const details = !locked ? getTokenDetails(token) : undefined;
const amount = details?.proofs.reduce((t, p) => t + p.amount, 0);
const [spent, setSpent] = useState<boolean>();
const check = useAsyncErrorHandler(async () => {
if (!details) return;
const wallet = await getCashuWallet(details.mint);
const state = await wallet.checkProofsStates(details.proofs);
setSpent(!state.some((t) => t.state === "UNSPENT"));
}, [details, setSpent]);
const { deleteEvent } = useDeleteEventContext();
const unlock = useAsyncErrorHandler(async () => {
if (!account) return;
await unlockTokenDetails(token, account);
eventStore.update(token);
}, [token, account, eventStore]);
return (
<Card ref={ref} w="full">
<CardHeader p="2" alignItems="center" flexDirection="row" display="flex" gap="2">
<ECashIcon color="green.400" boxSize={8} />
{amount && <Text fontSize="xl">{amount}</Text>}
<ButtonGroup size="sm" ms="auto" alignItems="center">
{locked && (
<Button onClick={unlock} variant="link" p="2">
Unlock
</Button>
)}
<Timestamp timestamp={token.created_at} />
<DebugEventButton variant="ghost" event={token} />
<IconButton
icon={<TrashIcon boxSize={5} />}
aria-label="Delete entry"
onClick={() => deleteEvent(token)}
colorScheme="red"
variant="ghost"
/>
</ButtonGroup>
</CardHeader>
{details && (
<CardFooter px="2" pt="0" pb="0" gap="2" display="flex">
<Button
variant="link"
colorScheme={spent === undefined ? undefined : spent ? "red" : "green"}
onClick={check}
>
{spent === undefined ? "Check" : spent ? "Spent" : "Unspent"}
</Button>
<Spacer />
<Text fontSize="sm" fontStyle="italic">
{details.mint}
</Text>
</CardFooter>
)}
</Card>
);
}
export default function WalletTokensTab({ ...props }: Omit<FlexProps, "children">) {
const account = useActiveAccount()!;
const eventStore = useEventStore();
const tokens = useStoreQuery(WalletTokensQuery, [account.pubkey]) ?? [];
const locked = useStoreQuery(WalletTokensQuery, [account.pubkey, true]) ?? [];
const unlock = useAsyncErrorHandler(async () => {
if (!locked) return;
for (const token of locked) {
await unlockTokenDetails(token, account);
eventStore.update(token);
}
}, [locked, account, eventStore]);
return (
<Flex direction="column" gap="2" {...props}>
{locked && locked.length > 0 && (
<Button onClick={unlock} size="sm" variant="link" p="2" ms="auto">
Unlock all ({locked?.length})
</Button>
)}
{tokens.map((token) => (
<TokenEvent key={token.id} token={token} />
))}
</Flex>
);
}