Files
GMW/services/frontend/src/features/messages/components/MessageCard.tsx
T

402 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(
<img
key={`${id}-${match.index}`}
src={url}
alt={name}
className="inline-block h-[22px] w-[22px] align-middle object-contain"
loading="lazy"
draggable={false}
title={`:${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 <Fragment>{parts}</Fragment>;
}
interface MessageCardProps {
message: MessageRecord;
onReanalyze: (id: string) => Promise<void>;
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 (
<article
className={`group rounded-2xl border bg-white shadow-sm transition-all hover:border-primary/30 hover:shadow-md ${
compact ? "px-4 py-1.5" : "p-4"
} ${
message.deleted_at ? "border-red-200 opacity-60" : "border-primary/20"
}`}
>
<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-2 ring-primary/30"
/>
)}
<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>
<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
</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"
>
{aiStatus === "clean" && (
<CheckCircle2 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" && (
<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>
)}
{displayContent ? (
<p className="whitespace-pre-wrap break-words text-sm leading-6 text-foreground/90">
{renderContentWithCustomEmojis(displayContent)}
</p>
) : null}
{stickers.length > 0 && (
<div className="flex flex-wrap gap-3">
{stickers.map((sticker) => (
<div
key={sticker.name || sticker.url}
className="flex items-center gap-2"
>
{sticker.url ? (
<img
src={sticker.url}
alt={sticker.name || "sticker"}
className="h-16 w-16 rounded-xl border border-border object-contain bg-muted/50"
loading="lazy"
/>
) : (
<div className="flex h-16 w-16 items-center justify-center rounded-xl border border-border bg-muted/50">
<Smile className="h-8 w-8 text-muted-foreground" />
</div>
)}
<span
className="text-xs text-muted-foreground max-w-[120px] truncate"
title={sticker.name}
>
{sticker.name}
</span>
</div>
))}
</div>
)}
{hasImages && (
<div className="flex gap-2 overflow-x-auto">
{imageAttachments.slice(0, 4).map((img) => (
<a
key={img.url}
href={img.url}
target="_blank"
rel="noreferrer"
className="shrink-0 overflow-hidden rounded-xl border border-border"
>
<img
src={img.url}
alt={img.name}
className="h-20 w-20 object-cover transition-transform hover:scale-105"
loading="lazy"
/>
</a>
))}
{imageAttachments.length > 4 && (
<div className="flex h-20 w-20 items-center justify-center rounded-xl border border-border bg-muted text-xs text-muted-foreground">
+{imageAttachments.length - 4}{" "}
<ImageIcon className="ml-1 h-3 w-3" />
</div>
)}
</div>
)}
{categories.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{categories.map((category) => (
<Badge key={category} variant="secondary" className="text-xs">
{category}
</Badge>
))}
</div>
)}
{message.ai_analysis ? (
<div
className={`rounded-xl border-l-4 p-3 ${
aiStatus === "flagged"
? "border-l-pink-400 bg-pink-50/50"
: "border-l-emerald-400 bg-emerald-50/50"
}`}
>
<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" ? "🚨 " : "️ "}
{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}
{message.ai_error ? (
<div className="rounded-xl bg-pink-50/50 p-3 text-sm text-pink-700">
AI error: {message.ai_error}
</div>
) : null}
<div className="flex items-center gap-2 pt-1">
<Button
size="sm"
variant={aiStatus === "error" ? "destructive" : "outline"}
onClick={handleReanalyze}
disabled={aiStatus === "pending" || isReanalyzing}
className="text-xs"
>
<RotateCw
className={`h-3.5 w-3.5 ${isReanalyzing ? "animate-spin" : ""}`}
/>
{isReanalyzing ? "Reanalyzing..." : "Re-analyze"}
</Button>
{aiStatus === "error" && (
<span className="text-xs text-pink-600/80">
Click to retry analysis
</span>
)}
</div>
</div>
</div>
</article>
);
}
export function MessageCardSkeleton() {
return (
<article className="rounded-2xl border border-primary/20 bg-white p-4 shadow-sm">
<div className="flex gap-3">
<Skeleton className="h-10 w-10 shrink-0 rounded-full" />
<div className="min-w-0 flex-1 space-y-3">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<div className="flex gap-2">
<Skeleton className="h-6 w-16 rounded-full" />
<Skeleton className="h-6 w-20 rounded-full" />
</div>
</div>
</div>
</article>
);
}