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; +} 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 */} +
+ +
); } 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() { )} )} + +
+ +
); } 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; +} 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; +} diff --git a/src/components/organisms/ActivityFeed.tsx b/src/components/organisms/ActivityFeed.tsx new file mode 100644 index 0000000..803c0dc --- /dev/null +++ b/src/components/organisms/ActivityFeed.tsx @@ -0,0 +1,190 @@ +'use client'; +import { useMemo, useState } from 'react'; +import { + useStreamEvents, + type StreamEvent, +} 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 ──────────────────────────────────────────────────────────────── + +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}`; +} + +// ── 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; + /** Max events per page */ + pageSize?: number; +} + +export function ActivityFeed({ + walletAddress, + 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 }); + + return ( +
+
+

Activity

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

{error}

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

No activity yet.

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

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

+ )} +
+ ); +} 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 }; } diff --git a/src/hooks/useStreamEvents.ts b/src/hooks/useStreamEvents.ts new file mode 100644 index 0000000..b34437e --- /dev/null +++ b/src/hooks/useStreamEvents.ts @@ -0,0 +1,215 @@ +'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'; +import { SYMBOL_TO_TYPE, type StreamEventType } from '@/lib/eventTypes'; + +export type { StreamEventType } from '@/lib/eventTypes'; + +// ── Types ────────────────────────────────────────────────────────────────── + +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 classification ────────────────────────────────────────────── + +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 }; +} diff --git a/src/lib/eventTypes.ts b/src/lib/eventTypes.ts new file mode 100644 index 0000000..b324843 --- /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 PascalCase symbols like + * "StreamCreated", "Withdrawn", "Cancelled", etc. + */ +export const SYMBOL_TO_TYPE: Record = { + StreamCreated: 'StreamCreated', + Withdrawn: 'Withdrawn', + Cancelled: 'Cancelled', + VestingCreated: 'VestingCreated', + VestingClaimed: 'VestingClaimed', + VestingRevoked: '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' }, +];