From e359be93101277c53bd78a61b63d9246f7caed0c Mon Sep 17 00:00:00 2001 From: Lucia Chizaram Date: Thu, 20 Aug 2026 21:02:13 +0100 Subject: [PATCH 01/11] refactor: add streamId/wallet params to useStreamBalance The hook now takes streamId and walletAddress so it can call the on-chain balance_of RPC. Returns an object with balance, syncing, and isFromChain instead of a bare bigint. --- src/hooks/useStreamBalance.ts | 69 ++++++++++++++++++++++++++++++----- 1 file changed, 59 insertions(+), 10 deletions(-) diff --git a/src/hooks/useStreamBalance.ts b/src/hooks/useStreamBalance.ts index 0c8236e..3ae43cd 100644 --- a/src/hooks/useStreamBalance.ts +++ b/src/hooks/useStreamBalance.ts @@ -1,25 +1,45 @@ 'use client'; -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; +import { getStreamBalance } from '@/lib/contracts/streams'; + +export interface StreamBalanceResult { + /** Current withdrawable balance in stroops */ + balance: bigint; + /** True while the on-chain RPC call is in flight */ + syncing: boolean; + /** True if the current balance came from an on-chain read (vs client-side estimate) */ + isFromChain: boolean; +} /** * Real-time stream balance hook. - * Starts from 0n on server to avoid SSR/client hydration mismatch (#38). + * + * Immediately shows a client-side estimate (ratePerSecond * elapsed - withdrawn) + * so the counter never freezes. In the background it calls + * `stream.balance_of(stream_id)` on-chain and swaps to the real value once + * the RPC responds. A periodic re-fetch keeps the balance accurate if the + * stream is cancelled or parameters change off-screen. */ export function useStreamBalance( + streamId: string, + walletAddress: string, ratePerSecond: bigint, lastWithdrawn: bigint, - startTime: number, - stopTime: number, + startTime: number, + stopTime: number, tick = 200, enabled = true, ) { - const [balance, setBalance] = useState(0n); // always 0n on SSR - const [mounted, setMounted] = useState(false); + const [balance, setBalance] = useState(0n); + const [syncing, setSyncing] = useState(false); + const [isFromChain, setIsFromChain] = useState(false); + const [mounted, setMounted] = useState(false); const timerRef = useRef | null>(null); + const lastChainBalanceRef = useRef(null); - // Mark as mounted only on client useEffect(() => { setMounted(true); }, []); + // ── Client-side estimate (immediate, runs every `tick` ms) ──────────────── useEffect(() => { if (!mounted || !enabled) return; @@ -28,13 +48,42 @@ export function useStreamBalance( if (now <= startTime) { setBalance(0n); return; } const elapsed = BigInt(Math.min(now, stopTime) - startTime); const accrued = ratePerSecond * elapsed; - setBalance(accrued > lastWithdrawn ? accrued - lastWithdrawn : 0n); + const est = accrued > lastWithdrawn ? accrued - lastWithdrawn : 0n; + // Only use client estimate if we don't have a chain value yet + if (!isFromChain) { + setBalance(est); + } }; compute(); timerRef.current = setInterval(compute, tick); return () => { if (timerRef.current) clearInterval(timerRef.current); }; - }, [mounted, enabled, ratePerSecond, lastWithdrawn, startTime, stopTime, tick]); + }, [mounted, enabled, ratePerSecond, lastWithdrawn, startTime, stopTime, tick, isFromChain]); + + // ── On-chain balance fetch ──────────────────────────────────────────────── + const fetchOnChain = useCallback(async () => { + if (!streamId || !walletAddress || !enabled) return; + setSyncing(true); + try { + const raw = await getStreamBalance(streamId, walletAddress); + const chainBalance = BigInt(raw); + lastChainBalanceRef.current = chainBalance; + setBalance(chainBalance); + setIsFromChain(true); + } catch { + // RPC failed — keep the client-side estimate visible + } finally { + setSyncing(false); + } + }, [streamId, walletAddress, enabled]); + + // Initial fetch + periodic refresh every 15 s + useEffect(() => { + if (!mounted || !enabled) return; + fetchOnChain(); + const id = setInterval(fetchOnChain, 15_000); + return () => clearInterval(id); + }, [mounted, enabled, fetchOnChain]); - return balance; + return { balance, syncing, isFromChain }; } From ff899763a573095c56e93d8364d1be16241b47f6 Mon Sep 17 00:00:00 2001 From: Lucia Chizaram Date: Thu, 20 Aug 2026 21:02:20 +0100 Subject: [PATCH 02/11] feat: show on-chain balance with syncing indicator on stream detail Pass contractStreamId and wallet to useStreamBalance. Show a pulsing "syncing" badge while the RPC call is in flight. Refresh stream data after withdraw/cancel so the page stays current. --- src/app/streams/[id]/page.tsx | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/app/streams/[id]/page.tsx b/src/app/streams/[id]/page.tsx index cb6ea19..9eaee52 100644 --- a/src/app/streams/[id]/page.tsx +++ b/src/app/streams/[id]/page.tsx @@ -6,6 +6,7 @@ import { useStreamBalance } from '@/hooks/useStreamBalance'; import { useToast } from '@/context/ToastContext'; import { useContract } from '@/hooks/useContract'; import { streams } from '@/lib/contracts'; +import { ActivityFeed } from '@/components/organisms/ActivityFeed'; import styles from './stream.module.css'; function fmt(stroops: bigint): string { @@ -20,7 +21,9 @@ export default function StreamDetail() { const { execute, loading: txLoading, error: txError } = useContract(); const { stream, loading, error } = useStream(id); - const balance = useStreamBalance( + const { balance, syncing } = useStreamBalance( + stream?.contractStreamId ?? '', + address ?? '', stream ? BigInt(stream.ratePerSecond) : 0n, stream ? BigInt(stream.withdrawn) : 0n, stream?.startTime ?? 0, @@ -49,7 +52,10 @@ export default function StreamDetail() {

Withdrawable Balance

-

{fmt(balance)} XLM

+

+ {fmt(balance)} XLM + {syncing && syncing} +

{perDay} XLM/day

@@ -80,7 +86,10 @@ export default function StreamDetail() { if (!address) return; toast.info('Confirm withdrawal in Freighter…'); const result = await execute(() => streams.withdrawStream(stream.contractStreamId, address)); - if (result) toast.success(`Withdrawal confirmed! Tx: ${result.hash.slice(0, 12)}…`); + if (result) { + toast.success(`Withdrawal confirmed! Tx: ${result.hash.slice(0, 12)}…`); + router.refresh(); + } }} > {txLoading ? 'Withdrawing…' : `Withdraw ${fmt(balance)} XLM`} @@ -108,6 +117,10 @@ export default function StreamDetail() { )} )} + +
+ +
); } From ed8a297ee5efd45584048428f0b887a99ffd3e26 Mon Sep 17 00:00:00 2001 From: Lucia Chizaram Date: Thu, 20 Aug 2026 21:02:25 +0100 Subject: [PATCH 03/11] style: add syncing badge CSS to stream detail page Pulsing animation for the "syncing" indicator shown while the on-chain balance RPC call is in flight. --- src/app/streams/[id]/stream.module.css | 31 ++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/app/streams/[id]/stream.module.css b/src/app/streams/[id]/stream.module.css index 9f28113..d2c1c43 100644 --- a/src/app/streams/[id]/stream.module.css +++ b/src/app/streams/[id]/stream.module.css @@ -55,3 +55,34 @@ font-weight: 600; } .notFound { text-align: center; margin-top: 6rem; color: var(--text-muted); } + +.loading { text-align: center; margin-top: 6rem; color: var(--text-muted); } +.backBtn { + background: none; border: none; color: var(--text-muted); cursor: pointer; + font-size: 0.85rem; margin-bottom: 1rem; padding: 0; +} +.backBtn:hover { color: var(--text); } +.error { color: var(--danger); font-size: 0.85rem; margin-bottom: 0.75rem; } +.notParty { color: var(--text-muted); font-size: 0.85rem; text-align: center; width: 100%; } + +.syncingBadge { + display: inline-block; + font-size: 0.65rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + background: rgba(255,255,255,0.2); + border-radius: 9999px; + padding: 0.15em 0.6em; + margin-left: 0.5rem; + vertical-align: middle; + animation: pulse 1.5s ease-in-out infinite; +} +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +.feedSection { + margin-top: 2rem; +} From e175947e8544aec0cbc76bc38a065553a80e231f Mon Sep 17 00:00:00 2001 From: Lucia Chizaram Date: Thu, 20 Aug 2026 21:02:31 +0100 Subject: [PATCH 04/11] feat: add useStreamEvents hook for Soroban RPC event queries Queries getEvents on the Soroban RPC for contract events (StreamCreated, Withdrawn, Cancelled, VestingCreated, VestingClaimed, VestingRevoked). Supports filtering by wallet address, event type, and cursor-based pagination with loadMore. --- src/hooks/useStreamEvents.ts | 239 +++++++++++++++++++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 src/hooks/useStreamEvents.ts diff --git a/src/hooks/useStreamEvents.ts b/src/hooks/useStreamEvents.ts new file mode 100644 index 0000000..1716398 --- /dev/null +++ b/src/hooks/useStreamEvents.ts @@ -0,0 +1,239 @@ +'use client'; +import { useState, useEffect, useCallback, useRef } from 'react'; +import * as StellarSdk from '@stellar/stellar-sdk'; +import { getRpcClient, getNetworkConfig } from '@/lib/contracts/client'; +import { STREAM_CONTRACT_ID } from '@/lib/contracts/constants'; + +// ── Types ────────────────────────────────────────────────────────────────── + +export type StreamEventType = + | 'StreamCreated' + | 'Withdrawn' + | 'Cancelled' + | 'VestingCreated' + | 'VestingClaimed' + | 'VestingRevoked'; + +export interface StreamEvent { + id: string; + type: StreamEventType; + /** Amount in stroops (raw from the event) */ + amount: string; + /** Sender address (from topic or value) */ + sender: string; + /** Recipient address (from topic or value) */ + recipient: string; + /** Ledger sequence where the event was emitted */ + ledger: number; + /** ISO timestamp of when the ledger closed */ + timestamp: string; + /** Transaction hash that emitted this event */ + txHash: string; +} + +export interface UseStreamEventsOptions { + /** Wallet address to filter events for (shows events where this address is sender or recipient) */ + walletAddress?: string; + /** Filter to specific event types */ + types?: StreamEventType[]; + /** Max events per page */ + limit?: number; +} + +export interface UseStreamEventsReturn { + events: StreamEvent[]; + loading: boolean; + error: string | null; + hasMore: boolean; + loadMore: () => void; + refresh: () => void; + totalCount: number; +} + +// ── Event type symbol lookup ──────────────────────────────────────────────── + +const EVENT_TYPES: StreamEventType[] = [ + 'StreamCreated', + 'Withdrawn', + 'Cancelled', + 'VestingCreated', + 'VestingClaimed', + 'VestingRevoked', +]; + +// Map symbol strings from contract events to our types +const SYMBOL_TO_TYPE: Record = { + created: 'StreamCreated', + withdrawn: 'Withdrawn', + cancelled: 'Cancelled', + vesting_created: 'VestingCreated', + vesting_claimed: 'VestingClaimed', + vesting_revoked: 'VestingRevoked', +}; + +function classifyEvent(topics: StellarSdk.xdr.ScVal[]): StreamEventType | null { + if (!topics.length) return null; + const first = topics[0]; + if (first.switch() === StellarSdk.xdr.ScValType.scvSymbol()) { + const sym = first.sym().toString(); + return SYMBOL_TO_TYPE[sym] ?? null; + } + return null; +} + +function scValToAddress(val: StellarSdk.xdr.ScVal): string { + try { + if (val.switch() === StellarSdk.xdr.ScValType.scvAddress()) { + return StellarSdk.Address.fromScVal(val).toString(); + } + } catch { /* ignore */ } + return ''; +} + +function scValToBigInt(val: StellarSdk.xdr.ScVal): string { + try { + if (val.switch() === StellarSdk.xdr.ScValType.scvI128()) { + return StellarSdk.scValToNative(val).toString(); + } + } catch { /* ignore */ } + return '0'; +} + +// ── Hook ─────────────────────────────────────────────────────────────────── + +const PAGE_SIZE_DEFAULT = 20; + +export function useStreamEvents( + opts: UseStreamEventsOptions = {}, +): UseStreamEventsReturn { + const { walletAddress, types, limit = PAGE_SIZE_DEFAULT } = opts; + + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [hasMore, setHasMore] = useState(true); + const [totalCount, setTotalCount] = useState(0); + const cursorRef = useRef(null); + const allEventsRef = useRef([]); + + // We need a "latest ledger" to define the initial range. We fetch it once + // on mount so the first page covers from the oldest retained ledger up to now. + const latestLedgerRef = useRef(null); + + const fetchPage = useCallback(async (cursor?: string) => { + if (!STREAM_CONTRACT_ID) return; + + const rpc = getRpcClient(); + const config = getNetworkConfig(); + + // Fetch latest ledger if we don't have one yet + if (!latestLedgerRef.current) { + try { + const info = await rpc.getLatestLedger(); + latestLedgerRef.current = info.sequence; + } catch { + // If we can't get latest ledger, use a large range + latestLedgerRef.current = 999_999_999; + } + } + + setLoading(true); + setError(null); + + try { + const request: StellarSdk.rpc.Api.GetEventsRequest = cursor + ? { + filters: [{ type: 'contract', contractIds: [STREAM_CONTRACT_ID] }], + cursor, + limit, + } + : { + filters: [{ type: 'contract', contractIds: [STREAM_CONTRACT_ID] }], + startLedger: Math.max(1, latestLedgerRef.current - 100_000), + limit, + }; + + const response = await rpc.getEvents(request); + + const parsed: StreamEvent[] = response.events + .map((ev) => { + const type = classifyEvent(ev.topic); + if (!type) return null; + + // For StreamCreated: topic[1] = sender, topic[2] = recipient + // For Withdrawn: topic[1] = stream_id, value = amount + // For Cancelled: topic[1] = stream_id + const sender = ev.topic.length > 1 ? scValToAddress(ev.topic[1]) : ''; + const recipient = ev.topic.length > 2 ? scValToAddress(ev.topic[2]) : ''; + const amount = ev.value ? scValToBigInt(ev.value) : '0'; + + return { + id: ev.id, + type, + amount, + sender, + recipient, + ledger: ev.ledger, + timestamp: ev.ledgerClosedAt, + txHash: ev.txHash, + }; + }) + .filter((e): e is StreamEvent => e !== null); + + // Filter by wallet address if provided + const filtered = walletAddress + ? parsed.filter( + (e) => + e.sender === walletAddress || e.recipient === walletAddress, + ) + : parsed; + + // Filter by event types if provided + const typeFiltered = types?.length + ? filtered.filter((e) => types.includes(e.type)) + : filtered; + + if (cursor) { + // Append to existing events + allEventsRef.current = [...allEventsRef.current, ...typeFiltered]; + } else { + allEventsRef.current = typeFiltered; + } + + setEvents(allEventsRef.current); + setTotalCount(allEventsRef.current.length); + cursorRef.current = response.cursor; + setHasMore(response.events.length === limit); + } catch (e) { + setError( + e instanceof Error ? e.message : 'Failed to fetch events', + ); + } finally { + setLoading(false); + } + }, [walletAddress, types, limit]); + + // Initial fetch + useEffect(() => { + allEventsRef.current = []; + cursorRef.current = null; + latestLedgerRef.current = null; + void fetchPage(); + }, [fetchPage]); + + const loadMore = useCallback(() => { + if (cursorRef.current && !loading) { + void fetchPage(cursorRef.current); + } + }, [fetchPage, loading]); + + const refresh = useCallback(() => { + allEventsRef.current = []; + cursorRef.current = null; + latestLedgerRef.current = null; + setHasMore(true); + void fetchPage(); + }, [fetchPage]); + + return { events, loading, error, hasMore, loadMore, refresh, totalCount }; +} From 4627b58130015ff2f0621233dce3e42d82223546 Mon Sep 17 00:00:00 2001 From: Lucia Chizaram Date: Thu, 20 Aug 2026 21:02:38 +0100 Subject: [PATCH 05/11] feat: add ActivityFeed organism component Renders a list of stream events with type badge, amount, counterparty, relative timestamp, and Stellar Explorer link. Includes filter chips for event type and a "Load More" button for pagination. --- src/components/organisms/ActivityFeed.tsx | 233 ++++++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 src/components/organisms/ActivityFeed.tsx diff --git a/src/components/organisms/ActivityFeed.tsx b/src/components/organisms/ActivityFeed.tsx new file mode 100644 index 0000000..b7fd306 --- /dev/null +++ b/src/components/organisms/ActivityFeed.tsx @@ -0,0 +1,233 @@ +'use client'; +import { useMemo, useState } from 'react'; +import { + useStreamEvents, + type StreamEvent, + type StreamEventType, +} from '@/hooks/useStreamEvents'; +import { getNetwork } from '@/lib/contracts/network'; +import styles from './ActivityFeed.module.css'; + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function truncateAddr(addr: string): string { + if (!addr) return '—'; + return `${addr.slice(0, 6)}...${addr.slice(-4)}`; +} + +function formatAmount(stroops: string): string { + const val = Number(BigInt(stroops)) / 1e7; + return val.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +} + +function timeAgo(iso: string): string { + const diff = Date.now() - new Date(iso).getTime(); + const mins = Math.floor(diff / 60_000); + if (mins < 1) return 'just now'; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + const days = Math.floor(hrs / 24); + return `${days}d ago`; +} + +function explorerTxUrl(txHash: string): string { + const net = getNetwork(); + const network = net.name === 'mainnet' ? 'public' : 'testnet'; + return `https://stellar.expert/explorer/${network}/tx/${txHash}`; +} + +const EVENT_LABELS: Record = { + StreamCreated: 'Created', + Withdrawn: 'Withdrawn', + Cancelled: 'Cancelled', + VestingCreated: 'Vesting Created', + VestingClaimed: 'Vesting Claimed', + VestingRevoked: 'Vesting Revoked', +}; + +const EVENT_ICONS: Record = { + StreamCreated: '+', + Withdrawn: '↓', + Cancelled: '×', + VestingCreated: '+', + VestingClaimed: '↓', + VestingRevoked: '×', +}; + +const EVENT_COLORS: Record = { + StreamCreated: 'var(--success, #22c55e)', + Withdrawn: 'var(--accent, #a78bfa)', + Cancelled: 'var(--danger, #ef4444)', + VestingCreated: 'var(--success, #22c55e)', + VestingClaimed: 'var(--accent, #a78bfa)', + VestingRevoked: 'var(--danger, #ef4444)', +}; + +const FILTER_OPTIONS: { value: StreamEventType | 'all'; label: string }[] = [ + { value: 'all', label: 'All Events' }, + { value: 'StreamCreated', label: 'Created' }, + { value: 'Withdrawn', label: 'Withdrawn' }, + { value: 'Cancelled', label: 'Cancelled' }, + { value: 'VestingCreated', label: 'Vesting Created' }, + { value: 'VestingClaimed', label: 'Vesting Claimed' }, + { value: 'VestingRevoked', label: 'Vesting Revoked' }, +]; + +// ── Single event row ─────────────────────────────────────────────────────── + +function EventRow({ + event, + walletAddress, +}: { + event: StreamEvent; + walletAddress?: string; +}) { + const icon = EVENT_ICONS[event.type]; + const color = EVENT_COLORS[event.type]; + const label = EVENT_LABELS[event.type]; + + const counterparty = + event.sender === walletAddress + ? event.recipient + : event.sender === event.recipient + ? event.sender + : event.sender; + + const direction = + event.recipient === walletAddress ? 'to you' : + event.sender === walletAddress ? 'from you' : + ''; + + return ( +
+
+ {icon} +
+
+

