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:
@@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user