From 7662e83bea869d91e23de0b59c38eac6689971a5 Mon Sep 17 00:00:00 2001 From: Johnero542 Date: Sat, 29 Aug 2026 02:36:08 +0100 Subject: [PATCH] Harden Share popover, wire up address-book edit/delete and archived-stream cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace the custom Share popover on the stream detail page with the app's Base-UI-backed DropdownMenu, giving it click-outside, Escape, aria-expanded/aria-haspopup, and focus return to the trigger for free (#685). - Wire up deleteAddressBookEntry()/updateAddressBookEntry() in the create-stream form: "Recent recipients" pills now have rename and remove controls, with a rename dialog (#687). - Add lib/contract.ts wrappers for the contract's get_archived_sent_streams/get_archived_received_streams, a new useArchivedStreams() hook, and an "Archived" tab on the streams page (#688). - Add lib/contract.ts's cleanupStream() wrapper, wire it into useContract(), and add a "Remove from history" action + confirmation dialog on the stream detail page and on each archived-stream row (#689). Along the way, fixed pre-existing breakage blocking this work: - contracts/streaming/src/lib.rs: get_archived_sent_streams/ get_archived_received_streams had dead, syntactically-invalid leftover pagination code after an incomplete refactor to a shared paginate() helper — removed the dead code so the contract compiles. - hooks/use-streams.ts: useStreams() had two competing implementations merged together (duplicate pollIntervalRef declarations, duplicate early-return blocks, two conflicting return statements) — reconciled into one implementation keeping both real features (offline-cache fallback and the tab-refocus "Refreshing…" indicator). - app/app/streams/page.tsx: similarly had duplicate imports, duplicate state/JSX blocks, and malformed JSX with content after the root element's closing tag — reconstructed into one coherent page and added the Archived tab to it. Closes #685 Closes #687 Closes #688 Closes #689 --- app/app/create/create-form.tsx | 117 ++++++- app/app/stream/[id]/page.tsx | 177 +++++++---- app/app/streams/page.tsx | 547 ++++++++++++++++++--------------- contracts/streaming/src/lib.rs | 32 -- hooks/use-archived-streams.ts | 53 ++++ hooks/use-contract.ts | 12 + hooks/use-streams.ts | 94 +++--- lib/contract.ts | 97 ++++++ lib/mock-data.ts | 14 + 9 files changed, 740 insertions(+), 403 deletions(-) create mode 100644 hooks/use-archived-streams.ts diff --git a/app/app/create/create-form.tsx b/app/app/create/create-form.tsx index 809eb54..82aaf5b 100644 --- a/app/app/create/create-form.tsx +++ b/app/app/create/create-form.tsx @@ -11,6 +11,8 @@ import { Copy, Clock, CheckCircle2, + Pencil, + X, } from "lucide-react"; import Link from "next/link"; import { toast } from "sonner"; @@ -35,9 +37,18 @@ import { CreateConfirmation } from "@/components/streams/create-confirmation"; import { TxPreviewDialog } from "@/components/ui/tx-preview-dialog"; import { addAddressBookEntry, + deleteAddressBookEntry, getAddressBookEntries, touchAddressBookEntry, + updateAddressBookEntry, } from "@/lib/address-book"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { isFederationAddress, resolveFederationAddress } from "@/lib/federation"; import { buildNextRunAt, @@ -154,6 +165,12 @@ export function CreateForm() { const [addressBookEntries, setAddressBookEntries] = useState(() => getAddressBookEntries(), ); + // Issue #687: rename dialog state for the "Recent recipients" list. + const [renamingEntry, setRenamingEntry] = useState<{ + id: string; + label: string; + } | null>(null); + const [renameValue, setRenameValue] = useState(""); const [recurrenceCadence, setRecurrenceCadence] = useState("none"); @@ -420,6 +437,28 @@ export function CreateForm() { setErrors((prev) => ({ ...prev, [key]: undefined })); } + // Issue #687: remove/rename controls for the "Recent recipients" list. + function handleRemoveAddressBookEntry(id: string) { + deleteAddressBookEntry(id); + setAddressBookEntries(getAddressBookEntries()); + toast.success("Recipient removed"); + } + + function openRenameDialog(entry: { id: string; label: string }) { + setRenamingEntry(entry); + setRenameValue(entry.label); + } + + function handleConfirmRename() { + if (!renamingEntry) return; + const trimmed = renameValue.trim(); + if (!trimmed) return; + updateAddressBookEntry(renamingEntry.id, { label: trimmed }); + setAddressBookEntries(getAddressBookEntries()); + setRenamingEntry(null); + toast.success("Recipient renamed"); + } + function validate(): boolean { const newErrors: Partial> = {}; @@ -955,21 +994,41 @@ export function CreateForm() {

{addressBookEntries.slice(0, 6).map((entry) => ( - + + + +
))} @@ -1333,6 +1392,38 @@ export function CreateForm() { amountPerSecond={amountPerSecond} /> )} + + {/* Issue #687: rename a saved recipient */} + !open && setRenamingEntry(null)} + > + + + Rename recipient + +
+ + setRenameValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleConfirmRename(); + }} + autoFocus + /> +
+ + + + +
+
); } diff --git a/app/app/stream/[id]/page.tsx b/app/app/stream/[id]/page.tsx index 230c2ec..9cd2f44 100644 --- a/app/app/stream/[id]/page.tsx +++ b/app/app/stream/[id]/page.tsx @@ -18,6 +18,7 @@ import { MessageCircle, Send, QrCode, + Trash2, } from "lucide-react"; import { toast } from "sonner"; import { ConnectWalletButton } from "@/components/layout/connect-wallet-button"; @@ -31,6 +32,12 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { FeeEstimateDialog } from "@/components/ui/fee-estimate-dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Dialog, DialogContent, @@ -368,6 +375,58 @@ function CancelDialog({ ); } +// ─── Cleanup (remove from history) dialog ─────────────────────────────────── + +// Issue #689: wires the contract's `cleanup_stream` into the UI — lets either +// party permanently remove a completed/cancelled stream's on-chain data. +function CleanupDialog({ + open, + onClose, + streamId, + onCleaned, +}: { + open: boolean; + onClose: () => void; + streamId: string; + onCleaned: () => void; +}) { + const { cleanup, pending, error } = useContract(); + + async function handleConfirm() { + try { + await cleanup(streamId); + onClose(); + onCleaned(); + } catch { + // error shown inline via useContract + } + } + + return ( + !o && onClose()}> + + + Remove from history + + This permanently deletes this stream's on-chain record. This + can't be undone, and it won't affect any funds already + transferred. + + + {error &&

