diff --git a/app/app/dashboard.tsx b/app/app/dashboard.tsx index 62c571e..076c8f1 100644 --- a/app/app/dashboard.tsx +++ b/app/app/dashboard.tsx @@ -2,7 +2,7 @@ import Link from 'next/link' import { useState } from 'react' -import { Plus, ArrowDownToLine } 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' @@ -15,12 +15,21 @@ import { ComponentErrorBoundary } from '@/components/error-boundary/component-er import { useStreams } from '@/hooks/use-streams' import { useContract } from '@/hooks/use-contract' import { useNow } from '@/hooks/use-now' +import { useHiddenStreams } from '@/hooks/use-hidden-streams' import { getWithdrawableAmount } from '@/lib/stream-utils' +import type { StreamData } from '@/types/stream' export function Dashboard() { - const { sent, received, all, loading } = useStreams() + const { sent, received: allReceived, all: allStreams, loading, stale, lastUpdated } = useStreams() const { withdrawAll, pending } = useContract() const now = useNow(1000) + const { hiddenIds, blockedSenders } = useHiddenStreams() + + // Hidden streams (and streams from blocked senders) never appear on the + // dashboard — neither in the lists nor in the counts (issue #151). + const isVisible = (s: StreamData) => !hiddenIds.has(s.id) && !blockedSenders.has(s.sender) + const received = allReceived.filter(isVisible) + const all = allStreams.filter(isVisible) const [withdrawProgress, setWithdrawProgress] = useState<{ current: number; total: number } | null>(null) const withdrawableStreams = received.filter((s) => getWithdrawableAmount(s, now) > 0n) @@ -77,6 +86,21 @@ export function Dashboard() { + {/* Offline / stale-data banner */} + {stale && ( +
+ + + You're offline — showing cached stream data + {lastUpdated && ` from ${new Date(lastUpdated).toLocaleString()}`}. It may be + outdated. + +
+ )} + {/* Testnet faucet banner */} diff --git a/app/app/stream/[id]/page.tsx b/app/app/stream/[id]/page.tsx index 991cd7e..230c2ec 100644 --- a/app/app/stream/[id]/page.tsx +++ b/app/app/stream/[id]/page.tsx @@ -45,6 +45,7 @@ import { } from "@/lib/fee-utils"; import { useStream } from "@/hooks/use-streams"; import { useContract } from "@/hooks/use-contract"; +import { useUndoableCancel, useIsStreamCancelling } from "@/hooks/use-undo-cancel"; import { useWallet } from "@/hooks/use-wallet"; import { useNow } from "@/hooks/use-now"; import { @@ -296,9 +297,7 @@ function CancelDialog({ onClose: () => void; streamId: string; }) { - const { cancel, pending, error } = useContract(); - const { network } = useNetwork(); - const router = useRouter(); + const { scheduleCancel } = useUndoableCancel(); const { usdPrice: xlmPrice } = useTokenPrice("XLM"); const [showFeeEstimate, setShowFeeEstimate] = useState(false); @@ -306,25 +305,13 @@ function CancelDialog({ const feeBreakdown = calculateFeeBreakdown(estimatedFee, xlmPrice ?? undefined); const cancelFeeHigh = isHighFee(feeBreakdown.totalEstimated, TYPICAL_FEES.cancel.typical); - async function handleCancel() { - try { - const hash = await cancel(streamId); - toast.success("Stream cancelled", { - description: - "Unlocked funds sent to recipient. Remainder returned to you.", - ...(hash && { - action: { - label: "View transaction", - onClick: () => - window.open(explorerUrl(network, "tx", hash), "_blank"), - }, - }), - }); - onClose(); - router.push("/app"); - } catch { - // error shown inline - } + function handleCancel() { + // Don't submit immediately — schedule an undoable cancellation. The + // actual `cancel` transaction (and its success/error toast) fires from + // useUndoableCancel once the countdown expires without being undone. + scheduleCancel(streamId); + setShowFeeEstimate(false); + onClose(); } return ( @@ -338,7 +325,8 @@ function CancelDialog({ Cancel stream Unlocked funds will be sent to the recipient. Any remaining locked - tokens will be returned to your wallet. This cannot be undone. + tokens will be returned to your wallet. You'll have a few + seconds to undo before this is submitted. @@ -352,17 +340,15 @@ function CancelDialog({

- {error &&

{error}

}
-
@@ -376,7 +362,7 @@ function CancelDialog({ action="stream cancellation" averageFee={TYPICAL_FEES.cancel.typical} isHighFee={cancelFeeHigh} - loading={pending} + loading={false} /> ); @@ -940,6 +926,7 @@ function StreamDetail({ id }: { id: string }) { const now = useNow(1000); const [withdrawOpen, setWithdrawOpen] = useState(false); const [cancelOpen, setCancelOpen] = useState(false); + const isCancelling = useIsStreamCancelling(id); if (loading) { return ; @@ -972,7 +959,8 @@ function StreamDetail({ id }: { id: string }) { const isRecipient = address === stream.recipient; const isSender = address === stream.sender; const canWithdraw = isRecipient && !stream.cancelled && withdrawable > 0n; - const canCancel = isSender && !stream.cancelled && status !== "completed"; + const canCancel = + isSender && !stream.cancelled && status !== "completed" && !isCancelling; function handleDuplicate() { if (!stream) return; @@ -1125,6 +1113,14 @@ function StreamDetail({ id }: { id: string }) { )} + {/* Cancelling state */} + {isCancelling && ( +
+ + Cancelling… you can still undo this from the toast. +
+ )} + {/* Actions */} {(canWithdraw || canCancel || isSender) && (
diff --git a/app/app/streams/page.tsx b/app/app/streams/page.tsx index 70eafce..a73e72f 100644 --- a/app/app/streams/page.tsx +++ b/app/app/streams/page.tsx @@ -2,11 +2,12 @@ import { Suspense, useCallback, useState } from 'react' import { useRouter, useSearchParams } from 'next/navigation' -import { Search, Download, ListChecks, ArrowDownToLine, Ban, X } from 'lucide-react' +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' @@ -15,6 +16,8 @@ import { useWallet } from '@/hooks/use-wallet' import { useContract } from '@/hooks/use-contract' import { useBulkSelect } from '@/hooks/use-bulk-select' import { useBulkActions } from '@/hooks/use-bulk-actions' +import { useHiddenStreams } from '@/hooks/use-hidden-streams' +import { useStreamsViewPreference } from '@/hooks/use-streams-view-preference' import { getStreamStatus, getWithdrawableAmount } from '@/lib/stream-utils' import type { StreamStatus } from '@/types/stream' @@ -36,11 +39,18 @@ function StreamsPage() { const { address } = useWallet() const { withdraw, cancel } = useContract() const [selectMode, setSelectMode] = useState(false) + const { hiddenIds, blockedSenders } = useHiddenStreams() + const [showHidden, setShowHidden] = useState(false) + const { view, setView } = useStreamsViewPreference() const search = searchParams.get('q') ?? '' const statusFilter = (searchParams.get('status') ?? 'all') as StreamStatus | 'all' const tokenFilter = searchParams.get('token') ?? 'all' + const isConcealed = (s: (typeof all)[number]) => + hiddenIds.has(s.id) || blockedSenders.has(s.sender) + const hiddenCount = all.filter(isConcealed).length + const setParam = useCallback( (key: string, value: string) => { const params = new URLSearchParams(searchParams.toString()) @@ -59,6 +69,12 @@ function StreamsPage() { }, [router]) const filtered = all.filter((s) => { + // 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() @@ -124,19 +140,62 @@ function StreamsPage() {

Streams

All streams you've sent or received.

- +
+ {/* List / Timeline view toggle */} +
+ + +
+ + +
{/* Bulk select toggle */} @@ -288,6 +347,8 @@ function StreamsPage() { ) : ( ) + ) : view === 'timeline' ? ( + ) : (
{filtered.map((s) => ( @@ -297,6 +358,7 @@ function StreamsPage() { selectable={selectMode} selected={selected.has(s.id)} onToggleSelect={toggle} + isHiddenView={showHidden} /> ))}
diff --git a/app/layout.tsx b/app/layout.tsx index 0542f0e..fb365b7 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -5,6 +5,8 @@ import { ThemeProvider } from 'next-themes' import { NetworkProvider } from '@/components/providers/network-provider' import { WalletProvider } from '@/components/providers/wallet-provider' import { Toaster } from '@/components/ui/sonner' +import { ServiceWorkerRegister } from '@/components/pwa/service-worker-register' +import { InstallPrompt } from '@/components/pwa/install-prompt' import './globals.css' // JSON-LD structured data @@ -80,6 +82,12 @@ export const metadata: Metadata = { ], apple: '/apple-icon.png', }, + manifest: '/manifest.json', + appleWebApp: { + capable: true, + statusBarStyle: 'black-translucent', + title: 'FlowStar', + }, } export const viewport: Viewport = { @@ -114,8 +122,10 @@ export default function RootLayout({ {children} + + {process.env.NODE_ENV === 'production' && } diff --git a/components/layout/navbar.tsx b/components/layout/navbar.tsx index cd84f51..a5cad91 100644 --- a/components/layout/navbar.tsx +++ b/components/layout/navbar.tsx @@ -2,11 +2,12 @@ import Link from 'next/link' import { usePathname } from 'next/navigation' -import { Plus, Moon, Sun, Monitor, Network, AlertTriangle } from 'lucide-react' +import { Plus, Moon, Sun, Monitor, Network, AlertTriangle, WifiOff } from 'lucide-react' import { useTheme } from 'next-themes' import { Brand } from '@/components/brand' import { useNetwork } from '@/components/providers/network-provider' import { useWalletContext } from '@/components/providers/wallet-provider' +import { useOnlineStatus } from '@/hooks/use-online-status' import { Button } from '@/components/ui/button' import { DropdownMenu, @@ -30,6 +31,7 @@ export function Navbar() { const { setTheme } = useTheme() const { network, setNetwork } = useNetwork() const { networkMismatch, walletNetwork, isConnected } = useWalletContext() + const isOnline = useOnlineStatus() return (
@@ -58,6 +60,16 @@ export function Navbar() {
+ {!isOnline && ( + + + Offline + + )} {networkMismatch ? ( + +
+ ) +} diff --git a/components/pwa/service-worker-register.tsx b/components/pwa/service-worker-register.tsx new file mode 100644 index 0000000..7344bf8 --- /dev/null +++ b/components/pwa/service-worker-register.tsx @@ -0,0 +1,21 @@ +'use client' + +import { useEffect } from 'react' + +/** + * Registers the app-shell service worker (public/sw.js) on mount. Renders + * nothing — purely a side effect. Skipped outside the browser and when the + * platform doesn't support service workers (issue #150). + */ +export function ServiceWorkerRegister() { + useEffect(() => { + if (typeof window === 'undefined' || !('serviceWorker' in navigator)) return + // Service workers require a secure context (https, or localhost in dev). + navigator.serviceWorker.register('/sw.js').catch(() => { + // Registration failures (e.g. unsupported browser, blocked storage) + // shouldn't break the app — it just runs without offline support. + }) + }, []) + + return null +} diff --git a/components/streams/stream-card.tsx b/components/streams/stream-card.tsx index e50b2b6..1541cf1 100644 --- a/components/streams/stream-card.tsx +++ b/components/streams/stream-card.tsx @@ -2,11 +2,14 @@ import { memo } from 'react' import Link from 'next/link' -import { ArrowDownLeft, ArrowUpRight } from 'lucide-react' +import { toast } from 'sonner' +import { ArrowDownLeft, ArrowUpRight, MoreVertical, EyeOff, Eye, UserX, UserCheck } from 'lucide-react' import { useNow } from '@/hooks/use-now' import { useWallet } from '@/hooks/use-wallet' import { useTokenPrice, formatUsd } from '@/hooks/use-token-price' import { useShowUsd } from '@/hooks/use-show-usd' +import { useIsStreamCancelling } from '@/hooks/use-undo-cancel' +import { useHiddenStreams } from '@/hooks/use-hidden-streams' import { getStreamProgress, getStreamStatus, @@ -19,6 +22,12 @@ import { TokenAmount } from '@/components/ui/token-amount' import { CountdownTimer } from '@/components/ui/countdown-timer' import { AccessibleCountdownTimer } from '@/components/ui/accessible-countdown-timer' import { StreamStatusBadge } from '@/components/streams/stream-status-badge' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' import { getFederationNameForAddress } from '@/lib/address-book' import type { StreamData } from '@/types/stream' @@ -39,14 +48,24 @@ interface StreamCardProps { selectable?: boolean selected?: boolean onToggleSelect?: (id: string) => void + /** Render in "hidden streams" view — flips Hide/Block actions to Unhide/Unblock. */ + isHiddenView?: boolean } -function StreamCardInner({ stream, selectable, selected, onToggleSelect }: StreamCardProps) { +function StreamCardInner({ + stream, + selectable, + selected, + onToggleSelect, + isHiddenView, +}: StreamCardProps) { const interval = getInterval(stream) const now = useNow(interval) const { address } = useWallet() const { usdPrice } = useTokenPrice(stream.token.symbol) const [showUsd] = useShowUsd() + const isCancelling = useIsStreamCancelling(stream.id) + const { isBlocked, hideStream, unhideStream, blockSender, unblockSender } = useHiddenStreams() const status = getStreamStatus(stream, now) const progress = getStreamProgress(stream, now) const withdrawnFrac = @@ -69,6 +88,37 @@ function StreamCardInner({ stream, selectable, selected, onToggleSelect }: Strea ? (Number(stream.depositedAmount) / Math.pow(10, stream.token.decimals)) * usdPrice : null + // "Hide stream" / "Block sender" only make sense for incoming streams — + // recipients are the ones who didn't opt in (issue #151). + const showHideMenu = !isOutgoing && !selectable + + function handleHideToggle(e: { preventDefault: () => void }) { + e.preventDefault() + if (isHiddenView) { + unhideStream(stream.id) + toast.success('Stream unhidden') + } else { + hideStream(stream.id) + toast.success('Stream hidden', { + description: 'Use "Show hidden streams" on the streams page to bring it back.', + }) + } + } + + function handleBlockToggle(e: { preventDefault: () => void }) { + e.preventDefault() + if (isBlocked(stream.sender)) { + unblockSender(stream.sender) + toast.success('Sender unblocked') + } else { + blockSender(stream.sender) + hideStream(stream.id) + toast.success('Sender blocked', { + description: 'Future streams from this address will be hidden automatically.', + }) + } + } + return ( )} + {showHideMenu && ( +
{ + e.preventDefault() + e.stopPropagation() + }} + > + + + + + + + {isHiddenView ? ( + <> + + Unhide stream + + ) : ( + <> + + Hide stream + + )} + + + {isBlocked(stream.sender) ? ( + <> + + Unblock sender + + ) : ( + <> + + Block sender + + )} + + + +
+ )}
- + {isCancelling ? ( + + + Cancelling… + + ) : ( + + )}
diff --git a/components/streams/stream-gantt-view.tsx b/components/streams/stream-gantt-view.tsx new file mode 100644 index 0000000..f819507 --- /dev/null +++ b/components/streams/stream-gantt-view.tsx @@ -0,0 +1,169 @@ +'use client' + +import { useWallet } from '@/hooks/use-wallet' +import { + getStreamStatus, + getStreamProgress, + formatTokenAmount, + formatDateTime, + shortenAddress, +} from '@/lib/stream-utils' +import type { StreamData, StreamStatus } from '@/types/stream' + +// Bar color per status — mirrors StreamStatusBadge's palette (streaming = +// primary/green, scheduled = amber, completed = gray, cancelled = red). +const BAR_STYLES: Record = { + streaming: 'bg-primary', + scheduled: 'bg-chart-3', + completed: 'bg-muted-foreground/40', + cancelled: 'bg-destructive', +} + +// Width of the label column each row's bar track is offset by. Kept as a +// single constant so the axis ticks and the "today" line — which both live +// outside any individual row — line up with where the tracks actually start. +const LABEL_COL_REM = 11 + +interface StreamGanttViewProps { + streams: StreamData[] + nowSeconds: number +} + +/** + * Horizontal calendar/Gantt view of vesting schedules (issue #149). One row + * per stream, X-axis is time. A shared "today" line spans every row, and + * each bar shows a cliff marker when the stream has one. + */ +export function StreamGanttView({ streams, nowSeconds }: StreamGanttViewProps) { + const { address } = useWallet() + + if (streams.length === 0) return null + + const starts = streams.map((s) => Number(s.startTime)) + const ends = streams.map((s) => Number(s.endTime)) + const rawMin = Math.min(...starts, nowSeconds) + const rawMax = Math.max(...ends, nowSeconds) + const pad = Math.max((rawMax - rawMin) * 0.05, 3600) + const rangeStart = rawMin - pad + const rangeEnd = rawMax + pad + const rangeSpan = Math.max(rangeEnd - rangeStart, 1) + + const pct = (t: number) => ((t - rangeStart) / rangeSpan) * 100 + const nowPct = Math.min(Math.max(pct(nowSeconds), 0), 100) + const ticks = Array.from({ length: 5 }, (_, i) => rangeStart + (rangeSpan * i) / 4) + + return ( +
+
+
+ {/* Time axis */} +
+ {ticks.map((t, i) => ( + + {formatDateTime(t).split(',')[0]} + + ))} +
+ + {/* Rows + shared "today" line */} +
+
+ + Today + +
+ + {streams.map((s) => { + const status = getStreamStatus(s, nowSeconds) + const progress = getStreamProgress(s, nowSeconds) + const isOutgoing = address === s.sender + const counterparty = isOutgoing ? s.recipient : s.sender + const startPct = pct(Number(s.startTime)) + const endPct = pct(Number(s.endTime)) + const widthPct = Math.max(endPct - startPct, 0.5) + const hasCliff = s.cliffTime > s.startTime + const cliffPct = hasCliff ? pct(Number(s.cliffTime)) : null + const amount = formatTokenAmount(s.depositedAmount, s.token.decimals, 2) + + return ( +
+ {/* Label */} +
+

+ {s.metadata?.name ?? (isOutgoing ? 'Sending to' : 'Receiving from')} +

+

+ {shortenAddress(counterparty, 4)} +

+
+ + {/* Track + bar */} +
+
+ {hasCliff && cliffPct !== null && ( +
+ )} + + {/* Hover tooltip */} +
+

+ {amount} {s.token.symbol} +

+

+ {isOutgoing ? 'To' : 'From'} {shortenAddress(counterparty, 5)} +

+

{(progress * 100).toFixed(1)}% unlocked

+
+
+
+
+ ) + })} +
+ + {/* Legend */} +
+ + Streaming + + + Scheduled + + + Completed + + + Cancelled + + + Cliff + +
+
+
+
+ ) +} diff --git a/hooks/use-hidden-streams.ts b/hooks/use-hidden-streams.ts new file mode 100644 index 0000000..61cc339 --- /dev/null +++ b/hooks/use-hidden-streams.ts @@ -0,0 +1,44 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { + getHiddenStreamIds, + getBlockedSenders, + hideStream as hideStreamStorage, + unhideStream as unhideStreamStorage, + blockSender as blockSenderStorage, + unblockSender as unblockSenderStorage, + subscribeHiddenStreams, +} from '@/lib/hidden-streams' + +/** + * Reactive access to the localStorage-backed "hidden streams" / "blocked + * senders" lists (see `lib/hidden-streams.ts`). Values start empty on the + * server/first render and hydrate from localStorage on mount to avoid SSR + * mismatches, matching the pattern used by `useShowUsd`. + */ +export function useHiddenStreams() { + const [hiddenIds, setHiddenIds] = useState>(new Set()) + const [blockedSenders, setBlockedSenders] = useState>(new Set()) + + const sync = useCallback(() => { + setHiddenIds(getHiddenStreamIds()) + setBlockedSenders(getBlockedSenders()) + }, []) + + useEffect(() => { + sync() + return subscribeHiddenStreams(sync) + }, [sync]) + + return { + hiddenIds, + blockedSenders, + isHidden: useCallback((id: string) => hiddenIds.has(id), [hiddenIds]), + isBlocked: useCallback((address: string) => blockedSenders.has(address), [blockedSenders]), + hideStream: hideStreamStorage, + unhideStream: unhideStreamStorage, + blockSender: blockSenderStorage, + unblockSender: unblockSenderStorage, + } +} diff --git a/hooks/use-online-status.ts b/hooks/use-online-status.ts new file mode 100644 index 0000000..c4a633a --- /dev/null +++ b/hooks/use-online-status.ts @@ -0,0 +1,26 @@ +'use client' + +import { useEffect, useState } from 'react' + +/** + * Tracks browser connectivity via the `online`/`offline` window events + * (issue #150). Defaults to `true` so SSR/first paint never flashes an + * incorrect offline state. + */ +export function useOnlineStatus(): boolean { + const [online, setOnline] = useState(true) + + useEffect(() => { + setOnline(navigator.onLine) + const handleOnline = () => setOnline(true) + const handleOffline = () => setOnline(false) + window.addEventListener('online', handleOnline) + window.addEventListener('offline', handleOffline) + return () => { + window.removeEventListener('online', handleOnline) + window.removeEventListener('offline', handleOffline) + } + }, []) + + return online +} diff --git a/hooks/use-streams-view-preference.ts b/hooks/use-streams-view-preference.ts new file mode 100644 index 0000000..f921ded --- /dev/null +++ b/hooks/use-streams-view-preference.ts @@ -0,0 +1,31 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' + +const KEY = 'flowstar:streams-view' + +export type StreamsView = 'list' | 'timeline' + +/** + * Persists the user's preferred streams page layout ("list" vs "timeline") + * in localStorage (issue #149). Defaults to "list" on first render/SSR and + * hydrates from storage in an effect, matching the pattern in `useShowUsd`. + */ +export function useStreamsViewPreference(): { + view: StreamsView + setView: (v: StreamsView) => void +} { + const [view, setViewState] = useState('list') + + useEffect(() => { + const stored = localStorage.getItem(KEY) + if (stored === 'list' || stored === 'timeline') setViewState(stored) + }, []) + + const setView = useCallback((v: StreamsView) => { + setViewState(v) + localStorage.setItem(KEY, v) + }, []) + + return { view, setView } +} diff --git a/hooks/use-streams.ts b/hooks/use-streams.ts index de7017d..0cc7a1f 100644 --- a/hooks/use-streams.ts +++ b/hooks/use-streams.ts @@ -6,6 +6,7 @@ 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 { readCachedStreams, writeCachedStreams } from '@/lib/streams-cache' // ─── Refresh bus ───────────────────────────────────────────────────────────── // Components call `invalidateStreams()` after a write so all stream hooks @@ -36,6 +37,10 @@ export interface CategorizedStreams { all: StreamData[] loading: boolean refetch: () => void + /** True when `all` is being served from the offline cache rather than a live fetch. */ + stale: boolean + /** When the cached (or last successful) data was fetched, if known. */ + lastUpdated: number | null } interface UseStreamsOptions { @@ -48,6 +53,9 @@ export function useStreams(options?: UseStreamsOptions): CategorizedStreams { const { network } = useNetwork() const [streams, setStreams] = useState([]) const [loading, setLoading] = 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) // Monotonically increasing request ID — any response whose ID doesn't // match the current value is from a stale request and is discarded. @@ -72,20 +80,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) @@ -98,6 +133,14 @@ export function useStreams(options?: UseStreamsOptions): CategorizedStreams { // Re-fetch when a write invalidates the cache useInvalidation(fetch) + // Re-fetch as soon as connectivity returns, so the stale/cached view + // refreshes without waiting for the next poll tick. + useEffect(() => { + if (typeof window === 'undefined') return + window.addEventListener('online', fetch) + return () => window.removeEventListener('online', fetch) + }, [fetch]) + // Set up polling for real-time updates useEffect(() => { if (!enablePolling || !address) { @@ -122,7 +165,7 @@ 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, refetch: fetch } + return { all: streams, sent, received, loading, refetch: fetch, stale, lastUpdated } } export function useStream(id: string): { stream: StreamData | null; loading: boolean; refetch: () => void } { diff --git a/hooks/use-undo-cancel.ts b/hooks/use-undo-cancel.ts new file mode 100644 index 0000000..8df6337 --- /dev/null +++ b/hooks/use-undo-cancel.ts @@ -0,0 +1,137 @@ +'use client' + +import { useCallback, useEffect, useRef, useState } from 'react' +import { toast } from 'sonner' +import { useContract } from '@/hooks/use-contract' + +/** Default countdown before a scheduled cancellation is actually submitted. */ +export const CANCEL_UNDO_DELAY_MS = 10000 + +// ─── Shared "cancelling" store ────────────────────────────────────────────── +// A tiny module-level pub/sub so any stream card (dashboard, streams list, +// detail page) can reactively show a "Cancelling…" state for a stream that +// has a pending undoable cancellation in flight, without prop-drilling. + +type Listener = () => void +const cancellingIds = new Set() +const listeners = new Set() + +function setCancelling(id: string, value: boolean) { + const changed = value ? !cancellingIds.has(id) : cancellingIds.has(id) + if (value) cancellingIds.add(id) + else cancellingIds.delete(id) + if (changed) listeners.forEach((l) => l()) +} + +/** Whether a stream currently has a pending (undoable) cancellation in flight. */ +export function isStreamCancelling(id: string): boolean { + return cancellingIds.has(id) +} + +/** Reactive hook mirroring {@link isStreamCancelling} for a given stream id. */ +export function useIsStreamCancelling(id: string): boolean { + const [cancelling, setLocal] = useState(() => cancellingIds.has(id)) + useEffect(() => { + setLocal(cancellingIds.has(id)) + const handler = () => setLocal(cancellingIds.has(id)) + listeners.add(handler) + return () => { + listeners.delete(handler) + } + }, [id]) + return cancelling +} + +// ─── Undoable cancel hook ──────────────────────────────────────────────────── + +interface PendingCancel { + timeout: ReturnType + interval: ReturnType +} + +/** + * Schedules a stream cancellation with a "Gmail-style" undo delay instead of + * submitting the cancel transaction immediately. While the countdown runs: + * - the stream shows a "Cancelling…" state (via {@link useIsStreamCancelling}) + * - a toast displays the remaining seconds with an "Undo" action + * - the actual `cancel` transaction fires only once the countdown expires + * + * If the component that scheduled the cancellation unmounts before the + * countdown finishes (e.g. the user navigates away from the stream page), + * the cancellation is aborted as a safe default. + */ +export function useUndoableCancel(delayMs: number = CANCEL_UNDO_DELAY_MS) { + const { cancel } = useContract() + const pendingRef = useRef>(new Map()) + + const abortCancel = useCallback((streamId: string) => { + const entry = pendingRef.current.get(streamId) + if (!entry) return false + clearTimeout(entry.timeout) + clearInterval(entry.interval) + pendingRef.current.delete(streamId) + setCancelling(streamId, false) + toast.dismiss(`cancel-undo-${streamId}`) + return true + }, []) + + // Safe default: abort any in-flight countdown started by this component + // if it unmounts (e.g. navigating away from the stream detail page). + useEffect(() => { + return () => { + pendingRef.current.forEach((entry, streamId) => { + clearTimeout(entry.timeout) + clearInterval(entry.interval) + setCancelling(streamId, false) + }) + pendingRef.current.clear() + } + }, []); + + const scheduleCancel = useCallback( + (streamId: string) => { + // Only one pending cancellation per stream at a time. + if (pendingRef.current.has(streamId)) return + + setCancelling(streamId, true) + const toastId = `cancel-undo-${streamId}` + let secondsLeft = Math.ceil(delayMs / 1000) + + const render = () => { + toast(`Stream will be cancelled in ${secondsLeft}s`, { + id: toastId, + duration: delayMs + 1000, + action: { + label: 'Undo', + onClick: () => { + abortCancel(streamId) + toast.info('Cancellation aborted') + }, + }, + }) + } + render() + + const interval = setInterval(() => { + secondsLeft -= 1 + if (secondsLeft > 0) render() + }, 1000) + + const timeout = setTimeout(async () => { + clearInterval(interval) + pendingRef.current.delete(streamId) + toast.dismiss(toastId) + try { + await cancel(streamId) + } finally { + setCancelling(streamId, false) + } + }, delayMs) + + pendingRef.current.set(streamId, { timeout, interval }) + }, + [abortCancel, cancel, delayMs], + ) + + return { scheduleCancel, abortCancel } +} diff --git a/lib/hidden-streams.ts b/lib/hidden-streams.ts new file mode 100644 index 0000000..35ab4fe --- /dev/null +++ b/lib/hidden-streams.ts @@ -0,0 +1,94 @@ +/** + * Frontend-only "hide stream" / "block sender" mechanism (issue #151, Option A). + * + * Anyone can currently create a stream to any address without the + * recipient's consent. Since there's no contract-level opt-in yet, this + * gives recipients a way to declutter their dashboard: hidden stream IDs and + * blocked sender addresses are persisted in localStorage (this device only, + * no on-chain effect — the stream still exists and is still cancellable / + * withdrawable by navigating to it directly). + */ + +const HIDDEN_STREAMS_KEY = 'flowstar:hidden-streams' +const BLOCKED_SENDERS_KEY = 'flowstar:blocked-senders' + +type Listener = () => void +const listeners = new Set() + +/** Subscribe to changes made through this module (same-tab reactivity). */ +export function subscribeHiddenStreams(listener: Listener): () => void { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} + +function notify() { + listeners.forEach((l) => l()) +} + +function readSet(key: string): Set { + if (typeof window === 'undefined') return new Set() + try { + const stored = window.localStorage.getItem(key) + if (!stored) return new Set() + const parsed = JSON.parse(stored) + return Array.isArray(parsed) ? new Set(parsed) : new Set() + } catch { + return new Set() + } +} + +function writeSet(key: string, value: Set) { + if (typeof window === 'undefined') return + window.localStorage.setItem(key, JSON.stringify(Array.from(value))) + notify() +} + +// ─── Hidden streams ────────────────────────────────────────────────────────── + +export function getHiddenStreamIds(): Set { + return readSet(HIDDEN_STREAMS_KEY) +} + +export function isStreamHidden(id: string): boolean { + return getHiddenStreamIds().has(id) +} + +export function hideStream(id: string) { + const ids = getHiddenStreamIds() + if (ids.has(id)) return + ids.add(id) + writeSet(HIDDEN_STREAMS_KEY, ids) +} + +export function unhideStream(id: string) { + const ids = getHiddenStreamIds() + if (!ids.has(id)) return + ids.delete(id) + writeSet(HIDDEN_STREAMS_KEY, ids) +} + +// ─── Blocked senders ───────────────────────────────────────────────────────── + +export function getBlockedSenders(): Set { + return readSet(BLOCKED_SENDERS_KEY) +} + +export function isSenderBlocked(address: string): boolean { + return getBlockedSenders().has(address) +} + +export function blockSender(address: string) { + const senders = getBlockedSenders() + if (senders.has(address)) return + senders.add(address) + writeSet(BLOCKED_SENDERS_KEY, senders) +} + +export function unblockSender(address: string) { + const senders = getBlockedSenders() + if (!senders.has(address)) return + senders.delete(address) + writeSet(BLOCKED_SENDERS_KEY, senders) +} diff --git a/lib/streams-cache.ts b/lib/streams-cache.ts new file mode 100644 index 0000000..5f935d1 --- /dev/null +++ b/lib/streams-cache.ts @@ -0,0 +1,50 @@ +import type { StreamData } from '@/types/stream' + +/** + * localStorage cache of the last successfully fetched stream list per + * address/network, so the dashboard can render something useful when + * offline instead of an empty/loading state (issue #150). + * + * `StreamData` amounts/timestamps are `bigint`, which `JSON.stringify` + * can't serialize directly — we round-trip them through strings tagged + * with a `n:` prefix. + */ + +interface CachedEntry { + streams: StreamData[] + fetchedAt: number +} + +function cacheKey(network: string, address: string): string { + return `flowstar:streams-cache:${network}:${address}` +} + +function replacer(_key: string, value: unknown) { + return typeof value === 'bigint' ? `n:${value.toString()}` : value +} + +function reviver(_key: string, value: unknown) { + return typeof value === 'string' && /^n:-?\d+$/.test(value) ? BigInt(value.slice(2)) : value +} + +export function readCachedStreams(network: string, address: string): CachedEntry | null { + if (typeof window === 'undefined') return null + try { + const raw = window.localStorage.getItem(cacheKey(network, address)) + if (!raw) return null + return JSON.parse(raw, reviver) as CachedEntry + } catch { + return null + } +} + +export function writeCachedStreams(network: string, address: string, streams: StreamData[]) { + if (typeof window === 'undefined') return + try { + const entry: CachedEntry = { streams, fetchedAt: Date.now() } + window.localStorage.setItem(cacheKey(network, address), JSON.stringify(entry, replacer)) + } catch { + // Storage may be full or unavailable (private browsing) — caching is + // best-effort, never block the live fetch path on it. + } +} diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..a1cb1c1 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,33 @@ +{ + "name": "FlowStar — Real-Time Token Streaming on Stellar", + "short_name": "FlowStar", + "description": "Stream tokens by the second with cliffs and cancellations on Stellar Soroban. Vesting, payroll, and grants that unlock continuously.", + "start_url": "/app", + "id": "/app", + "scope": "/", + "display": "standalone", + "orientation": "portrait-primary", + "background_color": "#0c1014", + "theme_color": "#0c1014", + "icons": [ + { + "src": "/icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any" + }, + { + "src": "/icon-dark-32x32.png", + "sizes": "32x32", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/apple-icon.png", + "sizes": "180x180", + "type": "image/png", + "purpose": "any maskable" + } + ], + "categories": ["finance", "productivity"] +} diff --git a/public/offline.html b/public/offline.html new file mode 100644 index 0000000..f29d99c --- /dev/null +++ b/public/offline.html @@ -0,0 +1,54 @@ + + + + + + FlowStar — Offline + + + +
+

You're offline

+

+ FlowStar can't reach the network right now. Reconnect to load live stream data — any + page you've already visited is still available from cache. +

+ +
+ + diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..5d518b0 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,76 @@ +// FlowStar service worker — app-shell caching for offline access (issue #150). +// +// Strategy: +// - Navigations (HTML pages): network-first, falling back to the cached +// shell (or the dedicated offline page) when the network is unavailable. +// This keeps the app fresh online while still working offline. +// - Static assets (_next/static, icons, manifest): cache-first, since these +// are content-hashed / rarely change and are safe to serve from cache. +// +// Live stream data itself is fetched over Soroban RPC directly from the +// client and is cached separately in localStorage (see hooks/use-streams.ts) +// so the dashboard can render a stale-but-usable view offline. + +const CACHE_VERSION = 'flowstar-v1' +const APP_SHELL = ['/app', '/offline.html', '/manifest.json', '/icon.svg'] + +self.addEventListener('install', (event) => { + event.waitUntil( + caches + .open(CACHE_VERSION) + .then((cache) => cache.addAll(APP_SHELL)) + .then(() => self.skipWaiting()), + ) +}) + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => + Promise.all(keys.filter((key) => key !== CACHE_VERSION).map((key) => caches.delete(key))), + ) + .then(() => self.clients.claim()), + ) +}) + +self.addEventListener('fetch', (event) => { + const { request } = event + if (request.method !== 'GET') return + + const url = new URL(request.url) + if (url.origin !== self.location.origin) return + + // Navigations — network-first with offline fallback. + if (request.mode === 'navigate') { + event.respondWith( + fetch(request) + .then((response) => { + const copy = response.clone() + caches.open(CACHE_VERSION).then((cache) => cache.put(request, copy)) + return response + }) + .catch( + () => + caches.match(request).then((cached) => cached) || + caches.match('/offline.html'), + ), + ) + return + } + + // Static assets — cache-first. + if (url.pathname.startsWith('/_next/static/') || url.pathname.startsWith('/icon')) { + event.respondWith( + caches.match(request).then( + (cached) => + cached || + fetch(request).then((response) => { + const copy = response.clone() + caches.open(CACHE_VERSION).then((cache) => cache.put(request, copy)) + return response + }), + ), + ) + } +})