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 */}
+
);
}
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 (
+
+ );
+}
+
// ─── 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 */}
-
-
-
-
-
- {/* Bulk select toggle */}
-
- (selectMode ? exitSelectMode() : setSelectMode(true))}
- data-testid="bulk-select-toggle"
- >
-
- {selectMode ? 'Done selecting' : 'Select'}
-
-
- {selectMode && (
-
- )}
-
+ {activeTab === 'archived' ? (
+
+ ) : (
+ <>
+
+ {/* List / Timeline view toggle */}
+
+ setView('list')}
+ aria-pressed={view === 'list'}
+ aria-label="List view"
+ className={
+ 'inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors ' +
+ (view === 'list' ? 'bg-secondary text-foreground' : 'text-muted-foreground hover:text-foreground')
+ }
+ >
+
+ List
+
+ setView('timeline')}
+ aria-pressed={view === 'timeline'}
+ aria-label="Timeline view"
+ className={
+ 'inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors ' +
+ (view === 'timeline' ? 'bg-secondary text-foreground' : 'text-muted-foreground hover:text-foreground')
+ }
+ >
+
+ Timeline
+
+
+
setShowHidden((v) => !v)}
+ data-testid="show-hidden-toggle"
+ >
+ {showHidden ? : }
+
+ {showHidden ? 'Showing hidden' : `Hidden${hiddenCount > 0 ? ` (${hiddenCount})` : ''}`}
+
+
+
- {/* Bulk select toggle */}
-
- (selectMode ? exitSelectMode() : setSelectMode(true))}
- data-testid="bulk-select-toggle"
- >
-
- {selectMode ? 'Done selecting' : 'Select'}
-
- {selectMode && (
- toggleAll()}>
- Select all ({filtered.length})
-
- )}
-
+ {/* Bulk select toggle */}
+
+
(selectMode ? exitSelectMode() : setSelectMode(true))}
+ data-testid="bulk-select-toggle"
+ >
+
+ {selectMode ? 'Done selecting' : 'Select'}
+
- {/* Bulk action bar */}
- {selectMode && someSelected && (
-
-
{selected.size} selected
-
-
- Withdraw ({eligibleWithdrawIds.length})
-
-
-
- Cancel ({eligibleCancelIds.length})
-
-
clear()}>
-
- Clear
-
-
- )}
+ {selectMode && (
+
+ )}
+
- {/* Bulk action results */}
- {showBulkResults && (
-
-
- {succeeded} succeeded, {failed} failed
-
-
- Dismiss
-
-
- )}
+ {/* Bulk action bar */}
+ {selectMode && selected.size > 0 && (
+
+
{selected.size} selected
+
+
+ Withdraw ({eligibleWithdrawIds.length})
+
+
+
+ Cancel ({eligibleCancelIds.length})
+
+
clear()}>
+
+ Clear
+
+
+ )}
- {/* Filters */}
-
-
-
- setParam('q', e.target.value)}
- className="pl-9"
- data-testid="streams-search-input"
- />
-
+ {/* Bulk action results */}
+ {showBulkResults && (
+
+
+ {succeeded} succeeded, {failed} failed
+
+
+ Dismiss
+
+
+ )}
- {/* Token filter */}
-
- {TOKEN_OPTIONS.map((t) => (
- setParam('token', t)}
- aria-pressed={tokenFilter === t}
- className={
- 'rounded-full border px-3 py-1 text-xs font-medium transition-colors ' +
- (tokenFilter === t
- ? 'border-primary bg-primary/10 text-primary'
- : 'border-border bg-card text-muted-foreground hover:text-foreground')
- }
- >
- {t === 'all' ? 'All tokens' : t}
-
- ))}
-
+ {/* Filters */}
+
+
+
+ setParam('q', e.target.value)}
+ className="pl-9"
+ data-testid="streams-search-input"
+ />
+
- {/* Status filter */}
-
- {STATUS_FILTERS.map((f) => (
- setParam('status', f.value)}
- aria-pressed={statusFilter === f.value}
- className={
- 'rounded-full border px-3 py-1 text-xs font-medium transition-colors ' +
- (statusFilter === f.value
- ? 'border-primary bg-primary/10 text-primary'
- : 'border-border bg-card text-muted-foreground hover:text-foreground')
- }
- >
- {f.label}
-
- ))}
-
-
+ {/* Token filter */}
+
+ {TOKEN_OPTIONS.map((t) => (
+ setParam('token', t)}
+ aria-pressed={tokenFilter === t}
+ className={
+ 'rounded-full border px-3 py-1 text-xs font-medium transition-colors ' +
+ (tokenFilter === t
+ ? 'border-primary bg-primary/10 text-primary'
+ : 'border-border bg-card text-muted-foreground hover:text-foreground')
+ }
+ >
+ {t === 'all' ? 'All tokens' : t}
+
+ ))}
+
- {/* Results */}
- {filtered.length === 0 ? (
- hasFilters ? (
-
-
No streams match your filters
-
- Clear filters
-
+ {/* Status filter */}
+
+ {STATUS_FILTERS.map((f) => (
+ setParam('status', f.value)}
+ aria-pressed={statusFilter === f.value}
+ className={
+ 'rounded-full border px-3 py-1 text-xs font-medium transition-colors ' +
+ (statusFilter === f.value
+ ? 'border-primary bg-primary/10 text-primary'
+ : 'border-border bg-card text-muted-foreground hover:text-foreground')
+ }
+ >
+ {f.label}
+
+ ))}
+
- ) : (
-
- )
- ) : (
-
+
+ {/* Results */}
+ {filtered.length === 0 ? (
+ hasFilters ? (
+
+
No streams match your filters
+
+ Clear 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 }