improve wallet receive view

This commit is contained in:
hzrd149 2025-03-17 18:25:46 +00:00
parent 458c18db08
commit a973a6dbb1
9 changed files with 265 additions and 118 deletions

View File

@ -1,64 +0,0 @@
import { useState } from "react";
import { useActionHub } from "applesauce-react/hooks";
import { Button, Flex, Spacer, Textarea, useToast } from "@chakra-ui/react";
import { getDecodedToken, Token } from "@cashu/cashu-ts";
import { ReceiveToken } from "applesauce-wallet/actions";
import { useLocation, useNavigate } from "react-router-dom";
import SimpleView from "../../components/layout/presets/simple-view";
import { getCashuWallet } from "../../services/cashu-mints";
import RouterLink from "../../components/router-link";
import QRCodeScannerButton from "../../components/qr-code/qr-code-scanner-button";
export default function WalletReceiveView() {
const location = useLocation();
const actions = useActionHub();
const navigate = useNavigate();
const toast = useToast();
const [input, setInput] = useState(location.state?.input ?? "");
const [loading, setLoading] = useState(false);
const receive = async () => {
setLoading(true);
try {
const decoded = getDecodedToken(input.trim());
const originalAmount = decoded.proofs.reduce((t, p) => t + p.amount, 0);
// swap tokens
const wallet = await getCashuWallet(decoded.mint);
const proofs = await wallet.receive(decoded);
const token: Token = { mint: decoded.mint, proofs };
const amount = token.proofs.reduce((t, p) => t + p.amount, 0);
const fee = originalAmount - amount;
// save new tokens
await actions.run(ReceiveToken, token, undefined, fee || undefined);
toast({ status: "success", description: `Received ${amount} sats` });
navigate("/wallet");
} catch (error) {
if (error instanceof Error) toast({ status: "error", description: error.message });
console.log(error);
}
setLoading(false);
};
return (
<SimpleView title="Receive" maxW="2xl" center>
<Textarea value={input} onChange={(e) => setInput(e.target.value)} placeholder="cashuB...." rows={10} />
<Flex gap="2">
<Button as={RouterLink} to="/wallet">
Back
</Button>
<Spacer />
<QRCodeScannerButton onResult={setInput} />
<Button colorScheme="primary" onClick={receive} isLoading={loading}>
Receive
</Button>
</Flex>
</SimpleView>
);
}

View File

@ -0,0 +1,68 @@
import { useState } from "react";
import { Button, Flex, IconButton, Spacer, Textarea, useToast } from "@chakra-ui/react";
import { getDecodedToken, getEncodedToken } from "@cashu/cashu-ts";
import { decodeTokenFromEmojiString } from "applesauce-wallet/helpers";
import { useLocation, useNavigate } from "react-router-dom";
import SimpleView from "../../../components/layout/presets/simple-view";
import RouterLink from "../../../components/router-link";
import QRCodeScannerButton from "../../../components/qr-code/qr-code-scanner-button";
import Clipboard from "../../../components/icons/clipboard";
export default function WalletReceiveView() {
const location = useLocation();
const navigate = useNavigate();
const toast = useToast();
const [input, setInput] = useState(location.state?.input ?? "");
const handleScannerResult = (result: string) => {
try {
const token = getDecodedToken(result.trim());
navigate("/wallet/receive/token", { state: { token: getEncodedToken(token) }, replace: true });
} catch (error) {
setInput(result);
}
};
const receive = () => {
try {
const token = getDecodedToken(input.trim());
navigate("/wallet/receive/token", { state: { token: getEncodedToken(token) }, replace: true });
} catch (error) {
if (error instanceof Error) toast({ status: "error", description: error.message });
}
};
return (
<SimpleView title="Receive" maxW="2xl" center>
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onPaste={(e) => {
try {
const token = decodeTokenFromEmojiString(e.clipboardData.getData("text"));
if (token) navigate("/wallet/receive/token", { state: { token: getEncodedToken(token) } });
} catch (error) {}
}}
placeholder="cashuB...."
rows={10}
/>
<Flex gap="2">
<Button as={RouterLink} to="/wallet">
Back
</Button>
<Spacer />
<IconButton
icon={<Clipboard boxSize={5} />}
aria-label="Paste"
onClick={async () => handleScannerResult(await navigator.clipboard.readText())}
/>
<QRCodeScannerButton onResult={handleScannerResult} />
<Button colorScheme="primary" onClick={receive}>
Receive
</Button>
</Flex>
</SimpleView>
);
}

View File

