/**
* ChatMessage Component
*
* Renders individual chat messages with:
* - Markdown formatting (react-markdown + remark-gfm)
* - User vs. Assistant styling
* - Timestamp display
* - Copy to clipboard button
*/
'use client';
import React from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Copy, Check, Share2, Volume2, StopCircle, RefreshCw, AlertCircle } from 'lucide-react';
import { MapleLeafIcon } from '@canadagpt/design-system';
import type { Message } from '@/lib/types/chat';
import { useChatStore } from '@/lib/stores/chatStore';
import { ShareConversationCard } from './ShareConversationCard';
import { FeedbackButtons } from '@/components/feedback';
import { useSpeechSynthesis } from '@/hooks/useSpeechSynthesis';
import { MentionRenderer } from '@/components/mentions';
interface ChatMessageProps {
message: Message;
}
export function ChatMessage({ message }: ChatMessageProps) {
const [copied, setCopied] = React.useState(false);
const [shared, setShared] = React.useState(false);
const [showShareCard, setShowShareCard] = React.useState(true);
const [isRetrying, setIsRetrying] = React.useState(false);
const [isRegenerating, setIsRegenerating] = React.useState(false);
// Chat store actions
const retryMessage = useChatStore((state) => state.retryMessage);
const regenerateMessage = useChatStore((state) => state.regenerateMessage);
const messages = useChatStore((state) => state.messages);
const conversation = useChatStore((state) => state.conversation);
// Check if this message used tools (should show share card)
const usedTools = message.role === 'assistant' &&
(message.function_calls?.length ?? 0) > 0;
// Find the user's question that preceded this assistant message
const userQuestion = React.useMemo(() => {
if (message.role !== 'assistant') return '';
const messageIndex = messages.findIndex((m) => m.id === message.id);
if (messageIndex <= 0) return '';
const previousMessage = messages[messageIndex - 1];
return previousMessage?.role === 'user' ? previousMessage.content : '';
}, [message, messages]);
// Text-to-speech
const { speak, stop, isSpeaking, isSupported: isTTSSupported } = useSpeechSynthesis();
// Typing animation for assistant messages
const [displayedContent, setDisplayedContent] = React.useState('');
const [isTyping, setIsTyping] = React.useState(false);
const hasAnimated = React.useRef(false);
const lastContentRef = React.useRef('');
const messageRef = React.useRef<HTMLDivElement>(null);
// Capture initial content on mount to detect historical vs streaming messages
const initialContentRef = React.useRef<string | null>(null);
React.useEffect(() => {
// Capture initial content on first render only
if (initialContentRef.current === null) {
initialContentRef.current = message.content;
}
// Only animate assistant messages
if (message.role !== 'assistant') {
setDisplayedContent(message.content);
return;
}
// Skip animation for historical messages (had content on mount)
const isHistoricalMessage = initialContentRef.current.length > 0;
// If we've already animated this exact content, just show it
if (hasAnimated.current && lastContentRef.current === message.content) {
setDisplayedContent(message.content);
return;
}
// Historical message - show immediately without animation
if (isHistoricalMessage && !hasAnimated.current) {
hasAnimated.current = true;
lastContentRef.current = message.content;
setDisplayedContent(message.content);
return;
}
// New streaming content - animate
hasAnimated.current = true;
lastContentRef.current = message.content;
setIsTyping(true);
setDisplayedContent('');
let currentIndex = 0;
const content = message.content;
// Type out characters for readability
// Process multiple characters per tick for smoother animation
const charsPerTick = 9; // Show 9 characters at a time
const tickDelay = 50; // 50ms between ticks (180 chars/sec)
const interval = setInterval(() => {
if (currentIndex < content.length) {
currentIndex = Math.min(currentIndex + charsPerTick, content.length);
setDisplayedContent(content.slice(0, currentIndex));
// Auto-scroll to keep the message visible during typing
messageRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
} else {
setIsTyping(false);
clearInterval(interval);
}
}, tickDelay);
return () => clearInterval(interval);
}, [message.content, message.role]);
const handleCopy = async () => {
await navigator.clipboard.writeText(message.content);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const handleShare = async () => {
const shareText = message.content;
const shareUrl = window.location.href;
// Check if Web Share API is available (mobile/modern browsers)
if (navigator.share) {
try {
await navigator.share({
title: 'CanadaGPT Response',
text: shareText,
url: shareUrl,
});
setShared(true);
setTimeout(() => setShared(false), 2000);
} catch (error) {
// User cancelled or share failed
console.log('Share cancelled or failed:', error);
}
} else {
// Fallback: Copy link to clipboard
const shareableText = `${shareText}\n\nShared from: ${shareUrl}`;
await navigator.clipboard.writeText(shareableText);
setShared(true);
setTimeout(() => setShared(false), 2000);
}
};
const handleRetry = async () => {
setIsRetrying(true);
try {
await retryMessage(message.id);
} finally {
setIsRetrying(false);
}
};
const handleRegenerate = async () => {
setIsRegenerating(true);
try {
await regenerateMessage(message.id);
} finally {
setIsRegenerating(false);
}
};
const isUser = message.role === 'user';
const isAssistant = message.role === 'assistant';
const isFailed = message.status === 'failed';
return (
<div
ref={messageRef}
role="article"
aria-label={isUser ? 'Your message' : 'Gordie\'s response'}
className={`group flex gap-3 ${
isUser ? 'flex-row-reverse' : 'flex-row'
} mb-4`}
>
{/* Avatar */}
<div
aria-hidden="true"
className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
isUser
? 'bg-accent-red text-white'
: 'bg-gray-700 text-white border border-gray-600'
}`}
>
{isUser ? 'U' : <MapleLeafIcon className="w-5 h-5" size={20} />}
</div>
{/* Message Content */}
<div
className={`flex-1 max-w-[80%] ${
isUser ? 'items-end' : 'items-start'
}`}
>
{/* Message Bubble */}
<div
className={`rounded-lg px-4 py-3 ${
isUser
? 'bg-accent-red text-white'
: 'bg-gray-800 border border-gray-700 text-gray-100'
}`}
>
{isAssistant ? (
<div className="text-sm text-gray-100">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
// Custom link styling
a: ({ node, ...props }) => (
<a
{...props}
className="text-accent-red hover:underline"
target="_blank"
rel="noopener noreferrer"
/>
),
// Custom code block styling
code: ({ node, inline, ...props }: any) =>
inline ? (
<code
{...props}
className="bg-gray-700 text-gray-100 px-1.5 py-0.5 rounded text-sm font-mono"
/>
) : (
<code
{...props}
className="block bg-gray-700 text-gray-100 p-3 rounded-md text-sm overflow-x-auto font-mono"
/>
),
// Custom paragraph spacing with mention support
p: ({ node, children, ...props }) => (
<p {...props} className="mb-2 last:mb-0 text-gray-100">
{React.Children.map(children, (child, index) => {
// If it's a string, render with MentionRenderer
if (typeof child === 'string') {
return (
<MentionRenderer
key={`p-mention-${index}`}
text={child}
newTab={true}
/>
);
}
return child;
})}
</p>
),
// Custom list styling
ul: ({ node, ...props }) => (
<ul {...props} className="list-disc ml-4 mb-2 text-gray-100" />
),
ol: ({ node, ...props }) => (
<ol {...props} className="list-decimal ml-4 mb-2 text-gray-100" />
),
// List items with mention support
li: ({ node, children, ...props }) => (
<li {...props} className="text-gray-100">
{React.Children.map(children, (child, index) => {
if (typeof child === 'string') {
return (
<MentionRenderer
key={`li-mention-${index}`}
text={child}
newTab={true}
/>
);
}
return child;
})}
</li>
),
// Headers with mention support
h1: ({ node, children, ...props }) => (
<h1 {...props} className="text-xl font-bold mb-2 text-gray-100">
{React.Children.map(children, (child, index) => {
if (typeof child === 'string') {
return <MentionRenderer key={`h1-mention-${index}`} text={child} newTab={true} />;
}
return child;
})}
</h1>
),
h2: ({ node, children, ...props }) => (
<h2 {...props} className="text-lg font-bold mb-2 text-gray-100">
{React.Children.map(children, (child, index) => {
if (typeof child === 'string') {
return <MentionRenderer key={`h2-mention-${index}`} text={child} newTab={true} />;
}
return child;
})}
</h2>
),
h3: ({ node, children, ...props }) => (
<h3 {...props} className="text-base font-bold mb-2 text-gray-100">
{React.Children.map(children, (child, index) => {
if (typeof child === 'string') {
return <MentionRenderer key={`h3-mention-${index}`} text={child} newTab={true} />;
}
return child;
})}
</h3>
),
}}
>
{displayedContent}
</ReactMarkdown>
{isTyping && <span className="inline-block w-1 h-4 bg-gray-400 ml-1 animate-pulse" />}
</div>
) : (
<MentionRenderer
text={message.content}
className="text-sm whitespace-pre-wrap text-white"
newTab={true}
/>
)}
{/* Failed message indicator for user messages */}
{isUser && isFailed && (
<div className="flex items-center gap-2 mt-2 text-red-400 text-xs">
<AlertCircle className="w-3.5 h-3.5" aria-hidden="true" />
<span>Failed to send</span>
<button
onClick={handleRetry}
disabled={isRetrying}
className="flex items-center gap-1 px-2 py-1 bg-red-500/20 hover:bg-red-500/30 rounded text-red-300 transition-colors disabled:opacity-50"
aria-label="Retry sending this message"
>
<RefreshCw className={`w-3 h-3 ${isRetrying ? 'animate-spin' : ''}`} aria-hidden="true" />
<span>{isRetrying ? 'Retrying...' : 'Retry'}</span>
</button>
</div>
)}
</div>
{/* Share Conversation Card (shown when tools were used) */}
{isAssistant && usedTools && showShareCard && userQuestion && (
<ShareConversationCard
userQuestion={userQuestion}
aiResponse={message.content}
conversationId={conversation?.id || ''}
billContext={message.billContext}
contextType={conversation?.context_type}
navigationUrl={message.navigation?.url}
onDismiss={() => setShowShareCard(false)}
/>
)}
{/* Message Metadata */}
<div
className={`flex items-center gap-2 mt-1 text-xs text-gray-500 ${
isUser ? 'flex-row-reverse' : 'flex-row'
}`}
>
{/* Timestamp */}
<span>
{new Date(message.created_at).toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
})}
</span>
{/* Token Count (for assistant messages) */}
{isAssistant && message.tokens_total && (
<span className="text-gray-600">
{message.tokens_total.toLocaleString()} tokens
</span>
)}
{/* Action Buttons (only visible on hover for assistant messages) */}
{isAssistant && (
<>
<button
onClick={handleCopy}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-gray-700 rounded"
title="Copy message"
aria-label={copied ? 'Message copied' : 'Copy message to clipboard'}
>
{copied ? (
<Check className="w-3.5 h-3.5 text-green-600" aria-hidden="true" />
) : (
<Copy className="w-3.5 h-3.5 text-gray-400" aria-hidden="true" />
)}
</button>
<button
onClick={handleShare}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-gray-700 rounded"
title="Share message"
aria-label={shared ? 'Message shared' : 'Share this message'}
>
{shared ? (
<Check className="w-3.5 h-3.5 text-green-600" aria-hidden="true" />
) : (
<Share2 className="w-3.5 h-3.5 text-gray-400" aria-hidden="true" />
)}
</button>
{/* Regenerate button */}
<button
onClick={handleRegenerate}
disabled={isRegenerating}
className={`opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-gray-700 rounded ${
isRegenerating ? 'opacity-100' : ''
}`}
title="Regenerate response"
aria-label="Generate a new response"
>
<RefreshCw
className={`w-3.5 h-3.5 text-gray-400 ${isRegenerating ? 'animate-spin text-accent-red' : ''}`}
aria-hidden="true"
/>
</button>
{/* Listen button (text-to-speech) */}
{isTTSSupported && (
<button
onClick={() => {
if (isSpeaking) {
stop();
} else {
// Strip markdown for cleaner speech
const plainText = message.content
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Links
.replace(/[*_~`#]/g, '') // Markdown formatting
.replace(/\n+/g, '. '); // Line breaks to pauses
speak(plainText);
}
}}
className={`opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-gray-700 rounded ${
isSpeaking ? 'opacity-100 text-accent-red' : ''
}`}
title={isSpeaking ? 'Stop speaking' : 'Listen to message'}
aria-label={isSpeaking ? 'Stop speaking' : 'Listen to this message'}
aria-pressed={isSpeaking}
>
{isSpeaking ? (
<StopCircle className="w-3.5 h-3.5" aria-hidden="true" />
) : (
<Volume2 className="w-3.5 h-3.5 text-gray-400" aria-hidden="true" />
)}
</button>
)}
<FeedbackButtons
messageId={message.id}
conversationId={message.conversation_id}
messageContent={message.content}
/>
</>
)}
</div>
</div>
</div>
);
}