169 lines
6.5 KiB
TypeScript
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;
|