2026-07-02 03:54:44 +07:00
|
|
|
|
import {
|
|
|
|
|
|
AlertCircle,
|
|
|
|
|
|
CheckCircle2,
|
|
|
|
|
|
Forward,
|
|
|
|
|
|
Hash,
|
|
|
|
|
|
Image as ImageIcon,
|
|
|
|
|
|
MessageCircle,
|
|
|
|
|
|
Pencil,
|
|
|
|
|
|
Reply,
|
|
|
|
|
|
RotateCw,
|
|
|
|
|
|
Smile,
|
|
|
|
|
|
Trash2,
|
|
|
|
|
|
Video,
|
|
|
|
|
|
} from "lucide-react";
|
|
|
|
|
|
import { Fragment, useEffect, useMemo, useState } from "react";
|
|
|
|
|
|
import type { MessageRecord } from "../../../entities/message/types.js";
|
|
|
|
|
|
import { parseMetadata } from "../../../shared/lib/utils.js";
|
|
|
|
|
|
import { getMessageById } from "../../../shared/api/client.js";
|
|
|
|
|
|
import { Badge, Button, Skeleton, StatusBadge } from "../../../shared/ui";
|
|
|
|
|
|
|
|
|
|
|
|
const CUSTOM_EMOJI_REGEX = /<(a)?:([a-zA-Z0-9_]+):(\d+)>/g;
|
|
|
|
|
|
|
|
|
|
|
|
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) {
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (lastIndex < content.length) {
|
|
|
|
|
|
parts.push(content.slice(lastIndex));
|
|
|
|
|
|
}
|
|
|
|
|
|
if (parts.length === 0) return content;
|
|
|
|
|
|
return <Fragment>{parts}</Fragment>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Props ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
interface MessageCardProps {
|
|
|
|
|
|
messages: MessageRecord[];
|
|
|
|
|
|
onReanalyze: (id: string) => Promise<void>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
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 severityColor(severity: string) {
|
|
|
|
|
|
switch (severity) {
|
|
|
|
|
|
case "critical":
|
2026-07-02 05:58:36 +07:00
|
|
|
|
return "bg-destructive-soft text-destructive border-destructive/20";
|
2026-07-02 03:54:44 +07:00
|
|
|
|
case "high":
|
2026-07-02 05:58:36 +07:00
|
|
|
|
return "bg-warning-soft text-warning border-warning/20";
|
2026-07-02 03:54:44 +07:00
|
|
|
|
case "medium":
|
2026-07-02 05:58:36 +07:00
|
|
|
|
return "bg-warning-soft text-warning border-warning/20";
|
2026-07-02 03:54:44 +07:00
|
|
|
|
case "low":
|
2026-07-02 05:58:36 +07:00
|
|
|
|
return "bg-info-soft text-info border-info/20";
|
2026-07-02 03:54:44 +07:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatTime(ts: number): string {
|
|
|
|
|
|
return new Date(ts).toLocaleTimeString([], {
|
|
|
|
|
|
hour: "2-digit",
|
|
|
|
|
|
minute: "2-digit",
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Single message row inside a group ───────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
function MessageRow({
|
|
|
|
|
|
message,
|
|
|
|
|
|
onReanalyze,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
message: MessageRecord;
|
|
|
|
|
|
onReanalyze: (id: string) => Promise<void>;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
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);
|
|
|
|
|
|
|
|
|
|
|
|
// ── Fetch referenced message content for replies if not in metadata ──
|
|
|
|
|
|
const referenceMeta = metadata.reference;
|
|
|
|
|
|
const [fetchedRefContent, setFetchedRefContent] = useState<{
|
|
|
|
|
|
username: string;
|
|
|
|
|
|
content: string;
|
|
|
|
|
|
} | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (
|
|
|
|
|
|
message.is_reply &&
|
|
|
|
|
|
referenceMeta?.messageId &&
|
|
|
|
|
|
!referenceMeta?.content &&
|
|
|
|
|
|
!message.deleted_at
|
|
|
|
|
|
) {
|
|
|
|
|
|
getMessageById(referenceMeta.messageId)
|
|
|
|
|
|
.then((refMsg) => {
|
|
|
|
|
|
if (refMsg) {
|
|
|
|
|
|
setFetchedRefContent({
|
|
|
|
|
|
username: refMsg.username,
|
|
|
|
|
|
content: refMsg.content,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(() => {
|
|
|
|
|
|
// Referenced message might not exist in our DB
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [message.is_reply, referenceMeta?.messageId, referenceMeta?.content, message.deleted_at]);
|
|
|
|
|
|
|
|
|
|
|
|
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 videoAttachments = attachments.filter(
|
|
|
|
|
|
(a) =>
|
|
|
|
|
|
a.contentType?.startsWith("video/") ||
|
|
|
|
|
|
/\.(mp4|webm|mov|mkv|avi)$/i.test(a.name),
|
|
|
|
|
|
);
|
|
|
|
|
|
const hasImages = imageAttachments.length > 0;
|
|
|
|
|
|
const hasVideos = videoAttachments.length > 0;
|
|
|
|
|
|
|
|
|
|
|
|
/** Hide the fallback text ("[Attachment: ...]", "[Sticker: ...]", "[Embed]") when the actual media IS already shown visually. */
|
|
|
|
|
|
const isFallbackText =
|
|
|
|
|
|
/^\[(Attachment|Sticker):/i.test(displayContent) ||
|
|
|
|
|
|
/^\[Embed\]/i.test(displayContent);
|
|
|
|
|
|
const shouldShowContent = displayContent && !isFallbackText;
|
|
|
|
|
|
|
|
|
|
|
|
const handleReanalyze = async () => {
|
|
|
|
|
|
setIsReanalyzing(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await onReanalyze(message.id);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setIsReanalyzing(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// ── Reference context (reply / forward / crosspost) ─────────────────
|
|
|
|
|
|
const renderReferenceIndicator = () => {
|
|
|
|
|
|
// Use fetched content if metadata doesn't have it
|
|
|
|
|
|
const effectiveRepliedUsername =
|
|
|
|
|
|
referenceMeta?.repliedUsername ?? fetchedRefContent?.username ?? null;
|
|
|
|
|
|
const effectiveRepliedContent =
|
|
|
|
|
|
referenceMeta?.content ?? fetchedRefContent?.content ?? null;
|
|
|
|
|
|
|
|
|
|
|
|
if (message.is_reply) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex items-start gap-1.5 mb-2 text-[12px] text-muted-foreground/70 border-l-2 border-muted-foreground/20 pl-2.5 py-1 hover:border-primary/40 transition-colors">
|
|
|
|
|
|
<Reply className="h-3 w-3 mt-0.5 shrink-0" />
|
|
|
|
|
|
<span className="min-w-0">
|
|
|
|
|
|
<span className="font-medium text-foreground/60">
|
|
|
|
|
|
Replying to{" "}
|
|
|
|
|
|
{effectiveRepliedUsername
|
|
|
|
|
|
? `@${effectiveRepliedUsername}`
|
|
|
|
|
|
: "a message"}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{effectiveRepliedContent && (
|
|
|
|
|
|
<span className="block truncate max-w-[400px] text-ellipsis text-[11px] text-muted-foreground/50 mt-0.5">
|
|
|
|
|
|
{effectiveRepliedContent}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (message.is_forward) {
|
|
|
|
|
|
return (
|
2026-07-02 05:58:36 +07:00
|
|
|
|
<div className="flex items-center gap-1.5 mb-2 text-[12px] text-muted-foreground/70 border-l-2 border-warning/40 pl-2.5 py-1">
|
|
|
|
|
|
<Forward className="h-3 w-3 shrink-0 text-warning" />
|
|
|
|
|
|
<span className="font-medium text-warning/70">Forwarded</span>
|
2026-07-02 03:54:44 +07:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (message.is_crosspost) {
|
|
|
|
|
|
return (
|
2026-07-02 05:58:36 +07:00
|
|
|
|
<div className="flex items-center gap-1.5 mb-2 text-[12px] text-muted-foreground/70 border-l-2 border-info/40 pl-2.5 py-1">
|
|
|
|
|
|
<MessageCircle className="h-3 w-3 shrink-0 text-info" />
|
|
|
|
|
|
<span className="font-medium text-info/70">Crossposted</span>
|
2026-07-02 03:54:44 +07:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const referenceIndicator = renderReferenceIndicator();
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
{/* Row header: time + edit/delete indicators + AI badges */}
|
|
|
|
|
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
|
|
|
|
|
<span
|
|
|
|
|
|
className="text-[11px] text-muted-foreground/70"
|
|
|
|
|
|
title={new Date(message.created_at).toLocaleString()}
|
|
|
|
|
|
>
|
|
|
|
|
|
{formatTime(message.created_at)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{message.edited_at && (
|
|
|
|
|
|
<span className="flex items-center gap-0.5 text-[11px] text-muted-foreground/70">
|
|
|
|
|
|
<Pencil className="h-2.5 w-2.5" /> edited
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{message.deleted_at && (
|
|
|
|
|
|
<span className="flex items-center gap-0.5 text-[11px] text-destructive/70">
|
|
|
|
|
|
<Trash2 className="h-2.5 w-2.5" /> deleted
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<div className="ml-auto flex items-center gap-1">
|
|
|
|
|
|
<StatusBadge status={aiStatus} className="text-[10px] px-1.5 py-0">
|
|
|
|
|
|
{aiStatus === "clean" && <CheckCircle2 className="h-3 w-3" />}
|
|
|
|
|
|
{aiStatus === "flagged" && <AlertCircle className="h-3 w-3" />}
|
|
|
|
|
|
{aiStatus === "error" && <AlertCircle className="h-3 w-3" />}
|
|
|
|
|
|
</StatusBadge>
|
|
|
|
|
|
{message.ai_severity && message.ai_severity !== "none" && (
|
|
|
|
|
|
<Badge
|
|
|
|
|
|
className={`text-[10px] px-1.5 py-0 ${severityColor(message.ai_severity)}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
{message.ai_severity}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{confidence != null && (
|
|
|
|
|
|
<Badge
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
className="text-[10px] px-1.5 py-0 tabular-nums"
|
|
|
|
|
|
>
|
|
|
|
|
|
{Math.round(confidence * 100)}%
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Reference context: reply / forward / crosspost */}
|
|
|
|
|
|
{referenceIndicator}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Content — hidden when it's just an "[Attachment: ...]" fallback and the image is shown below */}
|
|
|
|
|
|
{shouldShowContent ? (
|
|
|
|
|
|
<p
|
|
|
|
|
|
className={`whitespace-pre-wrap break-words text-sm leading-6 ${
|
|
|
|
|
|
message.deleted_at
|
|
|
|
|
|
? "text-muted-foreground/60"
|
|
|
|
|
|
: "text-foreground/90"
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
{renderContentWithCustomEmojis(displayContent)}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Stickers */}
|
|
|
|
|
|
{stickers.length > 0 && (
|
|
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
|
|
|
|
{stickers.map((sticker) => (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={sticker.name || sticker.url}
|
|
|
|
|
|
className="flex items-center gap-1.5"
|
|
|
|
|
|
>
|
|
|
|
|
|
{sticker.url ? (
|
|
|
|
|
|
<img
|
|
|
|
|
|
src={sticker.url}
|
|
|
|
|
|
alt={sticker.name || "sticker"}
|
|
|
|
|
|
className="h-12 w-12 rounded-lg border border-border object-contain bg-muted/50"
|
|
|
|
|
|
loading="lazy"
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="flex h-12 w-12 items-center justify-center rounded-lg border border-border bg-muted/50">
|
|
|
|
|
|
<Smile className="h-6 w-6 text-muted-foreground" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Attached images */}
|
|
|
|
|
|
{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-lg border border-border"
|
|
|
|
|
|
>
|
|
|
|
|
|
<img
|
|
|
|
|
|
src={img.url}
|
|
|
|
|
|
alt={img.name}
|
|
|
|
|
|
className="h-16 w-16 object-cover transition-transform hover:scale-105"
|
|
|
|
|
|
loading="lazy"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</a>
|
|
|
|
|
|
))}
|
|
|
|
|
|
{imageAttachments.length > 4 && (
|
|
|
|
|
|
<div className="flex h-16 w-16 items-center justify-center rounded-lg border border-border bg-muted text-[11px] text-muted-foreground">
|
|
|
|
|
|
+{imageAttachments.length - 4}
|
|
|
|
|
|
<ImageIcon className="ml-0.5 h-3 w-3" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Attached videos */}
|
|
|
|
|
|
{hasVideos && (
|
|
|
|
|
|
<div className="flex gap-2 overflow-x-auto">
|
|
|
|
|
|
{videoAttachments.slice(0, 4).map((vid) => (
|
|
|
|
|
|
<video
|
|
|
|
|
|
key={vid.url}
|
|
|
|
|
|
src={vid.url}
|
|
|
|
|
|
controls
|
2026-07-02 03:54:44 +07:00
|
|
|
|
className="h-28 w-48 shrink-0 rounded-lg border border-border object-cover bg-black"
|
2026-07-02 03:54:44 +07:00
|
|
|
|
preload="metadata"
|
|
|
|
|
|
/>
|
|
|
|
|
|
))}
|
|
|
|
|
|
{videoAttachments.length > 4 && (
|
|
|
|
|
|
<div className="flex h-28 w-16 items-center justify-center rounded-lg border border-border bg-muted text-[11px] text-muted-foreground">
|
|
|
|
|
|
+{videoAttachments.length - 4}
|
|
|
|
|
|
<Video className="ml-0.5 h-3 w-3" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Categories */}
|
|
|
|
|
|
{categories.length > 0 && (
|
|
|
|
|
|
<div className="flex flex-wrap gap-1">
|
|
|
|
|
|
{categories.map((category) => (
|
|
|
|
|
|
<Badge key={category} variant="secondary" className="text-[10px]">
|
|
|
|
|
|
{category}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* AI Analysis — always expanded */}
|
|
|
|
|
|
{message.ai_analysis ? (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={`rounded-lg border-l-[3px] px-3 py-2 ${
|
|
|
|
|
|
aiStatus === "flagged"
|
2026-07-02 05:58:36 +07:00
|
|
|
|
? "border-l-tertiary bg-tertiary/5"
|
|
|
|
|
|
: "border-l-success bg-success-soft"
|
2026-07-02 03:54:44 +07:00
|
|
|
|
}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="flex items-start gap-2 text-[11px]">
|
|
|
|
|
|
<span className="mt-0.5 shrink-0">
|
|
|
|
|
|
{aiStatus === "flagged" ? "🚨" : "ℹ️"}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
|
|
<span className="block font-medium text-foreground/70 mb-1">
|
|
|
|
|
|
{analysisSummary}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<div className="text-[12px] text-muted-foreground leading-relaxed whitespace-pre-wrap">
|
|
|
|
|
|
{message.ai_analysis}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
|
|
{/* AI Error */}
|
|
|
|
|
|
{message.ai_error ? (
|
2026-07-02 05:58:36 +07:00
|
|
|
|
<div className="rounded-lg bg-tertiary/5 px-3 py-2 text-[12px] text-tertiary">
|
2026-07-02 03:54:44 +07:00
|
|
|
|
AI error: {message.ai_error}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Re-analyze button */}
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
variant={aiStatus === "error" ? "destructive" : "outline"}
|
|
|
|
|
|
onClick={handleReanalyze}
|
|
|
|
|
|
disabled={aiStatus === "pending" || isReanalyzing}
|
|
|
|
|
|
className="text-[11px] h-7 px-2.5"
|
|
|
|
|
|
>
|
|
|
|
|
|
<RotateCw
|
|
|
|
|
|
className={`h-3 w-3 ${isReanalyzing ? "animate-spin" : ""}`}
|
|
|
|
|
|
/>
|
|
|
|
|
|
{isReanalyzing ? "Reanalyzing..." : "Re-analyze"}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
{aiStatus === "error" && (
|
2026-07-02 05:58:36 +07:00
|
|
|
|
<span className="text-[11px] text-tertiary/70">
|
2026-07-02 03:54:44 +07:00
|
|
|
|
Click to retry analysis
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Group card: one card per user group ─────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
export function MessageCard({ messages, onReanalyze }: MessageCardProps) {
|
|
|
|
|
|
const firstMsg = messages[0];
|
|
|
|
|
|
const hasMultiple = messages.length > 1;
|
|
|
|
|
|
const meta = useMemo(
|
|
|
|
|
|
() => parseMetadata(firstMsg.metadata),
|
|
|
|
|
|
[firstMsg.metadata],
|
|
|
|
|
|
);
|
|
|
|
|
|
const channelMeta = meta.channel;
|
|
|
|
|
|
const locationLabel = useMemo(() => {
|
|
|
|
|
|
if (channelMeta?.threadName) {
|
|
|
|
|
|
return `# ${channelMeta.channelName || "unknown"} › ${channelMeta.threadName}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (channelMeta?.channelName) {
|
|
|
|
|
|
return `# ${channelMeta.channelName}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}, [channelMeta]);
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<article
|
|
|
|
|
|
className={`group rounded-xl border bg-card shadow-sm transition-all hover:border-primary/30 hover:shadow-md ${
|
2026-07-02 05:58:36 +07:00
|
|
|
|
firstMsg.deleted_at ? "border-destructive/20 opacity-60" : "border-border"
|
2026-07-02 03:54:44 +07:00
|
|
|
|
}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="flex gap-3 p-4">
|
|
|
|
|
|
{/* Avatar — only for first message */}
|
|
|
|
|
|
<img
|
|
|
|
|
|
src={
|
|
|
|
|
|
firstMsg.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">
|
|
|
|
|
|
{/* Group header: username + location + timestamp */}
|
|
|
|
|
|
<div className="flex items-baseline gap-2 mb-2">
|
|
|
|
|
|
<span className="font-semibold text-sm text-foreground">
|
|
|
|
|
|
{firstMsg.username || firstMsg.user_id}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{locationLabel && (
|
|
|
|
|
|
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/50 bg-muted/50 px-1.5 py-0.5 rounded-full">
|
|
|
|
|
|
<Hash className="h-2.5 w-2.5" />
|
|
|
|
|
|
{locationLabel}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<span
|
|
|
|
|
|
className="text-[11px] text-muted-foreground/60"
|
|
|
|
|
|
title={new Date(firstMsg.created_at).toLocaleString()}
|
|
|
|
|
|
>
|
|
|
|
|
|
{formatTimeAgo(firstMsg.created_at)}
|
|
|
|
|
|
{hasMultiple && ` · ${messages.length} messages`}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Message rows — divided by separator when multiple */}
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={
|
|
|
|
|
|
hasMultiple ? "divide-y divide-border/30 space-y-2.5" : ""
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
{messages.map((msg, idx) => (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={msg.id}
|
|
|
|
|
|
className={hasMultiple && idx > 0 ? "pt-2.5" : ""}
|
|
|
|
|
|
>
|
|
|
|
|
|
<MessageRow message={msg} onReanalyze={onReanalyze} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Skeleton ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
export function MessageCardSkeleton() {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<article className="rounded-xl border border-border bg-card 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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|