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:
MythEclipse
2026-06-02 12:17:51 +07:00
co-authored by Claude Opus 4.8
parent 74400376a0
commit c5c667b147
11 changed files with 470 additions and 91 deletions
@@ -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 && (