Prediction Marketsv0.3.0
Market Activity & Discussion
A live trade stream and a threaded discussion.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/market-activity.jsonManual
Install the dependencies
npm install clsx lucide-react motion tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/markets/avatar.tsximport { cn } from "@/lib/utils"; /** A generated avatar: a gradient from a hash of the name, with initials. No images needed. */ export function MarketAvatar({ name, className }: { name: string; className?: string }) { let hash = 0; for (let i = 0; i < name.length; i++) hash = (Math.imul(hash, 31) + name.charCodeAt(i)) | 0; const hue = Math.abs(hash) % 360; const initials = name .replace(/^@/, "") .split(/[\s._-]+/) .map((part) => part[0]) .join("") .slice(0, 2) .toUpperCase(); return ( <span aria-hidden="true" className={cn("grid size-7 shrink-0 place-items-center rounded-full text-[10px] font-semibold text-white ring-2 ring-background", className)} style={{ background: `linear-gradient(135deg, hsl(${hue} 70% 55%), hsl(${(hue + 40) % 360} 70% 40%))` }} > {initials} </span> ); } /** A small "Simulated" badge for demos that use mock data. */ export function SimulatedBadge({ className }: { className?: string }) { return ( <span className={cn("inline-flex h-5 items-center gap-1 rounded-full border border-warning/30 bg-warning/10 px-2 text-[11px] font-medium text-[color-mix(in_oklab,var(--warning)_75%,var(--foreground))]", className)}> <span aria-hidden="true" className="size-1.5 rounded-full bg-warning" /> Simulated </span> ); }lib/markets/format.tsimport { FULL } from "@/lib/markets/math"; const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }); // minimumFractionDigits: 0 matters: currency defaults to 2, and Node and browsers clamp it differently ("$412.0K" vs "$412K"). const usdCompact = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", notation: "compact", minimumFractionDigits: 0, maximumFractionDigits: 1 }); /** "64%" (or "64.2%" with decimals). */ export const formatProbability = (bps: number, decimals = 0) => `${(bps / 100).toFixed(decimals)}%`; /** "64¢". */ export const formatPrice = (bps: number) => `${Math.round(bps / 100)}¢`; /** "$1,234.56", or "$1.2M" compact. Input in cents. */ export const formatUsd = (cents: number, compact = false) => (compact ? usdCompact.format(cents / 100) : usd.format(cents / 100)); /** Shares from hundredths: "156.25". */ export const formatShares = (hundredths: number) => (hundredths / 100).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); /** "▲ 4%" / "▼ 2.5%" from a basis-point change (never color alone: the arrow carries the direction). */ export function formatChange(bps: number) { const value = Math.abs(bps) / 100; return `${bps >= 0 ? "▲" : "▼"} ${value % 1 === 0 ? value.toFixed(0) : value.toFixed(1)}%`; } /** Signed percent from basis points: "+56%". */ export const formatReturn = (bps: number) => `${bps >= 0 ? "+" : "−"}${(Math.abs(bps) / 100).toFixed(bps % 100 === 0 ? 0 : 1)}%`; /** "Ends in 3d 4h", "Ends in 42m" or "Ended". */ export function timeLeft(endsAt: number, now: number) { const ms = endsAt - now; if (ms <= 0) return "Ended"; const minutes = Math.floor(ms / 60000); const days = Math.floor(minutes / 1440); const hours = Math.floor((minutes % 1440) / 60); if (days > 0) return `Ends in ${days}d ${hours}h`; if (hours > 0) return `Ends in ${hours}h ${minutes % 60}m`; return `Ends in ${Math.max(1, minutes)}m`; } /** A fixed, locale-stable date: "Mar 14, 2027". */ export const formatDate = (ms: number) => new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" }).format(ms); export { FULL };lib/markets/math.ts/** * Prediction market math on integers only: prices in basis points (10000 = $1.00 = 100%), money in * cents and shares in hundredths. A share pays $1 (100 cents) if its outcome wins. */ export const FULL = 10000; const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); /** The other side of a binary price: Yes + No = 100¢. */ export const complement = (price: number) => FULL - price; /** Shares (in hundredths) that `amount` cents buys at `price` basis points. */ export const sharesFor = (amount: number, price: number) => (price > 0 ? Math.floor((amount * FULL) / price) : 0); /** Payout in cents if those shares win: each share pays 100 cents, so hundredths of a share map 1:1 to cents. */ export const payoutFor = (shares: number) => shares; /** Value in cents of shares (hundredths) at a price. */ export const valueOf = (shares: number, price: number) => Math.floor((shares * price) / FULL); export interface BuyQuote { /** Average fill price, basis points. */ averagePrice: number; /** Price after the trade, basis points. */ newPrice: number; /** Price impact, basis points (newPrice − price). */ impact: number; /** Shares received, hundredths. */ shares: number; /** Payout if it wins, cents. */ payout: number; /** Return if it wins, basis points of the amount (5600 = +56%). */ returnBps: number; } /** * A simulated buy against a constant-product style curve: the more of the pool you take, the further * the price moves toward $1. Good enough to show impact and average price in a demo. */ export function quoteBuy(price: number, amount: number, liquidity: number): BuyQuote { const p = clamp(Math.round(price), 1, FULL - 1); if (amount <= 0) return { averagePrice: p, newPrice: p, impact: 0, shares: 0, payout: 0, returnBps: 0 }; const take = Math.floor((amount * FULL) / (amount + Math.max(1, liquidity))); const newPrice = clamp(p + Math.floor(((FULL - p) * take) / FULL), 1, FULL - 1); const averagePrice = Math.floor((p + newPrice) / 2); const shares = sharesFor(amount, averagePrice); const payout = payoutFor(shares); return { averagePrice, newPrice, impact: newPrice - p, shares, payout, returnBps: Math.floor(((payout - amount) * FULL) / amount) }; } /** A simulated limit buy: fills entirely at the limit price, no impact. */ export function quoteLimit(limitPrice: number, amount: number): BuyQuote { const p = clamp(Math.round(limitPrice), 1, FULL - 1); const shares = sharesFor(Math.max(0, amount), p); return { averagePrice: p, newPrice: p, impact: 0, shares, payout: shares, returnBps: amount > 0 ? Math.floor(((shares - amount) * FULL) / amount) : 0 }; } /** Proceeds in cents from selling shares (hundredths), with the price sliding toward 0 by the same curve. */ export function quoteSell(price: number, shares: number, liquidity: number) { const p = clamp(Math.round(price), 1, FULL - 1); const gross = valueOf(shares, p); const take = Math.floor((gross * FULL) / (gross + Math.max(1, liquidity))); const newPrice = clamp(p - Math.floor((p * take) / FULL), 1, FULL - 1); const averagePrice = Math.floor((p + newPrice) / 2); return { proceeds: valueOf(shares, averagePrice), averagePrice, newPrice, impact: newPrice - p }; } /** Profit and loss in cents for shares bought at an average price, marked at the current price. */ export function profitAndLoss(shares: number, averagePrice: number, currentPrice: number) { const cost = valueOf(shares, averagePrice); const value = valueOf(shares, currentPrice); return { cost, value, pnl: value - cost, pnlBps: cost ? Math.floor(((value - cost) * FULL) / cost) : 0 }; } /** Scales positive weights so they sum to exactly 10000 (rounding error goes to the largest). */ export function normalize(prices: number[]) { const total = prices.reduce((sum, p) => sum + Math.max(0, p), 0) || 1; const scaled = prices.map((p) => Math.floor((Math.max(0, p) * FULL) / total)); const rest = FULL - scaled.reduce((sum, p) => sum + p, 0); const largest = scaled.indexOf(Math.max(...scaled)); if (largest >= 0) scaled[largest] += rest; return scaled; }lib/markets/use-now.tsimport { useSyncExternalStore } from "react"; let now = 0; const listeners = new Set<() => void>(); let timer = 0; function subscribe(listener: () => void) { listeners.add(listener); // Refresh on every subscribe, so a component that mounts while the timer is already running // does not start with a timestamp up to 15 s old. now = Date.now(); if (!timer) { timer = window.setInterval(() => { now = Date.now(); listeners.forEach((l) => l()); }, 15000); } return () => { listeners.delete(listener); if (!listeners.size) { window.clearInterval(timer); timer = 0; } }; } /** * The current time, refreshed every 15 s. It is 0 on the server and during hydration, so countdowns * render a placeholder first and never mismatch. */ export function useNow() { return useSyncExternalStore( subscribe, () => now || Date.now(), () => 0, ); } const noop = () => () => {}; /** * False on the server and during hydration, true after. Gate text derived from timestamps (axis * labels, dates) with it when the data may be generated relative to page load. */ export function useHydrated() { return useSyncExternalStore( noop, () => true, () => false, ); }Add the theme tokens
This component uses --success and --warning, which shadcn does not define by default. Add them to your global CSS:
app/globals.css:root { --success: #15803d; --warning: #b45309; } .dark { --success: #4ade80; --warning: #fbbf24; } @theme inline { --color-success: var(--success); --color-warning: var(--warning); }Copy the source
components/ui/market-activity/market-activity.tsx"use client"; import { useId, useRef, useState } from "react"; import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react"; import { Heart, MessageCircle } from "lucide-react"; import { cn } from "@/lib/utils"; import { MarketAvatar } from "@/lib/markets/avatar"; import { formatPrice, formatShares, formatUsd } from "@/lib/markets/format"; import { useNow } from "@/lib/markets/use-now"; export interface ActivityTrade { id: string; trader: string; side: "buy" | "sell"; /** Outcome name, e.g. "Yes". */ outcome: string; /** Shares, in hundredths. */ shares: number; /** Fill price, basis points. */ price: number; /** Time of the trade, ms since epoch. */ at: number; } export interface DiscussionComment { id: string; author: string; /** The author's position, shown as a badge. Shares in hundredths. */ position?: { outcome: string; shares: number }; text: string; likes: number; /** Relative time label, e.g. "2h". */ ago: string; replies?: DiscussionComment[]; } type Tab = "activity" | "comments"; type Filter = "all" | "yes" | "no"; export interface MarketActivityProps { /** Recent trades, newest first. New items at the head slide in from the top. */ trades: ActivityTrade[]; /** Starting comment thread (the component keeps its own copy for likes, replies and new posts). */ comments: DiscussionComment[]; /** Name used for new comments and replies. @default "@alibar" */ currentUser?: string; /** Trades worth at least this many cents are highlighted. @default 50000 */ largeTrade?: number; /** Rows kept in the activity stream. @default 10 */ maxItems?: number; /** Tab shown first. @default "activity" */ defaultTab?: Tab; /** Called with the text (and the parent comment id for a reply) when the viewer posts. */ onComment?: (text: string, parentId?: string) => void; /** Classes for the panel. */ className?: string; } const value = (t: ActivityTrade) => Math.round((t.shares * t.price) / 10000); function ago(at: number, now: number) { const s = Math.round((now - at) / 1000); if (s < 30) return "now"; if (s < 60) return `${s}s`; if (s < 3600) return `${Math.floor(s / 60)}m`; return `${Math.floor(s / 3600)}h`; } function PositionBadge({ position }: { position: NonNullable<DiscussionComment["position"]> }) { const tone = position.outcome === "Yes" ? "border-success/30 bg-success/8" : position.outcome === "No" ? "border-destructive/30 bg-destructive/8" : "border-border bg-surface-2"; return ( <span className={cn("inline-flex h-5 items-center rounded-full border px-1.5 text-[11px] text-foreground", tone)}> Holds {position.outcome} · {formatShares(position.shares).replace(/\.00$/, "")} shares </span> ); } function LikeButton({ liked, count, onToggle }: { liked: boolean; count: number; onToggle: () => void }) { const reduceMotion = useReducedMotion(); return ( <button type="button" aria-pressed={liked} aria-label={`Like, ${count} likes`} onClick={onToggle} className={cn("inline-flex h-8 min-w-10 cursor-pointer items-center gap-1 rounded-full px-2 text-[12px] tabular-nums outline-none focus-visible:ring-2 focus-visible:ring-ring", liked ? "text-[color-mix(in_oklab,var(--destructive)_85%,var(--foreground))]" : "text-muted-foreground hover:text-foreground")}> <motion.span key={liked ? "on" : "off"} initial={reduceMotion || !liked ? false : { scale: 0.6 }} animate={{ scale: [null, 1.35, 1] }} transition={{ duration: 0.35 }} className="inline-flex"> <Heart className={cn("size-3.5", liked && "fill-current")} aria-hidden="true" /> </motion.span> {count} </button> ); } function Composer({ placeholder, label, onSubmit, autoFocus }: { placeholder: string; label: string; onSubmit: (text: string) => void; autoFocus?: boolean }) { const [text, setText] = useState(""); return ( <form className="flex items-center gap-2" onSubmit={(e) => { e.preventDefault(); if (!text.trim()) return; onSubmit(text.trim()); setText(""); }} > <input value={text} onChange={(e) => setText(e.target.value)} placeholder={placeholder} aria-label={label} autoFocus={autoFocus} maxLength={280} className="h-10 min-w-0 flex-1 rounded-full border border-border bg-background px-3.5 text-[13px] text-foreground outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30" /> <button type="submit" disabled={!text.trim()} className="h-10 shrink-0 cursor-pointer rounded-full bg-foreground px-4 text-[13px] font-medium text-background outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-40"> Post </button> </form> ); } /** * A market's social layer: a live trade stream (new trades slide in, large ones highlighted) and a * discussion thread with position badges, likes, one level of replies, Top/Newest sort and holder filters. */ export function MarketActivity({ trades, comments: initialComments, currentUser = "@alibar", largeTrade = 50000, maxItems = 10, defaultTab = "activity", onComment, className }: MarketActivityProps) { const id = useId(); const reduceMotion = useReducedMotion(); const now = useNow(); const [tab, setTab] = useState<Tab>(defaultTab); const [comments, setComments] = useState(initialComments); const [liked, setLiked] = useState<Set<string>>(() => new Set()); const [sort, setSort] = useState<"top" | "newest">("top"); const [filter, setFilter] = useState<Filter>("all"); const [replyTo, setReplyTo] = useState<string | null>(null); const [announce, setAnnounce] = useState(""); const posted = useRef(0); const tabRefs = useRef<(HTMLButtonElement | null)[]>([]); const spring = reduceMotion ? { duration: 0 } : { type: "spring" as const, stiffness: 420, damping: 36 }; const like = (cid: string) => setLiked((current) => { const next = new Set(current); if (next.has(cid)) next.delete(cid); else next.add(cid); return next; }); const likes = (c: DiscussionComment) => c.likes + (liked.has(c.id) ? 1 : 0); const post = (text: string, parentId?: string) => { posted.current += 1; const comment: DiscussionComment = { id: `${id}-new-${posted.current}`, author: currentUser, text, likes: 0, ago: "now" }; setComments((current) => (parentId ? current.map((c) => (c.id === parentId ? { ...c, replies: [...(c.replies ?? []), comment] } : c)) : [comment, ...current])); setReplyTo(null); setSort((s) => (parentId ? s : "newest")); setAnnounce(parentId ? "Reply posted" : "Comment posted"); onComment?.(text, parentId); }; const visible = comments .map((c, order) => ({ c, order })) .filter(({ c }) => filter === "all" || c.position?.outcome.toLowerCase() === filter) .sort((a, b) => (sort === "top" ? likes(b.c) - likes(a.c) || a.order - b.order : a.order - b.order)) .map(({ c }) => c); const tabs: { id: Tab; label: string; count: number }[] = [ { id: "activity", label: "Activity", count: trades.length }, { id: "comments", label: "Comments", count: comments.reduce((n, c) => n + 1 + (c.replies?.length ?? 0), 0) }, ]; return ( <section aria-label="Market activity and discussion" className={cn("w-full rounded-2xl border border-border bg-background", className)}> <LayoutGroup id={`${id}-tabs`}> <div role="tablist" aria-label="Activity and comments" className="flex gap-1 border-b border-border px-3 pt-2" onKeyDown={(e) => { if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return; const next = tab === "activity" ? 1 : 0; setTab(tabs[next].id); tabRefs.current[next]?.focus(); }} > {tabs.map((t, i) => ( <button key={t.id} ref={(node) => { tabRefs.current[i] = node; }} type="button" role="tab" id={`${id}-tab-${t.id}`} aria-selected={tab === t.id} aria-controls={`${id}-panel-${t.id}`} tabIndex={tab === t.id ? 0 : -1} onClick={() => setTab(t.id)} className={cn("relative h-10 cursor-pointer px-2.5 text-[13px] font-medium outline-none focus-visible:rounded-md focus-visible:ring-2 focus-visible:ring-ring", tab === t.id ? "text-foreground" : "text-muted-foreground hover:text-foreground")} > {t.label} <span className="ml-1.5 rounded-full bg-surface-2 px-1.5 py-px text-[11px] text-[color-mix(in_oklab,var(--muted-foreground)_75%,var(--foreground))] tabular-nums">{t.count}</span> {tab === t.id ? <motion.span layoutId="tab-line" transition={spring} className="absolute inset-x-1 -bottom-px h-0.5 rounded-full bg-foreground" /> : null} </button> ))} </div> </LayoutGroup> {tab === "activity" ? ( <div role="tabpanel" id={`${id}-panel-activity`} aria-labelledby={`${id}-tab-activity`} className="px-3 py-2"> <ul aria-label="Recent trades" aria-live="polite" aria-relevant="additions" className="overflow-hidden"> <AnimatePresence initial={false}> {trades.slice(0, maxItems).map((t) => { const big = value(t) >= largeTrade; return ( <motion.li key={t.id} layout={reduceMotion ? false : "position"} initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -14 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, transition: { duration: 0.12 } }} transition={spring} className={cn("flex items-center gap-2.5 rounded-lg px-2 py-2 text-[13px]", big && "bg-[color-mix(in_oklab,var(--warning)_9%,transparent)]")} > <MarketAvatar name={t.trader} className="size-7" /> <p className="min-w-0 flex-1 leading-5 text-muted-foreground"> <span className="font-medium text-foreground">{t.trader}</span>{" "} <span className={cn("font-medium", t.side === "buy" ? "text-[color-mix(in_oklab,var(--success)_75%,var(--foreground))]" : "text-[color-mix(in_oklab,var(--destructive)_80%,var(--foreground))]")}>{t.side === "buy" ? "bought" : "sold"}</span> {formatShares(t.shares).replace(/\.00$/, "")} <span className="font-medium text-foreground">{t.outcome}</span> at {formatPrice(t.price)} {big ? <span className="ml-1.5 rounded-full border border-warning/40 px-1.5 text-[10px] font-medium text-[color-mix(in_oklab,var(--warning)_70%,var(--foreground))] uppercase">Large</span> : null} </p> <span className="shrink-0 text-right text-[12px] tabular-nums"> <span className="block font-medium text-foreground">{formatUsd(value(t))}</span> <span className="block text-muted-foreground">{now ? ago(t.at, now) : " "}</span> </span> </motion.li> ); })} </AnimatePresence> </ul> {trades.length === 0 ? <p className="py-8 text-center text-[13px] text-muted-foreground">No trades yet.</p> : null} </div> ) : ( <div role="tabpanel" id={`${id}-panel-comments`} aria-labelledby={`${id}-tab-comments`} className="px-3 py-3"> <Composer placeholder="Share your take…" label="Add a comment" onSubmit={(text) => post(text)} /> <div className="mt-3 flex flex-wrap items-center justify-between gap-2"> <LayoutGroup id={`${id}-filter`}> <div role="radiogroup" aria-label="Show comments from" className="flex gap-1"> {(["all", "yes", "no"] as const).map((f) => ( <button key={f} type="button" role="radio" aria-checked={filter === f} onClick={() => setFilter(f)} className={cn("relative h-8 cursor-pointer rounded-full px-2.5 text-[12px] outline-none focus-visible:ring-2 focus-visible:ring-ring", filter === f ? "text-background" : "text-muted-foreground hover:text-foreground")}> {filter === f ? <motion.span layoutId="filter-pill" transition={spring} className="absolute inset-0 rounded-full bg-foreground" /> : null} <span className="relative">{f === "all" ? "All" : f === "yes" ? "Yes holders" : "No holders"}</span> </button> ))} </div> </LayoutGroup> <label className="flex items-center gap-1.5 text-[12px] text-muted-foreground"> Sort <select value={sort} onChange={(e) => setSort(e.target.value as "top" | "newest")} className="h-8 cursor-pointer rounded-md border border-border bg-background px-1.5 text-[12px] text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"> <option value="top">Top</option> <option value="newest">Newest</option> </select> </label> </div> <ul className="mt-2"> <AnimatePresence initial={false} mode="popLayout"> {visible.map((c) => ( <motion.li key={c.id} layout={reduceMotion ? false : "position"} initial={{ opacity: 0, scale: reduceMotion ? 1 : 0.98 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: reduceMotion ? 1 : 0.98 }} transition={spring} className="border-b border-border py-3 last:border-0"> <CommentBody comment={c} likes={likes(c)} liked={liked.has(c.id)} onLike={() => like(c.id)} onReply={() => setReplyTo((r) => (r === c.id ? null : c.id))} replying={replyTo === c.id} /> {(c.replies?.length ?? 0) > 0 || replyTo === c.id ? ( <ul className="mt-2 ml-4 space-y-3 border-l border-border pl-3"> {c.replies?.map((r) => ( <li key={r.id}> <CommentBody comment={r} likes={likes(r)} liked={liked.has(r.id)} onLike={() => like(r.id)} small /> </li> ))} {replyTo === c.id ? ( <li> <Composer placeholder={`Reply to ${c.author}…`} label={`Reply to ${c.author}`} onSubmit={(text) => post(text, c.id)} autoFocus /> </li> ) : null} </ul> ) : null} </motion.li> ))} </AnimatePresence> </ul> {visible.length === 0 ? <p className="py-8 text-center text-[13px] text-muted-foreground">No comments from {filter === "yes" ? "Yes" : "No"} holders yet.</p> : null} </div> )} <p aria-live="polite" className="sr-only"> {announce} </p> </section> ); } function CommentBody({ comment, likes, liked, onLike, onReply, replying, small }: { comment: DiscussionComment; likes: number; liked: boolean; onLike: () => void; onReply?: () => void; replying?: boolean; small?: boolean }) { return ( <div className="flex gap-2.5"> <MarketAvatar name={comment.author} className={small ? "size-6" : "size-8"} /> <div className="min-w-0 flex-1"> <p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[12px]"> <span className="font-medium text-foreground">{comment.author}</span> {comment.position ? <PositionBadge position={comment.position} /> : null} <span className="text-muted-foreground">{comment.ago}</span> </p> <p className="mt-1 text-[13px] leading-5 text-foreground">{comment.text}</p> <div className="mt-0.5 -ml-2 flex items-center"> <LikeButton liked={liked} count={likes} onToggle={onLike} /> {onReply ? ( <button type="button" aria-expanded={replying} onClick={onReply} className="inline-flex h-8 cursor-pointer items-center gap-1 rounded-full px-2 text-[12px] text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"> <MessageCircle className="size-3.5" aria-hidden="true" /> Reply </button> ) : null} </div> </div> </div> ); }
Usage
import { MarketActivity } from "@/components/ui/market-activity/market-activity";
<MarketActivity
trades={trades} // newest first: { id, trader, side, outcome, shares, price, at }
comments={comments}
currentUser="@alibar"
onComment={(text, parentId) => save(text, parentId)}
/>API reference
| Prop | Type | Default | Description |
|---|---|---|---|
trades* | ActivityTrade[] | – | Recent trades, newest first. New items at the head slide in from the top. |
comments* | DiscussionComment[] | – | Starting comment thread (the component keeps its own copy for likes, replies and new posts). |
currentUser | string | "@alibar" | Name used for new comments and replies. |
largeTrade | number | 50000 | Trades worth at least this many cents are highlighted. |
maxItems | number | 10 | Rows kept in the activity stream. |
defaultTab | Tab | "activity" | Tab shown first. |
onComment | (text: string, parentId?: string) => void | – | Called with the text (and the parent comment id for a reply) when the viewer posts. |
className | string | – | Classes for the panel. |
- Trades worth at least largeTrade cents (default $500) are highlighted. Relative times render after hydration from a shared clock.
- The thread keeps its own copy of comments for likes, replies (one level) and new posts; Top sorts by likes, Newest keeps posting order.
- Position badges ("Holds Yes · 340 shares") come from each comment's position; trade and comment data in the demo are simulated.
Accessibility
- Activity and Comments are tabs with arrow-key support. New trades are announced politely as they arrive.
- Buy and sell are written as "bought"/"sold" as well as colored; large trades carry a "Large" label.
- Like buttons are toggles (aria-pressed) with the count in their label. Reply buttons report aria-expanded and focus the reply field. Posting announces "Comment posted".
- Holder filters are a radio group and Sort is a native select. With reduced motion items appear without sliding.