Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions app/app/dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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)
Expand Down Expand Up @@ -77,6 +86,21 @@ export function Dashboard() {
</div>
</div>

{/* Offline / stale-data banner */}
{stale && (
<div
role="status"
className="flex items-center gap-2 rounded-2xl border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-sm text-amber-700 dark:text-amber-400"
>
<WifiOff className="size-4 shrink-0" />
<span>
You&apos;re offline — showing cached stream data
{lastUpdated && ` from ${new Date(lastUpdated).toLocaleString()}`}. It may be
outdated.
</span>
</div>
)}

{/* Testnet faucet banner */}
<TestnetFaucetBanner />

Expand Down
54 changes: 25 additions & 29 deletions app/app/stream/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -296,35 +297,21 @@ 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);

const estimatedFee = TYPICAL_FEES.cancel.typical;
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 (
Expand All @@ -338,7 +325,8 @@ function CancelDialog({
<DialogTitle>Cancel stream</DialogTitle>
<DialogDescription>
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&apos;ll have a few
seconds to undo before this is submitted.
</DialogDescription>
</DialogHeader>

Expand All @@ -352,17 +340,15 @@ function CancelDialog({
</p>
</div>

{error && <p className="text-sm text-destructive">{error}</p>}
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" onClick={onClose} disabled={pending}>
<Button variant="ghost" onClick={onClose}>
Keep stream
</Button>
<Button
variant="destructive"
onClick={() => setShowFeeEstimate(true)}
disabled={pending}
>
{pending ? "Cancelling…" : "Review & cancel"}
Review & cancel
</Button>
</div>
</DialogContent>
Expand All @@ -376,7 +362,7 @@ function CancelDialog({
action="stream cancellation"
averageFee={TYPICAL_FEES.cancel.typical}
isHighFee={cancelFeeHigh}
loading={pending}
loading={false}
/>
</>
);
Expand Down Expand Up @@ -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 <StreamDetailSkeleton />;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1125,6 +1113,14 @@ function StreamDetail({ id }: { id: string }) {
</div>
)}

{/* Cancelling state */}
{isCancelling && (
<div className="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
<span className="size-1.5 animate-pulse rounded-full bg-current" />
Cancelling… you can still undo this from the toast.
</div>
)}

{/* Actions */}
{(canWithdraw || canCancel || isSender) && (
<div className="flex flex-wrap gap-2 pt-1 border-t border-border">
Expand Down
90 changes: 76 additions & 14 deletions app/app/streams/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'

Expand All @@ -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())
Expand All @@ -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()
Expand Down Expand Up @@ -124,19 +140,62 @@ function StreamsPage() {
<h1 className="text-2xl font-semibold tracking-tight">Streams</h1>
<p className="mt-1 text-sm text-muted-foreground">All streams you've sent or received.</p>
</div>
<Button
variant="outline"
size="sm"
className="gap-1.5"
disabled={all.length === 0}
onClick={() => {
const csv = streamsToCSV(all, now)
downloadCSV(csv, `flowstar-streams-${new Date().toISOString().slice(0, 10)}.csv`)
}}
>
<Download className="size-4" />
Download CSV
</Button>
<div className="flex items-center gap-2">
{/* List / Timeline view toggle */}
<div className="flex items-center rounded-lg border border-border p-0.5">
<button
type="button"
onClick={() => 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')
}
>
<LayoutList className="size-3.5" />
<span className="hidden sm:inline">List</span>
</button>
<button
type="button"
onClick={() => 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')
}
>
<GanttChartSquare className="size-3.5" />
<span className="hidden sm:inline">Timeline</span>
</button>
</div>
<Button
variant={showHidden ? 'default' : 'outline'}
size="sm"
className="gap-1.5"
onClick={() => setShowHidden((v) => !v)}
data-testid="show-hidden-toggle"
>
{showHidden ? <Eye className="size-4" /> : <EyeOff className="size-4" />}
<span className="hidden sm:inline">
{showHidden ? 'Showing hidden' : `Hidden${hiddenCount > 0 ? ` (${hiddenCount})` : ''}`}
</span>
</Button>
<Button
variant="outline"
size="sm"
className="gap-1.5"
disabled={all.length === 0}
onClick={() => {
const csv = streamsToCSV(all, now)
downloadCSV(csv, `flowstar-streams-${new Date().toISOString().slice(0, 10)}.csv`)
}}
>
<Download className="size-4" />
<span className="hidden sm:inline">Download CSV</span>
</Button>
</div>
</div>

{/* Bulk select toggle */}
Expand Down Expand Up @@ -288,6 +347,8 @@ function StreamsPage() {
) : (
<EmptyStreams />
)
) : view === 'timeline' ? (
<StreamGanttView streams={filtered} nowSeconds={now} />
) : (
<div className="grid gap-3 sm:grid-cols-2">
{filtered.map((s) => (
Expand All @@ -297,6 +358,7 @@ function StreamsPage() {
selectable={selectMode}
selected={selected.has(s.id)}
onToggleSelect={toggle}
isHiddenView={showHidden}
/>
))}
</div>
Expand Down
10 changes: 10 additions & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -114,8 +122,10 @@ export default function RootLayout({
<WalletProvider>
{children}
<Toaster position="top-right" />
<InstallPrompt />
</WalletProvider>
</NetworkProvider>
<ServiceWorkerRegister />
{process.env.NODE_ENV === 'production' && <Analytics />}
</ThemeProvider>
</body>
Expand Down
14 changes: 13 additions & 1 deletion components/layout/navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -30,6 +31,7 @@ export function Navbar() {
const { setTheme } = useTheme()
const { network, setNetwork } = useNetwork()
const { networkMismatch, walletNetwork, isConnected } = useWalletContext()
const isOnline = useOnlineStatus()

return (
<header className="sticky top-0 z-40 border-b border-border bg-background/80 backdrop-blur-xl">
Expand Down Expand Up @@ -58,6 +60,16 @@ export function Navbar() {
</nav>

<div className="ml-auto flex items-center gap-2">
{!isOnline && (
<span
role="status"
title="You're offline — showing cached data"
className="flex items-center gap-1.5 rounded-full border border-amber-500/40 bg-amber-500/10 px-2.5 py-1 text-xs font-medium text-amber-600 dark:text-amber-400"
>
<WifiOff className="size-3.5" />
<span className="hidden sm:inline">Offline</span>
</span>
)}
{networkMismatch ? (
<Button
variant="ghost"
Expand Down
Loading
Loading