mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-12 00:17:02 +02:00
feat: add NIP-60 Cashu wallet viewer with wallet subcommand
Implements the "wallet nip-61" subcommand for viewing NIP-60 Cashu wallets: - Add wallet-parser.ts for subcommand parsing (nwc, nip-61, cashu, etc.) - Create shared wallet UI components for reuse across wallet types - Add useNip61Wallet hook for reactive wallet state via applesauce-wallet - Create Nip61WalletViewer showing balance, mints breakdown, and history - Update WindowRenderer to route wallet subcommands appropriately The viewer supports: - Unlocking encrypted wallet content - Displaying total balance and per-mint breakdown - Viewing transaction history (placeholders for send/receive)
This commit is contained in:
307
src/components/Nip61WalletViewer.tsx
Normal file
307
src/components/Nip61WalletViewer.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* Nip61WalletViewer Component
|
||||
*
|
||||
* Displays NIP-60 Cashu wallet information:
|
||||
* - Wallet balance (total and per-mint)
|
||||
* - Transaction history
|
||||
* - Placeholder send/receive buttons
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback } from "react";
|
||||
import { Send, Download, RefreshCw, Settings, Coins } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
WalletBalance,
|
||||
WalletHeader,
|
||||
WalletHistoryList,
|
||||
TransactionRow,
|
||||
NoWalletView,
|
||||
WalletLockedView,
|
||||
type HistoryItem,
|
||||
} from "@/components/wallet";
|
||||
import { useNip61Wallet } from "@/hooks/useNip61Wallet";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { useAccount } from "@/hooks/useAccount";
|
||||
import type { WalletHistory } from "applesauce-wallet/casts";
|
||||
|
||||
/**
|
||||
* Component to display balance breakdown by mint
|
||||
*/
|
||||
function MintBalanceBreakdown({
|
||||
balance,
|
||||
blurred,
|
||||
}: {
|
||||
balance: Record<string, number> | undefined;
|
||||
blurred: boolean;
|
||||
}) {
|
||||
if (!balance || Object.keys(balance).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 pb-4">
|
||||
<div className="max-w-md mx-auto">
|
||||
<div className="text-xs text-muted-foreground mb-2 flex items-center gap-1">
|
||||
<Coins className="size-3" />
|
||||
Balance by Mint
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{Object.entries(balance).map(([mint, amount]) => (
|
||||
<div
|
||||
key={mint}
|
||||
className="flex justify-between items-center py-1.5 px-2 bg-muted/50 rounded text-sm"
|
||||
>
|
||||
<span className="font-mono text-xs truncate max-w-[200px] text-muted-foreground">
|
||||
{new URL(mint).hostname}
|
||||
</span>
|
||||
<span className="font-mono font-medium">
|
||||
{blurred ? "✦✦✦" : amount.toLocaleString()} sats
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform WalletHistory into HistoryItem for the list
|
||||
*/
|
||||
function historyToItem(entry: WalletHistory): HistoryItem {
|
||||
return {
|
||||
id: entry.id,
|
||||
timestamp: entry.event.created_at,
|
||||
data: entry,
|
||||
};
|
||||
}
|
||||
|
||||
export default function Nip61WalletViewer() {
|
||||
const { state, toggleWalletBalancesBlur } = useGrimoire();
|
||||
const { isLoggedIn } = useAccount();
|
||||
const {
|
||||
hasWallet,
|
||||
isUnlocked,
|
||||
balance,
|
||||
totalBalance,
|
||||
history,
|
||||
mints,
|
||||
unlock,
|
||||
unlocking,
|
||||
error,
|
||||
} = useNip61Wallet();
|
||||
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
// Transform history for the list component
|
||||
const historyItems = useMemo(() => {
|
||||
if (!history) return [];
|
||||
return history.map(historyToItem);
|
||||
}, [history]);
|
||||
|
||||
// Refresh wallet data
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
// Re-unlock to refresh encrypted content
|
||||
await unlock();
|
||||
toast.success("Wallet refreshed");
|
||||
} catch (err) {
|
||||
console.error("Failed to refresh wallet:", err);
|
||||
toast.error("Failed to refresh wallet");
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [unlock]);
|
||||
|
||||
// Render history entry
|
||||
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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Get meta synchronously if available
|
||||
// Note: In a real implementation, we'd need to handle the observable
|
||||
// For now, we'll show a simplified view
|
||||
return (
|
||||
<TransactionRow
|
||||
key={entry.id}
|
||||
direction="in" // TODO: Get from meta$
|
||||
amount={0} // TODO: Get from meta$
|
||||
blurred={state.walletBalancesBlurred ?? false}
|
||||
label={
|
||||
<span className="text-sm">
|
||||
{entry.event.created_at
|
||||
? new Date(entry.event.created_at * 1000).toLocaleTimeString()
|
||||
: "Transaction"}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[state.walletBalancesBlurred],
|
||||
);
|
||||
|
||||
// Not logged in
|
||||
if (!isLoggedIn) {
|
||||
return (
|
||||
<NoWalletView
|
||||
title="Not Logged In"
|
||||
message="Log in with a Nostr account to access your Cashu wallet."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// No wallet found
|
||||
if (!hasWallet) {
|
||||
return (
|
||||
<NoWalletView
|
||||
title="No Cashu Wallet"
|
||||
message="No NIP-60 Cashu wallet found for your account. Wallet creation is not yet supported in Grimoire."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Wallet locked
|
||||
if (!isUnlocked) {
|
||||
return (
|
||||
<WalletLockedView
|
||||
message="Your Cashu wallet is encrypted. Unlock it to view your balance and history."
|
||||
loading={unlocking}
|
||||
onUnlock={unlock}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Determine wallet status
|
||||
const walletStatus = unlocking || refreshing ? "loading" : "connected";
|
||||
|
||||
return (
|
||||
<div className="h-full w-full flex flex-col bg-background text-foreground">
|
||||
{/* Header */}
|
||||
<WalletHeader
|
||||
name="Cashu Wallet"
|
||||
status={walletStatus}
|
||||
info={
|
||||
mints && mints.length > 0 ? (
|
||||
<span className="text-muted-foreground ml-2">
|
||||
{mints.length} mint{mints.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
actions={
|
||||
<div className="flex items-center gap-3">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
|
||||
aria-label="Refresh wallet"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`size-3 ${refreshing ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Refresh Wallet</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors opacity-50 cursor-not-allowed"
|
||||
aria-label="Settings (coming soon)"
|
||||
disabled
|
||||
>
|
||||
<Settings className="size-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Settings (coming soon)</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div className="px-4 py-2 bg-destructive/10 text-destructive text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Balance */}
|
||||
<WalletBalance
|
||||
balance={totalBalance}
|
||||
blurred={state.walletBalancesBlurred ?? false}
|
||||
onToggleBlur={toggleWalletBalancesBlur}
|
||||
label="sats"
|
||||
/>
|
||||
|
||||
{/* Balance by mint */}
|
||||
<MintBalanceBreakdown
|
||||
balance={balance}
|
||||
blurred={state.walletBalancesBlurred ?? false}
|
||||
/>
|
||||
|
||||
{/* Send / Receive Buttons (Placeholders) */}
|
||||
<div className="px-4 pb-3">
|
||||
<div className="max-w-md mx-auto grid grid-cols-2 gap-3">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" disabled className="opacity-50">
|
||||
<Download className="mr-2 size-4" />
|
||||
Receive
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Coming soon</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="default" disabled className="opacity-50">
|
||||
<Send className="mr-2 size-4" />
|
||||
Send
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Coming soon</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transaction History */}
|
||||
<div className="flex-1 overflow-hidden flex justify-center">
|
||||
<div className="w-full max-w-md">
|
||||
<WalletHistoryList
|
||||
items={historyItems}
|
||||
loading={false}
|
||||
loadFailed={false}
|
||||
hasMore={false}
|
||||
emptyMessage="No transaction history"
|
||||
renderItem={renderHistoryEntry}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -43,6 +43,7 @@ const BlossomViewer = lazy(() =>
|
||||
import("./BlossomViewer").then((m) => ({ default: m.BlossomViewer })),
|
||||
);
|
||||
const WalletViewer = lazy(() => import("./WalletViewer"));
|
||||
const Nip61WalletViewer = lazy(() => import("./Nip61WalletViewer"));
|
||||
const ZapWindow = lazy(() =>
|
||||
import("./ZapWindow").then((m) => ({ default: m.ZapWindow })),
|
||||
);
|
||||
@@ -232,9 +233,15 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) {
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case "wallet":
|
||||
content = <WalletViewer />;
|
||||
case "wallet": {
|
||||
const walletSubcommand = window.props.subcommand || "nwc";
|
||||
if (walletSubcommand === "nip-61") {
|
||||
content = <Nip61WalletViewer />;
|
||||
} else {
|
||||
content = <WalletViewer />;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "zap":
|
||||
content = (
|
||||
<ZapWindow
|
||||
|
||||
58
src/components/wallet/TransactionRow.tsx
Normal file
58
src/components/wallet/TransactionRow.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* TransactionRow Component
|
||||
*
|
||||
* Displays a single transaction in a list.
|
||||
* Shared between NWC and NIP-61 wallet viewers.
|
||||
*/
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { ArrowUpRight, ArrowDownLeft } from "lucide-react";
|
||||
|
||||
interface TransactionRowProps {
|
||||
/** Transaction direction */
|
||||
direction: "in" | "out";
|
||||
/** Amount in satoshis */
|
||||
amount: number;
|
||||
/** Whether amount should be blurred */
|
||||
blurred?: boolean;
|
||||
/** Transaction label/description */
|
||||
label: ReactNode;
|
||||
/** Click handler */
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format satoshi amount with locale-aware thousands separator
|
||||
*/
|
||||
function formatSats(sats: number): string {
|
||||
return sats.toLocaleString();
|
||||
}
|
||||
|
||||
export function TransactionRow({
|
||||
direction,
|
||||
amount,
|
||||
blurred = false,
|
||||
label,
|
||||
onClick,
|
||||
}: TransactionRowProps) {
|
||||
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">{label}</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0 ml-4">
|
||||
<p className="text-sm font-semibold font-mono">
|
||||
{blurred ? "✦✦✦✦" : formatSats(amount)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
src/components/wallet/WalletBalance.tsx
Normal file
54
src/components/wallet/WalletBalance.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* WalletBalance Component
|
||||
*
|
||||
* Displays a large centered balance with privacy blur toggle.
|
||||
* Shared between NWC and NIP-61 wallet viewers.
|
||||
*/
|
||||
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
|
||||
interface WalletBalanceProps {
|
||||
/** Balance in satoshis */
|
||||
balance: number | undefined;
|
||||
/** Whether balance is blurred for privacy */
|
||||
blurred: boolean;
|
||||
/** Callback to toggle blur */
|
||||
onToggleBlur: () => void;
|
||||
/** Optional label shown below balance */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format satoshi amount with locale-aware thousands separator
|
||||
*/
|
||||
function formatSats(sats: number | undefined): string {
|
||||
if (sats === undefined) return "—";
|
||||
return sats.toLocaleString();
|
||||
}
|
||||
|
||||
export function WalletBalance({
|
||||
balance,
|
||||
blurred,
|
||||
onToggleBlur,
|
||||
label,
|
||||
}: WalletBalanceProps) {
|
||||
return (
|
||||
<div className="py-4 flex flex-col items-center justify-center">
|
||||
<button
|
||||
onClick={onToggleBlur}
|
||||
className="text-4xl font-bold font-mono hover:opacity-70 transition-opacity cursor-pointer flex items-center gap-3"
|
||||
title="Click to toggle privacy blur"
|
||||
>
|
||||
<span>{blurred ? "✦✦✦✦✦✦" : formatSats(balance)}</span>
|
||||
{blurred ? (
|
||||
<EyeOff className="size-5 text-muted-foreground" />
|
||||
) : (
|
||||
<Eye className="size-5 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
{label && (
|
||||
<span className="text-sm text-muted-foreground mt-1">{label}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
src/components/wallet/WalletHeader.tsx
Normal file
53
src/components/wallet/WalletHeader.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* WalletHeader Component
|
||||
*
|
||||
* Displays wallet name, status indicator, and action buttons.
|
||||
* Shared between NWC and NIP-61 wallet viewers.
|
||||
*/
|
||||
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export type WalletStatus = "connected" | "locked" | "disconnected" | "loading";
|
||||
|
||||
interface WalletHeaderProps {
|
||||
/** Wallet name/alias */
|
||||
name: string;
|
||||
/** Connection/unlock status */
|
||||
status: WalletStatus;
|
||||
/** Action buttons (refresh, settings, disconnect, etc.) */
|
||||
actions?: ReactNode;
|
||||
/** Additional info content (dropdown, badges, etc.) */
|
||||
info?: ReactNode;
|
||||
}
|
||||
|
||||
function StatusIndicator({ status }: { status: WalletStatus }) {
|
||||
const colors: Record<WalletStatus, string> = {
|
||||
connected: "bg-green-500",
|
||||
locked: "bg-yellow-500",
|
||||
disconnected: "bg-red-500",
|
||||
loading: "bg-blue-500 animate-pulse",
|
||||
};
|
||||
|
||||
return <div className={`size-1.5 rounded-full ${colors[status]}`} />;
|
||||
}
|
||||
|
||||
export function WalletHeader({
|
||||
name,
|
||||
status,
|
||||
actions,
|
||||
info,
|
||||
}: WalletHeaderProps) {
|
||||
return (
|
||||
<div className="border-b border-border px-4 py-2 font-mono text-xs flex items-center justify-between">
|
||||
{/* Left: Wallet Name + Status */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold">{name}</span>
|
||||
<StatusIndicator status={status} />
|
||||
{info}
|
||||
</div>
|
||||
|
||||
{/* Right: Actions */}
|
||||
{actions && <div className="flex items-center gap-3">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
209
src/components/wallet/WalletHistoryList.tsx
Normal file
209
src/components/wallet/WalletHistoryList.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* WalletHistoryList Component
|
||||
*
|
||||
* Virtualized list of wallet transactions/history with day markers.
|
||||
* Shared between NWC and NIP-61 wallet viewers.
|
||||
*/
|
||||
|
||||
import { ReactNode, useMemo } from "react";
|
||||
import { Virtuoso } from "react-virtuoso";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export interface HistoryItem {
|
||||
/** Unique identifier */
|
||||
id: string;
|
||||
/** Unix timestamp in seconds */
|
||||
timestamp: number;
|
||||
/** Custom data for rendering */
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
interface WalletHistoryListProps<T extends HistoryItem> {
|
||||
/** History items to display */
|
||||
items: T[];
|
||||
/** Whether initial load is in progress */
|
||||
loading: boolean;
|
||||
/** Whether more items are being loaded */
|
||||
loadingMore?: boolean;
|
||||
/** Whether there are more items to load */
|
||||
hasMore?: boolean;
|
||||
/** Whether loading failed */
|
||||
loadFailed?: boolean;
|
||||
/** Callback to load more items */
|
||||
onLoadMore?: () => void;
|
||||
/** Callback to retry loading */
|
||||
onRetry?: () => void;
|
||||
/** Render function for each item */
|
||||
renderItem: (item: T, index: number) => ReactNode;
|
||||
/** Empty state message */
|
||||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format timestamp as a readable day marker
|
||||
*/
|
||||
function formatDayMarker(timestamp: number): string {
|
||||
const date = new Date(timestamp * 1000);
|
||||
const today = new Date();
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
// Reset time parts for comparison
|
||||
const dateOnly = new Date(
|
||||
date.getFullYear(),
|
||||
date.getMonth(),
|
||||
date.getDate(),
|
||||
);
|
||||
const todayOnly = new Date(
|
||||
today.getFullYear(),
|
||||
today.getMonth(),
|
||||
today.getDate(),
|
||||
);
|
||||
const yesterdayOnly = new Date(
|
||||
yesterday.getFullYear(),
|
||||
yesterday.getMonth(),
|
||||
yesterday.getDate(),
|
||||
);
|
||||
|
||||
if (dateOnly.getTime() === todayOnly.getTime()) {
|
||||
return "Today";
|
||||
} else if (dateOnly.getTime() === yesterdayOnly.getTime()) {
|
||||
return "Yesterday";
|
||||
} else {
|
||||
// Format as "Jan 15" (short month, no year, respects locale)
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two timestamps are on different days
|
||||
*/
|
||||
function isDifferentDay(timestamp1: number, timestamp2: number): boolean {
|
||||
const date1 = new Date(timestamp1 * 1000);
|
||||
const date2 = new Date(timestamp2 * 1000);
|
||||
|
||||
return (
|
||||
date1.getFullYear() !== date2.getFullYear() ||
|
||||
date1.getMonth() !== date2.getMonth() ||
|
||||
date1.getDate() !== date2.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
type ListItem<T> =
|
||||
| { type: "item"; data: T }
|
||||
| { type: "day-marker"; label: string; timestamp: number };
|
||||
|
||||
export function WalletHistoryList<T extends HistoryItem>({
|
||||
items,
|
||||
loading,
|
||||
loadingMore = false,
|
||||
hasMore = false,
|
||||
loadFailed = false,
|
||||
onLoadMore,
|
||||
onRetry,
|
||||
renderItem,
|
||||
emptyMessage = "No transactions found",
|
||||
}: WalletHistoryListProps<T>) {
|
||||
// Process items to include day markers
|
||||
const itemsWithMarkers = useMemo(() => {
|
||||
if (!items || items.length === 0) return [];
|
||||
|
||||
const result: ListItem<T>[] = [];
|
||||
|
||||
items.forEach((item, index) => {
|
||||
// Add day marker if this is the first item or if day changed
|
||||
if (index === 0) {
|
||||
result.push({
|
||||
type: "day-marker",
|
||||
label: formatDayMarker(item.timestamp),
|
||||
timestamp: item.timestamp,
|
||||
});
|
||||
} else if (isDifferentDay(items[index - 1].timestamp, item.timestamp)) {
|
||||
result.push({
|
||||
type: "day-marker",
|
||||
label: formatDayMarker(item.timestamp),
|
||||
timestamp: item.timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
result.push({ type: "item", data: item });
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [items]);
|
||||
|
||||
// Loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<RefreshCw className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (loadFailed) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 p-4">
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Failed to load transaction history
|
||||
</p>
|
||||
{onRetry && (
|
||||
<Button variant="outline" size="sm" onClick={onRetry}>
|
||||
<RefreshCw className="mr-2 size-4" />
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty state
|
||||
if (itemsWithMarkers.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">{emptyMessage}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Virtuoso
|
||||
data={itemsWithMarkers}
|
||||
endReached={hasMore ? onLoadMore : undefined}
|
||||
itemContent={(index, item) => {
|
||||
if (item.type === "day-marker") {
|
||||
return (
|
||||
<div
|
||||
className="flex justify-center py-2"
|
||||
key={`marker-${item.timestamp}`}
|
||||
>
|
||||
<Label className="text-[10px] text-muted-foreground">
|
||||
{item.label}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return renderItem(item.data, index);
|
||||
}}
|
||||
components={{
|
||||
Footer: () =>
|
||||
loadingMore ? (
|
||||
<div className="flex justify-center py-4 border-b border-border">
|
||||
<RefreshCw className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : !hasMore && items.length > 0 ? (
|
||||
<div className="py-4 text-center text-xs text-muted-foreground border-b border-border">
|
||||
No more transactions
|
||||
</div>
|
||||
) : null,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
117
src/components/wallet/WalletStates.tsx
Normal file
117
src/components/wallet/WalletStates.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Wallet State Components
|
||||
*
|
||||
* Shared components for common wallet states (no wallet, locked, etc.)
|
||||
*/
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { Wallet, Lock, RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
interface NoWalletViewProps {
|
||||
/** Title to display */
|
||||
title?: string;
|
||||
/** Message to display */
|
||||
message: string;
|
||||
/** Action button */
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
export function NoWalletView({
|
||||
title = "No Wallet Found",
|
||||
message,
|
||||
action,
|
||||
}: NoWalletViewProps) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-8">
|
||||
<Card className="max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Wallet className="size-5" />
|
||||
{title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">{message}</p>
|
||||
{action}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface WalletLockedViewProps {
|
||||
/** Message to display */
|
||||
message?: string;
|
||||
/** Whether unlock is in progress */
|
||||
loading: boolean;
|
||||
/** Unlock button handler */
|
||||
onUnlock: () => void;
|
||||
}
|
||||
|
||||
export function WalletLockedView({
|
||||
message = "Your wallet is locked. Unlock it to view your balance and history.",
|
||||
loading,
|
||||
onUnlock,
|
||||
}: WalletLockedViewProps) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-8">
|
||||
<Card className="max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Lock className="size-5" />
|
||||
Wallet Locked
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">{message}</p>
|
||||
<Button onClick={onUnlock} disabled={loading} className="w-full">
|
||||
{loading ? (
|
||||
<>
|
||||
<RefreshCw className="mr-2 size-4 animate-spin" />
|
||||
Unlocking...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Lock className="mr-2 size-4" />
|
||||
Unlock Wallet
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface WalletErrorViewProps {
|
||||
/** Error message */
|
||||
message: string;
|
||||
/** Retry handler */
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
export function WalletErrorView({ message, onRetry }: WalletErrorViewProps) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-8">
|
||||
<Card className="max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-destructive">
|
||||
<Wallet className="size-5" />
|
||||
Wallet Error
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">{message}</p>
|
||||
{onRetry && (
|
||||
<Button onClick={onRetry} variant="outline" className="w-full">
|
||||
<RefreshCw className="mr-2 size-4" />
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
src/components/wallet/index.ts
Normal file
15
src/components/wallet/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Shared Wallet Components
|
||||
*
|
||||
* Reusable components for NWC and NIP-61 wallet viewers.
|
||||
*/
|
||||
|
||||
export { WalletBalance } from "./WalletBalance";
|
||||
export { WalletHeader, type WalletStatus } from "./WalletHeader";
|
||||
export { WalletHistoryList, type HistoryItem } from "./WalletHistoryList";
|
||||
export { TransactionRow } from "./TransactionRow";
|
||||
export {
|
||||
NoWalletView,
|
||||
WalletLockedView,
|
||||
WalletErrorView,
|
||||
} from "./WalletStates";
|
||||
154
src/hooks/useNip61Wallet.ts
Normal file
154
src/hooks/useNip61Wallet.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* useNip61Wallet Hook
|
||||
*
|
||||
* Provides access to the user's NIP-60 Cashu wallet state.
|
||||
* Uses applesauce-wallet casts for reactive wallet data.
|
||||
*/
|
||||
|
||||
import { useMemo, useCallback, useState, useEffect, useRef } from "react";
|
||||
import { use$ } from "applesauce-react/hooks";
|
||||
import { castUser } from "applesauce-common/casts";
|
||||
import type { Subscription } from "rxjs";
|
||||
import { useAccount } from "@/hooks/useAccount";
|
||||
import eventStore from "@/services/event-store";
|
||||
import pool from "@/services/relay-pool";
|
||||
import { hub } from "@/services/hub";
|
||||
import {
|
||||
couch,
|
||||
UnlockWallet,
|
||||
WALLET_KIND,
|
||||
WALLET_TOKEN_KIND,
|
||||
WALLET_HISTORY_KIND,
|
||||
} from "@/services/nip61-wallet";
|
||||
import { kinds, relaySet } from "applesauce-core/helpers";
|
||||
|
||||
// Import casts to enable user.wallet$ property
|
||||
import "applesauce-wallet/casts";
|
||||
|
||||
/**
|
||||
* Hook to access the user's NIP-60 Cashu wallet
|
||||
*
|
||||
* @returns Wallet state and actions
|
||||
*/
|
||||
export function useNip61Wallet() {
|
||||
const { pubkey, canSign } = useAccount();
|
||||
const [unlocking, setUnlocking] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const subscriptionRef = useRef<Subscription | null>(null);
|
||||
|
||||
// Create User cast for the active pubkey
|
||||
const user = useMemo(
|
||||
() => (pubkey ? castUser(pubkey, eventStore) : undefined),
|
||||
[pubkey],
|
||||
);
|
||||
|
||||
// Get wallet observable from user cast
|
||||
const wallet = use$(() => user?.wallet$, [user]);
|
||||
|
||||
// Get wallet state observables
|
||||
const balance = use$(() => wallet?.balance$, [wallet]);
|
||||
const tokens = use$(() => wallet?.tokens$, [wallet]);
|
||||
const history = use$(() => wallet?.history$, [wallet]);
|
||||
const mints = use$(() => wallet?.mints$, [wallet]);
|
||||
const relays = use$(() => wallet?.relays$, [wallet]);
|
||||
const received = use$(() => wallet?.received$, [wallet]);
|
||||
|
||||
// Get user's outbox relays for subscriptions
|
||||
const outboxes = use$(() => user?.outboxes$, [user]);
|
||||
|
||||
// Subscribe to wallet events when pubkey is available
|
||||
useEffect(() => {
|
||||
if (!pubkey) return;
|
||||
|
||||
// Get all relevant relays
|
||||
const walletRelays = relays || [];
|
||||
const userOutboxes = outboxes || [];
|
||||
const allRelays = relaySet(walletRelays, userOutboxes);
|
||||
|
||||
if (allRelays.length === 0) return;
|
||||
|
||||
// Subscribe to wallet-related events
|
||||
const observable = pool.subscription(
|
||||
allRelays,
|
||||
[
|
||||
// Wallet events
|
||||
{
|
||||
kinds: [WALLET_KIND, WALLET_TOKEN_KIND, WALLET_HISTORY_KIND],
|
||||
authors: [pubkey],
|
||||
},
|
||||
// Token deletions
|
||||
{
|
||||
kinds: [kinds.EventDeletion],
|
||||
"#k": [String(WALLET_TOKEN_KIND)],
|
||||
authors: [pubkey],
|
||||
},
|
||||
],
|
||||
{ eventStore },
|
||||
);
|
||||
|
||||
const subscription = observable.subscribe();
|
||||
subscriptionRef.current = subscription;
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
subscriptionRef.current = null;
|
||||
};
|
||||
}, [pubkey, relays?.join(","), outboxes?.join(",")]);
|
||||
|
||||
// Unlock wallet action
|
||||
const unlock = useCallback(async () => {
|
||||
if (!canSign) {
|
||||
setError("Cannot unlock: no signer available");
|
||||
return false;
|
||||
}
|
||||
|
||||
setUnlocking(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await hub.run(UnlockWallet, { history: true, tokens: true });
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("Failed to unlock wallet:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to unlock wallet");
|
||||
return false;
|
||||
} finally {
|
||||
setUnlocking(false);
|
||||
}
|
||||
}, [canSign]);
|
||||
|
||||
// Calculate total balance across all mints
|
||||
const totalBalance = useMemo(() => {
|
||||
if (!balance) return 0;
|
||||
return Object.values(balance).reduce((sum, amount) => sum + amount, 0);
|
||||
}, [balance]);
|
||||
|
||||
return {
|
||||
// Wallet state
|
||||
wallet,
|
||||
hasWallet: wallet !== undefined,
|
||||
isUnlocked: wallet?.unlocked ?? false,
|
||||
|
||||
// Balances
|
||||
balance, // { [mint: string]: number }
|
||||
totalBalance, // Total across all mints
|
||||
|
||||
// Data
|
||||
tokens,
|
||||
history,
|
||||
mints,
|
||||
relays,
|
||||
received, // Received nutzap IDs
|
||||
|
||||
// Actions
|
||||
unlock,
|
||||
unlocking,
|
||||
|
||||
// Error state
|
||||
error,
|
||||
|
||||
// Utilities
|
||||
canSign,
|
||||
couch, // For advanced operations
|
||||
};
|
||||
}
|
||||
57
src/lib/wallet-parser.ts
Normal file
57
src/lib/wallet-parser.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Wallet Command Parser
|
||||
*
|
||||
* Parses arguments for the wallet command with subcommands:
|
||||
* - nwc: NWC Lightning wallet (default)
|
||||
* - nip-61: NIP-60 Cashu ecash wallet
|
||||
*/
|
||||
|
||||
export type WalletSubcommand = "nwc" | "nip-61";
|
||||
|
||||
export interface WalletCommandResult {
|
||||
subcommand: WalletSubcommand;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse wallet command arguments
|
||||
*
|
||||
* Usage:
|
||||
* wallet - Open NWC Lightning wallet (default)
|
||||
* wallet nwc - Open NWC Lightning wallet
|
||||
* wallet nip-61 - Open NIP-60 Cashu ecash wallet
|
||||
* wallet cashu - Alias for nip-61
|
||||
* wallet ecash - Alias for nip-61
|
||||
*/
|
||||
export function parseWalletCommand(args: string[]): WalletCommandResult {
|
||||
// Default to 'nwc' if no subcommand
|
||||
if (args.length === 0) {
|
||||
return { subcommand: "nwc" };
|
||||
}
|
||||
|
||||
const subcommand = args[0].toLowerCase();
|
||||
|
||||
switch (subcommand) {
|
||||
case "nwc":
|
||||
case "lightning":
|
||||
case "ln":
|
||||
return { subcommand: "nwc" };
|
||||
|
||||
case "nip-61":
|
||||
case "nip61":
|
||||
case "cashu":
|
||||
case "ecash":
|
||||
case "nuts":
|
||||
return { subcommand: "nip-61" };
|
||||
|
||||
default:
|
||||
throw new Error(
|
||||
`Unknown wallet type: ${subcommand}
|
||||
|
||||
Available wallet types:
|
||||
nwc NWC Lightning wallet (default)
|
||||
nip-61 NIP-60 Cashu ecash wallet
|
||||
cashu Alias for nip-61
|
||||
ecash Alias for nip-61`,
|
||||
);
|
||||
}
|
||||
}
|
||||
52
src/services/nip61-wallet.ts
Normal file
52
src/services/nip61-wallet.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* NIP-61 Wallet Service
|
||||
*
|
||||
* Manages NIP-60 Cashu wallet operations using applesauce-wallet.
|
||||
* Provides wallet unlocking, history, and token management.
|
||||
*/
|
||||
|
||||
import { IndexedDBCouch } from "applesauce-wallet/helpers";
|
||||
|
||||
// Re-export wallet constants for use throughout the app
|
||||
export {
|
||||
WALLET_KIND,
|
||||
WALLET_TOKEN_KIND,
|
||||
WALLET_HISTORY_KIND,
|
||||
NUTZAP_KIND,
|
||||
} from "applesauce-wallet/helpers";
|
||||
|
||||
// Re-export wallet actions
|
||||
export {
|
||||
UnlockWallet,
|
||||
ReceiveToken,
|
||||
ReceiveNutzaps,
|
||||
ConsolidateTokens,
|
||||
RecoverFromCouch,
|
||||
SetWalletMints,
|
||||
SetWalletRelays,
|
||||
} from "applesauce-wallet/actions";
|
||||
|
||||
// Re-export casts - IMPORTANT: this enables user.wallet$ property
|
||||
export type {
|
||||
Wallet,
|
||||
WalletToken,
|
||||
WalletHistory,
|
||||
Nutzap,
|
||||
} from "applesauce-wallet/casts";
|
||||
|
||||
// Import casts to enable User extension with wallet$ property
|
||||
import "applesauce-wallet/casts";
|
||||
|
||||
/**
|
||||
* Singleton IndexedDB couch for safe token operations
|
||||
*
|
||||
* The "couch" is temporary storage for proofs during operations that could fail.
|
||||
* This prevents losing proofs if the app crashes mid-operation.
|
||||
*/
|
||||
export const couch = new IndexedDBCouch();
|
||||
|
||||
/**
|
||||
* NIP-60 Nutzap Info event kind (kind:10019)
|
||||
* Used to advertise mints, relays, and P2PK pubkey for receiving nutzaps
|
||||
*/
|
||||
export const NUTZAP_INFO_KIND = 10019;
|
||||
@@ -9,6 +9,7 @@ import { resolveNip05Batch, resolveDomainDirectoryBatch } from "@/lib/nip05";
|
||||
import { parseChatCommand } from "@/lib/chat-parser";
|
||||
import { parseBlossomCommand } from "@/lib/blossom-parser";
|
||||
import { parseZapCommand } from "@/lib/zap-parser";
|
||||
import { parseWalletCommand } from "@/lib/wallet-parser";
|
||||
|
||||
export interface ManPageEntry {
|
||||
name: string;
|
||||
@@ -843,14 +844,32 @@ export const manPages: Record<string, ManPageEntry> = {
|
||||
wallet: {
|
||||
name: "wallet",
|
||||
section: "1",
|
||||
synopsis: "wallet",
|
||||
synopsis: "wallet [nwc|nip-61]",
|
||||
description:
|
||||
"View and manage your Nostr Wallet Connect (NWC) Lightning wallet. Display wallet balance, transaction history, send/receive payments, and view wallet capabilities. The wallet interface adapts based on the methods supported by your connected wallet provider.",
|
||||
examples: ["wallet Open wallet viewer and manage Lightning payments"],
|
||||
"View and manage your wallet. Supports NWC (Nostr Wallet Connect) for Lightning payments and NIP-61 for Cashu ecash. The wallet interface adapts based on the wallet type and supported capabilities.",
|
||||
options: [
|
||||
{
|
||||
flag: "nwc",
|
||||
description:
|
||||
"NWC Lightning wallet (default). Connect via nostr+walletconnect:// URI.",
|
||||
},
|
||||
{
|
||||
flag: "nip-61",
|
||||
description:
|
||||
"NIP-60 Cashu ecash wallet. View balance, history, and manage ecash tokens stored on Nostr relays.",
|
||||
},
|
||||
],
|
||||
examples: [
|
||||
"wallet Open NWC Lightning wallet (default)",
|
||||
"wallet nwc Open NWC Lightning wallet",
|
||||
"wallet nip-61 Open Cashu ecash wallet",
|
||||
"wallet cashu Alias for nip-61",
|
||||
],
|
||||
seeAlso: ["profile"],
|
||||
appId: "wallet",
|
||||
category: "Nostr",
|
||||
defaultProps: {},
|
||||
argParser: (args: string[]) => parseWalletCommand(args),
|
||||
defaultProps: { subcommand: "nwc" },
|
||||
},
|
||||
post: {
|
||||
name: "post",
|
||||
|
||||
Reference in New Issue
Block a user