diff --git a/app/app/dashboard.tsx b/app/app/dashboard.tsx index 076c8f1..e5144f1 100644 --- a/app/app/dashboard.tsx +++ b/app/app/dashboard.tsx @@ -1,12 +1,13 @@ 'use client' - import Link from 'next/link' import { useState } from 'react' +import { Plus, ArrowDownToLine, RefreshCw } from 'lucide-react' import { Plus, ArrowDownToLine, WifiOff } from 'lucide-react' import { RequireWallet } from '@/components/layout/require-wallet' import { TestnetFaucetBanner } from '@/components/layout/testnet-faucet-banner' import { DashboardStats, DashboardStatsSkeleton } from '@/components/streams/dashboard-stats' -import { StreamCard, StreamCardSkeleton } from '@/components/streams/stream-card' +import { StreamCardSkeleton } from '@/components/streams/stream-card' +import { VirtualStreamList } from '@/components/streams/virtual-stream-list' import { EmptyStreams } from '@/components/streams/empty-state' import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' import { Button } from '@/components/ui/button' @@ -20,6 +21,13 @@ import { getWithdrawableAmount } from '@/lib/stream-utils' import type { StreamData } from '@/types/stream' export function Dashboard() { + const { sent, received, all, loading, isRefreshingAfterHidden } = useStreams() + const { withdrawAll, pending } = useContract() + const now = useNow(1000) + const [withdrawProgress, setWithdrawProgress] = useState<{ + current: number + total: number + } | null>(null) const { sent, received: allReceived, all: allStreams, loading, stale, lastUpdated } = useStreams() const { withdrawAll, pending } = useContract() const now = useNow(1000) @@ -47,45 +55,59 @@ export function Dashboard() { } return ( -
- {/* Header */} -
-
-

Dashboard

-

- Your active and historical token streams. -

