diff --git a/services/frontend/src/features/messages/components/MessageCard.tsx b/services/frontend/src/features/messages/components/MessageCard.tsx index e6aed5e..4665665 100644 --- a/services/frontend/src/features/messages/components/MessageCard.tsx +++ b/services/frontend/src/features/messages/components/MessageCard.tsx @@ -16,10 +16,6 @@ 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"); @@ -27,15 +23,12 @@ function renderContentWithCustomEmojis(content: string): React.ReactNode { 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( , ); - 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; - } - + if (parts.length === 0) return content; return {parts}; } +// ─── Props ─────────────────────────────────────────────────────────────────── + interface MessageCardProps { - message: MessageRecord; + messages: MessageRecord[]; onReanalyze: (id: string) => Promise; - compact?: boolean; } +// ─── Helpers ───────────────────────────────────────────────────────────────── + function parseStringList(value?: string | null): string[] { if (!value) return []; try { @@ -114,11 +102,22 @@ function formatTimeAgo(ts: number): string { return new Date(ts).toLocaleDateString(); } -export function MessageCard({ +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, - compact, -}: MessageCardProps) { +}: { + message: MessageRecord; + onReanalyze: (id: string) => Promise; +}) { const metadata = useMemo( () => parseMetadata(message.metadata), [message.metadata], @@ -136,7 +135,6 @@ export function MessageCard({ 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) { @@ -171,209 +169,242 @@ export function MessageCard({ } }; + return ( +
+ {/* Row header: time + edit/delete indicators + AI badges */} +
+ + {formatTime(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)}% + + )} +
+
+ + {/* Content */} + {displayContent ? ( +

+ {renderContentWithCustomEmojis(displayContent)} +

+ ) : null} + + {/* Stickers */} + {stickers.length > 0 && ( +
+ {stickers.map((sticker) => ( +
+ {sticker.url ? ( + {sticker.name + ) : ( +
+ +
+ )} +
+ ))} +
+ )} + + {/* Attached images */} + {hasImages && ( +
+ {imageAttachments.slice(0, 4).map((img) => ( + + {img.name} + + ))} + {imageAttachments.length > 4 && ( +
+ +{imageAttachments.length - 4} + +
+ )} +
+ )} + + {/* Categories */} + {categories.length > 0 && ( +
+ {categories.map((category) => ( + + {category} + + ))} +
+ )} + + {/* AI Analysis */} + {message.ai_analysis ? ( +
+ + {showAnalysis && ( +
+ {message.ai_analysis} +
+ )} +
+ ) : null} + + {/* AI Error */} + {message.ai_error ? ( +
+ AI error: {message.ai_error} +
+ ) : null} + + {/* Re-analyze button */} +
+ + {aiStatus === "error" && ( + + Click to retry analysis + + )} +
+
+ ); +} + +// ─── Group card: one card per user group ───────────────────────────────────── + +export function MessageCard({ messages, onReanalyze }: MessageCardProps) { + const firstMsg = messages[0]; + const hasMultiple = messages.length > 1; + return (
-
- {!compact && ( - - )} -
- {!compact && ( -
- - {message.username || message.user_id} - - + {/* Avatar — only for first message */} + + +
+ {/* Group header: username + timestamp of first message */} +
+ + {firstMsg.username || firstMsg.user_id} + + + {formatTimeAgo(firstMsg.created_at)} + {hasMultiple && ` · ${messages.length} messages`} + +
+ + {/* Message rows — divided by separator when multiple */} +
+ {messages.map((msg, idx) => ( +
0 ? "pt-2.5" : ""} > - {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 - - )} + ))}
@@ -381,6 +412,8 @@ export function MessageCard({ ); } +// ─── Skeleton ──────────────────────────────────────────────────────────────── + export function MessageCardSkeleton() { return (
diff --git a/services/frontend/src/features/messages/components/MessageFeed.tsx b/services/frontend/src/features/messages/components/MessageFeed.tsx index 9443e3e..733bfe6 100644 --- a/services/frontend/src/features/messages/components/MessageFeed.tsx +++ b/services/frontend/src/features/messages/components/MessageFeed.tsx @@ -84,7 +84,7 @@ export function MessageFeed({ } if (messages.length === 0) { - return ; + return ; } return ( @@ -95,21 +95,14 @@ export function MessageFeed({ initial="initial" animate="animate" > - {groupedMessages.map((group) => - group.messages.map((message, idx) => { - const isFirstInGroup = idx === 0; - const isCompact = !isFirstInGroup; - return ( - - - - ); - }), - )} + {groupedMessages.map((group) => ( + + + + ))} {/* Infinite-scroll sentinel */} {hasMore && (