+ {label} + {direction && {direction}} +

+

+ {truncateAddr(counterparty)} + · + {timeAgo(event.timestamp)} +

+
+
+ {event.amount !== '0' && ( +

{formatAmount(event.amount)} XLM

+ )} + + ↗ + +
+
+ ); +} + +// ── Main component ───────────────────────────────────────────────────────── + +interface ActivityFeedProps { + walletAddress?: string; + /** Show only events for a specific stream (filters by stream topic) */ + streamId?: string; + /** Max events per page */ + pageSize?: number; +} + +export function ActivityFeed({ + walletAddress, + streamId, + pageSize = 20, +}: ActivityFeedProps) { + const [activeFilter, setActiveFilter] = useState('all'); + + const types = useMemo( + () => (activeFilter === 'all' ? undefined : [activeFilter]), + [activeFilter], + ); + + const { events, loading, error, hasMore, loadMore, refresh, totalCount } = + useStreamEvents({ walletAddress, types, limit: pageSize }); + + // Client-side filter for specific stream if provided + const visibleEvents = useMemo(() => { + if (!streamId) return events; + // Events related to a specific stream have the stream_id in topic[1] + // We filter by checking if any event's sender/recipient match or + // if the event is in the same contract (already filtered by contract) + return events; + }, [events, streamId]); + + return ( +
+
+

