import { AlertCircle, CheckCircle2, ChevronDown, ChevronUp, Image as ImageIcon, Pencil, RotateCw, Smile, Trash2, } from "lucide-react"; import { Fragment, useMemo, useState } from "react"; import { parseMetadata } from "../../../entities/message/types"; import type { MessageRecord } from "../../../shared/api/client"; import { Badge, Button, Skeleton } from "../../../shared/ui"; const CUSTOM_EMOJI_REGEX = /<(a)?:([a-zA-Z0-9_]+):(\d+)>/g; /** * Renders message content with Discord custom emojis displayed as images * instead of raw text like `<:name:id>`. */ function renderContentWithCustomEmojis(content: string): React.ReactNode { const parts: React.ReactNode[] = []; const regex = new RegExp(CUSTOM_EMOJI_REGEX.source, "g"); let lastIndex = 0; let match: RegExpExecArray | null; while ((match = regex.exec(content)) !== null) { // Text before the emoji if (match.index > lastIndex) { parts.push(content.slice(lastIndex, match.index)); } const [, animated, name, id] = match; const ext = animated ? "gif" : "png"; const url = `https://cdn.discordapp.com/emojis/${id}.${ext}?size=128`; parts.push( {name}, ); lastIndex = regex.lastIndex; } // Remaining text after last emoji if (lastIndex < content.length) { parts.push(content.slice(lastIndex)); } // If no emojis were found, just return the raw content if (parts.length === 0) { return content; } return {parts}; } interface MessageCardProps { message: MessageRecord; onReanalyze: (id: string) => Promise; compact?: boolean; } function parseStringList(value?: string | null): string[] { if (!value) return []; try { const parsed = JSON.parse(value) as unknown; return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === "string") : []; } catch { return value .split(",") .map((item) => item.trim()) .filter(Boolean); } } function aiVariant(status: string) { if (status === "clean") return "success"; if (status === "flagged" || status === "error") return "destructive"; return "secondary"; } function severityColor(severity: string) { switch (severity) { case "critical": return "bg-red-100 text-red-700 border-red-200"; case "high": return "bg-orange-100 text-orange-700 border-orange-200"; case "medium": return "bg-yellow-100 text-yellow-700 border-yellow-200"; case "low": return "bg-blue-100 text-blue-700 border-blue-200"; default: return "bg-muted text-muted-foreground border-border"; } } function formatTimeAgo(ts: number): string { const seconds = Math.floor((Date.now() - ts) / 1000); if (seconds < 60) return `${seconds}s ago`; if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; return new Date(ts).toLocaleDateString(); } export function MessageCard({ message, onReanalyze, compact, }: MessageCardProps) { const metadata = useMemo( () => parseMetadata(message.metadata), [message.metadata], ); const displayContent = message.edited_content ?? message.content; const aiStatus = message.ai_status ?? "pending"; const categories = useMemo(() => { const list = parseStringList( message.ai_categories ?? message.ai_moderation_flags, ); return list.filter((c) => c !== "analysis_incomplete"); }, [message.ai_categories, message.ai_moderation_flags]); const confidence = message.ai_confidence ?? message.ai_moderation_score ?? null; const [isReanalyzing, setIsReanalyzing] = useState(false); const [showAnalysis, setShowAnalysis] = useState(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 ?? []; const imageAttachments = attachments.filter( (a) => a.contentType?.startsWith("image/") || /\.(png|jpe?g|gif|webp)$/i.test(a.name), ); const hasImages = imageAttachments.length > 0; const handleReanalyze = async () => { setIsReanalyzing(true); try { await onReanalyze(message.id); } finally { setIsReanalyzing(false); } }; return (
{!compact && ( )}
{!compact && (
{message.username || message.user_id} {formatTimeAgo(message.created_at)} {message.edited_at && ( edited )} {message.deleted_at && ( deleted )}
{aiStatus === "clean" && ( )} {aiStatus === "flagged" && ( )} {aiStatus === "error" && ( )} {aiStatus} {message.ai_severity && message.ai_severity !== "none" && ( {message.ai_severity} )} {confidence != null && ( {Math.round(confidence * 100)}% )}
)} {displayContent ? (

{renderContentWithCustomEmojis(displayContent)}

) : null} {stickers.length > 0 && (
{stickers.map((sticker) => (
{sticker.url ? ( {sticker.name ) : (
)} {sticker.name}
))}
)} {hasImages && (
{imageAttachments.slice(0, 4).map((img) => ( {img.name} ))} {imageAttachments.length > 4 && (
+{imageAttachments.length - 4}{" "}
)}
)} {categories.length > 0 && (
{categories.map((category) => ( {category} ))}
)} {message.ai_analysis ? (
{showAnalysis && (
{message.ai_analysis}
)}
) : null} {message.ai_error ? (
AI error: {message.ai_error}
) : null}
{aiStatus === "error" && ( Click to retry analysis )}
); } export function MessageCardSkeleton() { return (
); }