187 lines
7.2 KiB
TypeScript
187 lines
7.2 KiB
TypeScript
import React from 'react';
|
|
import { motion } from 'motion/react';
|
|
import { format } from 'date-fns';
|
|
import { AlertCircle, CheckCheck, FileText, Music, ShieldCheck } from 'lucide-react';
|
|
import { Message, Attachment } from '../types';
|
|
import { cn } from '../lib/utils';
|
|
import ReactMarkdown from 'react-markdown';
|
|
|
|
interface MessageBubbleProps {
|
|
message: Message;
|
|
}
|
|
|
|
const AttachmentPreview = ({ attachment }: { attachment: Attachment }) => {
|
|
if (attachment.type === 'image') {
|
|
return (
|
|
<div className="group relative max-w-[320px] overflow-hidden rounded-2xl border border-white/10 bg-black/20">
|
|
<img
|
|
src={attachment.url}
|
|
alt={attachment.file.name}
|
|
className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.02]"
|
|
/>
|
|
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent px-3 py-2 text-[11px] font-medium text-white/90">
|
|
<div className="truncate">{attachment.file.name}</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const Icon = attachment.type === 'audio' ? Music : FileText;
|
|
|
|
return (
|
|
<div className="flex w-full max-w-[360px] items-center gap-3 rounded-2xl border border-white/10 bg-white/5 px-3 py-3">
|
|
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-black/25">
|
|
<Icon className="h-4 w-4 text-indigo-200" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-sm font-medium text-white/90">{attachment.file.name}</p>
|
|
<p className="text-[11px] text-zinc-400">{(attachment.file.size / 1024).toFixed(1)} KB</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const MessageBubble: React.FC<MessageBubbleProps> = ({ message }) => {
|
|
const isUser = message.sender === 'user';
|
|
const [isPlaying, setIsPlaying] = React.useState(false);
|
|
const audioRef = React.useRef<HTMLAudioElement | null>(null);
|
|
|
|
const togglePlay = () => {
|
|
if (audioRef.current) {
|
|
if (isPlaying) {
|
|
audioRef.current.pause();
|
|
} else {
|
|
audioRef.current.play();
|
|
}
|
|
setIsPlaying(!isPlaying);
|
|
}
|
|
};
|
|
|
|
React.useEffect(() => {
|
|
if (message.audio) {
|
|
audioRef.current = new Audio(message.audio.url);
|
|
audioRef.current.onended = () => setIsPlaying(false);
|
|
}
|
|
|
|
return () => {
|
|
if (audioRef.current) {
|
|
audioRef.current.pause();
|
|
audioRef.current = null;
|
|
}
|
|
};
|
|
}, [message.audio]);
|
|
|
|
return (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 14, scale: 0.98 }}
|
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
transition={{ duration: 0.32, ease: [0.23, 1, 0.32, 1] }}
|
|
className={cn('mb-5 flex w-full', isUser ? 'justify-end' : 'justify-start')}
|
|
>
|
|
<div
|
|
className={cn(
|
|
'flex flex-col gap-1',
|
|
isUser ? 'max-w-3xl items-end' : 'w-full max-w-none items-start'
|
|
)}
|
|
>
|
|
{!isUser && (
|
|
<div className="mb-1 inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.2em] text-zinc-400">
|
|
<ShieldCheck className="h-3.5 w-3.5 text-indigo-300" />
|
|
Respuesta del portal
|
|
</div>
|
|
)}
|
|
|
|
<div
|
|
className={cn(
|
|
'relative overflow-hidden border shadow-[0_16px_40px_rgba(0,0,0,0.25)]',
|
|
isUser
|
|
? 'max-w-[85%] rounded-[26px] rounded-br-md border-indigo-400/20 bg-[linear-gradient(135deg,rgba(79,70,229,0.96),rgba(37,99,235,0.92))] px-4 py-4 text-white sm:px-5'
|
|
: 'w-full max-w-none rounded-[28px] rounded-bl-md border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.07),rgba(255,255,255,0.03))] px-5 py-5 text-zinc-100 backdrop-blur-xl sm:px-6 sm:py-6'
|
|
)}
|
|
>
|
|
{!isUser && (
|
|
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(129,140,248,0.14),transparent_26%)]" />
|
|
)}
|
|
|
|
<div className="relative">
|
|
{message.attachments && message.attachments.length > 0 && (
|
|
<div className="mb-4 flex flex-wrap gap-3">
|
|
{message.attachments.map((att) => (
|
|
<AttachmentPreview key={att.id} attachment={att} />
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{message.audio && (
|
|
<div className="mb-4 flex items-center gap-3 rounded-2xl bg-black/20 p-3 pr-4">
|
|
<button
|
|
onClick={togglePlay}
|
|
className="flex h-10 w-10 items-center justify-center rounded-full bg-white/15 transition hover:bg-white/25"
|
|
>
|
|
{isPlaying ? (
|
|
<div className="flex h-3 w-3 gap-1">
|
|
<div className="h-full w-1 rounded-sm bg-white" />
|
|
<div className="h-full w-1 rounded-sm bg-white" />
|
|
</div>
|
|
) : (
|
|
<div className="ml-0.5 h-0 w-0 border-b-[6px] border-l-[9px] border-t-[6px] border-b-transparent border-l-white border-t-transparent" />
|
|
)}
|
|
</button>
|
|
|
|
<div className="h-1.5 w-28 overflow-hidden rounded-full bg-white/15">
|
|
<div
|
|
className={cn(
|
|
'h-full w-1/3 rounded-full bg-white/65',
|
|
isPlaying && 'animate-pulse'
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
<span className="text-xs font-mono opacity-80">
|
|
0:{message.audio.duration.toString().padStart(2, '0')}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{message.text && (
|
|
<div
|
|
className={cn(
|
|
'break-words text-[15px] leading-7 sm:text-base',
|
|
isUser ? 'whitespace-pre-wrap' : ''
|
|
)}
|
|
>
|
|
{isUser ? (
|
|
message.text
|
|
) : (
|
|
<div className="prose prose-invert max-w-none prose-p:my-3 prose-p:leading-8 prose-headings:mb-3 prose-headings:mt-1 prose-headings:text-white prose-strong:text-white prose-li:my-1 prose-ul:my-3 prose-ol:my-3 prose-pre:overflow-x-auto prose-pre:rounded-2xl prose-pre:border prose-pre:border-white/10 prose-pre:bg-black/35 prose-pre:p-4 prose-pre:text-[13px] prose-code:text-indigo-200">
|
|
<ReactMarkdown>{message.text}</ReactMarkdown>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-1 flex items-center gap-1.5 px-1">
|
|
<span className="text-[11px] font-medium text-zinc-500">
|
|
{format(message.timestamp, 'HH:mm')}
|
|
</span>
|
|
|
|
{isUser && (
|
|
<span className="text-zinc-500">
|
|
{message.status === 'sending' && (
|
|
<AlertCircle className="h-3.5 w-3.5 animate-pulse text-zinc-400" />
|
|
)}
|
|
{message.status === 'sent' && (
|
|
<CheckCheck className="h-3.5 w-3.5 text-indigo-400" />
|
|
)}
|
|
{message.status === 'error' && (
|
|
<AlertCircle className="h-3.5 w-3.5 text-red-500" />
|
|
)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
}; |