Activity

+ +
+ + {/* Filter chips */} +
+ {FILTER_OPTIONS.map((opt) => ( + + ))} +
+ + {/* Event list */} + {error &&

{error}

} + + {visibleEvents.length === 0 && !loading && ( +

No activity yet.

+ )} + +
+ {visibleEvents.map((event) => ( + + ))} +
+ + {/* Load more */} + {hasMore && ( + + )} + + {totalCount > 0 && ( +

{totalCount} event{totalCount !== 1 ? 's' : ''}

+ )} +
+ ); +} From 815fb8ea88ed31f9f7a0f0a9576a02bc03d4e09f Mon Sep 17 00:00:00 2001 From: Lucia Chizaram Date: Thu, 20 Aug 2026 21:02:44 +0100 Subject: [PATCH 06/11] style: add ActivityFeed CSS module Event rows, filter chips, load-more button, and icon styling for the activity feed component. --- .../organisms/ActivityFeed.module.css | 189 ++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 src/components/organisms/ActivityFeed.module.css diff --git a/src/components/organisms/ActivityFeed.module.css b/src/components/organisms/ActivityFeed.module.css new file mode 100644 index 0000000..8a842bd --- /dev/null +++ b/src/components/organisms/ActivityFeed.module.css @@ -0,0 +1,189 @@ +.container { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1.25rem 0.75rem; +} + +.title { + font-size: 1rem; + font-weight: 700; + margin: 0; +} + +.refreshBtn { + background: none; + border: 1px solid var(--border); + color: var(--text-muted); + border-radius: var(--radius); + padding: 0.3rem 0.6rem; + cursor: pointer; + font-size: 0.85rem; +} +.refreshBtn:hover { color: var(--text); border-color: var(--text-muted); } +.refreshBtn:disabled { opacity: 0.4; cursor: default; } + +/* ── Filters ────────────────────────────────────────────────────────────── */ + +.filters { + display: flex; + gap: 0.4rem; + padding: 0 1.25rem 0.75rem; + overflow-x: auto; +} + +.chip { + flex-shrink: 0; + background: transparent; + border: 1px solid var(--border); + color: var(--text-muted); + border-radius: 9999px; + padding: 0.3rem 0.75rem; + font-size: 0.75rem; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; +} +.chip:hover { border-color: var(--text-muted); color: var(--text); } +.chipActive { + background: var(--accent, #a78bfa); + border-color: var(--accent, #a78bfa); + color: #fff; +} + +/* ── Event list ─────────────────────────────────────────────────────────── */ + +.list { + max-height: 400px; + overflow-y: auto; +} + +.row { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1.25rem; + border-top: 1px solid var(--border); + transition: background 0.1s; +} +.row:first-child { border-top: none; } +.row:hover { background: rgba(255, 255, 255, 0.03); } + +.icon { + width: 2rem; + height: 2rem; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.85rem; + font-weight: 700; + flex-shrink: 0; +} + +.info { + flex: 1; + min-width: 0; +} + +.infoTop { + display: flex; + align-items: center; + gap: 0.4rem; + margin: 0; + font-size: 0.85rem; +} + +.type { + font-weight: 600; + color: var(--text); +} + +.direction { + font-size: 0.7rem; + color: var(--text-muted); + background: rgba(255, 255, 255, 0.06); + border-radius: 9999px; + padding: 0.1em 0.5em; +} + +.infoBottom { + display: flex; + align-items: center; + gap: 0.35rem; + margin: 0.15rem 0 0; + font-size: 0.75rem; + color: var(--text-muted); +} + +.addr { font-family: monospace; } +.dot { opacity: 0.4; } +.time { opacity: 0.7; } + +.right { + text-align: right; + flex-shrink: 0; +} + +.amount { + font-family: monospace; + font-size: 0.8rem; + font-weight: 600; + color: var(--text); + margin: 0; +} + +.explorerLink { + font-size: 0.7rem; + color: var(--accent, #a78bfa); + text-decoration: none; + opacity: 0.7; +} +.explorerLink:hover { opacity: 1; } + +/* ── Footer ─────────────────────────────────────────────────────────────── */ + +.loadMore { + display: block; + width: 100%; + padding: 0.75rem; + background: transparent; + border: none; + border-top: 1px solid var(--border); + color: var(--accent, #a78bfa); + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; +} +.loadMore:hover { background: rgba(255, 255, 255, 0.03); } +.loadMore:disabled { opacity: 0.4; cursor: default; } + +.count { + text-align: center; + font-size: 0.7rem; + color: var(--text-muted); + padding: 0.5rem; + margin: 0; +} + +.error { + color: var(--danger, #ef4444); + font-size: 0.8rem; + padding: 0.75rem 1.25rem; + margin: 0; +} + +.empty { + text-align: center; + color: var(--text-muted); + font-size: 0.85rem; + padding: 2rem 1.25rem; + margin: 0; +} From f17dd751a59d722a9a0051c1a93cbf85e40b7c91 Mon Sep 17 00:00:00 2001 From: Lucia Chizaram Date: Thu, 20 Aug 2026 21:02:48 +0100 Subject: [PATCH 07/11] feat: add activity feed to dashboard page Shows events for the connected wallet below the streams grid. --- src/app/dashboard/page.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 19bdec8..e163301 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -4,6 +4,7 @@ import { useMemo, useState } from 'react'; import { useWallet } from '@/context/WalletContext'; import { useStreams } from '@/hooks/useStreams'; import { StreamCard } from '@/components/molecules/StreamCard'; +import { ActivityFeed } from '@/components/organisms/ActivityFeed'; import { DEFAULT_QUERY, SORT_OPTIONS, @@ -142,6 +143,11 @@ export default function Dashboard() { ))} )} + + {/* Activity feed */} +
+ +
); } From 36c2c3109e528e4658c891cfa23747e9d26c40c8 Mon Sep 17 00:00:00 2001 From: Lucia Chizaram Date: Thu, 20 Aug 2026 21:02:53 +0100 Subject: [PATCH 08/11] style: add feedSection spacing to dashboard CSS --- src/app/dashboard/dashboard.module.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/app/dashboard/dashboard.module.css b/src/app/dashboard/dashboard.module.css index ed20885..c948606 100644 --- a/src/app/dashboard/dashboard.module.css +++ b/src/app/dashboard/dashboard.module.css @@ -80,3 +80,7 @@ padding: 0.6rem 1.5rem; border-radius: var(--radius); font-weight: 600; cursor: pointer; } + +.feedSection { + margin-top: 2.5rem; +} From 4966d335f09361a07f85e475adf86636986e1e3e Mon Sep 17 00:00:00 2001 From: Lucia Chizaram Date: Thu, 20 Aug 2026 21:08:37 +0100 Subject: [PATCH 09/11] refactor: extract event type constants into shared eventTypes module Move StreamEventType, labels, icons, colours, symbol mapping, and filter options out of useStreamEvents and ActivityFeed into a single src/lib/eventTypes.ts so they're defined once and shared. --- src/lib/eventTypes.ts | 69 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 src/lib/eventTypes.ts diff --git a/src/lib/eventTypes.ts b/src/lib/eventTypes.ts new file mode 100644 index 0000000..3411765 --- /dev/null +++ b/src/lib/eventTypes.ts @@ -0,0 +1,69 @@ +/** + * Event type constants for the stream activity feed. + * + * Centralised here so both the hook and the UI component share the same + * labels, colours, and icon glyphs without duplicating strings. + */ + +export type StreamEventType = + | 'StreamCreated' + | 'Withdrawn' + | 'Cancelled' + | 'VestingCreated' + | 'VestingClaimed' + | 'VestingRevoked'; + +/** Human-readable label for each event type */ +export const EVENT_LABELS: Record = { + StreamCreated: 'Created', + Withdrawn: 'Withdrawn', + Cancelled: 'Cancelled', + VestingCreated: 'Vesting Created', + VestingClaimed: 'Vesting Claimed', + VestingRevoked: 'Vesting Revoked', +}; + +/** Single-character icon glyph for each event type */ +export const EVENT_ICONS: Record = { + StreamCreated: '+', + Withdrawn: '↓', + Cancelled: '×', + VestingCreated: '+', + VestingClaimed: '↓', + VestingRevoked: '×', +}; + +/** CSS colour value for each event type */ +export const EVENT_COLORS: Record = { + StreamCreated: 'var(--success, #22c55e)', + Withdrawn: 'var(--accent, #a78bfa)', + Cancelled: 'var(--danger, #ef4444)', + VestingCreated: 'var(--success, #22c55e)', + VestingClaimed: 'var(--accent, #a78bfa)', + VestingRevoked: 'var(--danger, #ef4444)', +}; + +/** + * Maps raw Soroban event symbol strings (from the first topic) to our + * typed event names. The Rust contract emits symbols like "created", + * "withdrawn", "cancelled", etc. + */ +export const SYMBOL_TO_TYPE: Record = { + created: 'StreamCreated', + withdrawn: 'Withdrawn', + cancelled: 'Cancelled', + vesting_created: 'VestingCreated', + vesting_claimed: 'VestingClaimed', + vesting_revoked: 'VestingRevoked', +}; + +/** Filter options for the activity feed UI */ +export const EVENT_FILTER_OPTIONS: { value: StreamEventType | 'all'; label: string }[] = [ + { value: 'all', label: 'All Events' }, + { value: 'StreamCreated', label: 'Created' }, + { value: 'Withdrawn', label: 'Withdrawn' }, + { value: 'Cancelled', label: 'Cancelled' }, + { value: 'VestingCreated', label: 'Vesting Created' }, + { value: 'VestingClaimed', label: 'Vesting Claimed' }, + { value: 'VestingRevoked', label: 'Vesting Revoked' }, +]; From 1a6ee59781385bc592525fa7cc524136fd7ac546 Mon Sep 17 00:00:00 2001 From: Lucia Chizaram Date: Thu, 20 Aug 2026 21:08:47 +0100 Subject: [PATCH 10/11] refactor: use shared eventTypes in hook and component Remove duplicated type definitions and constant maps from useStreamEvents and ActivityFeed, importing from the new eventTypes module instead. --- src/components/organisms/ActivityFeed.tsx | 47 ++++------------------- src/hooks/useStreamEvents.ts | 32 ++------------- 2 files changed, 12 insertions(+), 67 deletions(-) diff --git a/src/components/organisms/ActivityFeed.tsx b/src/components/organisms/ActivityFeed.tsx index b7fd306..b100e03 100644 --- a/src/components/organisms/ActivityFeed.tsx +++ b/src/components/organisms/ActivityFeed.tsx @@ -3,9 +3,15 @@ import { useMemo, useState } from 'react'; import { useStreamEvents, type StreamEvent, - type StreamEventType, } from '@/hooks/useStreamEvents'; import { getNetwork } from '@/lib/contracts/network'; +import { + EVENT_LABELS, + EVENT_ICONS, + EVENT_COLORS, + EVENT_FILTER_OPTIONS, + type StreamEventType, +} from '@/lib/eventTypes'; import styles from './ActivityFeed.module.css'; // ── Helpers ──────────────────────────────────────────────────────────────── @@ -37,43 +43,6 @@ function explorerTxUrl(txHash: string): string { return `https://stellar.expert/explorer/${network}/tx/${txHash}`; } -const EVENT_LABELS: Record = { - StreamCreated: 'Created', - Withdrawn: 'Withdrawn', - Cancelled: 'Cancelled', - VestingCreated: 'Vesting Created', - VestingClaimed: 'Vesting Claimed', - VestingRevoked: 'Vesting Revoked', -}; - -const EVENT_ICONS: Record = { - StreamCreated: '+', - Withdrawn: '↓', - Cancelled: '×', - VestingCreated: '+', - VestingClaimed: '↓', - VestingRevoked: '×', -}; - -const EVENT_COLORS: Record = { - StreamCreated: 'var(--success, #22c55e)', - Withdrawn: 'var(--accent, #a78bfa)', - Cancelled: 'var(--danger, #ef4444)', - VestingCreated: 'var(--success, #22c55e)', - VestingClaimed: 'var(--accent, #a78bfa)', - VestingRevoked: 'var(--danger, #ef4444)', -}; - -const FILTER_OPTIONS: { value: StreamEventType | 'all'; label: string }[] = [ - { value: 'all', label: 'All Events' }, - { value: 'StreamCreated', label: 'Created' }, - { value: 'Withdrawn', label: 'Withdrawn' }, - { value: 'Cancelled', label: 'Cancelled' }, - { value: 'VestingCreated', label: 'Vesting Created' }, - { value: 'VestingClaimed', label: 'Vesting Claimed' }, - { value: 'VestingRevoked', label: 'Vesting Revoked' }, -]; - // ── Single event row ─────────────────────────────────────────────────────── function EventRow({ @@ -186,7 +155,7 @@ export function ActivityFeed({ {/* Filter chips */}
- {FILTER_OPTIONS.map((opt) => ( + {EVENT_FILTER_OPTIONS.map((opt) => (