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.
- {
- const csv = streamsToCSV(all, now)
- downloadCSV(csv, `flowstar-streams-${new Date().toISOString().slice(0, 10)}.csv`)
- }}
- >
-
- Download CSV
-
+
+ {/* 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})` : ''}`}
+
+
+
{
+ const csv = streamsToCSV(all, now)
+ downloadCSV(csv, `flowstar-streams-${new Date().toISOString().slice(0, 10)}.csv`)
+ }}
+ >
+
+ Download CSV
+
+
{/* 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' && }