{error}

} +
+ + +
+
+
+ ); +} + // ─── Auto-withdraw settings ───────────────────────────────────────────────── const INTERVAL_OPTIONS = [ @@ -808,8 +867,6 @@ function ShareButtons({ status: import("@/types/stream").StreamStatus; }) { const streamId = stream.id; - const [copied, setCopied] = useState(false); - const [showShare, setShowShare] = useState(false); const [showQr, setShowQr] = useState(false); const streamUrl = @@ -820,8 +877,6 @@ function ShareButtons({ function copyLink() { navigator.clipboard.writeText(streamUrl); - setCopied(true); - setTimeout(() => setCopied(false), 1500); toast.success("Link copied to clipboard"); } @@ -837,62 +892,39 @@ function ShareButtons({ return (
- - - {showShare && ( -
-
- - - - -
-
- )} + {/* Issue #685: DropdownMenu (Base UI) gives us click-outside, Escape, + aria-expanded/aria-haspopup, and focus return to the trigger for free. */} + + + + + + + + Copy link + + + + + + Twitter + + + + Telegram + + setShowQr(true)}> + + QR code + + + {/* Issue #153: QR code sharing modal */} 0n; const canCancel = isSender && !stream.cancelled && status !== "completed" && !isCancelling; + // Issue #689: eligible for cleanup once terminal — cancelled, or fully + // withdrawn past end_time — mirroring the contract's own eligibility check. + const canCleanup = + (isSender || isRecipient) && + (stream.cancelled || + (status === "completed" && stream.withdrawnAmount >= stream.depositedAmount)); function handleDuplicate() { if (!stream) return; @@ -1122,7 +1161,7 @@ function StreamDetail({ id }: { id: string }) { )} {/* Actions */} - {(canWithdraw || canCancel || isSender) && ( + {(canWithdraw || canCancel || canCleanup || isSender) && (
{canWithdraw && ( )} + {canCleanup && ( + + )}
)}
@@ -1291,6 +1340,12 @@ function StreamDetail({ id }: { id: string }) { onClose={() => setCancelOpen(false)} streamId={stream.id} /> + setCleanupOpen(false)} + streamId={stream.id} + onCleaned={() => router.push("/app")} + /> ); } diff --git a/app/app/streams/page.tsx b/app/app/streams/page.tsx index 330506e..dd145a0 100644 --- a/app/app/streams/page.tsx +++ b/app/app/streams/page.tsx @@ -1,20 +1,29 @@ 'use client' import { Suspense, useCallback, useState } from 'react' import { useRouter, useSearchParams } from 'next/navigation' -import { Search, Download, ListChecks, ArrowDownToLine, Ban, X, RefreshCw } from 'lucide-react' +import { + Search, + Download, + ListChecks, + ArrowDownToLine, + Ban, + X, + RefreshCw, + EyeOff, + Eye, + LayoutList, + GanttChartSquare, + Archive, +} from 'lucide-react' import { RequireWallet } from '@/components/layout/require-wallet' import { Button } from '@/components/ui/button' import { streamsToCSV, downloadCSV } from '@/lib/export' import { VirtualStreamList } from '@/components/streams/virtual-stream-list' -import { Search, Download, ListChecks, ArrowDownToLine, Ban, X, EyeOff, Eye, LayoutList, GanttChartSquare } from 'lucide-react' -import { RequireWallet } from '@/components/layout/require-wallet' -import { Button } from '@/components/ui/button' -import { streamsToCSV, downloadCSV } from '@/lib/export' -import { StreamCard } from '@/components/streams/stream-card' import { StreamGanttView } from '@/components/streams/stream-gantt-view' import { EmptyStreams } from '@/components/streams/empty-state' import { Input } from '@/components/ui/input' import { useStreams } from '@/hooks/use-streams' +import { useArchivedStreams } from '@/hooks/use-archived-streams' import { useNow } from '@/hooks/use-now' import { useWallet } from '@/hooks/use-wallet' import { useContract } from '@/hooks/use-contract' @@ -35,6 +44,74 @@ const STATUS_FILTERS: { label: string; value: StreamStatus | 'all' }[] = [ const TOKEN_OPTIONS = ['all', 'XLM', 'USDC', 'EURC'] as const +// Issue #688: read-only card for the Archived tab. Archived streams are +// terminal (cancelled, or fully withdrawn past end_time) so none of the +// active-stream actions (withdraw/cancel/select) apply — just the option to +// permanently clean them up via cleanup_stream(). +function ArchivedStreamRow({ + streamId, + onRemoved, +}: { + streamId: string + onRemoved: () => void +}) { + const { cleanup, pending } = useContract() + const [confirming, setConfirming] = useState(false) + + async function handleRemove() { + await cleanup(streamId) + setConfirming(false) + onRemoved() + } + + return ( +
+ Stream #{streamId} + {confirming ? ( +
+ Remove permanently? + + +
+ ) : ( + + )} +
+ ) +} + +function ArchivedStreamsTab() { + const { address } = useWallet() + const { sent, received, loading, refetch } = useArchivedStreams(address) + const ids = [...new Set([...sent, ...received].map((id) => id))] + + if (loading && ids.length === 0) { + return

Loading archive…

+ } + + if (ids.length === 0) { + return ( +

+ No archived streams yet. Streams appear here once cancelled or fully withdrawn. +

+ ) + } + + return ( +
+ {ids.map((id) => ( + + ))} +
+ ) +} + function StreamsPage() { const router = useRouter() const searchParams = useSearchParams() @@ -46,6 +123,8 @@ function StreamsPage() { const { hiddenIds, blockedSenders } = useHiddenStreams() const [showHidden, setShowHidden] = useState(false) const { view, setView } = useStreamsViewPreference() + // Issue #688: Active vs Archived tab. + const [activeTab, setActiveTab] = useState<'active' | 'archived'>('active') const search = searchParams.get('q') ?? '' const statusFilter = (searchParams.get('status') ?? 'all') as StreamStatus | 'all' @@ -73,18 +152,15 @@ function StreamsPage() { }, [router]) const filtered = all.filter((s) => { - const matchesStatus = - statusFilter === 'all' || getStreamStatus(s, now) === statusFilter + const matchesStatus = statusFilter === 'all' || getStreamStatus(s, now) === statusFilter // When "Show hidden streams" is off, hidden/blocked streams don't appear // at all (issue #151). When it's on, only the concealed ones are shown, // so the user can review/un-hide them. const concealed = isConcealed(s) if (showHidden ? !concealed : concealed) return false - const matchesStatus = statusFilter === 'all' || getStreamStatus(s, now) === statusFilter const matchesToken = - tokenFilter === 'all' || - s.token.symbol.toUpperCase() === tokenFilter.toUpperCase() + tokenFilter === 'all' || s.token.symbol.toUpperCase() === tokenFilter.toUpperCase() const q = search.toLowerCase() const matchesSearch = !q || @@ -97,7 +173,7 @@ function StreamsPage() { const hasFilters = search || statusFilter !== 'all' || tokenFilter !== 'all' - const { selected, selectedItems, allSelected, someSelected, toggle, toggleAll, clear } = + const { selected, selectedItems, allSelected, toggle, toggleAll, clear } = useBulkSelect(filtered) const { @@ -114,12 +190,7 @@ function StreamsPage() { .map((s) => s.id) const eligibleCancelIds = selectedItems - .filter( - (s) => - s.sender === address && - !s.cancelled && - getStreamStatus(s, now) !== 'completed', - ) + .filter((s) => s.sender === address && !s.cancelled && getStreamStatus(s, now) !== 'completed') .map((s) => s.id) const isBulkRunning = bulkStatus === 'running' @@ -154,9 +225,7 @@ function StreamsPage() {

Streams

-

- All streams you've sent or received. -

+

All streams you've sent or received.

{/* Tab re-focus refreshing indicator */} @@ -173,6 +242,7 @@ function StreamsPage() {
-
- {/* List / Timeline view toggle */} -
- - -
- - + + + Archived +
- - {/* Bulk select toggle */} -
- - - {selectMode && ( - - )} -
+ {activeTab === 'archived' ? ( + + ) : ( + <> +
+ {/* List / Timeline view toggle */} +
+ + +
+ +
- {/* Bulk select toggle */} -
- - {selectMode && ( - - )} -
+ {/* Bulk select toggle */} +
+ - {/* Bulk action bar */} - {selectMode && someSelected && ( -
- {selected.size} selected - - - -
- )} + {selectMode && ( + + )} +
- {/* Bulk action results */} - {showBulkResults && ( -
- - {succeeded} succeeded, {failed} failed - - -
- )} + {/* Bulk action bar */} + {selectMode && selected.size > 0 && ( +
+ {selected.size} selected + + + +
+ )} - {/* Filters */} -
-
- - setParam('q', e.target.value)} - className="pl-9" - data-testid="streams-search-input" - /> -
+ {/* Bulk action results */} + {showBulkResults && ( +
+ + {succeeded} succeeded, {failed} failed + + +
+ )} - {/* Token filter */} -
- {TOKEN_OPTIONS.map((t) => ( - - ))} -
+ {/* Filters */} +
+
+ + setParam('q', e.target.value)} + className="pl-9" + data-testid="streams-search-input" + /> +
- {/* Status filter */} -
- {STATUS_FILTERS.map((f) => ( - - ))} -
-
+ {/* Token filter */} +
+ {TOKEN_OPTIONS.map((t) => ( + + ))} +
- {/* Results */} - {filtered.length === 0 ? ( - hasFilters ? ( -
-

No streams match your filters

- + {/* Status filter */} +
+ {STATUS_FILTERS.map((f) => ( + + ))} +
- ) : ( - - ) - ) : ( - + + {/* Results */} + {filtered.length === 0 ? ( + hasFilters ? ( +
+

No streams match your filters

+ +
+ ) : ( + + ) + ) : view === 'timeline' ? ( + + ) : ( + + )} + )}
- - ) - ) : view === 'timeline' ? ( - - ) : ( -
- {filtered.map((s) => ( - - ))} -
- )} - ) } diff --git a/contracts/streaming/src/lib.rs b/contracts/streaming/src/lib.rs index 5f97fac..043a491 100644 --- a/contracts/streaming/src/lib.rs +++ b/contracts/streaming/src/lib.rs @@ -1279,22 +1279,6 @@ impl StreamingContract { .persistent() .get(&DataKey::ArchiveSentBy(address)) .unwrap_or(Vec::new(&env)); - let len = all.len(); - let start = core::cmp::min(offset, len); - let end = if let Some(limit_end) = offset.checked_add(limit) { - core::cmp::min(limit_end, len) - } else { - len - }; - let mut result = Vec::new(&env); - let mut i = start; - while i < end { - if let Some(id) = all.get(i) { - result.push_back(id); - } - i += 1; - } - result Self::paginate(&env, &all, offset, limit) } @@ -1310,22 +1294,6 @@ impl StreamingContract { .persistent() .get(&DataKey::ArchiveReceivedBy(address)) .unwrap_or(Vec::new(&env)); - let len = all.len(); - let start = core::cmp::min(offset, len); - let end = if let Some(limit_end) = offset.checked_add(limit) { - core::cmp::min(limit_end, len) - } else { - len - }; - let mut result = Vec::new(&env); - let mut i = start; - while i < end { - if let Some(id) = all.get(i) { - result.push_back(id); - } - i += 1; - } - result Self::paginate(&env, &all, offset, limit) } diff --git a/hooks/use-archived-streams.ts b/hooks/use-archived-streams.ts new file mode 100644 index 0000000..ecfa355 --- /dev/null +++ b/hooks/use-archived-streams.ts @@ -0,0 +1,53 @@ +'use client' +import { useCallback, useEffect, useRef, useState } from 'react' +import { fetchArchivedSentStreamIds, fetchArchivedReceivedStreamIds } from '@/lib/contract' +import { useNetwork } from '@/components/providers/network-provider' +import { captureError } from '@/lib/sentry' + +/** + * Issue #688: paginated archived (cancelled/fully-withdrawn) stream IDs for + * `address`, backed by the contract's `get_archived_sent_streams` / + * `get_archived_received_streams`. Call the returned `refetch` after a write + * that affects the archive (e.g. `cleanup_stream`) to refresh the list. + */ +export function useArchivedStreams(address: string | null) { + const { network } = useNetwork() + const [sent, setSent] = useState([]) + const [received, setReceived] = useState([]) + const [loading, setLoading] = useState(false) + const requestIdRef = useRef(0) + + const fetch = useCallback(async () => { + requestIdRef.current += 1 + const req = requestIdRef.current + + if (!address) { + setSent([]) + setReceived([]) + setLoading(false) + return + } + + setLoading(true) + try { + const [sentIds, receivedIds] = await Promise.all([ + fetchArchivedSentStreamIds(network, address), + fetchArchivedReceivedStreamIds(network, address), + ]) + if (req !== requestIdRef.current) return + setSent(sentIds) + setReceived(receivedIds) + } catch (e) { + if (req !== requestIdRef.current) return + captureError(e, { operation: 'use-archived-streams:fetch' }) + } finally { + if (req === requestIdRef.current) setLoading(false) + } + }, [address, network]) + + useEffect(() => { + fetch() + }, [fetch]) + + return { sent, received, loading, refetch: fetch } +} diff --git a/hooks/use-contract.ts b/hooks/use-contract.ts index 3193a99..50bfef8 100644 --- a/hooks/use-contract.ts +++ b/hooks/use-contract.ts @@ -7,6 +7,7 @@ import { createStreamsBatch as createStreamsBatchCall, withdrawFromStream, cancelStream as cancelStreamCall, + cleanupStream as cleanupStreamCall, estimateCreateStreamFee, type TxStep, } from "@/lib/contract"; @@ -127,6 +128,16 @@ export function useContract() { [run, network], ); + // Issue #689: permanently remove a completed/cancelled stream from history. + const cleanup = useCallback( + (id: string) => + run("Remove stream", (onStep) => { + if (!address) throw new Error("Connect a wallet first."); + return cleanupStreamCall(id, address, network, onStep); + }), + [run, address, network], + ); + const estimateFee = useCallback( async (input: CreateStreamInput): Promise => { if (!isConnected || !address) return null; @@ -199,6 +210,7 @@ export function useContract() { createStreamsBatch, withdraw, cancel, + cleanup, withdrawAll, estimateFee, pending, diff --git a/hooks/use-streams.ts b/hooks/use-streams.ts index 1d18f3f..ddd4a78 100644 --- a/hooks/use-streams.ts +++ b/hooks/use-streams.ts @@ -58,15 +58,13 @@ export function useStreams(options?: UseStreamsOptions): CategorizedStreams { const [streams, setStreams] = useState([]) const [loading, setLoading] = useState(false) const [isRefreshingAfterHidden, setIsRefreshingAfterHidden] = useState(false) + // True while `streams` is serving the offline cache instead of a live fetch. + const [stale, setStale] = useState(false) + const [lastUpdated, setLastUpdated] = useState(null) const pollIntervalRef = useRef | null>(null) // Tracks whether the polling interval is currently running. const pollingActiveRef = useRef(false) - - // True while `streams` is serving the offline cache instead of a live fetch. - const [stale, setStale] = useState(false) - const [lastUpdated, setLastUpdated] = useState(null) - const pollIntervalRef = useRef(null) // Monotonically increasing request ID — any response whose ID doesn't // match the current value is from a stale request and is discarded. const requestIdRef = useRef(0) @@ -96,19 +94,47 @@ export function useStreams(options?: UseStreamsOptions): CategorizedStreams { if (!address) { setStreams([]) + setStale(false) + setLastUpdated(null) if (req === requestIdRef.current) setLoading(false) return } + + // Offline (or the request will fail shortly): serve cached stream data + // immediately with a stale indicator instead of an empty dashboard + // (issue #150). + if (typeof navigator !== 'undefined' && navigator.onLine === false) { + const cached = readCachedStreams(network, address) + if (cached) { + setStreams(cached.streams) + setStale(true) + setLastUpdated(cached.fetchedAt) + } + setLoading(false) + return + } + setLoading(true) try { const data = await fetchStreamsForAddress(network, address) // Discard if a newer request has already started. if (req !== requestIdRef.current) return setStreams(data) + setStale(false) + setLastUpdated(Date.now()) + writeCachedStreams(network, address, data) } catch (e) { if (req !== requestIdRef.current) return // Suppress errors from intentionally aborted requests. if (e instanceof DOMException && e.name === 'AbortError') return + // Network-level failure (e.g. connectivity dropped mid-request) — + // fall back to whatever we last cached rather than showing nothing. + const cached = readCachedStreams(network, address) + if (cached) { + setStreams(cached.streams) + setStale(true) + setLastUpdated(cached.fetchedAt) + } captureError(e, { operation: 'use-streams:fetch' }) } finally { if (req === requestIdRef.current) setLoading(false) @@ -137,52 +163,6 @@ export function useStreams(options?: UseStreamsOptions): CategorizedStreams { if (pollIntervalRef.current) { clearInterval(pollIntervalRef.current) pollIntervalRef.current = null - if (!address) { - setStreams([]) - setStale(false) - setLastUpdated(null) - if (req === requestIdRef.current) setLoading(false) - return - } - - // Offline (or the request will fail shortly): serve cached stream data - // immediately with a stale indicator instead of an empty dashboard - // (issue #150). - if (typeof navigator !== 'undefined' && navigator.onLine === false) { - const cached = readCachedStreams(network, address) - if (cached) { - setStreams(cached.streams) - setStale(true) - setLastUpdated(cached.fetchedAt) - } - setLoading(false) - return - } - - setLoading(true) - try { - const data = await fetchStreamsForAddress(network, address) - // Discard if a newer request has already started. - if (req !== requestIdRef.current) return - setStreams(data) - setStale(false) - setLastUpdated(Date.now()) - writeCachedStreams(network, address, data) - } catch (e) { - if (req !== requestIdRef.current) return - // Suppress errors from intentionally aborted requests. - if (e instanceof DOMException && e.name === 'AbortError') return - // Network-level failure (e.g. connectivity dropped mid-request) — - // fall back to whatever we last cached rather than showing nothing. - const cached = readCachedStreams(network, address) - if (cached) { - setStreams(cached.streams) - setStale(true) - setLastUpdated(cached.fetchedAt) - } - captureError(e, { operation: 'use-streams:fetch' }) - } finally { - if (req === requestIdRef.current) setLoading(false) } pollingActiveRef.current = false }, []) @@ -256,8 +236,16 @@ export function useStreams(options?: UseStreamsOptions): CategorizedStreams { const sent = streams.filter((s) => s.sender === address) const received = streams.filter((s) => s.recipient === address) - return { all: streams, sent, received, loading, isRefreshingAfterHidden, refetch: fetch } - return { all: streams, sent, received, loading, refetch: fetch, stale, lastUpdated } + return { + all: streams, + sent, + received, + loading, + isRefreshingAfterHidden, + refetch: fetch, + stale, + lastUpdated, + } } export function useStream(id: string): { diff --git a/lib/contract.ts b/lib/contract.ts index c74c38a..d4fd545 100644 --- a/lib/contract.ts +++ b/lib/contract.ts @@ -812,3 +812,100 @@ export async function fetchStreamsForAddress( const streams = await Promise.all(unique.map((id) => fetchStream(network, id))) return streams.filter((s): s is StreamData => s !== null) } + +/** + * Issue #688: paginated archived (cancelled/fully-withdrawn) stream IDs where + * `address` is the sender, mirroring the contract's `get_archived_sent_streams`. + */ +export async function fetchArchivedSentStreamIds( + network: NetworkName, + address: string, + offset = 0, + limit = 100, +): Promise { + const config = getNetworkConfig(network) + const isMockMode = !config.streamContractId + + if (isMockMode) { + return mockStore + .getArchived(address) + .filter((s) => s.sender === address) + .map((s) => s.id) + } + + const result = await query( + network, + 'get_archived_sent_streams', + [ + new Address(address).toScVal(), + nativeToScVal(offset, { type: 'u32' }), + nativeToScVal(limit, { type: 'u32' }), + ], + config.streamContractId, + ) + return (scValToNative(result) as bigint[]).map(String) +} + +/** + * Issue #688: paginated archived (cancelled/fully-withdrawn) stream IDs where + * `address` is the recipient, mirroring the contract's + * `get_archived_received_streams`. + */ +export async function fetchArchivedReceivedStreamIds( + network: NetworkName, + address: string, + offset = 0, + limit = 100, +): Promise { + const config = getNetworkConfig(network) + const isMockMode = !config.streamContractId + + if (isMockMode) { + return mockStore + .getArchived(address) + .filter((s) => s.recipient === address) + .map((s) => s.id) + } + + const result = await query( + network, + 'get_archived_received_streams', + [ + new Address(address).toScVal(), + nativeToScVal(offset, { type: 'u32' }), + nativeToScVal(limit, { type: 'u32' }), + ], + config.streamContractId, + ) + return (scValToNative(result) as bigint[]).map(String) +} + +/** + * Issue #689: permanently remove a completed/cancelled stream's on-chain data + * via the contract's `cleanup_stream`. Either the sender or recipient may call + * this once the stream is cancelled or fully withdrawn past `end_time`. + */ +export async function cleanupStream( + id: string, + callerAddress: string, + network: NetworkName = 'testnet', + onStep?: (step: TxStep) => void, +): Promise { + const config = getNetworkConfig(network) + const isMockMode = !config.streamContractId + + if (isMockMode) { + await new Promise((r) => setTimeout(r, 500)) + mockStore.cleanup(id) + return null + } + + return invoke( + network, + 'cleanup_stream', + [new Address(callerAddress).toScVal(), nativeToScVal(BigInt(id), { type: 'u64' })], + callerAddress, + config.streamContractId, + onStep, + ) +} diff --git a/lib/mock-data.ts b/lib/mock-data.ts index 53e5fb0..ad89baf 100644 --- a/lib/mock-data.ts +++ b/lib/mock-data.ts @@ -156,6 +156,20 @@ export const mockStore = { streams = streams.map((s) => (s.id === id ? { ...s, cancelled: true } : s)) emit() }, + /** Streams that are terminal — cancelled, or fully withdrawn past end_time. */ + getArchived(address: string): StreamData[] { + const nowSec = Math.floor(Date.now() / 1000) + return streams.filter( + (s) => + (s.sender === address || s.recipient === address) && + (s.cancelled || (s.withdrawnAmount >= s.depositedAmount && nowSec >= Number(s.endTime))), + ) + }, + /** Issue #689: permanently remove a stream (mirrors cleanup_stream on-chain). */ + cleanup(id: string) { + streams = streams.filter((s) => s.id !== id) + emit() + }, } export { DEMO_ME }