From 19e7dd3149c06d006fec8527ea288ac603bcb019 Mon Sep 17 00:00:00 2001
From: Ndifreke000
Date: Thu, 27 Aug 2026 17:56:54 +0100
Subject: [PATCH] feat: undo delay on cancel, hide/block streams, PWA offline
support, vesting timeline view
- Cancel stream now schedules a 10s undoable countdown toast (Gmail-style
"Undo Send") instead of submitting immediately; stream cards/detail page
show a "Cancelling..." state during the delay, and navigating away aborts
it as a safe default. (#154)
- Recipients can hide unwanted incoming streams or block a sender entirely
from the stream card menu; hidden/blocked state is persisted in
localStorage and excluded from dashboard lists/counts, with a "Show
hidden streams" toggle on the streams page to review/un-hide. (#151)
- Added PWA support: manifest.json, a hand-rolled service worker caching
the app shell with an offline fallback page, an online/offline indicator
in the navbar, an "Add to Home Screen" install prompt, and a stale-data
banner on the dashboard backed by a localStorage cache of the last
successful stream fetch. (#150)
- Added a horizontal Gantt/timeline view for the streams page (toggle next
to the existing list view, preference persisted in localStorage) showing
each stream as a bar with cliff markers, a shared "today" line, and a
hover tooltip. (#149)
Co-Authored-By: Claude Sonnet 5
---
app/app/dashboard.tsx | 28 +++-
app/app/stream/[id]/page.tsx | 54 +++----
app/app/streams/page.tsx | 90 +++++++++--
app/layout.tsx | 10 ++
components/layout/navbar.tsx | 14 +-
components/pwa/install-prompt.tsx | 70 +++++++++
components/pwa/service-worker-register.tsx | 21 +++
components/streams/stream-card.tsx | 113 +++++++++++++-
components/streams/stream-gantt-view.tsx | 169 +++++++++++++++++++++
hooks/use-hidden-streams.ts | 44 ++++++
hooks/use-online-status.ts | 26 ++++
hooks/use-streams-view-preference.ts | 31 ++++
hooks/use-streams.ts | 45 +++++-
hooks/use-undo-cancel.ts | 137 +++++++++++++++++
lib/hidden-streams.ts | 94 ++++++++++++
lib/streams-cache.ts | 50 ++++++
public/manifest.json | 33 ++++
public/offline.html | 54 +++++++
public/sw.js | 76 +++++++++
19 files changed, 1109 insertions(+), 50 deletions(-)
create mode 100644 components/pwa/install-prompt.tsx
create mode 100644 components/pwa/service-worker-register.tsx
create mode 100644 components/streams/stream-gantt-view.tsx
create mode 100644 hooks/use-hidden-streams.ts
create mode 100644 hooks/use-online-status.ts
create mode 100644 hooks/use-streams-view-preference.ts
create mode 100644 hooks/use-undo-cancel.ts
create mode 100644 lib/hidden-streams.ts
create mode 100644 lib/streams-cache.ts
create mode 100644 public/manifest.json
create mode 100644 public/offline.html
create mode 100644 public/sw.js
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 2abe08d..1bd5ea6 100644
--- a/app/app/stream/[id]/page.tsx
+++ b/app/app/stream/[id]/page.tsx
@@ -44,6 +44,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 {
@@ -289,9 +290,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);
@@ -299,25 +298,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 (
@@ -331,7 +318,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.
@@ -345,17 +333,15 @@ function CancelDialog({
- {error && {error}
}
-
@@ -369,7 +355,7 @@ function CancelDialog({
action="stream cancellation"
averageFee={TYPICAL_FEES.cancel.typical}
isHighFee={cancelFeeHigh}
- loading={pending}
+ loading={false}
/>
>
);
@@ -905,6 +891,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 ;
@@ -937,7 +924,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;
@@ -1090,6 +1078,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' && }