Skip to content

Commit 8bb6217

Browse files
authored
Merge pull request #197 from edwarddavid929-png/feat/network-mismatch-tests-toasts-filters
feat: network mismatch detection, stream-utils tests, tx toasts, sear…
2 parents 107e61a + 1e02c02 commit 8bb6217

9 files changed

Lines changed: 438 additions & 49 deletions

File tree

‎.gitignore‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,20 @@ node_modules
1616

1717
# Rust build artifacts
1818
contracts/target/
19+
20+
# Test snapshots
21+
contracts/streaming/test_snapshots/
22+
23+
# Vitest
24+
*.snap
25+
coverage/
26+
27+
# TypeScript build info
28+
tsconfig.tsbuildinfo
29+
30+
# Editor
31+
.vscode/
32+
.idea/
33+
34+
# OS
35+
Thumbs.db

‎app/app/streams/page.tsx‎

Lines changed: 70 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
'use client'
22

3-
import { useState } from 'react'
3+
import { Suspense, useCallback } from 'react'
4+
import { useRouter, useSearchParams } from 'next/navigation'
45
import { Search, Download } from 'lucide-react'
56
import { RequireWallet } from '@/components/layout/require-wallet'
67
import { Button } from '@/components/ui/button'
78
import { streamsToCSV, downloadCSV } from '@/lib/export'
89
import { StreamCard } from '@/components/streams/stream-card'
910
import { EmptyStreams } from '@/components/streams/empty-state'
10-
import { StreamStatusBadge } from '@/components/streams/stream-status-badge'
1111
import { Input } from '@/components/ui/input'
1212
import { useStreams } from '@/hooks/use-streams'
1313
import { useNow } from '@/hooks/use-now'
14-
import { getStreamStatus, shortenAddress } from '@/lib/stream-utils'
14+
import { getStreamStatus } from '@/lib/stream-utils'
1515
import type { StreamStatus } from '@/types/stream'
1616

