From ac3a635b0a099b9ff6f9464282604e7803a2a4f1 Mon Sep 17 00:00:00 2001 From: Danlee76 Date: Sat, 17 Oct 2026 22:59:40 +0100 Subject: [PATCH] feat: add stream UX and Stellar asset helpers --- app/create/page.tsx | 21 +++++++++++++++++++++ app/streams/page.tsx | 33 ++++++++++++++++++++++++++++++--- lib/stellar-address.ts | 17 +++++++++++++++++ lib/tokens.ts | 16 ++++++++++++++++ 4 files changed, 84 insertions(+), 3 deletions(-) diff --git a/app/create/page.tsx b/app/create/page.tsx index b1e87ad..46aa730 100644 --- a/app/create/page.tsx +++ b/app/create/page.tsx @@ -86,6 +86,7 @@ const DURATION_PRESETS = [ ] as const; const CREATE_STREAM_TIMEOUT_MS = 60_000; +const DRAFT_KEY = 'conduit:create-stream-draft'; /** * Timeout message for the create pipeline. It is rendered inline in the form, @@ -134,6 +135,25 @@ export default function CreatePage() { }, }); + useEffect(() => { + try { + const saved = localStorage.getItem(DRAFT_KEY); + if (saved) { + const draft = JSON.parse(saved) as Partial; + for (const key of ['recipient', 'token', 'depositAmount', 'durationSeconds', 'clawback'] as const) { + if (draft[key] !== undefined) setValue(key, draft[key] as never); + } + } + } catch { /* stale or unavailable storage is non-fatal */ } + }, [setValue]); + + useEffect(() => { + const subscription = watch((values) => { + try { localStorage.setItem(DRAFT_KEY, JSON.stringify(values)); } catch { /* storage is optional */ } + }); + return () => subscription.unsubscribe(); + }, [watch]); + const deposit = watch('depositAmount'); const duration = watch('durationSeconds'); const token = watch('token'); @@ -389,6 +409,7 @@ export default function CreatePage() { await refreshStreamData(); setTxHash(hash); + try { localStorage.removeItem(DRAFT_KEY); } catch { /* storage is optional */ } // #362 — createStream now decodes the confirmed transaction's return // value, so we can deep-link straight to the new stream instead of // redirecting to /streams and hoping the user finds it. Fall back to diff --git a/app/streams/page.tsx b/app/streams/page.tsx index 745463a..a352d8c 100644 --- a/app/streams/page.tsx +++ b/app/streams/page.tsx @@ -3,12 +3,12 @@ import { Suspense, useEffect, useMemo, useState } from "react"; import { useRouter, usePathname, useSearchParams } from "next/navigation"; import Link from "next/link"; -import { Plus, AlertCircle, Columns } from "lucide-react"; +import { Plus, AlertCircle, Columns, Pause, Wallet, X } from "lucide-react"; import { useWallet } from "@/contexts/WalletContext"; import { StreamCard } from "@/components/stream/StreamCard"; import { StreamCardSkeleton } from "@/components/stream/StreamCardSkeleton"; import { streamsBySender, streamsByRecipient } from "@/lib/factory"; -import { getStreamAddress, getStreamInfo, type StreamInfo } from '@/lib/stream'; +import { getStreamAddress, getStreamInfo, getWithdrawable, pause, cancel, withdraw, type StreamInfo } from '@/lib/stream'; import { readSnapshot, saveSnapshot } from "@/lib/offline-cache"; import { useNetworkStatus } from "@/hooks/useNetworkStatus"; import { deriveStatus, MAX_COMPARE, MIN_COMPARE, type StreamStatus } from "@/lib/stream-compare"; @@ -20,6 +20,7 @@ type Tab = "receiving" | "sending"; interface StreamRow { id: string; + address: string; info: StreamInfo; status: StreamStatus; } @@ -52,6 +53,7 @@ async function loadRows( const info = await getStreamInfo(publicKey, addr); rows.push({ id: id.toString(), + address: addr, info, status: deriveStatus(info, now), }); @@ -77,7 +79,7 @@ function statusFromParam(value: string | null): "ALL" | StreamStatus { } function StreamsView() { - const { publicKey, connected } = useWallet(); + const { publicKey, connected, signTx } = useWallet(); // The global NetworkTroubleBanner already covers RPC-down / fetch-failure // cases, so suppress this page's own error row when it's showing. const { status: networkStatus } = useNetworkStatus(); @@ -106,6 +108,8 @@ function StreamsView() { }); // Stream ids ticked for the side-by-side /streams/compare view. const [selected, setSelected] = useState([]); + const [batchPending, setBatchPending] = useState(false); + const [batchMessage, setBatchMessage] = useState(null); useEffect(() => { const params = new URLSearchParams(); @@ -206,6 +210,25 @@ function StreamsView() { sort, ); + const selectedRows = displayed.filter(row => selected.includes(row.id)); + async function runBatch(action: 'pause' | 'withdraw' | 'cancel') { + if (!publicKey || !signTx || selectedRows.length === 0) return; + setBatchPending(true); setBatchMessage(null); + try { + for (const row of selectedRows) { + if (action === 'pause') await pause(publicKey, row.address, signTx); + else if (action === 'cancel') await cancel(publicKey, row.address, signTx); + else { + const amount = await getWithdrawable(publicKey, row.address); + if (amount > 0n) await withdraw(publicKey, row.address, amount, signTx); + } + } + setBatchMessage(`${action[0].toUpperCase() + action.slice(1)} completed for ${selectedRows.length} stream${selectedRows.length === 1 ? '' : 's'}.`); + setSelected([]); + } catch (error) { setBatchMessage(error instanceof Error ? error.message : 'Batch action failed'); } + finally { setBatchPending(false); } + } + return (
{/* Header */} @@ -297,6 +320,9 @@ function StreamsView() { : ""}
+ {tab === 'sending' && } + {tab === 'receiving' && } + {tab === 'sending' && }
)} + {batchMessage &&

{batchMessage}

} {/* Content */} {cachedAt && ( diff --git a/lib/stellar-address.ts b/lib/stellar-address.ts index d6fec26..c47ea8e 100644 --- a/lib/stellar-address.ts +++ b/lib/stellar-address.ts @@ -25,3 +25,20 @@ export function isValidStellarContract(address: string): boolean { export function isValidStellarAddress(address: string): boolean { return isValidStellarPublicKey(address) || isValidStellarContract(address); } + +export interface FederationResolveOptions { signal?: AbortSignal; endpoint?: string; } + +/** Resolve a Stellar federation address such as `user*domain.com`. */ +export async function resolveFederationAddress(federationAddress: string, options: FederationResolveOptions = {}): Promise { + const match = federationAddress.trim().match(/^([^*]+)\*([^*]+)$/); + if (!match) throw new Error('Invalid federation address (expected user*domain.com)'); + const endpoint = options.endpoint ?? `https://${match[2]}/federation`; + const url = new URL(endpoint); + url.searchParams.set('q', federationAddress.trim()); + url.searchParams.set('type', 'name'); + const response = await fetch(url, { signal: options.signal, headers: { Accept: 'application/json' } }); + if (!response.ok) throw new Error(`Federation lookup failed (${response.status})`); + const payload = await response.json() as { account_id?: string }; + if (!payload.account_id || !isValidStellarPublicKey(payload.account_id)) throw new Error('Federation response did not contain a valid account'); + return payload.account_id; +} diff --git a/lib/tokens.ts b/lib/tokens.ts index 9eee401..2a5b885 100644 --- a/lib/tokens.ts +++ b/lib/tokens.ts @@ -14,6 +14,22 @@ export interface TokenMeta { logoUrl?: string; } +export interface StellarExpertVerification { verified: boolean; token?: TokenMeta; error?: string; } + +/** Verify an asset against Stellar Expert's public token directory. */ +export async function verifyTokenAgainstStellarExpert(code: string, issuer: string, network: 'mainnet' | 'testnet' = 'mainnet', options: { signal?: AbortSignal } = {}): Promise { + if (!code || !issuer) return { verified: false, error: 'Token code and issuer are required' }; + const url = `https://api.stellar.expert/explorer/${network}/asset/${encodeURIComponent(code)}-${encodeURIComponent(issuer)}`; + try { + const response = await fetch(url, { signal: options.signal, headers: { Accept: 'application/json' } }); + if (!response.ok) return { verified: false, error: `Stellar Expert returned ${response.status}` }; + const data = await response.json() as { asset?: { code?: string; issuer?: string; name?: string; decimals?: number; image?: string } }; + const asset = data.asset; + if (!asset?.code || asset.issuer !== issuer) return { verified: false, error: 'Asset was not found in Stellar Expert' }; + return { verified: true, token: { symbol: asset.code, name: asset.name ?? asset.code, decimals: asset.decimals ?? 7, address: issuer, logoUrl: asset.image } }; + } catch (error) { return { verified: false, error: error instanceof Error ? error.message : 'Token verification failed' }; } +} + export const TOKENS_TESTNET: TokenMeta[] = [ { symbol: 'XLM',