PROJECTS / src /components /ChatMessage.tsx
Adeen
Initial Deployment
bb17288
import React, { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import { User, Copy, Check } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
export interface MessageProps {
id: string;
role: 'user' | 'bot';
content: string;
isNew?: boolean;
}
const CodeBlock = ({ inline, className, children, ...props }: { inline?: boolean; className?: string; children?: React.ReactNode } & React.HTMLAttributes<HTMLElement>) => {
const match = /language-(\w+)/.exec(className || '');
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(String(children).replace(/\n$/, ''));
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return !inline && match ? (
<div className="relative group mt-5 mb-5 shadow-lg rounded-lg overflow-hidden border border-white/5">
<div className="flex justify-between items-center bg-[#18181b] px-4 py-2 border-b border-white/5">
<span className="text-xs font-mono-custom text-slate-400 capitalize">{match[1]}</span>
<button
onClick={handleCopy}
className="flex items-center gap-1.5 text-xs text-slate-400 hover:text-slate-200 transition-colors"
>
{copied ? <Check size={14} className="text-emerald-400" /> : <Copy size={14} />}
<span>{copied ? 'Copied!' : 'Copy code'}</span>
</button>
</div>
<SyntaxHighlighter
{...props}
style={vscDarkPlus}
language={match[1]}
PreTag="div"
className="!m-0 !bg-[#0b0b0f] !p-4 font-mono-custom text-sm custom-scrollbar"
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
</div>
) : (
<code {...props} className={`${className} bg-primary/10 px-1.5 py-0.5 rounded-md text-[13px] font-mono-custom text-primary`}>
{children}
</code>
);
};
export const ChatMessage: React.FC<{ message: MessageProps }> = ({ message }) => {
const isUser = message.role === 'user';
const [displayedContent, setDisplayedContent] = useState(
!isUser && message.isNew ? '' : message.content
);
useEffect(() => {
if (isUser || !message.isNew) {
setDisplayedContent(message.content);
return;
}
let index = 0;
setDisplayedContent('');
const timer = setInterval(() => {
if (index < message.content.length) {
setDisplayedContent(message.content.substring(0, index + 1));
index++;
} else {
clearInterval(timer);
}
}, 15);
return () => clearInterval(timer);
}, [message.content, isUser, message.isNew]);
return (
<motion.div
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.3, ease: 'easeOut' }}
className={`flex w-full mb-6 ${isUser ? 'justify-end' : 'justify-start'}`}
>
<div className={`flex max-w-[85%] sm:max-w-[75%] gap-4 ${isUser ? 'flex-row-reverse' : 'flex-row items-start'}`}>
{/* Avatar */}
<div className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center mt-1
${isUser ? 'bg-[#3c4043]' : 'bg-transparent'}
`}>
{isUser ? (
<User size={18} className="text-white" />
) : (
<div className="w-8 h-8 relative flex items-center justify-center bg-white/5 rounded-full border border-white/10 shadow-[0_0_15px_rgba(139,92,246,0.2)]">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" className="text-primary drop-shadow-[0_0_8px_rgba(139,92,246,0.6)]">
<path d="M12 2L14.809 9.19098L22 12L14.809 14.809L12 22L9.19098 14.809L2 12L9.19098 9.19098L12 2Z" fill="currentColor" />
</svg>
</div>
)}
</div>
{/* Message Bubble */}
<div className={`
leading-[1.65] text-[16px] break-words overflow-x-auto
${isUser
? 'bg-primary text-white px-5 py-3 rounded-[24px] rounded-br-sm shadow-[0_4px_15px_rgba(139,92,246,0.15)]'
: 'text-foreground pt-1.5'
}
`}>
{isUser ? (
message.content
) : (
<div className="markdown-prose z-10 w-full">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code: CodeBlock,
p: ({ children }) => <p className="mb-4 last:mb-0 leading-[1.65] text-slate-300">{children}</p>,
ul: ({ children }) => <ul className="list-disc ml-5 mb-4 space-y-1 text-slate-300">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal ml-5 mb-4 space-y-1 text-slate-300">{children}</ol>,
li: ({ children }) => <li className="mb-1 leading-[1.65]">{children}</li>,
a: ({ href, children }) => <a href={href} className="text-primary hover:text-primary-hover underline underline-offset-2 transition-colors" target="_blank" rel="noreferrer">{children}</a>,
h1: ({ children }) => <h1 className="text-2xl font-bold mb-4 mt-6 text-slate-100">{children}</h1>,
h2: ({ children }) => <h2 className="text-xl font-bold mb-3 mt-5 text-slate-100">{children}</h2>,
h3: ({ children }) => <h3 className="text-lg font-bold mb-3 mt-4 text-slate-100">{children}</h3>,
strong: ({ children }) => <strong className="font-semibold text-slate-200">{children}</strong>,
blockquote: ({ children }) => <blockquote className="border-l-4 border-primary/50 pl-4 py-1.5 my-4 bg-white/5 rounded-r-lg text-slate-300 italic">{children}</blockquote>
}}
>
{displayedContent}
</ReactMarkdown>
{!displayedContent && !isUser && message.isNew && (
<div className="flex gap-1.5 mt-2 h-6 items-center">
<span className="w-1.5 h-1.5 bg-primary/80 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
<span className="w-1.5 h-1.5 bg-primary/80 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-1.5 h-1.5 bg-primary/80 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
</div>
)}
</div>
)}
</div>
</div>
</motion.div>
);
};