fix: improve Cashu wallet transaction display and relays

- Subscribe to WalletHistory.meta$ observable for actual amounts and direction
- Create HistoryEntryRow component that properly shows +/- amounts
- Show effective relays (wallet + user outbox) instead of just wallet relays
- Remove settings dropdown (feature deferred)
- Add colored amount display (+green for in, -red for out)
- Show mint in transaction detail dialog when available
This commit is contained in:
Claude
2026-01-28 10:21:39 +00:00
parent 935661bae5
commit 0491ebfcd8
2 changed files with 219 additions and 209 deletions

View File

@@ -13,7 +13,6 @@ import {
Send,
Download,
RefreshCw,
Settings,
Coins,
Loader2,
Wallet,
@@ -21,11 +20,13 @@ import {
ChevronRight,
ChevronDown,
ArrowDownLeft,
ArrowUpRight,
Search,
Landmark,
Radio,
} from "lucide-react";
import { toast } from "sonner";
import { use$ } from "applesauce-react/hooks";
import { Button } from "@/components/ui/button";
import {
Tooltip,
@@ -46,12 +47,10 @@ import {
DialogFooter,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
WalletBalance,
WalletHeader,
WalletHistoryList,
TransactionRow,
type HistoryItem,
} from "@/components/wallet";
import { CodeCopyButton } from "@/components/CodeCopyButton";
@@ -206,6 +205,57 @@ function historyToItem(entry: WalletHistory): HistoryItem {
};
}
/**
* History entry row that subscribes to meta$ for amounts
*/
function HistoryEntryRow({
entry,
blurred,
onClick,
}: {
entry: WalletHistory;
blurred: boolean;
onClick: () => void;
}) {
// Subscribe to meta$ observable to get direction and amount
const meta = use$(() => entry.meta$, [entry]);
const direction = meta?.direction || "in";
const amount = meta?.amount || 0;
return (
<div
className="flex items-center justify-between border-b border-border px-4 py-2.5 hover:bg-muted/50 transition-colors flex-shrink-0 cursor-pointer"
onClick={onClick}
>
<div className="flex items-center gap-3 min-w-0 flex-1">
{direction === "in" ? (
<ArrowDownLeft className="size-4 text-green-500 flex-shrink-0" />
) : (
<ArrowUpRight className="size-4 text-red-500 flex-shrink-0" />
)}
<div className="min-w-0 flex-1">
<span className="text-sm">
{formatTimestamp(entry.event.created_at, "datetime")}
</span>
{!entry.unlocked && (
<span className="text-xs text-muted-foreground ml-2">(locked)</span>
)}
</div>
</div>
<div className="flex-shrink-0 ml-4">
<p
className={`text-sm font-semibold font-mono ${direction === "in" ? "text-green-500" : "text-red-500"}`}
>
{blurred
? "✦✦✦✦"
: `${direction === "in" ? "+" : "-"}${amount.toLocaleString()}`}
</p>
</div>
</div>
);
}
export default function Nip61WalletViewer() {
const { state, toggleWalletBalancesBlur } = useGrimoire();
const { isLoggedIn } = useAccount();
@@ -221,8 +271,6 @@ export default function Nip61WalletViewer() {
unlock,
unlocking,
error,
syncEnabled,
toggleSyncEnabled,
} = useNip61Wallet();
const [refreshing, setRefreshing] = useState(false);
@@ -231,7 +279,6 @@ export default function Nip61WalletViewer() {
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
const [showRawTransaction, setShowRawTransaction] = useState(false);
const [copiedRawTx, setCopiedRawTx] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const blurred = state.walletBalancesBlurred ?? false;
@@ -265,34 +312,11 @@ export default function Nip61WalletViewer() {
const renderHistoryEntry = useCallback(
(item: HistoryItem) => {
const entry = item.data as WalletHistory;
// If not unlocked, show placeholder
if (!entry.unlocked) {
return (
<TransactionRow
key={entry.id}
direction="in"
amount={0}
blurred={true}
label={
<span className="text-sm text-muted-foreground">Locked</span>
}
onClick={() => handleTransactionClick(entry)}
/>
);
}
return (
<TransactionRow
<HistoryEntryRow
key={entry.id}
direction="in" // TODO: Determine from meta$
amount={0} // TODO: Get from meta$
entry={entry}
blurred={blurred}
label={
<span className="text-sm">
{formatTimestamp(entry.event.created_at, "datetime")}
</span>
}
onClick={() => handleTransactionClick(entry)}
/>
);
@@ -424,19 +448,6 @@ export default function Nip61WalletViewer() {
)}
</DropdownMenuContent>
</DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => setSettingsOpen(true)}
className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
aria-label="Settings"
>
<Settings className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent>Settings</TooltipContent>
</Tooltip>
</div>
}
/>
@@ -498,7 +509,8 @@ export default function Nip61WalletViewer() {
</div>
{/* Transaction Detail Dialog */}
<Dialog
<TransactionDetailDialog
transaction={selectedTransaction}
open={detailDialogOpen}
onOpenChange={(open) => {
setDetailDialogOpen(open);
@@ -507,159 +519,161 @@ export default function Nip61WalletViewer() {
setCopiedRawTx(false);
}
}}
>
<DialogContent className="max-w-md max-h-[70vh] flex flex-col">
<DialogHeader>
<DialogTitle>Transaction Details</DialogTitle>
</DialogHeader>
<div className="overflow-y-auto max-h-[calc(70vh-8rem)] pr-2">
{selectedTransaction && (
<div className="space-y-4">
<div className="flex items-center gap-3">
<ArrowDownLeft className="size-6 text-green-500" />
<div>
<p className="text-lg font-semibold">Transaction</p>
<p className="text-sm text-muted-foreground">
{formatTimestamp(
selectedTransaction.event.created_at,
"datetime",
)}
</p>
</div>
</div>
<div className="space-y-2">
<div>
<Label className="text-xs text-muted-foreground">
Event ID
</Label>
<p className="text-xs font-mono break-all bg-muted p-2 rounded">
{selectedTransaction.event.id}
</p>
</div>
<div>
<Label className="text-xs text-muted-foreground">
Status
</Label>
<p className="text-sm">
{selectedTransaction.unlocked ? "Unlocked" : "Locked"}
</p>
</div>
<div>
<Label className="text-xs text-muted-foreground">
Created At
</Label>
<p className="text-sm font-mono">
{formatTimestamp(
selectedTransaction.event.created_at,
"absolute",
)}
</p>
</div>
</div>
{/* Raw Transaction (expandable) */}
<div className="border-t border-border pt-4 mt-4">
<button
onClick={() => setShowRawTransaction(!showRawTransaction)}
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors w-full"
>
{showRawTransaction ? (
<ChevronDown className="size-4" />
) : (
<ChevronRight className="size-4" />
)}
<span>Show Raw Event</span>
</button>
{showRawTransaction && (
<div className="mt-3 space-y-2">
<div className="relative">
<pre className="text-xs font-mono bg-muted p-3 rounded overflow-x-auto max-h-60 overflow-y-auto">
{JSON.stringify(selectedTransaction.event, null, 2)}
</pre>
<CodeCopyButton
copied={copiedRawTx}
onCopy={() => {
navigator.clipboard.writeText(
JSON.stringify(
selectedTransaction.event,
null,
2,
),
);
setCopiedRawTx(true);
setTimeout(() => setCopiedRawTx(false), 2000);
}}
label="Copy event JSON"
/>
</div>
</div>
)}
</div>
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setDetailDialogOpen(false);
setShowRawTransaction(false);
}}
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Settings Dialog */}
<Dialog open={settingsOpen} onOpenChange={setSettingsOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Wallet Settings</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Keep Wallet Synced</Label>
<p className="text-xs text-muted-foreground">
Keep wallet unlocked and sync history automatically
</p>
</div>
<Switch
checked={syncEnabled}
onCheckedChange={toggleSyncEnabled}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Blur Balances</Label>
<p className="text-xs text-muted-foreground">
Hide balance amounts for privacy
</p>
</div>
<Switch
checked={blurred}
onCheckedChange={toggleWalletBalancesBlur}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setSettingsOpen(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
showRaw={showRawTransaction}
onToggleRaw={() => setShowRawTransaction(!showRawTransaction)}
copiedRaw={copiedRawTx}
onCopyRaw={() => {
if (selectedTransaction) {
navigator.clipboard.writeText(
JSON.stringify(selectedTransaction.event, null, 2),
);
setCopiedRawTx(true);
setTimeout(() => setCopiedRawTx(false), 2000);
}
}}
blurred={blurred}
/>
</div>
);
}
/**
* Transaction detail dialog component
*/
function TransactionDetailDialog({
transaction,
open,
onOpenChange,
showRaw,
onToggleRaw,
copiedRaw,
onCopyRaw,
blurred,
}: {
transaction: WalletHistory | null;
open: boolean;
onOpenChange: (open: boolean) => void;
showRaw: boolean;
onToggleRaw: () => void;
copiedRaw: boolean;
onCopyRaw: () => void;
blurred: boolean;
}) {
// Subscribe to meta$ for transaction details
const meta = use$(() => transaction?.meta$, [transaction]);
const direction = meta?.direction || "in";
const amount = meta?.amount || 0;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md max-h-[70vh] flex flex-col">
<DialogHeader>
<DialogTitle>Transaction Details</DialogTitle>
</DialogHeader>
<div className="overflow-y-auto max-h-[calc(70vh-8rem)] pr-2">
{transaction && (
<div className="space-y-4">
<div className="flex items-center gap-3">
{direction === "in" ? (
<ArrowDownLeft className="size-6 text-green-500" />
) : (
<ArrowUpRight className="size-6 text-red-500" />
)}
<div>
<p className="text-lg font-semibold">
{direction === "in" ? "Received" : "Sent"}
</p>
<p
className={`text-2xl font-bold font-mono ${direction === "in" ? "text-green-500" : "text-red-500"}`}
>
{blurred
? "✦✦✦✦✦✦"
: `${direction === "in" ? "+" : "-"}${amount.toLocaleString()}`}
</p>
</div>
</div>
<div className="space-y-2">
<div>
<Label className="text-xs text-muted-foreground">
Event ID
</Label>
<p className="text-xs font-mono break-all bg-muted p-2 rounded">
{transaction.event.id}
</p>
</div>
{meta?.mint && (
<div>
<Label className="text-xs text-muted-foreground">
Mint
</Label>
<p className="text-sm font-mono">
{new URL(meta.mint).hostname}
</p>
</div>
)}
<div>
<Label className="text-xs text-muted-foreground">
Status
</Label>
<p className="text-sm">
{transaction.unlocked ? "Unlocked" : "Locked"}
</p>
</div>
<div>
<Label className="text-xs text-muted-foreground">
Created At
</Label>
<p className="text-sm font-mono">
{formatTimestamp(transaction.event.created_at, "absolute")}
</p>
</div>
</div>
{/* Raw Transaction (expandable) */}
<div className="border-t border-border pt-4 mt-4">
<button
onClick={onToggleRaw}
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors w-full"
>
{showRaw ? (
<ChevronDown className="size-4" />
) : (
<ChevronRight className="size-4" />
)}
<span>Show Raw Event</span>
</button>
{showRaw && (
<div className="mt-3 space-y-2">
<div className="relative">
<pre className="text-xs font-mono bg-muted p-3 rounded overflow-x-auto max-h-60 overflow-y-auto">
{JSON.stringify(transaction.event, null, 2)}
</pre>
<CodeCopyButton
copied={copiedRaw}
onCopy={onCopyRaw}
label="Copy event JSON"
/>
</div>
</div>
)}
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -24,7 +24,6 @@ import {
WALLET_HISTORY_KIND,
} from "@/services/nip61-wallet";
import { kinds, relaySet } from "applesauce-core/helpers";
import { useGrimoire } from "@/core/state";
// Import casts to enable user.wallet$ property
import "applesauce-wallet/casts";
@@ -44,7 +43,6 @@ export type WalletState =
*/
export function useNip61Wallet() {
const { pubkey, canSign } = useAccount();
const { state: appState, setCashuWalletSyncEnabled } = useGrimoire();
const [unlocking, setUnlocking] = useState(false);
const [error, setError] = useState<string | null>(null);
const [discoveryComplete, setDiscoveryComplete] = useState(false);
@@ -190,10 +188,12 @@ export function useNip61Wallet() {
return Object.fromEntries(entries);
}, [balance]);
// Toggle sync setting
const toggleSyncEnabled = useCallback(() => {
setCashuWalletSyncEnabled(!appState.cashuWalletSyncEnabled);
}, [appState.cashuWalletSyncEnabled, setCashuWalletSyncEnabled]);
// Compute effective relays (wallet relays + user outbox relays)
const effectiveRelays = useMemo(() => {
const walletRelays = relays || [];
const userOutboxes = outboxes || [];
return relaySet(walletRelays, userOutboxes);
}, [relays, outboxes]);
return {
// State machine
@@ -214,17 +214,13 @@ export function useNip61Wallet() {
tokens,
history,
mints,
relays,
relays: effectiveRelays, // Combined wallet + outbox relays
received, // Received nutzap IDs
// Actions
unlock,
unlocking,
// Sync setting
syncEnabled: appState.cashuWalletSyncEnabled ?? false,
toggleSyncEnabled,
// Error state
error,