1717
const STATUS_FILTERS: { label: string; value: StreamStatus | 'all' }[] = [
@@ -22,25 +22,53 @@ const STATUS_FILTERS: { label: string; value: StreamStatus | 'all' }[] = [
2222
{ label: 'Cancelled', value: 'cancelled' },
2323
]
2424

25+
const TOKEN_OPTIONS = ['all', 'XLM', 'USDC', 'EURC'] as const
26+
2527
function StreamsPage() {
28+
const router = useRouter()
29+
const searchParams = useSearchParams()
2630
const { all } = useStreams()
2731
const now = useNow(5000)
28-
const [search, setSearch] = useState('')
29-
const [statusFilter, setStatusFilter] = useState<StreamStatus | 'all'>('all')
32+
33+
const search = searchParams.get('q') ?? ''
34+
const statusFilter = (searchParams.get('status') ?? 'all') as StreamStatus | 'all'
35+
const tokenFilter = searchParams.get('token') ?? 'all'
36+
37+
const setParam = useCallback(
38+
(key: string, value: string) => {
39+
const params = new URLSearchParams(searchParams.toString())
40+
if (!value || value === 'all') {
41+
params.delete(key)
42+
} else {
43+
params.set(key, value)
44+
}
45+
router.replace(`?${params.toString()}`, { scroll: false })
46+
},
47+
[router, searchParams],
48+
)
49+
50+
const clearFilters = useCallback(() => {
51+
router.replace('?', { scroll: false })
52+
}, [router])
3053

3154
const filtered = all.filter((s) => {
3255
const matchesStatus =
3356
statusFilter === 'all' || getStreamStatus(s, now) === statusFilter
57+
const matchesToken =
58+
tokenFilter === 'all' ||
59+
s.token.symbol.toUpperCase() === tokenFilter.toUpperCase()
3460
const q = search.toLowerCase()
3561
const matchesSearch =
3662
!q ||
3763
s.id.includes(q) ||
3864
s.sender.toLowerCase().includes(q) ||
3965
s.recipient.toLowerCase().includes(q) ||
4066
s.token.symbol.toLowerCase().includes(q)
41-
return matchesStatus && matchesSearch
67+
return matchesStatus && matchesToken && matchesSearch
4268
})
4369

70+
const hasFilters = search || statusFilter !== 'all' || tokenFilter !== 'all'
71+
4472
return (
4573
<div className="space-y-6">
4674
<div className="flex items-center justify-between">
@@ -66,21 +94,42 @@ function StreamsPage() {
6694
</div>
6795

6896
{/* Filters */}
69-
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
70-
<div className="relative flex-1 sm:max-w-xs">
71-
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
72-
<Input
73-
placeholder="Search by address or token…"
74-
value={search}
75-
onChange={(e) => setSearch(e.target.value)}
76-
className="pl-9"
77-
/>
97+
<div className="flex flex-col gap-3">
98+
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
99+
<div className="relative flex-1 sm:max-w-xs">
100+
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
101+
<Input
102+
placeholder="Search by address or token…"
103+
value={search}
104+
onChange={(e) => setParam('q', e.target.value)}
105+
className="pl-9"
106+
/>
107+
</div>
108+
{/* Token filter */}
109+
<div className="flex flex-wrap gap-2">
110+
{TOKEN_OPTIONS.map((t) => (
111+
<button
112+
key={t}
113+
onClick={() => setParam('token', t)}
114+
className={
115+
'rounded-full border px-3 py-1 text-xs font-medium transition-colors ' +
116+
(tokenFilter === t
117+
? 'border-primary bg-primary/10 text-primary'
118+
: 'border-border bg-card text-muted-foreground hover:text-foreground')
119+
}
120+
>
121+
{t === 'all' ? 'All tokens' : t}
122+
</button>
123+
))}
124+
</div>
78125
</div>
126+
127+
{/* Status filter */}
79128
<div className="flex flex-wrap gap-2">
80129
{STATUS_FILTERS.map((f) => (
81130
<button
82131
key={f.value}
83-
onClick={() => setStatusFilter(f.value)}
132+
onClick={() => setParam('status', f.value)}
84133
className={
85134
'rounded-full border px-3 py-1 text-xs font-medium transition-colors ' +
86135
(statusFilter === f.value
@@ -96,11 +145,11 @@ function StreamsPage() {
96145

97146
{/* Results */}
98147
{filtered.length === 0 ? (
99-
search || statusFilter !== 'all' ? (
148+
hasFilters ? (
100149
<div className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-border bg-card/40 px-6 py-16 text-center">
101150
<p className="text-sm font-medium">No streams match your filters</p>
102151
<button
103-
onClick={() => { setSearch(''); setStatusFilter('all') }}
152+
onClick={clearFilters}
104153
className="mt-2 text-xs text-primary hover:underline"
105154
>
106155
Clear filters
@@ -123,7 +172,9 @@ function StreamsPage() {
123172
export default function StreamsRoute() {
124173
return (
125174
<RequireWallet>
126-
<StreamsPage />
175+
<Suspense>
176+
<StreamsPage />
177+
</Suspense>
127178
</RequireWallet>
128179
)
129180
}

‎components/layout/navbar.tsx‎

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@
22

33
import Link from 'next/link'
44
import { usePathname } from 'next/navigation'
5-
import { Plus, Moon, Sun, Monitor, Network } from 'lucide-react'
5+
import { Plus, Moon, Sun, Monitor, Network, AlertTriangle } from 'lucide-react'
66
import { useTheme } from 'next-themes'
77
import { Brand } from '@/components/brand'
88
import { useNetwork } from '@/components/providers/network-provider'
9+
import { useWalletContext } from '@/components/providers/wallet-provider'
910
import { Button } from '@/components/ui/button'
1011
import {
1112
DropdownMenu,
@@ -25,8 +26,9 @@ const NAV_LINKS = [
2526

2627
export function Navbar() {
2728
const pathname = usePathname()
28-
const { setTheme, theme } = useTheme()
29+
const { setTheme } = useTheme()
2930
const { network, setNetwork } = useNetwork()
31+
const { networkMismatch, walletNetwork, isConnected } = useWalletContext()
3032

3133
return (
3234
<header className="sticky top-0 z-40 border-b border-border bg-background/80 backdrop-blur-xl">
@@ -57,7 +59,13 @@ export function Navbar() {
5759
</nav>
5860

5961
<div className="ml-auto flex items-center gap-2">
60-
<Button asChild variant="ghost" size="sm" className="gap-1.5">
62+
<Button
63+
asChild
64+
variant="ghost"
65+
size="sm"
66+
className="gap-1.5"
67+
disabled={networkMismatch}
68+
>
6169
<Link href="/app/create">
6270
<Plus className="size-4" />
6371
<span className="hidden sm:inline">New stream</span>
@@ -110,6 +118,17 @@ export function Navbar() {
110118
</div>
111119
</div>
112120

121+
{/* Network mismatch banner */}
122+
{isConnected && networkMismatch && (
123+
<div className="flex items-center gap-2 border-t border-yellow-500/30 bg-yellow-500/10 px-4 py-2 text-sm text-yellow-600 dark:text-yellow-400">
124+
<AlertTriangle className="size-4 shrink-0" />
125+
<span>
126+
Your wallet is on <strong>{walletNetwork}</strong> — please switch to{' '}
127+
<strong>{network}</strong> in Freighter settings. Transactions are disabled until you switch.
128+
</span>
129+
</div>
130+
)}
131+
113132
{/* Mobile nav */}
114133
<nav className="flex items-center gap-1 border-t border-border px-4 py-2 md:hidden">
115134
{NAV_LINKS.map((link) => {

‎components/providers/wallet-provider.tsx‎

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ interface WalletContextValue {
3737
connecting: boolean
3838
reconnecting: boolean
3939
isConnected: boolean
40+
networkMismatch: boolean
41+
walletNetwork: string | null
4042
connect: (walletId: string) => Promise<void>
4143
disconnect: () => void
4244
signTransaction: (xdr: string, network?: NetworkName) => Promise<string>
@@ -52,23 +54,30 @@ async function connectFreighter(): Promise<string> {
5254
if (!connected.isConnected) {
5355
throw new Error('Freighter is not installed. Please install the Freighter extension.')
5456
}
55-
// Request access prompts the user to approve
5657
await requestAccess()
5758
const result = await getAddress()
5859
if (result.error) throw new Error(result.error)
5960
return result.address
6061
}
6162

63+
async function getFreighterNetwork(): Promise<string | null> {
64+
try {
65+
const { getNetwork } = await import('@stellar/freighter-api')
66+
const result = await getNetwork()
67+
if (result.error) return null
68+
return result.network ?? null
69+
} catch {
70+
return null
71+
}
72+
}
73+
6274
async function signWithFreighter(xdr: string, networkPassphrase: string): Promise<string> {
6375
const { signTransaction } = await import('@stellar/freighter-api')
64-
const result = await signTransaction(xdr, {
65-
networkPassphrase,
66-
})
76+
const result = await signTransaction(xdr, { networkPassphrase })
6777
if (result.error) throw new Error(result.error)
6878
return result.signedTxXdr
6979
}
7080

71-
// Stubs for wallets that need a dedicated SDK — shows a helpful message
7281
async function connectStub(name: string): Promise<string> {
7382
throw new Error(
7483
`${name} connection requires the ${name} browser extension. Install it and refresh.`,
@@ -85,13 +94,22 @@ async function connectWallet(id: string): Promise<string> {
8594
}
8695
}
8796

97+
// Maps Freighter network names → our NetworkName
98+
function normalizeFreighterNetwork(raw: string): NetworkName | null {
99+
const lower = raw.toLowerCase()
100+
if (lower.includes('test')) return 'testnet'
101+
if (lower === 'mainnet' || lower === 'public' || lower.includes('public')) return 'mainnet'
102+
return null
103+
}
104+
88105
// ─── Provider ─────────────────────────────────────────────────────────────────
89106

90107
export function WalletProvider({ children }: { children: ReactNode }) {
91108
const [address, setAddress] = useState<string | null>(null)
92109
const [walletId, setWalletId] = useState<string | null>(null)
93110
const [connecting, setConnecting] = useState(false)
94111
const [reconnecting, setReconnecting] = useState(true)
112+
const [walletNetwork, setWalletNetwork] = useState<string | null>(null)
95113
const { network } = useNetwork()
96114

97115
// Auto-reconnect on mount using persisted walletId
@@ -105,6 +123,29 @@ export function WalletProvider({ children }: { children: ReactNode }) {
105123
// eslint-disable-next-line react-hooks/exhaustive-deps
106124
}, [])
107125

126+
// Poll Freighter's active network while connected
127+
useEffect(() => {
128+
if (!address || walletId !== 'freighter') {
129+
setWalletNetwork(null)
130+
return
131+
}
132+
let cancelled = false
133+
const check = () => {
134+
getFreighterNetwork().then((net) => {
135+
if (!cancelled) setWalletNetwork(net)
136+
})
137+
}
138+
check()
139+
const interval = setInterval(check, 5000)
140+
return () => { cancelled = true; clearInterval(interval) }
141+
}, [address, walletId])
142+
143+
const networkMismatch = useMemo(() => {
144+
if (!address || walletId !== 'freighter' || !walletNetwork) return false
145+
const normalized = normalizeFreighterNetwork(walletNetwork)
146+
return normalized !== null && normalized !== network
147+
}, [address, walletId, walletNetwork, network])
148+
108149
const connect = useCallback(async (id: string) => {
109150
setConnecting(true)
110151
try {
@@ -121,6 +162,7 @@ export function WalletProvider({ children }: { children: ReactNode }) {
121162
const disconnect = useCallback(() => {
122163
setAddress(null)
123164
setWalletId(null)
165+
setWalletNetwork(null)
124166
localStorage.removeItem('walletId')
125167
setSentryUser(null)
126168
}, [])
@@ -149,11 +191,13 @@ export function WalletProvider({ children }: { children: ReactNode }) {
149191
connecting,
150192
reconnecting,
151193
isConnected: address !== null,
194+
networkMismatch,
195+
walletNetwork,
152196
connect,
153197
disconnect,
154198
signTransaction,
155199
}),
156-
[address, walletId, connecting, reconnecting, connect, disconnect, signTransaction],
200+
[address, walletId, connecting, reconnecting, networkMismatch, walletNetwork, connect, disconnect, signTransaction],
157201
)
158202

159203
return <WalletContext.Provider value={value}>{children}</WalletContext.Provider>

0 commit comments

Comments
 (0)