feat(wallet): implement lazy-loaded transactions observable

- Add shared wallet types (Transaction, TransactionsState) in src/types/wallet.ts
- Add transactionsState$ observable to NWC service for shared tx state
- Implement loadTransactions, loadMoreTransactions, and retryLoadTransactions
- Auto-refresh transactions on payment notifications
- Simplify WalletViewer to use observable state instead of local state
- Remove manual transaction loading logic from component

https://claude.ai/code/session_01CnJgjFMvZHZWs2ujAiWAiQ
This commit is contained in:
Claude
2026-01-29 16:29:39 +00:00
parent 39a66cb4dd
commit 99e26f9c80
4 changed files with 275 additions and 141 deletions

View File

@@ -5,7 +5,7 @@
* Layout: Header → Big centered balance → Send/Receive buttons → Transaction list
*/
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { useState, useEffect, useMemo, useRef } from "react";
import { toast } from "sonner";
import {
Wallet,
@@ -62,21 +62,7 @@ import { KindRenderer } from "./nostr/kinds";
import { RichText } from "./nostr/RichText";
import { UserName } from "./nostr/UserName";
import { CodeCopyButton } from "./CodeCopyButton";
interface Transaction {
type: "incoming" | "outgoing";
invoice?: string;
description?: string;
description_hash?: string;
preimage?: string;
payment_hash?: string;
amount: number;
fees_paid?: number;
created_at: number;
expires_at?: number;
settled_at?: number;
metadata?: Record<string, any>;
}
import type { Transaction } from "@/types/wallet";
interface InvoiceDetails {
amount?: number;
@@ -85,7 +71,6 @@ interface InvoiceDetails {
expiry?: number;
}
const BATCH_SIZE = 20;
const PAYMENT_CHECK_INTERVAL = 5000; // Check every 5 seconds
/**
@@ -403,27 +388,24 @@ export default function WalletViewer() {
lastError,
support,
walletMethods, // Combined support$ + cached info fallback
transactionsState,
refreshBalance,
listTransactions,
makeInvoice,
payInvoice,
lookupInvoice,
disconnect,
reconnect,
loadTransactions,
loadMoreTransactions,
retryLoadTransactions,
} = useWallet();
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [refreshingBalance, setRefreshingBalance] = useState(false);
const [connectDialogOpen, setConnectDialogOpen] = useState(false);
const [disconnectDialogOpen, setDisconnectDialogOpen] = useState(false);
const [txLoadAttempted, setTxLoadAttempted] = useState(false);
const [txLoadFailed, setTxLoadFailed] = useState(false);
// Rate limiting refs
// Rate limiting ref
const lastBalanceRefreshRef = useRef(0);
const lastTxLoadRef = useRef(0);
// Send dialog state
const [sendDialogOpen, setSendDialogOpen] = useState(false);
@@ -456,73 +438,17 @@ export default function WalletViewer() {
const { copy: copyRawTx, copied: rawTxCopied } = useCopy(2000);
const { copy: copyNwc, copied: nwcCopied } = useCopy(2000);
// Reset transaction state when wallet disconnects
useEffect(() => {
if (!isConnected) {
setTxLoadAttempted(false);
setTxLoadFailed(false);
setTransactions([]);
setLoading(false);
setLoadingMore(false);
setHasMore(true);
}
}, [isConnected]);
// Reset transaction load flag when wallet connects (to trigger reload)
useEffect(() => {
if (connectionStatus === "connected") {
setTxLoadAttempted(false);
setTxLoadFailed(false);
setTransactions([]);
}
}, [connectionStatus]);
// Load transactions when wallet methods are available (only once)
// walletMethods combines support$ observable with cached info fallback
// Trigger lazy load of transactions when wallet supports it
useEffect(() => {
if (
walletMethods.includes("list_transactions") &&
!txLoadAttempted &&
!loading
!transactionsState.initialized
) {
setLoading(true);
setTxLoadAttempted(true);
listTransactions({
limit: BATCH_SIZE,
offset: 0,
})
.then((result) => {
const txs = result.transactions || [];
setTransactions(txs);
setHasMore(txs.length === BATCH_SIZE);
setTxLoadFailed(false);
})
.catch((error) => {
console.error("Failed to load transactions:", error);
setTxLoadFailed(true);
})
.finally(() => {
setLoading(false);
});
loadTransactions();
}
}, [walletMethods, txLoadAttempted, loading, listTransactions]);
// Helper to reload transactions (resets flags to trigger reload)
const reloadTransactions = useCallback(() => {
// Rate limiting: minimum 5 seconds between transaction reloads
const now = Date.now();
const timeSinceLastLoad = now - lastTxLoadRef.current;
if (timeSinceLastLoad < 5000) {
const waitTime = Math.ceil((5000 - timeSinceLastLoad) / 1000);
toast.warning(`Please wait ${waitTime}s before reloading transactions`);
return;
}
lastTxLoadRef.current = now;
setTxLoadAttempted(false);
setTxLoadFailed(false);
}, []);
}, [walletMethods, transactionsState.initialized, loadTransactions]);
// Poll for payment status when waiting for invoice to be paid
useEffect(() => {
if (!generatedPaymentHash || !receiveDialogOpen) return;
@@ -532,13 +458,11 @@ export default function WalletViewer() {
setCheckingPayment(true);
try {
const result = await lookupInvoice(generatedPaymentHash);
// If invoice is settled, close dialog and refresh
// If invoice is settled, close dialog (notifications will refresh transactions)
if (result.settled_at) {
toast.success("Payment received!");
setReceiveDialogOpen(false);
resetReceiveDialog();
// Reload transactions
reloadTransactions();
}
} catch {
// Ignore errors, will retry
@@ -549,45 +473,7 @@ export default function WalletViewer() {
const intervalId = setInterval(checkPayment, PAYMENT_CHECK_INTERVAL);
return () => clearInterval(intervalId);
}, [
generatedPaymentHash,
receiveDialogOpen,
walletMethods,
lookupInvoice,
reloadTransactions,
]);
const loadMoreTransactions = useCallback(async () => {
if (
!walletMethods.includes("list_transactions") ||
!hasMore ||
loadingMore
) {
return;
}
setLoadingMore(true);
try {
const result = await listTransactions({
limit: BATCH_SIZE,
offset: transactions.length,
});
const newTxs = result.transactions || [];
setTransactions((prev) => [...prev, ...newTxs]);
setHasMore(newTxs.length === BATCH_SIZE);
} catch (error) {
console.error("Failed to load more transactions:", error);
toast.error("Failed to load more transactions");
} finally {
setLoadingMore(false);
}
}, [
walletMethods,
hasMore,
loadingMore,
transactions.length,
listTransactions,
]);
}, [generatedPaymentHash, receiveDialogOpen, walletMethods, lookupInvoice]);
async function handleRefreshBalance() {
// Rate limiting: minimum 2 seconds between refreshes
@@ -600,7 +486,7 @@ export default function WalletViewer() {
}
lastBalanceRefreshRef.current = now;
setLoading(true);
setRefreshingBalance(true);
try {
await refreshBalance();
toast.success("Balance refreshed");
@@ -608,7 +494,7 @@ export default function WalletViewer() {
console.error("Failed to refresh balance:", error);
toast.error("Failed to refresh balance");
} finally {
setLoading(false);
setRefreshingBalance(false);
}
}
@@ -802,8 +688,7 @@ export default function WalletViewer() {
toast.success("Payment sent successfully");
resetSendDialog();
setSendDialogOpen(false);
// Reload transactions
reloadTransactions();
// Notifications will automatically refresh transactions
} catch (error) {
console.error("Payment failed:", error);
toast.error(error instanceof Error ? error.message : "Payment failed");
@@ -902,6 +787,13 @@ export default function WalletViewer() {
return new Date(timestamp * 1000).toLocaleString();
}
// Derive values from transactionsState for convenience
const transactions = transactionsState.items;
const txLoading = transactionsState.loading;
const txLoadingMore = transactionsState.loadingMore;
const txHasMore = transactionsState.hasMore;
const txError = transactionsState.error;
// Process transactions to include day markers
const transactionsWithMarkers = useMemo(() => {
if (!transactions || transactions.length === 0) return [];
@@ -1126,12 +1018,12 @@ export default function WalletViewer() {
<TooltipTrigger asChild>
<button
onClick={handleRefreshBalance}
disabled={loading}
disabled={refreshingBalance}
className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
aria-label="Refresh balance"
>
<RefreshCw
className={`size-3 ${loading ? "animate-spin" : ""}`}
className={`size-3 ${refreshingBalance ? "animate-spin" : ""}`}
/>
</button>
</TooltipTrigger>
@@ -1199,11 +1091,11 @@ export default function WalletViewer() {
<div className="flex-1 overflow-hidden flex justify-center">
<div className="w-full max-w-md">
{walletMethods.includes("list_transactions") ? (
loading ? (
txLoading ? (
<div className="flex h-full items-center justify-center">
<RefreshCw className="size-6 animate-spin text-muted-foreground" />
</div>
) : txLoadFailed ? (
) : txError ? (
<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
@@ -1211,7 +1103,7 @@ export default function WalletViewer() {
<Button
variant="outline"
size="sm"
onClick={reloadTransactions}
onClick={retryLoadTransactions}
>
<RefreshCw className="mr-2 size-4" />
Retry
@@ -1269,11 +1161,11 @@ export default function WalletViewer() {
}}
components={{
Footer: () =>
loadingMore ? (
txLoadingMore ? (
<div className="flex justify-center py-4 border-b border-border">
<RefreshCw className="size-4 animate-spin text-muted-foreground" />
</div>
) : !hasMore && transactions.length > 0 ? (
) : !txHasMore && transactions.length > 0 ? (
<div className="py-4 text-center text-xs text-muted-foreground border-b border-border">
No more transactions
</div>

View File

@@ -35,6 +35,10 @@ import {
balance$,
connectionStatus$,
lastError$,
transactionsState$,
loadTransactions as loadTransactionsService,
loadMoreTransactions as loadMoreTransactionsService,
retryLoadTransactions as retryLoadTransactionsService,
} from "@/services/nwc";
export function useWallet() {
@@ -47,6 +51,7 @@ export function useWallet() {
const balance = use$(balance$);
const connectionStatus = use$(connectionStatus$);
const lastError = use$(lastError$);
const transactionsState = use$(transactionsState$);
// Wallet support from library's support$ observable (cached)
const support = use$(() => wallet?.support$, [wallet]);
@@ -145,6 +150,18 @@ export function useWallet() {
return await refreshBalanceService();
}
async function loadTransactions() {
await loadTransactionsService();
}
async function loadMoreTransactions() {
await loadMoreTransactionsService();
}
async function retryLoadTransactions() {
await retryLoadTransactionsService();
}
return {
// State (all derived from observables)
wallet,
@@ -154,6 +171,7 @@ export function useWallet() {
lastError,
support,
walletMethods,
transactionsState,
// Operations
payInvoice,
@@ -166,5 +184,8 @@ export function useWallet() {
payKeysend,
disconnect,
reconnect,
loadTransactions,
loadMoreTransactions,
retryLoadTransactions,
};
}

View File

@@ -13,6 +13,10 @@
import { WalletConnect } from "applesauce-wallet-connect";
import type { NWCConnection } from "@/types/app";
import {
type TransactionsState,
INITIAL_TRANSACTIONS_STATE,
} from "@/types/wallet";
import pool from "./relay-pool";
import { BehaviorSubject, Subscription, firstValueFrom, timeout } from "rxjs";
@@ -49,6 +53,11 @@ export const lastError$ = new BehaviorSubject<Error | null>(null);
/** Current balance in millisats */
export const balance$ = new BehaviorSubject<number | undefined>(undefined);
/** Transaction list state (lazy loaded) */
export const transactionsState$ = new BehaviorSubject<TransactionsState>(
INITIAL_TRANSACTIONS_STATE,
);
// ============================================================================
// Internal helpers
// ============================================================================
@@ -89,8 +98,9 @@ function subscribeToNotifications(wallet: WalletConnect) {
lastError$.next(null);
}
// Refresh balance on any notification
// Refresh balance and transactions on any notification
refreshBalance();
refreshTransactions();
},
error: (error) => {
console.error("[NWC] Notification error:", error);
@@ -205,6 +215,7 @@ export function clearWallet(): void {
balance$.next(undefined);
connectionStatus$.next("disconnected");
lastError$.next(null);
resetTransactions();
}
/**
@@ -265,3 +276,162 @@ export async function reconnect(): Promise<void> {
subscribeToNotifications(wallet);
await refreshBalance();
}
// ============================================================================
// Transaction loading (lazy, paginated)
// ============================================================================
const TRANSACTIONS_PAGE_SIZE = 20;
/**
* Loads the initial batch of transactions.
* Only loads if not already initialized (lazy loading).
*/
export async function loadTransactions(): Promise<void> {
const wallet = wallet$.value;
if (!wallet) return;
const current = transactionsState$.value;
// Skip if already loading or initialized
if (current.loading || current.initialized) return;
transactionsState$.next({
...current,
loading: true,
error: null,
});
try {
const result = await wallet.listTransactions({
limit: TRANSACTIONS_PAGE_SIZE,
});
transactionsState$.next({
items: result.transactions,
loading: false,
loadingMore: false,
hasMore: result.transactions.length >= TRANSACTIONS_PAGE_SIZE,
error: null,
initialized: true,
});
} catch (error) {
console.error("[NWC] Failed to load transactions:", error);
transactionsState$.next({
...transactionsState$.value,
loading: false,
error:
error instanceof Error
? error
: new Error("Failed to load transactions"),
initialized: true,
});
}
}
/**
* Loads more transactions (pagination).
*/
export async function loadMoreTransactions(): Promise<void> {
const wallet = wallet$.value;
if (!wallet) return;
const current = transactionsState$.value;
// Skip if already loading or no more to load
if (current.loading || current.loadingMore || !current.hasMore) return;
transactionsState$.next({
...current,
loadingMore: true,
});
try {
// Get the oldest transaction timestamp for pagination
const oldestTx = current.items[current.items.length - 1];
const until = oldestTx?.created_at;
const result = await wallet.listTransactions({
limit: TRANSACTIONS_PAGE_SIZE,
until,
});
// Filter out any duplicates (in case of overlapping timestamps)
const existingHashes = new Set(current.items.map((tx) => tx.payment_hash));
const newTransactions = result.transactions.filter(
(tx) => !existingHashes.has(tx.payment_hash),
);
transactionsState$.next({
...current,
items: [...current.items, ...newTransactions],
loadingMore: false,
hasMore: result.transactions.length >= TRANSACTIONS_PAGE_SIZE,
});
} catch (error) {
console.error("[NWC] Failed to load more transactions:", error);
transactionsState$.next({
...current,
loadingMore: false,
error:
error instanceof Error
? error
: new Error("Failed to load more transactions"),
});
}
}
/**
* Refreshes the transaction list (prepends new transactions).
* Called automatically on payment notifications.
*/
export async function refreshTransactions(): Promise<void> {
const wallet = wallet$.value;
if (!wallet) return;
const current = transactionsState$.value;
// Only refresh if already initialized
if (!current.initialized) return;
try {
// Get the newest transaction timestamp
const newestTx = current.items[0];
const from = newestTx?.created_at ? newestTx.created_at + 1 : undefined;
const result = await wallet.listTransactions({
limit: TRANSACTIONS_PAGE_SIZE,
from,
});
// Filter out duplicates and prepend new transactions
const existingHashes = new Set(current.items.map((tx) => tx.payment_hash));
const newTransactions = result.transactions.filter(
(tx) => !existingHashes.has(tx.payment_hash),
);
if (newTransactions.length > 0) {
transactionsState$.next({
...current,
items: [...newTransactions, ...current.items],
});
}
} catch (error) {
console.error("[NWC] Failed to refresh transactions:", error);
}
}
/**
* Resets transaction state (called on wallet clear).
*/
function resetTransactions(): void {
transactionsState$.next(INITIAL_TRANSACTIONS_STATE);
}
/**
* Force reload transactions (used for retry after error).
*/
export async function retryLoadTransactions(): Promise<void> {
resetTransactions();
await loadTransactions();
}

51
src/types/wallet.ts Normal file
View File

@@ -0,0 +1,51 @@
/**
* Wallet-related type definitions for NWC (NIP-47)
*/
/**
* A Lightning/Bitcoin transaction from NWC list_transactions
*/
export interface Transaction {
type: "incoming" | "outgoing";
invoice?: string;
description?: string;
description_hash?: string;
preimage?: string;
payment_hash?: string;
amount: number;
fees_paid?: number;
created_at: number;
expires_at?: number;
settled_at?: number;
metadata?: Record<string, unknown>;
}
/**
* State for the transactions observable
*/
export interface TransactionsState {
/** The list of transactions */
items: Transaction[];
/** Whether we're loading the initial batch */
loading: boolean;
/** Whether we're loading more (pagination) */
loadingMore: boolean;
/** Whether there are more transactions to load */
hasMore: boolean;
/** Error from last load attempt */
error: Error | null;
/** Whether initial load has been triggered */
initialized: boolean;
}
/**
* Initial state for transactions
*/
export const INITIAL_TRANSACTIONS_STATE: TransactionsState = {
items: [],
loading: false,
loadingMore: false,
hasMore: true,
error: null,
initialized: false,
};