@ -0,0 +1,70 @@
import { Flex, Spacer, Button, Text, Card, CardBody, useToast, useDisclosure, ButtonGroup } from "@chakra-ui/react";
import { useLocation, Navigate, useNavigate } from "react-router-dom";
import { useActionHub } from "applesauce-react/hooks";
import { getDecodedToken, Token } from "@cashu/cashu-ts";
import { ReceiveToken } from "applesauce-wallet/actions";
import SimpleView from "../../../components/layout/presets/simple-view";
import RouterLink from "../../../components/router-link";
import CashuMintFavicon from "../../../components/cashu/cashu-mint-favicon";
import CashuMintName from "../../../components/cashu/cashu-mint-name";
import useAsyncErrorHandler from "../../../hooks/use-async-error-handler";
import { getCashuWallet } from "../../../services/cashu-mints";
export default function WalletReceiveTokenView() {
const navigate = useNavigate();
const toast = useToast();
const actions = useActionHub();
const location = useLocation();
const more = useDisclosure();
const token: string = location.state?.token;
if (!token) return <Navigate to="/wallet" />;
const decoded = getDecodedToken(token);
const originalAmount = decoded.proofs.reduce((t, p) => t + p.amount, 0);
const receive = useAsyncErrorHandler(async () => {
try {
// swap tokens
const wallet = await getCashuWallet(decoded.mint);
const proofs = await wallet.receive(decoded);
const token: Token = { mint: decoded.mint, proofs };
const amount = token.proofs.reduce((t, p) => t + p.amount, 0);
const fee = originalAmount - amount;
// save new tokens
await actions.run(ReceiveToken, token, undefined, fee || undefined);
toast({ status: "success", description: `Received ${amount} sats` });
navigate("/wallet");
} catch (error) {
if (error instanceof Error) toast({ status: "error", description: error.message });
console.log(error);
}
}, [decoded, originalAmount, actions, navigate, toast]);
const swap = useAsyncErrorHandler(async () => {}, [decoded, originalAmount, actions, navigate, toast]);
return (
<SimpleView title="Receive Token" maxW="xl" center>
<Card mb="4">
<CardBody>
<Text fontSize="4xl" textAlign="center">
{originalAmount} sats
</Text>
<Flex alignItems="center" justifyContent="center" gap="2" mt="4">
<CashuMintFavicon mint={decoded.mint} size="sm" />
<CashuMintName mint={decoded.mint} fontSize="lg" />
</Flex>
</CardBody>
</Card>
<Button colorScheme="primary" isLoading={receive.loading} onClick={receive.run} size="lg">
Receive
</Button>
</SimpleView>
);
}

View File

