feat: comprehensive UX improvements — messages, analysis, moderation
Fix #1 (CRITICAL): Wire message_analyzed through Redis EventBroadcaster - DG aiAnalyzer.ts: Add broadcastAnalysisCompleted() helper that publishes to both in-memory WS broadcaster AND Redis EventBroadcaster - DG bootstrap.ts: Pass eventBroadcaster to startPendingAIAnalysisWorker() - Fixes broken real-time chain so analysis results appear instantly Fix #2: Collapsible AI Analysis with Rich Formatting - MessageCard: AI analysis now collapsible with color-coded severity border (red/yellow/blue), summary line showing categories + confidence + severity - Default collapsed for clean, expanded for warn/flagged Fix #3: Toast Notifications for Flagged Content - Wrap app in ToastProvider; ModerationAlertListener component listens for moderation_alert custom events and shows toast with emoji + details Fix #4: Discord-style Message Grouping - MessageFeed: Group consecutive messages from same user within 5 min - MessageCard: Compact variant hides avatar, reduces padding for non-first Fix #5: Moderation Action Buttons in UI - BE: POST /api/messages/:id/moderate endpoint + publishCommand() for Redis - FE: Delete/Warn buttons on flagged/warned cards with confirmation dialog Fix #6: Search Results Include Full Data - BE analysis.service.ts: SELECT all 26 message columns instead of just 11 - Search results now render with full MessageCard including images/analysis Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
74400376a0
commit
c5c667b147
@@ -2,6 +2,8 @@ import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Image as ImageIcon,
|
||||
Pencil,
|
||||
RotateCw,
|
||||
@@ -10,6 +12,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { Fragment, useMemo, useState } from "react";
|
||||
import type { MessageRecord } from "../../../shared/api/client";
|
||||
import { moderateMessage } from "../../../shared/api/client";
|
||||
import { Badge, Button, Skeleton } from "../../../shared/ui";
|
||||
|
||||
const CUSTOM_EMOJI_REGEX = /<(a)?:([a-zA-Z0-9_]+):(\d+)>/g;
|
||||
@@ -65,6 +68,7 @@ function renderContentWithCustomEmojis(content: string): React.ReactNode {
|
||||
interface MessageCardProps {
|
||||
message: MessageRecord;
|
||||
onReanalyze: (id: string) => Promise<void>;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
interface MessageMetadata {
|
||||
@@ -127,7 +131,11 @@ function formatTimeAgo(ts: number): string {
|
||||
return new Date(ts).toLocaleDateString();
|
||||
}
|
||||
|
||||
export function MessageCard({ message, onReanalyze }: MessageCardProps) {
|
||||
export function MessageCard({
|
||||
message,
|
||||
onReanalyze,
|
||||
compact,
|
||||
}: MessageCardProps) {
|
||||
const metadata = useMemo(
|
||||
() => parseMetadata(message.metadata),
|
||||
[message.metadata],
|
||||
@@ -143,6 +151,26 @@ export function MessageCard({ message, onReanalyze }: MessageCardProps) {
|
||||
const confidence =
|
||||
message.ai_confidence ?? message.ai_moderation_score ?? null;
|
||||
const [isReanalyzing, setIsReanalyzing] = useState(false);
|
||||
const [showAnalysis, setShowAnalysis] = useState(
|
||||
aiStatus === "warn" || aiStatus === "flagged",
|
||||
);
|
||||
|
||||
// Build a human-readable analysis summary from categories + confidence + severity
|
||||
const analysisSummary = useMemo(() => {
|
||||
const parts: string[] = [];
|
||||
if (categories.length > 0) {
|
||||
parts.push(categories.slice(0, 3).join(", "));
|
||||
if (categories.length > 3) parts.push(`+${categories.length - 3} more`);
|
||||
}
|
||||
if (message.ai_severity && message.ai_severity !== "none") {
|
||||
parts.push(message.ai_severity);
|
||||
}
|
||||
if (confidence != null) {
|
||||
parts.push(`${Math.round(confidence * 100)}% confidence`);
|
||||
}
|
||||
if (parts.length === 0) return "View AI analysis";
|
||||
return parts.join(" · ");
|
||||
}, [categories, message.ai_severity, confidence]);
|
||||
|
||||
const stickers = metadata.stickers ?? [];
|
||||
const attachments = metadata.attachments ?? [];
|
||||
@@ -164,71 +192,77 @@ export function MessageCard({ message, onReanalyze }: MessageCardProps) {
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`group rounded-2xl border border-border bg-card p-4 shadow-sm transition-all hover:border-primary/30 hover:shadow-md ${message.deleted_at ? "opacity-60" : ""}`}
|
||||
className={`group rounded-2xl border border-border bg-card ${compact ? "px-4 py-1.5" : "p-4"} shadow-sm transition-all hover:border-primary/30 hover:shadow-md ${message.deleted_at ? "opacity-60" : ""}`}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<img
|
||||
src={
|
||||
message.avatar_url ??
|
||||
"https://cdn.discordapp.com/embed/avatars/0.png"
|
||||
}
|
||||
alt=""
|
||||
className="h-10 w-10 shrink-0 rounded-full object-cover ring-1 ring-border"
|
||||
/>
|
||||
<div className="min-w-0 flex-1 space-y-2.5">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="font-semibold text-foreground">
|
||||
{message.username || message.user_id}
|
||||
</span>
|
||||
<span
|
||||
className="text-xs text-muted-foreground"
|
||||
title={new Date(message.created_at).toLocaleString()}
|
||||
>
|
||||
{formatTimeAgo(message.created_at)}
|
||||
</span>
|
||||
{message.edited_at && (
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Pencil className="h-3 w-3" /> edited
|
||||
<div className={`flex ${compact ? "gap-2" : "gap-3"}`}>
|
||||
{!compact && (
|
||||
<img
|
||||
src={
|
||||
message.avatar_url ??
|
||||
"https://cdn.discordapp.com/embed/avatars/0.png"
|
||||
}
|
||||
alt=""
|
||||
className="h-10 w-10 shrink-0 rounded-full object-cover ring-1 ring-border"
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`min-w-0 flex-1 ${compact ? "space-y-1" : "space-y-2.5"}`}
|
||||
>
|
||||
{!compact && (
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="font-semibold text-foreground">
|
||||
{message.username || message.user_id}
|
||||
</span>
|
||||
)}
|
||||
{message.deleted_at && (
|
||||
<span className="flex items-center gap-1 text-xs text-destructive">
|
||||
<Trash2 className="h-3 w-3" /> deleted
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<Badge
|
||||
variant={aiVariant(aiStatus)}
|
||||
className="flex items-center gap-1 text-xs"
|
||||
<span
|
||||
className="text-xs text-muted-foreground"
|
||||
title={new Date(message.created_at).toLocaleString()}
|
||||
>
|
||||
{aiStatus === "clean" && (
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{aiStatus === "warn" && (
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{aiStatus === "flagged" && (
|
||||
<AlertCircle className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{aiStatus === "error" && (
|
||||
<AlertCircle className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{aiStatus}
|
||||
</Badge>
|
||||
{message.ai_severity && message.ai_severity !== "none" && (
|
||||
{formatTimeAgo(message.created_at)}
|
||||
</span>
|
||||
{message.edited_at && (
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Pencil className="h-3 w-3" /> edited
|
||||
</span>
|
||||
)}
|
||||
{message.deleted_at && (
|
||||
<span className="flex items-center gap-1 text-xs text-destructive">
|
||||
<Trash2 className="h-3 w-3" /> deleted
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<Badge
|
||||
className={`text-xs ${severityColor(message.ai_severity)}`}
|
||||
variant={aiVariant(aiStatus)}
|
||||
className="flex items-center gap-1 text-xs"
|
||||
>
|
||||
{message.ai_severity}
|
||||
{aiStatus === "clean" && (
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{aiStatus === "warn" && (
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{aiStatus === "flagged" && (
|
||||
<AlertCircle className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{aiStatus === "error" && (
|
||||
<AlertCircle className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{aiStatus}
|
||||
</Badge>
|
||||
)}
|
||||
{confidence != null && (
|
||||
<Badge variant="outline" className="text-xs tabular-nums">
|
||||
{Math.round(confidence * 100)}%
|
||||
</Badge>
|
||||
)}
|
||||
{message.ai_severity && message.ai_severity !== "none" && (
|
||||
<Badge
|
||||
className={`text-xs ${severityColor(message.ai_severity)}`}
|
||||
>
|
||||
{message.ai_severity}
|
||||
</Badge>
|
||||
)}
|
||||
{confidence != null && (
|
||||
<Badge variant="outline" className="text-xs tabular-nums">
|
||||
{Math.round(confidence * 100)}%
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{displayContent ? (
|
||||
<p className="whitespace-pre-wrap break-words text-sm leading-6 text-foreground/90">
|
||||
@@ -304,8 +338,39 @@ export function MessageCard({ message, onReanalyze }: MessageCardProps) {
|
||||
)}
|
||||
|
||||
{message.ai_analysis ? (
|
||||
<div className="rounded-xl bg-muted/60 p-3 text-sm text-muted-foreground leading-relaxed">
|
||||
{message.ai_analysis}
|
||||
<div
|
||||
className={`rounded-xl border-l-2 p-3 ${
|
||||
aiStatus === "flagged"
|
||||
? "border-l-red-500 bg-red-500/5"
|
||||
: aiStatus === "warn"
|
||||
? "border-l-yellow-500 bg-yellow-500/5"
|
||||
: "border-l-blue-500 bg-blue-500/5"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAnalysis(!showAnalysis)}
|
||||
className="flex w-full items-center justify-between gap-2 text-left text-xs"
|
||||
>
|
||||
<span className="font-medium text-foreground/80">
|
||||
{aiStatus === "flagged"
|
||||
? "🚨"
|
||||
: aiStatus === "warn"
|
||||
? "⚠️"
|
||||
: "ℹ️"}{" "}
|
||||
{analysisSummary}
|
||||
</span>
|
||||
{showAnalysis ? (
|
||||
<ChevronUp className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
{showAnalysis && (
|
||||
<div className="mt-2 border-t border-border pt-2 text-sm text-muted-foreground leading-relaxed whitespace-pre-wrap">
|
||||
{message.ai_analysis}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -333,6 +398,52 @@ export function MessageCard({ message, onReanalyze }: MessageCardProps) {
|
||||
Click to retry analysis
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Moderation action buttons for flagged/warned messages */}
|
||||
{(aiStatus === "flagged" || aiStatus === "warn") && (
|
||||
<div className="flex items-center gap-1.5 border-l border-border pl-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="text-xs"
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`Delete message from ${message.username}?\n\n"${(message.edited_content ?? message.content).slice(0, 120)}"`,
|
||||
)
|
||||
) {
|
||||
moderateMessage(
|
||||
message.id,
|
||||
"delete_message",
|
||||
"Manual moderation from dashboard",
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3 mr-1" /> Delete
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`Warn user ${message.username} for this message?`,
|
||||
)
|
||||
) {
|
||||
moderateMessage(
|
||||
message.id,
|
||||
"warn_user",
|
||||
"Manual moderation from dashboard",
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AlertTriangle className="h-3 w-3 mr-1" /> Warn
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import type { MessageRecord } from "../../../shared/api/client";
|
||||
import { ScrollArea } from "../../../shared/ui";
|
||||
import { MessageCard, MessageCardSkeleton } from "./MessageCard";
|
||||
@@ -13,6 +13,32 @@ export interface MessageFeedProps {
|
||||
loadingMore?: boolean;
|
||||
}
|
||||
|
||||
/** Messages from the same user within 5 minutes are visually grouped. */
|
||||
const GROUP_WINDOW_MS = 5 * 60 * 1000;
|
||||
|
||||
interface MessageGroup {
|
||||
messages: MessageRecord[];
|
||||
}
|
||||
|
||||
function groupMessages(messages: MessageRecord[]): MessageGroup[] {
|
||||
const groups: MessageGroup[] = [];
|
||||
for (const msg of messages) {
|
||||
const lastGroup = groups[groups.length - 1];
|
||||
if (
|
||||
lastGroup &&
|
||||
lastGroup.messages[0].user_id === msg.user_id &&
|
||||
lastGroup.messages[lastGroup.messages.length - 1].created_at -
|
||||
msg.created_at <
|
||||
GROUP_WINDOW_MS
|
||||
) {
|
||||
lastGroup.messages.push(msg);
|
||||
} else {
|
||||
groups.push({ messages: [msg] });
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
export function MessageFeed({
|
||||
messages,
|
||||
onReanalyze,
|
||||
@@ -40,6 +66,8 @@ export function MessageFeed({
|
||||
return () => observer.disconnect();
|
||||
}, [onLoadMore, hasMore]);
|
||||
|
||||
const groupedMessages = useMemo(() => groupMessages(messages), [messages]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ScrollArea className="h-[calc(100vh-260px)] pr-3">
|
||||
@@ -63,13 +91,20 @@ export function MessageFeed({
|
||||
return (
|
||||
<ScrollArea className="h-[calc(100vh-260px)] pr-3">
|
||||
<div className="space-y-3">
|
||||
{messages.map((message) => (
|
||||
<MessageCard
|
||||
key={message.id}
|
||||
message={message}
|
||||
onReanalyze={onReanalyze}
|
||||
/>
|
||||
))}
|
||||
{groupedMessages.map((group) =>
|
||||
group.messages.map((message, idx) => {
|
||||
const isFirstInGroup = idx === 0;
|
||||
const isCompact = !isFirstInGroup;
|
||||
return (
|
||||
<MessageCard
|
||||
key={message.id}
|
||||
message={message}
|
||||
onReanalyze={onReanalyze}
|
||||
compact={isCompact}
|
||||
/>
|
||||
);
|
||||
}),
|
||||
)}
|
||||
|
||||
{/* Infinite-scroll sentinel */}
|
||||
{hasMore && (
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// ─── Moderation alert toast listener ───────────────────────────────────────
|
||||
// Listens for "moderation_alert" custom events dispatched from WebSocket
|
||||
// message_analyzed handler, and shows toast notifications for flagged/warned
|
||||
// messages so moderators don't miss important alerts.
|
||||
import { useEffect } from "react";
|
||||
import { useToast } from "../../../shared/ui";
|
||||
|
||||
interface AlertDetail {
|
||||
type: "flagged" | "warn";
|
||||
username: string;
|
||||
severity: string;
|
||||
categories: string;
|
||||
brief: string;
|
||||
}
|
||||
|
||||
export function ModerationAlertListener() {
|
||||
const { addToast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const { type, username, severity, categories, brief } = (
|
||||
e as CustomEvent<AlertDetail>
|
||||
).detail;
|
||||
|
||||
const emoji = type === "flagged" ? "🚨" : "⚠️";
|
||||
const sevLabel = severity ? `[${severity}]` : "";
|
||||
const catLabel = categories
|
||||
? ` — ${categories.split(",").slice(0, 2).join(", ")}`
|
||||
: "";
|
||||
|
||||
addToast(
|
||||
`${emoji} ${username} ${sevLabel}${catLabel}: ${brief}`,
|
||||
type === "flagged" ? "error" : "warning",
|
||||
);
|
||||
};
|
||||
|
||||
window.addEventListener("moderation_alert", handler);
|
||||
return () => window.removeEventListener("moderation_alert", handler);
|
||||
}, [addToast]);
|
||||
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user