-
-
- {withdrawableStreams.length > 0 && ( - + )} + - )} - +
-
+ {/* Testnet faucet banner */} + {/* Offline / stale-data banner */} {stale && (
- {/* Stats */} - - {loading ? : } - + {/* Stats */} + + {loading ? : } + - {/* Stream list */} - + {/* Stream list */} All ({all.length}) - Receiving ({received.length}) - Sending ({sent.length}) + Receiving ({received.length}) + Sending ({sent.length}) - {loading ? ( -
- {Array.from({ length: 2 }).map((_, i) => ( - - ))} -
- ) : all.length === 0 ? ( - - ) : ( -
- {all.map((s) => ( - - - - ))} -
- )} + + {loading ? ( +
+ {Array.from({ length: 2 }).map((_, i) => ( + + ))} +
+ ) : all.length === 0 ? ( + + ) : ( + + )} +
- - {loading ? ( -
- {Array.from({ length: 2 }).map((_, i) => ( - - ))} -
- ) : received.length === 0 ? ( - - ) : ( -
- {received.map((s) => ( - - - - ))} -
- )} + + + {loading ? ( +
+ {Array.from({ length: 2 }).map((_, i) => ( + + ))} +
+ ) : received.length === 0 ? ( + + ) : ( + + )} +
- - {loading ? ( -
- {Array.from({ length: 2 }).map((_, i) => ( - - ))} -
- ) : sent.length === 0 ? ( - - ) : ( -
- {sent.map((s) => ( - - - - ))} -
- )} + + + {loading ? ( +
+ {Array.from({ length: 2 }).map((_, i) => ( + + ))} +
+ ) : sent.length === 0 ? ( + + ) : ( + + )} +
-
-
+ + ) } diff --git a/app/app/streams/page.tsx b/app/app/streams/page.tsx index a73e72f..330506e 100644 --- a/app/app/streams/page.tsx +++ b/app/app/streams/page.tsx @@ -1,7 +1,11 @@ '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 { 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' @@ -34,7 +38,7 @@ const TOKEN_OPTIONS = ['all', 'XLM', 'USDC', 'EURC'] as const function StreamsPage() { const router = useRouter() const searchParams = useSearchParams() - const { all } = useStreams() + const { all, isRefreshingAfterHidden } = useStreams() const now = useNow(5000) const { address } = useWallet() const { withdraw, cancel } = useContract() @@ -69,6 +73,8 @@ function StreamsPage() { }, [router]) const filtered = all.filter((s) => { + 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. @@ -77,7 +83,8 @@ function StreamsPage() { 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 || @@ -92,6 +99,7 @@ function StreamsPage() { const { selected, selectedItems, allSelected, someSelected, toggle, toggleAll, clear } = useBulkSelect(filtered) + const { status: bulkStatus, results: bulkResults, @@ -104,8 +112,14 @@ function StreamsPage() { const eligibleWithdrawIds = selectedItems .filter((s) => s.recipient === address && getWithdrawableAmount(s, now) > 0n) .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' @@ -134,11 +148,40 @@ function StreamsPage() { } return ( -
-
-
-

Streams

-

All streams you've sent or received.

+ +
+ {/* Header */} +
+
+

Streams

+

+ All streams you've sent or received. +

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

No streams match your filters

- -
+ {/* Results */} + {filtered.length === 0 ? ( + hasFilters ? ( +
+

No streams match your filters

+ +
+ ) : ( + + ) ) : ( + + )} +
+ ) ) : view === 'timeline' ? ( @@ -369,10 +426,8 @@ function StreamsPage() { export default function StreamsRoute() { return ( - - - - - + + + ) } diff --git a/components/streams/virtual-stream-list.tsx b/components/streams/virtual-stream-list.tsx new file mode 100644 index 0000000..bbe569e --- /dev/null +++ b/components/streams/virtual-stream-list.tsx @@ -0,0 +1,173 @@ +'use client' +import { useRef, useCallback } from 'react' +import { useVirtualizer } from '@tanstack/react-virtual' +import { StreamCard, StreamCardSkeleton } from '@/components/streams/stream-card' +import type { StreamData } from '@/types/stream' + +// ─── Constants ───────────────────────────────────────────────────────────────── +// Virtualization only pays off above this threshold. Below it the overhead of +// position calculations outweighs the DOM savings. +const VIRTUALIZATION_THRESHOLD = 50 + +// Estimated card height used before a card has been measured. Keeps the +// scrollbar thumb size reasonable on first render. Tweak if your design changes. +const ESTIMATED_CARD_HEIGHT = 160 + +// Cards to render above and below the visible area. Per the issue spec: 3–5. +const OVERSCAN_COUNT = 4 + +// ─── Types ───────────────────────────────────────────────────────────────────── +interface VirtualStreamListProps { + streams: StreamData[] + loading?: boolean + skeletonCount?: number + selectable?: boolean + selectedIds?: Set + onToggleSelect?: (id: string) => void + className?: string +} + +// ─── Flat list (< VIRTUALIZATION_THRESHOLD items) ────────────────────────────── +function FlatStreamList({ + streams, + selectable, + selectedIds, + onToggleSelect, + className = '', +}: Omit) { + return ( +
+ {streams.map((s) => ( + + ))} +
+ ) +} + +// ─── Virtualized list (≥ VIRTUALIZATION_THRESHOLD items) ─────────────────────── +function VirtualList({ + streams, + selectable, + selectedIds, + onToggleSelect, + className = '', +}: Omit) { + const parentRef = useRef(null) + + // Per-item height cache: keyed by stream id so that remeasuring one card + // doesn't invalidate unrelated entries. + const sizeCache = useRef>({}) + + const estimateSize = useCallback( + (index: number) => { + const id = streams[index]?.id + return id ? (sizeCache.current[id] ?? ESTIMATED_CARD_HEIGHT) : ESTIMATED_CARD_HEIGHT + }, + [streams], + ) + + const virtualizer = useVirtualizer({ + count: streams.length, + getScrollElement: () => parentRef.current, + estimateSize, + overscan: OVERSCAN_COUNT, + // Dynamic height measurement: after each item renders, the virtualizer + // calls this to get the real height and adjusts its layout. + measureElement: (element) => element.getBoundingClientRect().height, + }) + + const totalHeight = virtualizer.getTotalSize() + const items = virtualizer.getVirtualItems() + + return ( +
+ {/* Spacer div — tells the browser the full scrollable height so the + scrollbar thumb is sized correctly even before all items render. */} +
+ {items.map((virtualRow) => { + const stream = streams[virtualRow.index] + return ( +
+ +
+ ) + })} +
+
+ ) +} + +// ─── Skeleton list ────────────────────────────────────────────────────────────── +function SkeletonList({ count }: { count: number }) { + return ( +
+ {Array.from({ length: count }).map((_, i) => ( + + ))} +
+ ) +} + +// ─── Public component ─────────────────────────────────────────────────────────── +/** + * Renders a list of stream cards with automatic virtualization for large lists. + * + * - Flat list (no virtual overhead) when streams.length < 50 + * - @tanstack/react-virtual with dynamic row heights for 50+ streams + * - 4-item overscan buffer above and below the viewport + * - Falls back to skeletons while loading + */ +export function VirtualStreamList({ + streams, + loading = false, + skeletonCount = 3, + selectable, + selectedIds, + onToggleSelect, + className, +}: VirtualStreamListProps) { + if (loading) { + return + } + + const props = { streams, selectable, selectedIds, onToggleSelect, className } + + return streams.length >= VIRTUALIZATION_THRESHOLD ? ( + + ) : ( + + ) +} diff --git a/hooks/use-page-visibility.ts b/hooks/use-page-visibility.ts new file mode 100644 index 0000000..d3cc311 --- /dev/null +++ b/hooks/use-page-visibility.ts @@ -0,0 +1,42 @@ +'use client' +import { useEffect, useRef } from 'react' + +/** + * Subscribes to the Page Visibility API. + * + * Calls `onVisible` when the document becomes visible (tab refocused / + * browser restored) and `onHidden` when it becomes hidden. + * + * - Safe in SSR — no DOM access until `useEffect` runs in the browser. + * - Works correctly with multiple FlowStar tabs: each tab's document fires + * its own `visibilitychange` event independently. + * - The event listener is registered once and kept stable; callback identity + * changes are handled through refs so no teardown/re-register cycle occurs. + */ +export function usePageVisibility({ + onVisible, + onHidden, +}: { + onVisible?: () => void + onHidden?: () => void +}) { + // Keep a live ref to each callback so we never need to re-register the + // DOM listener just because the caller re-renders with a new function ref. + const onVisibleRef = useRef(onVisible) + const onHiddenRef = useRef(onHidden) + onVisibleRef.current = onVisible + onHiddenRef.current = onHidden + + useEffect(() => { + const handleChange = () => { + if (document.hidden) { + onHiddenRef.current?.() + } else { + onVisibleRef.current?.() + } + } + + document.addEventListener('visibilitychange', handleChange) + return () => document.removeEventListener('visibilitychange', handleChange) + }, []) // intentionally empty — refs keep callbacks current without re-running +} diff --git a/hooks/use-streams.ts b/hooks/use-streams.ts index 0cc7a1f..1d18f3f 100644 --- a/hooks/use-streams.ts +++ b/hooks/use-streams.ts @@ -1,20 +1,18 @@ 'use client' - import { useState, useEffect, useCallback, useRef } from 'react' import { fetchStreamsForAddress, fetchStream } from '@/lib/contract' import type { StreamData } from '@/types/stream' import { useWallet } from '@/hooks/use-wallet' import { useNetwork } from '@/components/providers/network-provider' import { captureError } from '@/lib/sentry' +import { usePageVisibility } from '@/hooks/use-page-visibility' import { readCachedStreams, writeCachedStreams } from '@/lib/streams-cache' -// ─── Refresh bus ───────────────────────────────────────────────────────────── +// ─── Refresh bus ─────────────────────────────────────────────────────────────── // Components call `invalidateStreams()` after a write so all stream hooks // re-fetch without prop-drilling or global state. - type Listener = () => void const listeners = new Set() - export function invalidateStreams() { listeners.forEach((l) => l()) } @@ -25,17 +23,20 @@ function useInvalidation(cb: () => void) { useEffect(() => { const handler = () => cbRef.current() listeners.add(handler) - return () => { listeners.delete(handler) } + return () => { + listeners.delete(handler) + } }, []) } -// ─── Hooks ─────────────────────────────────────────────────────────────────── - +// ─── Hooks ───────────────────────────────────────────────────────────────────── export interface CategorizedStreams { sent: StreamData[] received: StreamData[] all: StreamData[] loading: boolean + /** True when the tab just became visible after being hidden ≥ 3 seconds. */ + isRefreshingAfterHidden: boolean refetch: () => void /** True when `all` is being served from the offline cache rather than a live fetch. */ stale: boolean @@ -48,11 +49,20 @@ interface UseStreamsOptions { pollInterval?: number } +// How long the tab must have been hidden before we show "Refreshing…" +const STALE_THRESHOLD_MS = 3_000 + export function useStreams(options?: UseStreamsOptions): CategorizedStreams { const { address } = useWallet() const { network } = useNetwork() const [streams, setStreams] = useState([]) const [loading, setLoading] = useState(false) + const [isRefreshingAfterHidden, setIsRefreshingAfterHidden] = useState(false) + + 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) @@ -60,24 +70,73 @@ export function useStreams(options?: UseStreamsOptions): CategorizedStreams { // 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) + // Holds the AbortController for the currently in-flight fetch so we can // cancel the underlying network request when address/network changes, // not just guard the state update. const abortCtrlRef = useRef(null) + // Tracks when the tab was hidden so we can decide whether to show the + // "Refreshing…" indicator on tab re-focus. + const hiddenAtRef = useRef(null) + const { enablePolling = true, pollInterval = 30000 } = options ?? {} - const fetch = useCallback(async () => { - // Cancel any previous in-flight request at the network level. - abortCtrlRef.current?.abort() - const ctrl = new AbortController() - abortCtrlRef.current = ctrl + const fetch = useCallback( + async () => { + // Cancel any previous in-flight request at the network level. + abortCtrlRef.current?.abort() + const ctrl = new AbortController() + abortCtrlRef.current = ctrl - // Bump the generation counter so stale responses are discarded even - // if AbortController doesn't reach every internal fetch call. - requestIdRef.current += 1 - const req = requestIdRef.current + // Bump the generation counter so stale responses are discarded even + // if AbortController doesn't reach every internal fetch call. + requestIdRef.current += 1 + const req = requestIdRef.current + + if (!address) { + setStreams([]) + if (req === requestIdRef.current) 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) + } catch (e) { + if (req !== requestIdRef.current) return + // Suppress errors from intentionally aborted requests. + if (e instanceof DOMException && e.name === 'AbortError') return + captureError(e, { operation: 'use-streams:fetch' }) + } finally { + if (req === requestIdRef.current) setLoading(false) + } + }, + [address, network], + ) + + // Fetch on mount and when address changes + useEffect(() => { + fetch() + }, [fetch]) + + // Re-fetch when a write invalidates the cache + useInvalidation(fetch) + + // ─── Polling helpers ─────────────────────────────────────────────────────── + const startPolling = useCallback(() => { + if (pollingActiveRef.current || !enablePolling || !address) return + pollIntervalRef.current = setInterval(fetch, pollInterval) + pollingActiveRef.current = true + }, [enablePolling, address, fetch, pollInterval]) + + const stopPolling = useCallback(() => { + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current) + pollIntervalRef.current = null if (!address) { setStreams([]) setStale(false) @@ -125,13 +184,51 @@ export function useStreams(options?: UseStreamsOptions): CategorizedStreams { } finally { if (req === requestIdRef.current) setLoading(false) } - }, [address, network]) + pollingActiveRef.current = false + }, []) - // Fetch on mount and when address changes - useEffect(() => { fetch() }, [fetch]) + // ─── Page Visibility integration ────────────────────────────────────────── + // + // When the tab is hidden we pause the polling interval to avoid wasting + // bandwidth, battery, and RPC rate-limit quota. + // When it becomes visible again we: + // 1. Immediately fire a fresh fetch (no stale data shown). + // 2. Restart the polling interval. + // 3. Briefly show "Refreshing…" if the tab was hidden long enough that + // the cached data could be considered stale. + // + // Note: auto-withdraw (useAutoWithdraw) manages its own separate interval + // and is intentionally not affected here. - // Re-fetch when a write invalidates the cache - useInvalidation(fetch) + usePageVisibility({ + onHidden: useCallback(() => { + hiddenAtRef.current = Date.now() + stopPolling() + }, [stopPolling]), + + onVisible: useCallback(() => { + const hiddenDuration = hiddenAtRef.current + ? Date.now() - hiddenAtRef.current + : 0 + hiddenAtRef.current = null + + // Show "Refreshing…" indicator only when data could be noticeably stale. + if (hiddenDuration >= STALE_THRESHOLD_MS) { + setIsRefreshingAfterHidden(true) + fetch().finally(() => setIsRefreshingAfterHidden(false)) + } else { + fetch() + } + + startPolling() + }, [fetch, startPolling]), + }) + + // ─── Main polling setup ─────────────────────────────────────────────────── + // + // This effect owns the lifecycle of the polling interval. The visibility + // handlers above call startPolling/stopPolling without re-running this + // effect so there is no double-interval risk. // Re-fetch as soon as connectivity returns, so the stale/cached view // refreshes without waiting for the next poll tick. @@ -144,31 +241,30 @@ export function useStreams(options?: UseStreamsOptions): CategorizedStreams { // Set up polling for real-time updates useEffect(() => { if (!enablePolling || !address) { - if (pollIntervalRef.current) { - clearInterval(pollIntervalRef.current) - pollIntervalRef.current = null - } + stopPolling() return } - - // Poll for dashboard updates - pollIntervalRef.current = setInterval(fetch, pollInterval) - - return () => { - if (pollIntervalRef.current) { - clearInterval(pollIntervalRef.current) - pollIntervalRef.current = null - } + // Only start if the page is currently visible. If the page starts hidden + // (e.g. opened via Ctrl+click then later focused), the onVisible handler + // will call startPolling when the tab is first viewed. + if (typeof document === 'undefined' || !document.hidden) { + startPolling() } - }, [enablePolling, address, fetch, pollInterval]) + return () => stopPolling() + }, [enablePolling, address, startPolling, stopPolling]) 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 } } -export function useStream(id: string): { stream: StreamData | null; loading: boolean; refetch: () => void } { +export function useStream(id: string): { + stream: StreamData | null + loading: boolean + refetch: () => void +} { const { network } = useNetwork() const [stream, setStream] = useState(null) const [loading, setLoading] = useState(false) @@ -188,7 +284,6 @@ export function useStream(id: string): { stream: StreamData | null; loading: boo if (req === requestIdRef.current) setLoading(false) return } - setLoading(true) try { const data = await fetchStream(network, id) @@ -203,7 +298,10 @@ export function useStream(id: string): { stream: StreamData | null; loading: boo } }, [id, network]) - useEffect(() => { fetch() }, [fetch]) + useEffect(() => { + fetch() + }, [fetch]) + useInvalidation(fetch) return { stream, loading, refetch: fetch } diff --git a/package.json b/package.json index ec034fa..44edd05 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@sentry/nextjs": "^8.0.0", "@stellar/freighter-api": "^6.0.1", "@stellar/stellar-sdk": "^13.3.0", + "@tanstack/react-virtual": "^3.14.10", "@vercel/analytics": "1.6.1", "@vercel/og": "^0.11.1", "@walletconnect/modal": "^2.6.2", @@ -68,4 +69,4 @@ "typescript": "5.7.3", "vitest": "^2.1.9" } -} +} \ No newline at end of file