@ -3,7 +3,8 @@ import RequireActiveAccount from "../../components/router/require-active-account
import { lazy } from "react";
const WalletHomeView = lazy(() => import("."));
const WalletReceiveView = lazy(() => import("./receive"));
const WalletReceiveView = lazy(() => import("./receive/index"));
const WalletReceiveTokenView = lazy(() => import("./receive/token"));
const WalletSendView = lazy(() => import("./send/index"));
const WalletSendCashuView = lazy(() => import("./send/cashu"));
const WalletSendTokenView = lazy(() => import("./send/token"));
@ -17,7 +18,13 @@ export default [
</RequireActiveAccount>
),
},
{ path: "receive", Component: WalletReceiveView },
{
path: "receive",
children: [
{ index: true, Component: WalletReceiveView },
{ path: "token", Component: WalletReceiveTokenView },
],
},
{
path: "send",
children: [

View File

@ -20,8 +20,10 @@ export default function WalletSendCashuView() {
const balance = useStoreQuery(WalletBalanceQuery, [account.pubkey]);
const tokens = useStoreQuery(WalletTokensQuery, [account.pubkey, false]);
const defaultMint = balance && Object.keys(balance).reduce((a, b) => (balance[a] > balance[b] ? a : b));
const { register, getValues, watch, handleSubmit, formState } = useForm({
defaultValues: { amount: 0, mint: "" },
defaultValues: { amount: 0, mint: defaultMint ?? "" },
mode: "all",
});
@ -33,7 +35,9 @@ export default function WalletSendCashuView() {
const selected = dumbTokenSelection(tokens, values.amount, values.mint);
const wallet = await getCashuWallet(values.mint);
// swap
await wallet.mint.getKeySets();
// swap tokens for send
const send = await wallet.send(values.amount, selected.proofs);
// save the change

View File

@ -10,7 +10,7 @@ export default function WalletSendView() {
<SimpleView title="Send" maxW="xl" center>
<Card as={LinkBox} p="4" gap="4" display="flex" flexDirection="row" alignItems="center">
<ECashIcon boxSize={10} />
<HoverLinkOverlay as={RouterLink} to="/wallet/send/cashu">
<HoverLinkOverlay as={RouterLink} to="/wallet/send/cashu" replace>
<Text fontWeight="bold" fontSize="xl">
ECash
</Text>
@ -18,7 +18,7 @@ export default function WalletSendView() {
</Card>
<Card as={LinkBox} p="4" gap="4" display="flex" flexDirection="row" alignItems="center">
<LightningIcon boxSize={10} color="yellow.400" />
<HoverLinkOverlay as={RouterLink} to="/wallet/pay/lightning">
<HoverLinkOverlay as={RouterLink} to="/wallet/pay/lightning" replace>
<Text fontWeight="bold" fontSize="xl">
Lightning
</Text>

View File

@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import { filter, from, Observable, switchMap, take } from "rxjs";
import { Button, ButtonGroup, Flex, Spacer, useToast } from "@chakra-ui/react";
import { ANIMATED_QR_INTERVAL, sendAnimated } from "applesauce-wallet/helpers";
import { ANIMATED_QR_INTERVAL, encodeTokenToEmoji, sendAnimated } from "applesauce-wallet/helpers";
import { getDecodedToken, Proof, ProofState } from "@cashu/cashu-ts";
import { ReceiveToken } from "applesauce-wallet/actions";
import { useActionHub } from "applesauce-react/hooks";
@ -10,7 +11,6 @@ import SimpleView from "../../../components/layout/presets/simple-view";
import RouterLink from "../../../components/router-link";
import { CopyIconButton } from "../../../components/copy-icon-button";
import QrCodeSvg from "../../../components/qr-code/qr-code-svg";
import { filter, from, Observable, switchMap, take } from "rxjs";
import { getCashuWallet } from "../../../services/cashu-mints";
export default function WalletSendTokenView() {
@ -103,6 +103,7 @@ export default function WalletSendTokenView() {
<Flex gap="2">
<CopyIconButton value={token} aria-label="Copy token" />
<CopyIconButton value={encodeTokenToEmoji(token)} aria-label="Copy emoji" icon={<span>🥜</span>} />
<Spacer />
<Button onClick={cancel} isLoading={canceling}>
Cancel

View File

@ -5,10 +5,12 @@ import {
Card,
CardBody,
CardFooter,
CardHeader,
Flex,
IconButton,
Spacer,
Text,
useDisclosure,
} from "@chakra-ui/react";
import { useActiveAccount, useEventStore, useStoreQuery } from "applesauce-react/hooks";
import {
@ -27,7 +29,7 @@ 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 { ChevronDownIcon, ChevronUpIcon, TrashIcon } from "../../../components/icons";
import useEventUpdate from "../../../hooks/use-event-update";
import Timestamp from "../../../components/timestamp";
import useSingleEvents from "../../../hooks/use-single-events";
@ -38,6 +40,7 @@ import { usePublishEvent } from "../../../providers/global/publish-provider";
import factory from "../../../services/event-factory";
function HistoryEntry({ entry }: { entry: NostrEvent }) {
const more = useDisclosure();
const account = useActiveAccount()!;
const eventStore = useEventStore();
const locked = isHistoryContentLocked(entry);
@ -57,7 +60,7 @@ function HistoryEntry({ entry }: { entry: NostrEvent }) {
return (
<Card ref={ref}>
<CardBody p="2" display="flex" flexDirection="row" gap="2">
<CardHeader p="2" display="flex" flexDirection="row" gap="2" alignItems="center">
{locked ? (
<Lock01 boxSize={8} />
) : details?.direction === "in" ? (
@ -66,6 +69,7 @@ function HistoryEntry({ entry }: { entry: NostrEvent }) {
<ArrowBlockUp boxSize={8} color="orange.500" />
)}
<Text fontSize="xl">{details?.amount}</Text>
{details?.fee !== undefined && <Text>( fee {details.fee} )</Text>}
<Spacer />
<ButtonGroup size="sm" alignItems="center">
{locked && (
@ -74,18 +78,10 @@ function HistoryEntry({ entry }: { entry: NostrEvent }) {
</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>
</CardHeader>
{details && (
<CardFooter px="2" pt="0" pb="2">
<CardBody px="2" pt="0" pb="2" display="flex">
{details.mint && (
<>
<CashuMintFavicon mint={details.mint} size="xs" mr="2" />
@ -102,11 +98,30 @@ function HistoryEntry({ entry }: { entry: NostrEvent }) {
</AvatarGroup>
</>
)}
{details.fee !== undefined && (
<Text fontStyle="italic" ms="auto">
fee: {details.fee}
</Text>
)}
<Button
ms="auto"
size="sm"
variant="link"
onClick={more.onToggle}
rightIcon={more.isOpen ? <ChevronUpIcon boxSize={6} /> : <ChevronDownIcon boxSize={6} />}
>
Details
</Button>
</CardBody>
)}
{more.isOpen && (
<CardFooter pt="0" pb="2" px="2" gap="2" display="flex">
<ButtonGroup size="sm" ms="auto">
<DebugEventButton variant="ghost" event={entry} />
<IconButton
icon={<TrashIcon boxSize={5} />}
aria-label="Delete entry"
onClick={() => deleteEvent(entry)}
colorScheme="red"
variant="ghost"
/>
</ButtonGroup>
</CardFooter>
)}
</Card>

View File

@ -1,8 +1,9 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import {
Button,
ButtonGroup,
Card,
CardBody,
CardFooter,
CardHeader,
Flex,
@ -10,24 +11,37 @@ import {
IconButton,
Spacer,
Text,
useDisclosure,
} from "@chakra-ui/react";
import { useActiveAccount, useEventStore, useStoreQuery } from "applesauce-react/hooks";
import { WalletTokensQuery } from "applesauce-wallet/queries";
import { getTokenContent, isTokenContentLocked, unlockTokenContent } from "applesauce-wallet/helpers";
import { NostrEvent } from "nostr-tools";
import { ProofState } from "@cashu/cashu-ts";
import { getEncodedToken, ProofState } from "@cashu/cashu-ts";
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 {
ChevronDownIcon,
ChevronUpIcon,
ECashIcon,
ExternalLinkIcon,
QrCodeIcon,
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 { getCashuWallet } from "../../../services/cashu-mints";
import ConsolidateTokensButton from "../components/consolidate-tokens-button";
import CashuMintFavicon from "../../../components/cashu/cashu-mint-favicon";
import CashuMintName from "../../../components/cashu/cashu-mint-name";
import { CopyIconButton } from "../../../components/copy-icon-button";
import RouterLink from "../../../components/router-link";
function TokenEvent({ token }: { token: NostrEvent }) {
const more = useDisclosure();
const account = useActiveAccount();
const eventStore = useEventStore();
useEventUpdate(token.id);
@ -54,6 +68,8 @@ function TokenEvent({ token }: { token: NostrEvent }) {
eventStore.update(token);
}, [token, account, eventStore]);
const encoded = useMemo(() => details && getEncodedToken(details), [details]);
return (
<Card ref={ref} w="full">
<CardHeader p="2" alignItems="center" flexDirection="row" display="flex" gap="2">
@ -66,34 +82,64 @@ function TokenEvent({ token }: { token: NostrEvent }) {
</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={
spentState === undefined ? undefined : spentState.some((s) => s.state === "UNSPENT") ? "green" : "red"
}
onClick={check}
>
{spentState === undefined
? "Check"
: (spentState.some((s) => s.state === "UNSPENT") ? "Unspent" : "Spent") +
` ${spentState.filter((s) => s.state === "UNSPENT").length}/${spentState.length}`}
</Button>
<Spacer />
<Text fontSize="sm" fontStyle="italic">
{details.mint}
</Text>
<CardBody display="flex" gap="2" px="2" pt="0" pb="2">
{details && (
<>
<CashuMintFavicon mint={details.mint} size="xs" />
<CashuMintName mint={details.mint} />
</>
)}
<Spacer />
<Button
variant="link"
onClick={more.onToggle}
rightIcon={more.isOpen ? <ChevronUpIcon boxSize={6} /> : <ChevronDownIcon boxSize={6} />}
>
Details
</Button>
</CardBody>
{more.isOpen && (
<CardFooter px="2" pt="0" pb="2" gap="2" display="flex">
<ButtonGroup size="sm">
<Button
variant="ghost"
colorScheme={
spentState === undefined ? undefined : spentState.some((s) => s.state === "UNSPENT") ? "green" : "red"
}
onClick={check}
>
{spentState === undefined
? "Check"
: (spentState.some((s) => s.state === "UNSPENT") ? "Unspent" : "Spent") +
` ${spentState.filter((s) => s.state === "UNSPENT").length}/${spentState.length}`}
</Button>
</ButtonGroup>
<ButtonGroup ms="auto" size="sm">
{encoded && (
<>
<CopyIconButton value={encoded} aria-label="Copy token" variant="ghost" />
<IconButton
as={RouterLink}
to="/wallet/send/token"
state={{ token: encoded }}
icon={<ExternalLinkIcon />}
aria-label="Show token"
variant="ghost"
/>
</>
)}
<DebugEventButton variant="ghost" event={token} />
<IconButton
aria-label="Delete entry"
onClick={() => deleteEvent(token)}
colorScheme="red"
variant="ghost"
icon={<TrashIcon />}
/>
</ButtonGroup>
</CardFooter>
)}
</Card>