Skip to content

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.json

Manual

  1. Install the dependencies

    npm install clsx lucide-react motion tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/markets/avatar.tsx
    import { 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.ts
    import { 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.ts
    import { 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,
      );
    }
  3. 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);
    }
  4. 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

PropTypeDefaultDescription
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).
currentUserstring"@alibar"Name used for new comments and replies.
largeTradenumber50000Trades worth at least this many cents are highlighted.
maxItemsnumber10Rows kept in the activity stream.
defaultTabTab"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.
classNamestring–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.