feat(gmw): public features #2-#6 — live moderation feed, toxic topic trends, channel timeline, CSV export, activity heatmap

- Live Moderation Feed: gateway publishes discord:moderation:action (Redis) → backend WS emits moderation_action → public web shows realtime stream.
- Toxic Topic Trends: backend moderation.trends aggregates categories/severity/action_type (read-only) → SVG bar + donut.
- Channel Timeline: messages view gets Feed/Timeline toggle with date-grouped separators.
- CSV Export: client-side downloadCsv for moderation actions (no backend write scope).
- Activity Heatmap: backend messages.activity (per-hour volume by channel) → pure-SVG grid.

User reputation deliberately excluded — no such feature exists in the codebase.
All read-only / public-facing / fully automatic per project rules.
This commit is contained in:
asepharyana
2026-08-18 17:43:02 +07:00
parent 36363fa3db
commit 9b3134d767
26 changed files with 854 additions and 44 deletions
@@ -2,6 +2,7 @@
import {
AlertTriangle,
Calendar,
CheckCircle2,
Image as ImageIcon,
Loader2,
@@ -11,6 +12,7 @@ import {
ShieldAlert,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ActivityHeatmap } from "@/components/ActivityHeatmap";
import { useAmbient } from "@/components/ambient/ambient-context";
import {
Avatar,
@@ -28,6 +30,7 @@ import {
import { GuildChannelPicker } from "@/components/shared/guild-picker";
import {
useLoadMore,
useMessageActivity,
useMessageDetail,
useMessageSearch,
useMessages,
@@ -71,6 +74,8 @@ export function MessagesView({
// Search mode: "exact" (substring match over captured messages) or
// "semantic" (vector similarity over the persistent Qdrant archive).
const [semanticMode, setSemanticMode] = useState(false);
// feed | timeline: "timeline" groups messages into date-grouped cards.
const [viewMode, setViewMode] = useState<"feed" | "timeline">("feed");
// Guard against loading the entire history on a long scroll: cap how many
// older pages we append. Each page is 50 messages (backend limit default).
const MAX_OLDER_PAGES = 10;
@@ -106,6 +111,7 @@ export function MessagesView({
query,
query.trim().length >= 2 && semanticMode,
);
const activity = useMessageActivity(30);
const detail = useMessageDetail(selected);
const ambient = useAmbient();
@@ -140,6 +146,33 @@ export function MessagesView({
// returns DESC (newest first); reverse so the feed reads top→bottom like DC.
const display = useMemo(() => [...list].reverse(), [list]);
// Timeline mode: inject date-separator headers above the first message of
// each day. Messages are sorted oldest→newest (display is reversed), so a
// date change means a new group. Produces an array of either "date" or "msg"
// nodes so the render loop can switch easily.
const timelineNodes = useMemo(() => {
if (viewMode !== "timeline") return null;
const out: Array<
| { type: "date"; label: string; iso: string }
| { type: "msg"; m: (typeof display)[number] }
> = [];
let prev = "";
for (const m of display) {
const d = new Date(m.created_at).toLocaleDateString(undefined, {
weekday: "short",
month: "short",
day: "numeric",
});
const iso = new Date(m.created_at).toISOString().slice(0, 10);
if (d !== prev) {
out.push({ type: "date", label: d, iso });
prev = d;
}
out.push({ type: "msg", m });
}
return out;
}, [display, viewMode]);
// Ref to the scroll container so we can manage scroll position like Discord:
// open at the bottom (newest), keep the viewport stable when prepending older
// messages at the top, and follow new live messages only when already near
@@ -208,6 +241,20 @@ export function MessagesView({
>
{semanticMode ? "Semantic" : "Exact"}
</button>
<button
type="button"
onClick={() =>
setViewMode((v) => (v === "feed" ? "timeline" : "feed"))
}
className={`rounded-full border px-3 py-1.5 text-xs transition-colors ${
viewMode === "timeline"
? "border-signal/40 bg-signal/10 text-signal"
: "border-hairline bg-white/[0.03] text-ink-soft hover:bg-white/[0.06]"
}`}
title="Toggle timeline (date-grouped) view"
>
{viewMode === "timeline" ? "Timeline" : "Feed"}
</button>
</GlassPanel>
<div className="grid gap-4 lg:grid-cols-5">
@@ -326,45 +373,33 @@ export function MessagesView({
}
}}
>
{display.map((m, i) => (
<button
key={m.id}
type="button"
onClick={() => setSelected(m.id)}
className={`animate-stagger flex w-full items-start gap-3 rounded-[12px] border p-3 text-left transition-colors ${
selected === m.id
? "border-signal/40 bg-signal/8"
: "border-hairline bg-white/[0.03] hover:bg-white/[0.06]"
}`}
style={staggerDelay(i)}
>
<Avatar src={m.avatar_url} name={m.username} size={34} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-semibold text-ink">
{m.username}
</span>
<span className="mono text-[0.65rem] text-ink-faint">
{getMessageChannelLabel(m)}
</span>
<span className="mono ml-auto text-[0.6rem] text-ink-faint">
{formatRelativeTime(m.created_at)}
</span>
</div>
<div className="mt-0.5 line-clamp-2 text-sm text-ink-soft">
{renderMessageContent(m.content, m.metadata) || (
<span className="italic text-ink-faint">
(empty / embed)
</span>
)}
</div>
</div>
<AiBadge
status={m.ai_status}
durationMs={m.ai_analysis_duration_ms}
/>
</button>
))}
{viewMode === "timeline" && timelineNodes
? timelineNodes.map((node, _i) =>
node.type === "date" ? (
<div
key={`date-${node.iso}`}
className="flex items-center gap-2 px-1 text-[0.65rem] text-ink-faint"
>
<Calendar className="size-3" />
{node.label}
</div>
) : (
<MessageRow
key={node.m.id}
m={node.m}
selected={selected}
onSelect={setSelected}
/>
),
)
: display.map((m, _i) => (
<MessageRow
key={m.id}
m={m}
selected={selected}
onSelect={setSelected}
/>
))}
</div>
</div>
)}
@@ -392,6 +427,10 @@ export function MessagesView({
)}
</GlassPanel>
</div>
{activity.data && activity.data.length > 0 && (
<ActivityHeatmap buckets={activity.data} />
)}
</div>
);
}
@@ -513,3 +552,48 @@ function MessageDetail({
</div>
);
}
/** Single message card used by both the live feed and the date-grouped timeline. */
function MessageRow({
m,
selected,
onSelect,
}: {
m: MessageRecord;
selected: string | null;
onSelect: (id: string) => void;
}) {
return (
<button
key={m.id}
type="button"
onClick={() => onSelect(m.id)}
className={`animate-stagger flex w-full items-start gap-3 rounded-[12px] border p-3 text-left transition-colors ${
selected === m.id
? "border-signal/40 bg-signal/8"
: "border-hairline bg-white/[0.03] hover:bg-white/[0.06]"
}`}
>
<Avatar src={m.avatar_url} name={m.username} size={34} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-semibold text-ink">
{m.username}
</span>
<span className="mono text-[0.65rem] text-ink-faint">
{getMessageChannelLabel(m)}
</span>
<span className="mono ml-auto text-[0.6rem] text-ink-faint">
{formatRelativeTime(m.created_at)}
</span>
</div>
<div className="mt-0.5 line-clamp-2 text-sm text-ink-soft">
{renderMessageContent(m.content, m.metadata) || (
<span className="italic text-ink-faint">(empty / embed)</span>
)}
</div>
</div>
<AiBadge status={m.ai_status} durationMs={m.ai_analysis_duration_ms} />
</button>
);
}
@@ -16,6 +16,7 @@ import {
import { useEffect, useState } from "react";
import { useAmbient } from "@/components/ambient/ambient-context";
import { Donut } from "@/components/charts";
import { LiveModerationFeed } from "@/components/LiveModerationFeed";
import {
Badge,
GlassPanel,
@@ -30,8 +31,15 @@ import {
SkeletonPanel,
SkeletonRows,
} from "@/components/shared";
import { useModerationActions, useModerationStats } from "@/hooks";
import { TopicTrends } from "@/components/TopicTrends";
import {
useLiveModeration,
useModerationActions,
useModerationStats,
useModerationTrends,
} from "@/hooks";
import { aiTone } from "@/lib/ai-status";
import { downloadCsv } from "@/lib/csv";
import { formatNumber, formatRelativeTime } from "@/lib/format";
import type {
ModerationAction,
@@ -71,6 +79,8 @@ export function ModerationView({
typeFilter || undefined,
!statusFilter && !typeFilter ? initialActions : undefined,
);
const liveActions = useLiveModeration(initialActions ?? [], 50);
const { data: trends } = useModerationTrends(30);
const failedRate = stats ? stats.failed_rate * 100 : 0;
@@ -150,6 +160,18 @@ export function ModerationView({
</div>
<div className="grid gap-5 lg:grid-cols-5">
<div className="lg:col-span-2">
{trends ? (
<TopicTrends trends={trends} />
) : (
<SkeletonPanel rows={6} />
)}
</div>
<div className="lg:col-span-5">
<LiveModerationFeed actions={liveActions} />
</div>
<GlassPanel className="lg:col-span-2">
<SectionHeader eyebrow="health" title="Breakdown" />
<div className="flex items-center gap-5">
@@ -215,6 +237,30 @@ export function ModerationView({
size="sm"
className="w-32"
/>
<button
type="button"
onClick={() =>
downloadCsv(
"moderation-actions.csv",
(actions ?? []).map((a) => ({
id: a.id,
user: a.username ?? a.user_id,
action_type: a.action_type,
status: a.status,
severity: a.severity ?? "",
categories: (a.categories ?? []).join("|"),
reason: a.reason ?? "",
created_at: a.created_at
? new Date(a.created_at).toISOString()
: "",
})),
)
}
className="rounded-full border border-hairline bg-white/[0.03] px-3 py-1 text-xs text-ink-soft transition-colors hover:bg-white/[0.06]"
title="Download moderation actions as CSV"
>
CSV
</button>
</div>
}
/>
@@ -0,0 +1,89 @@
"use client";
import { GlassPanel } from "@/components/primitives";
import { SectionHeader } from "@/components/shared";
import type { MessageActivityBucket } from "@/lib/types";
const HOURS = Array.from({ length: 24 }, (_, i) => i);
function heatColor(t: number): string {
// t in [0,1] → signal gradient (dark → bright).
if (t <= 0) return "var(--color-hairline)";
return `rgba(45, 212, 191, ${0.15 + 0.85 * t})`;
}
export function ActivityHeatmap({
buckets,
}: {
buckets: MessageActivityBucket[];
}) {
// Group by channel, find max count for normalization.
const channels = Array.from(new Set(buckets.map((b) => b.channelId)));
const byKey = new Map<string, number>();
let max = 0;
for (const b of buckets) {
const k = `${b.channelId}:${b.hour}`;
byKey.set(k, (byKey.get(k) ?? 0) + b.count);
if (byKey.get(k)! > max) max = byKey.get(k)!;
}
if (buckets.length === 0) {
return (
<GlassPanel className="lg:col-span-2">
<SectionHeader eyebrow="insight" title="Activity Heatmap" />
<p className="py-6 text-center text-xs text-ink-faint">
No message activity recorded yet.
</p>
</GlassPanel>
);
}
return (
<GlassPanel className="lg:col-span-5">
<SectionHeader
eyebrow="insight"
title="Activity Heatmap"
action={
<span className="mono text-[0.65rem] text-ink-faint">
{channels.length} channels · messages/hour
</span>
}
/>
<div className="overflow-x-auto">
<div className="min-w-[640px] space-y-1">
{channels.map((ch) => (
<div key={ch} className="flex items-center gap-2">
<span className="mono w-24 shrink-0 truncate text-[0.6rem] text-ink-faint">
{ch.slice(-6)}
</span>
<div className="flex flex-1 gap-0.5">
{HOURS.map((h) => {
const c = byKey.get(`${ch}:${h}`) ?? 0;
const t = max > 0 ? c / max : 0;
return (
<div
key={h}
title={`${ch} · ${String(h).padStart(2, "0")}:00 — ${c} msgs`}
className="h-4 flex-1 rounded-[2px]"
style={{ background: heatColor(t) }}
/>
);
})}
</div>
</div>
))}
<div className="flex items-center gap-2 pt-1">
<span className="w-24 shrink-0" />
<div className="flex flex-1 justify-between">
{[0, 6, 12, 18, 23].map((h) => (
<span key={h} className="mono text-[0.55rem] text-ink-faint">
{String(h).padStart(2, "0")}h
</span>
))}
</div>
</div>
</div>
</div>
</GlassPanel>
);
}
@@ -0,0 +1,102 @@
"use client";
import { Badge, GlassPanel } from "@/components/primitives";
import { formatRelativeTime } from "@/lib/format";
import type { ModerationAction } from "@/lib/types";
const ACTION_LABEL: Record<string, string> = {
delete_message: "Deleted",
timeout_user: "Timeout",
warn_user: "Warned",
reset_nickname: "Nickname reset",
ban_user: "Banned",
kick_user: "Kicked",
notify_user: "Notified",
none: "None",
};
function severityTone(
sev?: string | null,
): "signal" | "amber" | "vermilion" | null {
switch (sev) {
case "critical":
case "high":
return "vermilion";
case "medium":
return "amber";
case "low":
return "signal";
default:
return null;
}
}
export function LiveModerationFeed({
actions,
}: {
actions: ModerationAction[];
}) {
return (
<GlassPanel className="flex max-h-[420px] flex-col">
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3">
<div className="flex items-center gap-2">
<span className="relative flex size-2.5">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex size-2.5 rounded-full bg-emerald-500" />
</span>
<h3 className="text-sm font-medium text-ink">Live Feed</h3>
</div>
<span className="text-xs text-ink-faint">{actions.length} recent</span>
</div>
<div className="flex-1 overflow-y-auto">
{actions.length === 0 ? (
<p className="px-4 py-6 text-center text-xs text-ink-faint">
Waiting for new moderation actions
</p>
) : (
<ul className="divide-y divide-white/5">
{actions.map((a, i) => {
const tone = severityTone(a.severity);
return (
<li
key={a.id}
className={`flex items-start gap-3 px-4 py-3 ${
i === 0 ? "animate-[fadeIn_0.4s_ease-out]" : ""
}`}
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Badge tone={tone ?? "signal"} className="capitalize">
{ACTION_LABEL[a.action_type] ?? a.action_type}
</Badge>
{a.severity && (
<span className="text-xs text-ink-faint">
{a.severity}
</span>
)}
{a.categories?.length ? (
<span className="truncate text-xs text-ink-soft">
{a.categories.slice(0, 3).join(", ")}
</span>
) : null}
</div>
{a.reason && (
<p className="mt-1 truncate text-xs text-ink-soft">
{a.reason}
</p>
)}
<p className="mt-0.5 text-[11px] text-ink-faint">
{a.username ?? a.user_id ?? "unknown"} ·{" "}
{formatRelativeTime(a.created_at)}
</p>
</div>
</li>
);
})}
</ul>
)}
</div>
</GlassPanel>
);
}
@@ -0,0 +1,146 @@
"use client";
import { Donut } from "@/components/charts/donut";
import { GlassPanel } from "@/components/primitives";
import { SectionHeader } from "@/components/shared";
import { formatNumber } from "@/lib/format";
import type { ModerationTrends } from "@/lib/types";
const SEVERITY_COLOR: Record<string, string> = {
critical: "var(--color-vermilion)",
high: "var(--color-vermilion)",
medium: "var(--color-amber)",
low: "var(--color-signal)",
none: "var(--color-ink-faint)",
};
function BarRow({
label,
count,
max,
color = "var(--color-signal)",
}: {
label: string;
count: number;
max: number;
color?: string;
}) {
const pct = max > 0 ? Math.max(2, (count / max) * 100) : 0;
return (
<div className="flex items-center gap-3 text-sm">
<span className="w-32 shrink-0 truncate text-ink-soft">{label}</span>
<div className="h-2 flex-1 overflow-hidden rounded-full bg-white/5">
<div
className="h-full rounded-full"
style={{ width: `${pct}%`, background: color }}
/>
</div>
<span className="mono w-10 shrink-0 text-right text-ink">
{formatNumber(count)}
</span>
</div>
);
}
export function TopicTrends({ trends }: { trends: ModerationTrends }) {
const maxCat = trends.categories.reduce((m, c) => Math.max(m, c.count), 0);
const maxAct = trends.actions.reduce((m, a) => Math.max(m, a.count), 0);
const totalSev = trends.severities.reduce((s, x) => s + x.count, 0);
const severitySegments = trends.severities.map((s) => ({
value: s.count,
color: SEVERITY_COLOR[s.level] ?? "var(--color-ink-faint)",
label: s.level,
}));
return (
<GlassPanel className="lg:col-span-2">
<SectionHeader eyebrow="insight" title="Toxic Topic Trends" />
{trends.categories.length === 0 && trends.severities.length === 0 ? (
<p className="py-6 text-center text-xs text-ink-faint">
No categorized actions in the last 30 days.
</p>
) : (
<div className="space-y-5">
<div>
<p className="mb-2 text-xs uppercase tracking-wide text-ink-faint">
Top flagged categories
</p>
<div className="space-y-2">
{trends.categories.slice(0, 10).map((c) => (
<BarRow
key={c.name}
label={c.name}
count={c.count}
max={maxCat}
/>
))}
{trends.categories.length === 0 && (
<p className="text-xs text-ink-faint">
No categories recorded.
</p>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="mb-2 text-xs uppercase tracking-wide text-ink-faint">
Severity
</p>
{totalSev > 0 ? (
<div className="flex items-center gap-4">
<Donut
segments={severitySegments}
centerLabel={formatNumber(totalSev)}
centerSub="total"
size={88}
/>
<div className="space-y-1 text-xs">
{trends.severities.map((s) => (
<div key={s.level} className="flex items-center gap-2">
<span
className="size-2.5 rounded-full"
style={{
background:
SEVERITY_COLOR[s.level] ??
"var(--color-ink-faint)",
}}
/>
<span className="capitalize text-ink-soft">
{s.level}
</span>
<span className="mono ml-auto text-ink">
{formatNumber(s.count)}
</span>
</div>
))}
</div>
</div>
) : (
<p className="text-xs text-ink-faint">No severity data.</p>
)}
</div>
<div>
<p className="mb-2 text-xs uppercase tracking-wide text-ink-faint">
Action types
</p>
<div className="space-y-2">
{trends.actions.slice(0, 6).map((a) => (
<BarRow
key={a.type}
label={a.type.replace("_", " ")}
count={a.count}
max={maxAct}
color="#8b5cf6"
/>
))}
</div>
</div>
</div>
</div>
)}
</GlassPanel>
);
}
+3
View File
@@ -30,10 +30,13 @@ export {
useReview,
useSemanticSearch,
useTextChannels,
useMessageActivity,
} from "./use-messages";
export {
useLiveModeration,
useModerationActions,
useModerationStats,
useModerationTrends,
} from "./use-moderation";
export {
useDeleteRecording,
@@ -5,6 +5,7 @@ import { messagesApi, voiceApi } from "@/lib/api";
import type {
AttachmentRecord,
Channel,
MessageActivityBucket,
MessageRecord,
SemanticSearchResult,
} from "@/lib/types";
@@ -374,3 +375,9 @@ export function useMessagesStream(
return { streaming, error };
}
export function useMessageActivity(days = 30) {
return useSWR<MessageActivityBucket[]>(["activity", days], () =>
messagesApi.getActivity(days),
);
}
+50 -1
View File
@@ -1,6 +1,12 @@
import { useCallback, useEffect, useRef, useState } from "react";
import useSWR from "swr";
import { moderationApi } from "@/lib/api";
import type { ModerationAction, ModerationStats } from "@/lib/types";
import type {
ModerationAction,
ModerationStats,
ModerationTrends,
} from "@/lib/types";
import { useWebSocket } from "@/lib/ws/context";
export function useModerationStats(initialData?: ModerationStats) {
return useSWR<ModerationStats>(
@@ -33,3 +39,46 @@ export function useModerationActions(
},
);
}
/**
* Live moderation feed: merges the initial SWR list with actions pushed over
* the WebSocket in real time. Returns a capped, newest-first buffer.
* Read-only / public — no write actions.
*/
export function useLiveModeration(
initialData: ModerationAction[] = [],
cap = 50,
) {
const { on: subscribe } = useWebSocket();
const [live, setLive] = useState<ModerationAction[]>(initialData);
const seen = useRef<Set<string>>(new Set(initialData.map((a) => a.id)));
useEffect(() => {
setLive(initialData);
seen.current = new Set(initialData.map((a) => a.id));
}, [initialData]);
const handle = useCallback(
(action: ModerationAction) => {
if (seen.current.has(action.id)) return;
seen.current.add(action.id);
setLive((prev) => [action, ...prev].slice(0, cap));
},
[cap],
);
useEffect(() => {
const unsub = subscribe("moderation_action", handle);
return unsub;
}, [subscribe, handle]);
return live;
}
export function useModerationTrends(days = 30, initialData?: ModerationTrends) {
return useSWR<ModerationTrends>(
["moderation-trends", days],
() => moderationApi.getTrends(days),
{ fallbackData: initialData },
);
}
@@ -1,6 +1,7 @@
import { orpc } from "@/lib/orpc/client";
import type {
AttachmentRecord,
MessageActivityBucket,
MessageRecord,
SemanticSearchResult,
} from "@/lib/types";
@@ -73,4 +74,10 @@ export const messagesApi = {
results: SemanticSearchResult[];
nextCursor: null;
}>,
// Public, read-only activity heatmap data (per-hour volume by channel).
getActivity: (days = 30) =>
orpc.messages.activity({ days }) as unknown as Promise<
MessageActivityBucket[]
>,
};
+8 -1
View File
@@ -1,5 +1,9 @@
import { orpc } from "@/lib/orpc/client";
import type { ModerationStats, PaginatedModerationActions } from "@/lib/types";
import type {
ModerationStats,
ModerationTrends,
PaginatedModerationActions,
} from "@/lib/types";
export const moderationApi = {
getStats: () =>
@@ -17,4 +21,7 @@ export const moderationApi = {
actionType,
cursor,
}) as unknown as Promise<PaginatedModerationActions>,
getTrends: (days = 30) =>
orpc.moderation.trends({ days }) as unknown as Promise<ModerationTrends>,
};
+32
View File
@@ -0,0 +1,32 @@
/** Client-side CSV export. Pure browser — no backend, no write scope. */
export function toCsv(rows: Record<string, unknown>[]): string {
if (rows.length === 0) return "";
const headers = Array.from(
rows.reduce<Set<string>>((s, r) => {
Object.keys(r).forEach((k) => s.add(k));
return s;
}, new Set()),
);
const esc = (v: unknown): string => {
if (v == null) return "";
const s = typeof v === "object" ? JSON.stringify(v) : String(v);
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
};
const head = headers.map(esc).join(",");
const body = rows
.map((r) => headers.map((h) => esc(r[h])).join(","))
.join("\n");
return `${head}\n${body}`;
}
export function downloadCsv(filename: string, rows: Record<string, unknown>[]) {
const csv = toCsv(rows);
if (!csv) return;
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
@@ -173,6 +173,12 @@ export interface SemanticSearchResult {
created_at: number;
}
export interface MessageActivityBucket {
channelId: string;
hour: number;
count: number;
}
export interface SemanticSearchResponse {
results: SemanticSearchResult[];
nextCursor: null;
@@ -44,3 +44,9 @@ export interface PaginatedModerationActions {
data: ModerationAction[];
nextCursor: string | null;
}
export interface ModerationTrends {
categories: { name: string; count: number }[];
severities: { level: string; count: number }[];
actions: { type: string; count: number }[];
}
+3
View File
@@ -2,6 +2,7 @@ import type {
ActiveSpeaker,
MediaState,
MessageRecord,
ModerationAction,
VoiceRecording,
} from "@/lib/types";
@@ -68,6 +69,8 @@ export interface WsEventMap {
presence_updated: unknown;
guild_member_added: unknown;
guild_member_removed: unknown;
/** Live moderation action broadcast (gateway → Redis → backend → WS). */
moderation_action: ModerationAction;
media_state: MediaState;
user_state: unknown;
ui_state: unknown;