semblance-dev/src/components/ChatMessage.tsx
2025-08-04 09:07:59 -05:00

169 lines
6.5 KiB
TypeScript

import { useState } from 'react';
import { MessageSquare, UserCircle, Bot, Star, User, Image as ImageIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Persona } from '@/types/persona';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { getPersonaAvatarSrc } from '@/utils/avatarUtils';
import { parseMentions, formatMentionsForDisplay } from '@/utils/mentionUtils';
import { focusGroupsApi } from '@/lib/api';
interface Message {
id: string;
senderId: string; // 'moderator' = AI Moderator, 'facilitator' = Human Facilitator, or participant ID
text: string;
timestamp: Date;
type: 'question' | 'response' | 'system' | 'highlight';
highlighted?: boolean;
}
interface ChatMessageProps {
message: Message;
persona: Persona | null;
toggleHighlight: () => void;
participants?: Persona[]; // For parsing @mentions in message text
focusGroupId?: string; // For loading creative assets
}
const ChatMessage = ({ message, persona, toggleHighlight, participants = [], focusGroupId }: ChatMessageProps) => {
const [isHovered, setIsHovered] = useState(false);
const isModerator = message.senderId === 'moderator';
const isFacilitator = message.senderId === 'facilitator';
// Parse and format mentions in the message text
const parsedMentions = parseMentions(message.text, participants);
const formattedText = formatMentionsForDisplay(message.text, parsedMentions.mentions);
// Extract creative asset filename from message text if this is a creative review
const extractAssetFilename = (text: string): string | null => {
// Look for patterns like "asset: filename.jpg" or similar
const patterns = [
// Match quoted filenames (most specific pattern first)
/titled\s+['"]([^'"]+\.(jpg|jpeg|png))['\"]/i, // "titled 'filename.jpg'"
/asset\s+['"]([^'"]+\.(jpg|jpeg|png))['\"]/i, // "asset 'filename.jpg'"
/image\s+['"]([^'"]+\.(jpg|jpeg|png))['\"]/i, // "image 'filename.jpg'"
/['"]([a-zA-Z0-9_\-]+\.(jpg|jpeg|png))['\"]/i, // Any quoted filename
// Match focus group asset pattern without quotes
/(fg-[a-f0-9]+-[a-f0-9]{32}\.(jpg|jpeg|png))/i, // fg-{id}-{uuid}.{ext}
];
for (const pattern of patterns) {
const match = text.match(pattern);
if (match) {
return match[1];
}
}
return null;
};
const assetFilename = extractAssetFilename(message.text);
const hasCreativeAsset = (isModerator || isFacilitator) && assetFilename && focusGroupId;
const handleToggleHighlight = () => {
toggleHighlight();
};
return (
<div
id={`message-${message.id}`}
className={cn(
"flex items-start p-3 rounded-lg transition-colors",
message.highlighted ? "bg-amber-50 border border-amber-200" : "hover:bg-slate-50",
isModerator ? "border-l-4 border-l-primary pl-4" : "",
isFacilitator ? "border-l-4 border-l-green-500 pl-4" : ""
)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
data-highlighted={message.highlighted ? "true" : "false"}
>
<div className="flex-shrink-0 mr-3">
{isModerator ? (
<div className="bg-primary/10 p-2 rounded-full">
<Bot className="h-6 w-6 text-primary" />
</div>
) : isFacilitator ? (
<div className="bg-green-100 p-2 rounded-full">
<User className="h-6 w-6 text-green-600" />
</div>
) : persona ? (
<div className="bg-slate-100 p-2 rounded-full">
<img
src={getPersonaAvatarSrc(persona)}
alt={`${persona.name} avatar`}
className="h-6 w-6 rounded-full object-cover"
/>
</div>
) : (
<div className="bg-slate-100 p-2 rounded-full">
<UserCircle className="h-6 w-6 text-slate-600" />
</div>
)}
</div>
<div className="flex-1">
<div className="flex items-center mb-1">
<span className="font-medium mr-2">
{isModerator ? 'AI Moderator' : isFacilitator ? 'Human Facilitator' : persona?.name || 'Unknown'}
</span>
{!isModerator && !isFacilitator && persona && (
<Badge variant="outline" className="text-xs font-normal">
{persona.occupation}
</Badge>
)}
<span className="text-xs text-slate-500 ml-auto">
{message.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
<p className="text-slate-700">{formattedText}</p>
{/* Display creative asset if this is a moderator message with an asset */}
{hasCreativeAsset && (
<div className="mt-3 p-3 border rounded-lg bg-slate-50">
<div className="flex items-center gap-2 mb-2">
<ImageIcon className="h-4 w-4 text-slate-600" />
<span className="text-sm font-medium text-slate-700">Creative Asset</span>
</div>
<img
src={focusGroupsApi.getAssetUrl(focusGroupId!, assetFilename!)}
alt="Creative asset for review"
className="max-w-full h-auto rounded border shadow-sm"
style={{ maxHeight: '300px' }}
onError={(e) => {
console.error('Failed to load creative asset:', focusGroupsApi.getAssetUrl(focusGroupId!, assetFilename!));
e.currentTarget.style.display = 'none';
// Show placeholder on error
const placeholder = document.createElement('div');
placeholder.className = 'text-xs text-slate-500 italic p-2 border rounded bg-slate-100';
placeholder.textContent = `Creative asset not found: ${assetFilename}`;
e.currentTarget.parentNode?.appendChild(placeholder);
}}
/>
</div>
)}
<div className={cn("flex mt-2 space-x-2", !isHovered && !message.highlighted && "hidden")}>
<Button
variant="ghost"
size="sm"
onClick={handleToggleHighlight}
className="h-8 px-2 text-xs"
>
<Star className={cn(
"h-3 w-3 mr-1",
message.highlighted ? "fill-amber-400 text-amber-400" : "text-slate-400"
)} />
{message.highlighted ? 'Highlighted' : 'Highlight'}
</Button>
</div>
</div>
</div>
);
};
export default ChatMessage;