Files
forkmessager/client-web/src/modules/chats/presentation/components/MessageBubble.tsx
Халимов Рустам 70acad56fb Удаление сообщений
2026-04-19 21:05:56 +03:00

1252 lines
62 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useRef, useEffect, memo } from 'react';
import { createPortal } from 'react-dom';
import { motion, AnimatePresence } from 'framer-motion';
import {
Check,
CheckCheck,
Play,
Pause,
Download,
FileText,
Copy,
Pencil,
Trash2,
Reply,
Smile,
MoreHorizontal,
X,
Volume2,
Pin,
Clock,
Forward,
Phone,
Video,
PhoneMissed,
PhoneIncoming,
PhoneOutgoing,
BarChart2,
Music,
} from 'lucide-react';
import { useAuthStore } from '../../../auth/application/authStore';
import { useChatStore } from '../../application/chatStore';
import { getSocket } from '../../../../core/infrastructure/socket';
import { useLang } from '../../../../core/infrastructure/i18n';
import { extractWaveform, getMediaUrl, generateAvatarColor, getInitials } from '../../../../core/utils/utils';
import { AUDIO_EXTENSIONS, type Message, type MediaItem, type Reaction, type ChatMember } from '../../../../core/domain/types';
import ImageLightbox from '../../../../core/presentation/components/ui/ImageLightbox';
import LinkPreview from './LinkPreview';
import Avatar from '../../../../core/presentation/components/ui/Avatar';
interface MessageBubbleProps {
message: Message;
isMine: boolean;
showAvatar: boolean;
onViewProfile?: (userId: string) => void;
selectionMode?: boolean;
isSelected?: boolean;
onToggleSelect?: (id: string) => void;
onStartSelectionMode?: (id: string) => void;
onForward?: (id: string) => void;
}
function MessageBubble({
message,
isMine,
showAvatar,
onViewProfile,
selectionMode,
isSelected,
onToggleSelect,
onStartSelectionMode,
onForward
}: MessageBubbleProps) {
const { user } = useAuthStore();
const { setReplyTo, setEditingMessage, pinnedMessages, chats } = useChatStore();
const { t, lang } = useLang();
const [showContext, setShowContext] = useState(false);
const [contextPos, setContextPos] = useState({ x: 0, y: 0 });
const [deleteMenuMode, setDeleteMenuMode] = useState(false);
const [lightboxData, setLightboxData] = useState<{ index: number } | null>(null);
const activeChatId = useChatStore(s => s.activeChat);
const activeChat = useChatStore(s => s.chats.find(c => c.id === activeChatId));
const isFavorites = activeChat?.type === 'favorites';
const [isPlaying, setIsPlaying] = useState(false);
const [audioProgress, setAudioProgress] = useState(0);
const [audioDuration, setAudioDuration] = useState(0);
const [waveformBars, setWaveformBars] = useState<number[] | null>(null);
const audioRef = useRef<HTMLAudioElement>(null);
const bubbleRef = useRef<HTMLDivElement>(null);
const [quotedText, setQuotedText] = useState<string | null>(null);
// Прочитано
const isRead = message.readBy?.some((r) => r.userId !== user?.id);
const timeStr = new Date(message.createdAt).toLocaleTimeString(lang === 'ru' ? 'ru-RU' : 'en-US', {
hour: '2-digit',
minute: '2-digit',
});
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (selectionMode) {
onToggleSelect?.(message.id);
return;
}
const rect = bubbleRef.current?.getBoundingClientRect();
if (!rect) return;
const selection = window.getSelection();
const text = selection?.toString().trim();
if (text && bubbleRef.current?.contains(selection?.anchorNode || null)) {
setQuotedText(text);
} else {
setQuotedText(null);
}
const menuWidth = 208;
const menuHeight = 350;
let x = e.clientX;
let y = e.clientY;
if (x + menuWidth > window.innerWidth) x = window.innerWidth - menuWidth - 8;
if (y + menuHeight > window.innerHeight) y = window.innerHeight - menuHeight - 8;
setContextPos({ x, y });
setShowContext(true);
};
const handleCopy = () => {
if (message.content) {
navigator.clipboard.writeText(message.content);
}
setShowContext(false);
};
const handleReply = () => {
setReplyTo({ ...message, quote: quotedText });
setShowContext(false);
setQuotedText(null);
};
const handleEdit = () => {
setEditingMessage(message);
setShowContext(false);
};
const handleDeleteForAll = () => {
const socket = getSocket();
if (socket) {
socket.emit('delete_messages', {
messageIds: [message.id],
chatId: message.chatId,
deleteForAll: true,
});
}
setShowContext(false);
setDeleteMenuMode(false);
};
const handleDeleteForMe = () => {
const socket = getSocket();
if (socket) {
socket.emit('delete_messages', {
messageIds: [message.id],
chatId: message.chatId,
deleteForAll: false,
});
}
useChatStore.getState().hideMessages([message.id], message.chatId);
setShowContext(false);
setDeleteMenuMode(false);
};
const chatForDelete = chats.find(c => c.id === message.chatId);
const otherMember = chatForDelete?.members.find(m => m.user.id !== user?.id);
const otherMemberName = chatForDelete?.type === 'personal'
? otherMember?.user.displayName || otherMember?.user.userName || otherMember?.user.username || ''
: '';
const isPinned = (pinnedMessages[message.chatId] || []).some(m => m.id === message.id);
const handlePin = () => {
const socket = getSocket();
if (socket) {
if (isPinned) {
socket.emit('unpin_message', { messageId: message.id, chatId: message.chatId });
} else {
socket.emit('pin_message', { messageId: message.id, chatId: message.chatId });
}
}
setShowContext(false);
};
const handleReaction = (emoji: string) => {
const socket = getSocket();
if (socket) {
const existingReaction = message.reactions?.find(
(r) => r.userId === user?.id && r.emoji === emoji
);
console.log('[Reaction] handleReaction:', {
emoji,
messageId: message.id,
chatId: message.chatId,
existingReaction: !!existingReaction,
userId: user?.id
});
if (existingReaction) {
console.log('[Reaction] Emitting remove_reaction');
socket.emit('remove_reaction', { messageId: message.id, chatId: message.chatId, emoji });
} else {
console.log('[Reaction] Emitting add_reaction');
socket.emit('add_reaction', { messageId: message.id, chatId: message.chatId, emoji });
}
} else {
console.warn('[Reaction] Socket not available');
}
setShowContext(false);
};
const toggleAudio = () => {
const audio = audioRef.current;
if (!audio) return;
if (isPlaying) {
audio.pause();
setIsPlaying(false);
} else {
if (audio.readyState < 2) {
audio.load();
}
audio.play().then(() => {
setIsPlaying(true);
}).catch((err) => {
console.error('Audio play error:', err);
audio.load();
audio.play().then(() => setIsPlaying(true)).catch(console.error);
});
}
};
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
const onTimeUpdate = () => {
if (audio.duration) {
setAudioProgress((audio.currentTime / audio.duration) * 100);
}
};
const onLoadedMetadata = () => {
setAudioDuration(audio.duration);
};
const onEnded = () => {
setIsPlaying(false);
setAudioProgress(0);
};
audio.addEventListener('timeupdate', onTimeUpdate);
audio.addEventListener('loadedmetadata', onLoadedMetadata);
audio.addEventListener('ended', onEnded);
return () => {
audio.removeEventListener('timeupdate', onTimeUpdate);
audio.removeEventListener('loadedmetadata', onLoadedMetadata);
audio.removeEventListener('ended', onEnded);
};
}, []);
useEffect(() => {
const voiceUrl = message.media?.find((m) => m.type === 'voice')?.url;
if (!voiceUrl) return;
extractWaveform(voiceUrl, 28).then(setWaveformBars);
}, [message.media]);
const formatDuration = (sec: number) => {
if (!sec || isNaN(sec) || !isFinite(sec)) return '0:00';
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
};
const contextMenuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!showContext) return;
const hideMenu = (e: MouseEvent) => {
if (contextMenuRef.current?.contains(e.target as Node)) {
return;
}
setShowContext(false);
setDeleteMenuMode(false);
};
window.addEventListener('click', hideMenu, true);
window.addEventListener('contextmenu', hideMenu, true);
return () => {
window.removeEventListener('click', hideMenu, true);
window.removeEventListener('contextmenu', hideMenu, true);
};
}, [showContext]);
if (message.isDeleted || message.isDeletedForUser) {
return null;
}
if (message.type === 'call') {
const isMissed = message.callStatus === 'missed' || message.callStatus === 'declined' || message.callStatus === 'cancelled';
const statusText = isMissed
? (message.callStatus === 'missed' ? t('missedCall') : message.callStatus === 'declined' ? t('declinedCall') : t('cancelledCall'))
: t('completedCall');
const StatusIcon = isMissed ? PhoneMissed : (isMine ? PhoneOutgoing : PhoneIncoming);
const CallIcon = message.callType === 'video' ? Video : StatusIcon;
return (
<div className={`flex ${isMine ? 'justify-end' : 'justify-start'} mb-3 px-4 group scroll-mt-20`} data-message-id={message.id}>
<div
onContextMenu={handleContextMenu}
className={`group/call relative flex items-center gap-3.5 px-4 py-3 rounded-[1.25rem] border backdrop-blur-sm transition-all duration-300 cursor-default select-none
${isMine
? 'bg-primary/10 border-primary/20 hover:bg-primary/20 shadow-[0_4px_12px_rgba(48,150,229,0.08)]'
: 'bg-surface-variant/10 border-white/5 hover:bg-surface-variant/15 shadow-[0_4px_12px_rgba(0,0,0,0.15)]'}`}>
<div className={`w-11 h-11 rounded-2xl flex items-center justify-center shrink-0 shadow-inner group-hover/call:scale-105 transition-transform duration-500
${isMissed ? 'bg-red-500/15 text-red-400' : 'bg-emerald-500/15 text-emerald-400'}`}>
<CallIcon size={22} strokeWidth={2.5} className={isMissed && message.callStatus === 'missed' ? 'animate-wiggle' : ''} />
</div>
<div className="flex-1 min-w-0 pr-4">
<h4 className="text-[15px] font-bold text-white tracking-tight leading-tight mb-0.5 truncate">
{message.callType === 'video' ? t('videoCall') : t('audioCall')}
</h4>
<div className="flex items-center gap-1.5 opacity-80">
<span className={`text-[13px] font-medium ${isMissed ? 'text-red-400' : 'text-zinc-400'}`}>
{statusText} {message.duration && message.duration > 0 ? `${formatDuration(message.duration)}` : ''}
</span>
</div>
</div>
<div className="flex flex-col items-end justify-between self-stretch pt-0.5">
<div className="text-[10px] font-bold tracking-tight text-white/30 tabular-nums uppercase flex items-center gap-1">
{isPinned && <Pin size={10} className="rotate-45 text-primary fill-primary/20" />}
{timeStr}
</div>
{isMine && (
<div className="opacity-60 flex gap-0.5">
{isRead ? <CheckCheck size={12} className="text-primary" /> : <Check size={12} className="text-zinc-500" />}
</div>
)}
</div>
<div className="absolute inset-0 rounded-[1.25rem] bg-white/[0.03] opacity-0 group-hover/call:opacity-100 transition-opacity pointer-events-none" />
</div>
</div>
);
}
const media = message.media || [];
const isMediaGif = (m: MediaItem) => {
if (m.type === 'gif') return true;
if (m.url?.toLowerCase().includes('klipy') || m.url?.toLowerCase().endsWith('.gif')) return true;
if (m.filename?.toLowerCase().includes('gif') || m.filename?.toLowerCase().endsWith('.mp4') || m.filename?.toLowerCase().endsWith('.gif')) return true;
if (m.type === 'image' && m.url?.toLowerCase().endsWith('.mp4')) return true;
return false;
};
const isAudioFile = (m: MediaItem) => m.type === 'audio' || AUDIO_EXTENSIONS.some(ext => m.filename?.toLowerCase().endsWith(ext));
const hasImage = media.some((m) => m.type === 'image' || isMediaGif(m));
const hasVoice = message.type === 'voice' || media.some((m) => m.type === 'voice');
const hasAudio = !hasVoice && (message.type === 'audio' || media.some(isAudioFile));
const hasVideo = media.some((m) => m.type === 'video' && !isMediaGif(m));
const hasFile = media.some((m) => m.type !== 'image' && m.type !== 'voice' && m.type !== 'video' && !isAudioFile(m) && !isMediaGif(m));
const reactionGroups: Record<string, { count: number; users: string[]; isMine: boolean; avatars: { url?: string | null, initials: string, colorClass?: string }[] }> = {};
(message.reactions || []).forEach((r) => {
if (!reactionGroups[r.emoji]) {
reactionGroups[r.emoji] = { count: 0, users: [], isMine: false, avatars: [] };
}
reactionGroups[r.emoji].count++;
const displayName = r.user?.displayName || r.user?.userName || r.user?.username || '?';
reactionGroups[r.emoji].users.push(displayName);
if (reactionGroups[r.emoji].avatars.length < 3) {
reactionGroups[r.emoji].avatars.push({
url: r.user?.avatar,
initials: getInitials(displayName),
colorClass: 'from-primary/80 to-primary-container'
});
}
if (r.userId === user?.id) reactionGroups[r.emoji].isMine = true;
});
const senderName = message.sender?.displayName || message.sender?.userName || message.sender?.username || '';
const senderAvatar = message.sender?.avatarUrl || message.sender?.avatar;
const firstUrlMatch = message.content?.match(/https?:\/\/[^\s]+/);
const firstUrl = firstUrlMatch ? firstUrlMatch[0] : null;
const renderFormattedText = (text: string) => {
if (!text) return text;
const parts = text.split(/(\*\*[\s\S]*?\*\*|\*[\s\S]*?\*|_[\s\S]*?_|~[\s\S]*?~|`[\s\S]*?`|@\w+|https?:\/\/[^\s]+)/g);
return parts.map((part, i) => {
if (part.match(/^https?:\/\/[^\s]+$/)) {
return (
<a
key={i}
href={part}
target="_blank"
rel="noopener noreferrer"
className="text-white underline decoration-white/30 underline-offset-2 hover:decoration-white transition-all"
onClick={(e) => e.stopPropagation()}
>
{part}
</a>
);
}
if (part.startsWith('**') && part.endsWith('**')) return <strong key={i} className="font-bold">{part.slice(2, -2)}</strong>;
if (part.startsWith('_') && part.endsWith('_')) return <em key={i} className="italic">{part.slice(1, -1)}</em>;
if (part.startsWith('*') && part.endsWith('*')) return <em key={i} className="italic">{part.slice(1, -1)}</em>;
if (part.startsWith('~') && part.endsWith('~')) return <del key={i} className="line-through opacity-80">{part.slice(1, -1)}</del>;
if (part.startsWith('`') && part.endsWith('`')) {
return <code key={i} className="font-mono text-[13px] bg-black/20 px-1 py-0.5 rounded-[0.35rem]">{part.slice(1, -1)}</code>;
}
if (part.startsWith('@') && part.length > 1) {
const mentionUsername = part.slice(1);
return (
<span
key={i}
className="font-semibold text-sky-300 cursor-pointer hover:underline"
onClick={(e) => {
e.stopPropagation();
const chat = chats.find(c => c.id === message.chatId);
const members = chat?.members || [];
const found = members.find((m) => m.user?.username === mentionUsername);
if (found) {
onViewProfile?.(found.user.id);
}
}}
>{part}</span>
);
}
return <span key={i}>{part}</span>;
});
};
return (
<>
<div
ref={bubbleRef}
className={`flex ${isMine ? 'justify-end' : 'justify-start'} group mb-0.5 relative transition-colors duration-200 ${selectionMode ? 'px-4 -mx-4 cursor-pointer hover:bg-white/5 rounded-xl' : ''
} ${isSelected ? 'bg-knot-500/10 hover:bg-knot-500/20' : ''}`}
onClick={() => {
if (selectionMode) onToggleSelect?.(message.id);
}}
onContextMenu={handleContextMenu}
>
{selectionMode && (
<div className="absolute left-1 top-1/2 -translate-y-1/2 w-5 h-5 rounded-full border border-white/30 flex items-center justify-center transition-colors">
{isSelected && <div className="w-5 h-5 rounded-full bg-knot-500 flex items-center justify-center">
<Check size={12} className="text-white" />
</div>}
</div>
)}
{!isMine && (
<div className="w-8 flex-shrink-0 mr-2 self-end">
{showAvatar ? (
<button onClick={() => onViewProfile?.(message.senderId)}>
{senderAvatar ? (
<img src={senderAvatar} alt="" className="w-8 h-8 rounded-lg object-cover" />
) : (
<div className="w-8 h-8 rounded-lg bg-linear-to-br from-primary/80 to-primary flex items-center justify-center text-on-primary text-xs font-black shadow-inner">
{getInitials(senderName)}
</div>
)}
</button>
) : null}
</div>
)}
<div className={`max-[500px]:max-w-[85%] max-w-[75%] lg:max-w-[65%] min-w-0 ${isMine ? 'items-end' : 'items-start'} flex flex-col`}>
{!isMine && showAvatar && (
<button
className="text-xs font-medium text-knot-400 ml-3 mb-0.5 hover:underline"
onClick={() => onViewProfile?.(message.senderId)}
>
{senderName}
</button>
)}
{(() => {
const hasReactions = Object.keys(reactionGroups).length > 0;
const needsFrame = !!message.content || !!message.forwardedFrom || !!message.replyTo || hasVoice || hasAudio || hasFile || !!message.storyId;
return (
<div
id={`msg-${message.id}`}
onContextMenu={handleContextMenu}
onDoubleClick={handleReply}
title={t('reply') ? `${t('reply')} (Double Click)` : 'Double click to reply'}
className={`cursor-pointer max-w-full min-w-[60px] transition-all duration-500 overflow-hidden ${!needsFrame
? 'p-0 shadow-none border-none bg-transparent'
: isMine
? 'bubble-sent px-4 py-3 hover:brightness-110'
: 'bubble-received px-4 py-3 hover:brightness-110'
}`}
>
{/* Reply */}
{message.replyTo && (
<div
className={`mb-2 pl-3 py-2 cursor-pointer transition-all -mx-1 px-2 rounded-xl ${isMine ? 'bg-[#1a1a1a] border-l-[3px] border-l-primary hover:bg-[#202020]' : 'bg-white/5 border-l-[3px] border-l-primary hover:bg-white/10'
}`}
onClick={(e) => {
e.stopPropagation();
const el = document.getElementById(`msg-${message.replyToId}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('highlight-message');
setTimeout(() => el.classList.remove('highlight-message'), 5000);
}
}}
>
<p className={`text-[12px] font-black uppercase tracking-wider mb-0.5 truncate ${isMine ? 'text-primary' : 'text-primary'}`}>
{message.replyTo.sender?.displayName || message.replyTo.sender?.userName || message.replyTo.sender?.username || ''}
</p>
<div className="flex items-center gap-1.5">
{message.replyTo.isDeleted ? (
<p className="text-[13px] text-zinc-500 italic truncate">{t('messageDeleted')}</p>
) : (
<>
{message.replyTo.media && message.replyTo.media.length > 0 && !message.quote && (() => {
const m = message.replyTo.media[0];
const isMp4 = m.type === 'image' && m.url?.toLowerCase().endsWith('.mp4');
return (
<div className="w-8 h-8 rounded bg-black/20 overflow-hidden flex-shrink-0 relative">
{m.type === 'image' ? (
isMp4 ? (
<video src={getMediaUrl(m.url)} className="w-full h-full object-cover" muted playsInline />
) : (
<img src={getMediaUrl(m.url)} className="w-full h-full object-cover" alt="" />
)
) : m.type === 'video' ? (
<>
<video src={getMediaUrl(m.url)} className="w-full h-full object-cover" muted playsInline />
<div className="absolute inset-0 flex items-center justify-center bg-black/40"><Play size={10} className="text-white" /></div>
</>
) : (
<div className="w-full h-full flex items-center justify-center"><FileText size={10} className="text-white/50" /></div>
)}
</div>
);
})()}
<p className={`text-[13px] line-clamp-1 break-words italic ${isMine ? 'text-zinc-200' : 'text-zinc-400'}`}>
{message.quote || message.replyTo.content || (message.replyTo.media && message.replyTo.media.length > 0 ? (() => {
const m = message.replyTo.media[0];
if (m.type === 'image' && m.url?.toLowerCase().endsWith('.mp4')) return 'GIF';
if (m.type === 'image') return t('photo');
if (m.type === 'video') return t('video');
if (m.type === 'voice') return t('voice');
return t('media');
})() : '')}
</p>
</>
)}
</div>
</div>
)}
{/* Story Reply Quote */}
{message.storyId && (
<div
className={`mb-1.5 pl-2.5 py-0.5 border-l-[3px] transition-colors -mx-1 px-1 rounded-sm ${isMine ? 'border-l-white/80 hover:bg-white/10' : 'border-l-knot-500 hover:bg-knot-500/10'
}`}
>
<p className={`text-[11px] font-bold uppercase tracking-wider mb-1 ${isMine ? 'text-white/80' : 'text-knot-500/80'}`}>
{t('story')}
</p>
<div className="flex items-center gap-2">
{message.storyMediaUrl && (
<div className="w-8 h-8 rounded bg-black/20 overflow-hidden flex-shrink-0">
{message.storyMediaType === 'video' ? (
<div className="w-full h-full relative">
<video src={getMediaUrl(message.storyMediaUrl)} className="w-full h-full object-cover" />
<div className="absolute inset-0 flex items-center justify-center bg-black/20"><Play size={10} className="text-white fill-white" /></div>
</div>
) : message.storyMediaType === 'image' ? (
<img src={getMediaUrl(message.storyMediaUrl)} className="w-full h-full object-cover" alt="" />
) : (
<div className="w-full h-full flex items-center justify-center bg-knot-500/20"><FileText size={10} className="text-knot-400" /></div>
)}
</div>
)}
<p className={`text-[13px] line-clamp-2 break-words whitespace-pre-wrap ${isMine ? 'text-[#0a0a0a]/70' : 'text-zinc-600 dark:text-zinc-300'}`}>
{message.quote}
</p>
</div>
</div>
)}
{/* Рендер пересланного сообщения */}
{message.forwardedFrom && (
<div
className="mb-1 text-[13.5px] cursor-pointer hover:bg-white/5 transition-colors -mx-1 px-1 rounded-sm"
onClick={() => onViewProfile?.(message.forwardedFromId!)}
>
<div className={`font-medium ${isMine ? 'text-white/90' : 'text-knot-500'}`}>
{(t('forwardedFrom' as any) === 'forwardedFrom' ? 'Переслано от' : t('forwardedFrom' as any))} <span className="font-semibold">{message.forwardedFrom.displayName || message.forwardedFrom.userName || message.forwardedFrom.username || ''}</span>
</div>
</div>
)}
{/* Изображения и Видео (Галерея) */}
{(hasImage || hasVideo) && (() => {
const galleryMedia = media.filter(m => m.type === 'image' || m.type === 'video' || isMediaGif(m));
const isSingleGif = galleryMedia.length === 1 && isMediaGif(galleryMedia[0]);
const hasReactions = Object.keys(reactionGroups).length > 0;
return (
<div className={`
${needsFrame ? `-mx-[14px] ${message.forwardedFrom || message.replyTo || message.storyId ? 'mt-2' : '-mt-[8px]'} ${message.content ? 'mb-2' : hasReactions ? 'mb-1' : '-mb-[8px]'}` : ''}
${isSingleGif ? 'max-w-[260px]' : ''}
overflow-hidden relative rounded-[1.25rem]
`}>
<div className={`grid gap-[2px] ${galleryMedia.length > 1 ? 'w-[80vw] sm:w-[380px] md:w-[450px]' : 'w-full'} ${galleryMedia.length === 1 ? 'grid-cols-1' : 'grid-cols-6'
}`}>
{galleryMedia.map((m, idx) => {
const gif = isMediaGif(m);
let cellClass = '';
const count = galleryMedia.length;
if (count === 1) {
cellClass = isSingleGif ? 'max-h-[260px] aspect-auto' : 'max-h-[350px] sm:max-h-[450px] md:max-h-[500px] h-auto aspect-auto';
} else if (count === 2) {
cellClass = 'col-span-3 aspect-square';
} else if (count === 3) {
cellClass = idx === 0 ? 'col-span-6 aspect-[2/1] max-h-[300px]' : 'col-span-3 aspect-square';
} else if (count === 4) {
cellClass = 'col-span-3 aspect-square';
} else if (count === 5) {
cellClass = idx < 2 ? 'col-span-3 aspect-square' : 'col-span-2 aspect-square';
} else if (count === 6) {
cellClass = idx === 0 ? 'col-span-6 aspect-[2/1] max-h-[300px]' : idx < 3 ? 'col-span-3 aspect-[4/3]' : 'col-span-2 aspect-square';
} else {
// 7+
cellClass = 'col-span-2 aspect-square';
}
return (
<div
key={m.id}
className={`relative cursor-pointer group/video overflow-hidden transition-all hover:brightness-90 bg-zinc-900 ${cellClass}`}
onClick={() => setLightboxData({ index: idx })}
>
{gif ? (
(m.url?.toLowerCase().endsWith('.gif') || m.filename?.toLowerCase().endsWith('.gif')) ? (
<img
src={getMediaUrl(m.url)}
alt=""
className={`w-full h-full object-cover ${galleryMedia.length === 1 ? 'relative h-auto' : 'absolute inset-0'}`}
/>
) : (
<video
src={getMediaUrl(m.url)}
autoPlay loop muted playsInline preload="metadata"
className={`w-full h-full object-cover min-h-[150px] min-w-[200px] bg-zinc-900 shadow-inner rounded-[1.25rem] ${galleryMedia.length === 1 ? 'relative h-auto' : 'absolute inset-0'}`}
/>
)
) : m.type === 'video' ? (
<>
{m.thumbnail ? (
<img src={getMediaUrl(m.thumbnail)} className={`w-full h-full object-cover ${galleryMedia.length === 1 ? 'relative h-auto' : 'absolute inset-0'}`} alt="" />
) : (
<video
src={getMediaUrl(m.url)}
preload="metadata"
className={`w-full h-full object-cover bg-zinc-900 ${galleryMedia.length === 1 ? 'relative h-auto min-h-[160px]' : 'absolute inset-0'}`}
/>
)}
<div className={`absolute inset-0 flex items-center justify-center bg-black/20 group-hover/video:bg-black/40 transition-colors`}>
<Play size={galleryMedia.length > 1 ? 24 : 48} className="text-white opacity-80" />
</div>
</>
) : (
<img
src={getMediaUrl(m.url)}
alt=""
className={`w-full h-full object-cover ${galleryMedia.length === 1 ? 'relative h-auto' : 'absolute inset-0'}`}
/>
)}
</div>
);
})}
</div>
{!message.content && (
<div className="absolute bottom-1.5 right-1.5 z-10 pointer-events-none flex justify-end">
<span className="text-[10px] font-bold text-on-surface-variant/40 bg-surface-container-highest/40 px-2 py-0.5 rounded-full flex items-center gap-1 backdrop-blur-md pointer-events-auto">
{isPinned && <Pin size={10} className="rotate-45 text-primary fill-primary/40" />}
{timeStr}
{isMine && !message.scheduledAt && (
<span className={`material-symbols-outlined text-[14px] ${isRead ? 'text-primary fill-1' : 'text-on-surface-variant/40'}`} style={{ fontVariationSettings: `'FILL' ${isRead ? 1 : 0}` }}>
{isRead ? 'done_all' : 'done'}
</span>
)}
</span>
</div>
)}
</div>
);
})()}
{/* Голосовое - Optimized Kinetic Layout */}
{hasVoice && (
<div className="flex items-center gap-3 min-w-[200px] py-0.5">
<audio
ref={audioRef}
src={media.find((m) => m.type === 'voice')?.url}
preload="auto"
/>
<button
onClick={toggleAudio}
className={`w-9 h-9 rounded-full flex items-center justify-center flex-shrink-0 ${isMine ? 'bg-white text-primary' : 'bg-primary text-white'} shadow-sm transition-all active:scale-95`}
>
{isPlaying ? (
<Pause size={16} fill="currentColor" />
) : (
<Play size={16} fill="currentColor" className="ml-0.5" />
)}
</button>
<div className="flex-1 min-w-0">
<div
className="flex items-center gap-[2px] h-6 cursor-pointer"
onClick={(e) => {
const audio = audioRef.current;
if (!audio || !audio.duration) return;
const rect = e.currentTarget.getBoundingClientRect();
const pct = (e.clientX - rect.left) / rect.width;
audio.currentTime = pct * audio.duration;
setAudioProgress(pct * 100);
if (!isPlaying) toggleAudio();
}}
>
{(waveformBars || Array(28).fill(0.5)).map((val, i) => {
const barHeight = Math.max(10, val * 85);
const progress = audioProgress / 100;
const barProgress = i / 28;
const isActive = barProgress < progress;
return (
<div
key={i}
className={`flex-1 rounded-full transition-all duration-200 ${isActive
? isMine ? 'bg-[#000000] opacity-70' : 'bg-primary'
: isMine ? 'bg-[#000000] opacity-20' : 'bg-white/30'
}`}
style={{ height: `${barHeight}%` }}
/>
);
})}
</div>
<div className="flex justify-end mt-0.5">
<span className={`text-[10px] font-bold tabular-nums ${isMine ? 'text-[#0a0a0a]/60' : 'text-white/60'}`}>
{isPlaying
? formatDuration(audioRef.current?.currentTime || 0)
: formatDuration(audioDuration || message.media?.find((m) => m.type === 'voice')?.duration || 0)}
</span>
</div>
</div>
</div>
)}
{/* Аудио (mp3 файлы) */}
{hasAudio && (() => {
const audioMedia = media.find(isAudioFile);
const formatSize = (bytes?: number | null) => {
if (!bytes) return "";
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + " MB";
return (bytes / (1024 * 1024 * 1024)).toFixed(1) + " GB";
};
return (
<div className="min-w-[220px]">
{audioMedia?.filename && (
<div className="flex items-center gap-2 mb-2 min-w-0">
<Volume2 size={14} className={isMine ? 'text-[#0a0a0a]/60' : 'text-zinc-400'} />
<span className={`text-[11px] font-bold truncate ${isMine ? 'text-[#0a0a0a]/80' : 'text-zinc-200'}`}>{audioMedia.filename}</span>
</div>
)}
<div className="flex items-center gap-3">
<audio
ref={audioRef}
src={getMediaUrl(audioMedia?.url)}
preload="auto"
/>
<button
onClick={toggleAudio}
className={`w-9 h-9 rounded-full flex items-center justify-center flex-shrink-0 ${isMine ? 'bg-[#0a0a0a]/10 hover:bg-[#0a0a0a]/20 text-[#0a0a0a]' : 'bg-primary text-white shadow-lg'} transition-all active:scale-95`}
>
{isPlaying ? (
<Pause size={16} fill="currentColor" />
) : (
<Play size={16} fill="currentColor" className="ml-0.5" />
)}
</button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-[2px] h-6 cursor-pointer"
onClick={(e) => {
const audio = audioRef.current;
if (!audio || !audio.duration) return;
const rect = e.currentTarget.getBoundingClientRect();
const pct = (e.clientX - rect.left) / rect.width;
audio.currentTime = pct * audio.duration;
setAudioProgress(pct * 100);
}}>
{Array.from({ length: 28 }).map((_, i) => {
const barHeight = [40, 65, 35, 80, 50, 90, 45, 70, 55, 85, 30, 75, 60, 95, 40, 80, 50, 70, 35, 90, 55, 65, 45, 85, 60, 75, 50, 40][i] || 50;
const progress = audioProgress / 100;
const barProgress = i / 28;
const isActive = barProgress < progress;
return (
<div
key={i}
className={`flex-1 rounded-full transition-all duration-150 ${isActive
? isMine ? 'bg-[#0a0a0a]/70' : 'bg-primary'
: isMine ? 'bg-[#0a0a0a]/10' : 'bg-white/20'
}`}
style={{ height: `${barHeight}%` }}
/>
);
})}
</div>
<div className="flex justify-between items-center mt-0.5">
<span className={`text-[10px] font-bold tabular-nums ${isMine ? 'text-[#0a0a0a]/60' : 'text-zinc-500'}`}>
{isPlaying
? formatDuration(audioRef.current?.currentTime || 0)
: (typeof audioMedia?.duration === 'number'
? formatDuration(audioMedia.duration)
: (audioMedia?.duration || formatDuration(audioDuration || 0)))}
</span>
<div className="flex items-center gap-2">
<span className={`text-[10px] font-black uppercase tracking-tighter ${isMine ? 'text-[#0a0a0a]/40' : 'text-zinc-500'}`}>{formatSize(audioMedia?.size)}</span>
<a
href={getMediaUrl(audioMedia?.url)}
download={audioMedia?.filename || 'audio'}
target="_blank"
rel="noopener noreferrer"
className={`flex items-center justify-center p-1 rounded-md transition-all ${isMine ? 'hover:bg-[#0a0a0a]/10 text-[#0a0a0a]/40 hover:text-[#0a0a0a]' : 'hover:bg-white/10 text-zinc-500 hover:text-white'}`}
>
<Download size={12} />
</a>
</div>
</div>
</div>
</div>
</div>
);
})()}
{/* Файлы */}
{hasFile &&
media
.filter((m) => m.type !== 'image' && m.type !== 'voice' && m.type !== 'video' && !isAudioFile(m) && m.type !== 'gif')
.map((m) => {
const formatSize = (bytes?: number | null) => {
if (!bytes) return "";
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + " MB";
return (bytes / (1024 * 1024 * 1024)).toFixed(1) + " GB";
};
return (
<a
key={m.id}
href={getMediaUrl(m.url)}
download={m.filename || 'file'}
target="_blank"
rel="noopener noreferrer"
className={`flex items-center gap-3 p-3 rounded-2xl ${isMine ? 'bg-[#0a0a0a]/5 hover:bg-[#0a0a0a]/10' : 'bg-zinc-900/50 hover:bg-zinc-800/80 border border-white/5'
} transition-all mb-1 group/file`}
>
<div className={`w-11 h-11 rounded-xl flex items-center justify-center ${isMine ? 'bg-[#0a0a0a]/10' : 'bg-primary/20'
} group-hover/file:scale-110 transition-transform`}>
<FileText size={22} className={isMine ? 'text-[#0a0a0a]' : 'text-primary'} />
</div>
<div className="flex-1 min-w-0">
<p className={`text-[13px] font-bold truncate ${isMine ? 'text-[#0a0a0a]' : 'text-zinc-200'}`}>{m.filename || t('fileLabel')}</p>
<p className={`text-[11px] font-medium ${isMine ? 'text-[#0a0a0a]/50' : 'text-zinc-500'}`}>
{formatSize(m.size) || t('download')}
</p>
</div>
<Download size={18} className={isMine ? 'text-[#0a0a0a]/30' : 'text-zinc-500'} />
</a>
);
})}
{/* Опрос */}
{message.type === 'poll' && message.pollOptions && (
<div className={`p-1.5 space-y-4 min-w-[260px] max-w-full ${isMine ? 'text-[#0a0a0a]' : 'text-zinc-200'}`}>
<div className="space-y-1">
<h4 className="text-[15px] font-bold leading-tight flex items-start gap-2 whitespace-pre-wrap break-words">
<BarChart2 size={18} className="mt-0.5 shrink-0 opacity-60" />
{message.content}
</h4>
<div className="flex items-center justify-between pl-7 pr-2">
<p className="text-[11px] font-medium opacity-50 uppercase tracking-widest">
{message.pollIsMultipleChoice ? t('multipleAnswers') : t('singleAnswer')}
</p>
{message.pollIsAnonymous && (
<span className="text-[10px] font-black uppercase tracking-tighter bg-black/5 px-2 py-0.5 rounded-sm opacity-40">
{t('anonymous')}
</span>
)}
</div>
</div>
<div className="space-y-2">
{(() => {
const hasVoted = (message.userVotedOptionIds && message.userVotedOptionIds.length > 0) ||
message.pollOptions?.some(o => o.voterIds?.includes(user?.id || ''));
const totalVotes = message.pollOptions!.reduce((sum, o) => sum + (o.voteCount || 0), 0);
return message.pollOptions.map((opt, idx) => {
const isVotedByMe = (message.userVotedOptionIds?.includes(opt.id)) ||
opt.voterIds?.includes(user?.id || '');
const percent = totalVotes > 0 ? Math.round(((opt.voteCount || 0) / totalVotes) * 100) : 0;
return (
<button
key={opt.id || idx}
disabled={hasVoted && !message.pollIsMultipleChoice} // If already voted and not multiple choice, disable.
onClick={(e) => {
e.stopPropagation();
if (hasVoted && !message.pollIsMultipleChoice) return;
// Optimistic update for better UX
const currentVoted = message.userVotedOptionIds || [];
if (!currentVoted.includes(opt.id)) {
useChatStore.getState().updateMessage({
...message,
userVotedOptionIds: message.pollIsMultipleChoice ? [...currentVoted, opt.id] : [opt.id]
});
}
const socket = getSocket();
if (socket) {
socket.emit('vote_poll', {
messageId: message.id,
chatId: message.chatId,
optionId: opt.id
});
}
}}
className={`w-full group/opt relative rounded-2xl border transition-all duration-300 text-left p-3 flex flex-col gap-1.5
${isMine
? 'bg-[#0a0a0a]/5 border-[#0a0a0a]/10 hover:bg-[#0a0a0a]/10 active:scale-[0.98] hover:z-20'
: 'bg-white/5 border-white/5 hover:bg-white/10 active:scale-[0.98] hover:z-20'}`}
>
<div className="flex items-center justify-between relative z-10">
<div className="flex items-center gap-2 truncate flex-1">
{isVotedByMe && <Check size={14} className={isMine ? 'text-[#0a0a0a]' : 'text-primary'} />}
<span className="text-[14px] font-semibold truncate">{opt.text}</span>
</div>
{hasVoted && (
<span className="text-[13px] font-black tabular-nums opacity-80">{percent}%</span>
)}
</div>
{hasVoted && (
<>
<div className="relative h-1.5 w-full bg-white/5 rounded-full overflow-hidden z-10">
<motion.div
initial={false}
animate={{ width: `${percent}%` }}
transition={{ duration: 0.8, ease: "easeOut" }}
className={`absolute inset-0 rounded-full ${isMine ? 'bg-[#0a0a0a]/40' : 'bg-primary'}`}
/>
</div>
<div className="flex items-center justify-between relative z-10 font-bold uppercase tracking-widest text-[10px]">
<span className="opacity-40">{opt.voteCount || 0} {t('votes')}</span>
{opt.voters && opt.voters.length > 0 && (
<div className="relative group/voters flex -space-x-1.5 hover:space-x-0.5 transition-all duration-300">
{opt.voters.slice(0, 5).map((voter) => (
<Avatar
key={voter.id}
src={voter.avatar}
name={voter.displayName}
size="xs"
className="ring-1 ring-white/20 shadow-lg"
/>
))}
{opt.voters.length > 5 && (
<div className="w-4 h-4 rounded-lg bg-white/10 flex items-center justify-center text-[7px] font-black tabular-nums ring-1 ring-white/20 text-white/50">
+{opt.voters.length - 5}
</div>
)}
{/* Подробный список при наведении */}
<div className="absolute bottom-full right-0 mb-3 opacity-0 group-hover/voters:opacity-100 transition-all duration-300 pointer-events-none scale-95 group-hover/voters:scale-100 origin-bottom-right z-50">
<div className="bg-[#1b1b1b] shadow-2xl rounded-2xl p-2 border border-white/10 min-w-[160px] backdrop-blur-xl">
<div className="space-y-1">
{opt.voters.map(v => (
<div key={v.id} className="flex items-center gap-2.5 p-1.5 hover:bg-white/10 rounded-xl transition-colors">
<Avatar src={v.avatar} name={v.displayName} size="xs" />
<span className="text-[11px] font-bold text-white/90 truncate">{v.displayName}</span>
</div>
))}
</div>
</div>
</div>
</div>
)}
</div>
</>
)}
</button>
);
});
})()}
</div>
{(() => {
const hasVoted = (message.userVotedOptionIds && message.userVotedOptionIds.length > 0) ||
message.pollOptions?.some(o => o.voterIds?.includes(user?.id || ''));
const totalVotes = message.pollOptions!.reduce((sum, o) => sum + (o.voteCount || 0), 0);
if (hasVoted) {
return <div className="text-[10px] font-black opacity-30 mt-2 px-2">ВСЕГО ПРОГОЛОСОВАЛО: {totalVotes}</div>
}
return null;
})()}
</div>
)}
{/* Текст */}
{message.content && message.type !== 'poll' && (() => {
const onlyEmojiRegex = /^[\p{Extended_Pictographic}\s]+$/u;
const isOnlyEmojis = onlyEmojiRegex.test(message.content) && message.content.length <= 15;
return (
<div className="flex items-end gap-2 text-sm w-full">
<div className="flex-1 min-w-0 w-full">
<p className={`whitespace-pre-wrap break-words leading-relaxed w-full ${isOnlyEmojis ? 'text-5xl my-1' : ''} ${isMine ? 'text-[#0a0a0a] font-normal' : 'text-zinc-200'}`}>
{renderFormattedText(message.content)}
</p>
{firstUrl && !hasImage && !hasVideo && !hasFile && (
<div className="w-full mt-1 mb-1 relative overflow-hidden">
<LinkPreview url={firstUrl} />
</div>
)}
</div>
<span className={`text-[10px] font-bold flex-shrink-0 flex items-center gap-0.5 self-end float-right leading-none ${isOnlyEmojis ? '-mb-1' : 'mb-0.5'} ${isMine ? 'text-[#0a0a0a]/50' : 'text-on-surface-variant/40'}`}>
{message.isEdited && <span className="mr-0.5">{t('edited')}</span>}
{message.scheduledAt && <span className="material-symbols-outlined text-[12px] text-amber-400 mr-0.5">schedule</span>}
{isPinned && <Pin size={10} className={`rotate-45 ${isMine ? 'text-[#0a0a0a]/60 fill-[#0a0a0a]/20' : 'text-primary fill-primary/20'} mr-0.5`} />}
{timeStr}
{isMine && !message.scheduledAt && (
<span className={`material-symbols-outlined text-[14px] ${isRead ? 'text-[#0a0a0a]/80 fill-1' : 'text-[#0a0a0a]/40'}`} style={{ fontVariationSettings: `'FILL' ${isRead ? 1 : 0}` }}>
{isRead ? 'done_all' : 'done'}
</span>
)}
</span>
</div>
);
})()}
</div>
);
})()}
{Object.keys(reactionGroups).length > 0 && (
<div className={`flex flex-wrap gap-1.5 mt-2 ${isMine ? 'justify-end' : 'justify-start'} relative z-20`}>
{Object.entries(reactionGroups).map(([emoji, data]) => (
<button
key={emoji}
onClick={(e) => { e.stopPropagation(); handleReaction(emoji); }}
className={`flex items-center gap-2 px-2.5 py-1.5 rounded-[10px] transition-all border ${data.isMine
? 'bg-primary/20 border-primary text-white shadow-lg'
: 'bg-[#201F1F] border-white/5 text-zinc-300 hover:bg-[#2a2a2a]'
} shadow-md group/react`}
title={data.users.join(', ')}
>
<span className="text-[14px] leading-none">{emoji}</span>
{data.count > 1 && (
<span className="text-[13px] font-bold tabular-nums">{data.count}</span>
)}
</button>
))}
</div>
)}
</div>
{isMine && (
<div className="w-8 flex-shrink-0 ml-2 self-end">
{showAvatar ? (
<button onClick={() => onViewProfile?.(message.senderId)}>
{senderAvatar ? (
<img src={senderAvatar} alt="" className="w-8 h-8 rounded-lg object-cover" />
) : (
<div className="w-8 h-8 rounded-lg bg-linear-to-br from-primary/80 to-primary flex items-center justify-center text-on-primary text-[10px] font-black shadow-inner">
{getInitials(senderName)}
</div>
)}
</button>
) : null}
</div>
)}
</div>
{typeof document !== 'undefined' && createPortal(
<AnimatePresence>
{showContext && (
<motion.div
ref={contextMenuRef}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
className="fixed z-[9999] w-52 rounded-[1.25rem] bg-[#1a1a1a] shadow-[0_20px_50px_rgba(0,0,0,0.5)] py-1.5 overflow-hidden border border-white/10"
style={{ left: contextPos.x, top: contextPos.y }}
onClick={(e) => e.stopPropagation()}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
{deleteMenuMode ? (
<>
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-border">
<button
onClick={() => setDeleteMenuMode(false)}
className="p-1 rounded-lg hover:bg-surface-hover transition-colors text-zinc-400 hover:text-white"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="15 18 9 12 15 6" /></svg>
</button>
<span className="text-sm font-medium text-zinc-300">{t('delete')}</span>
</div>
<button
onClick={handleDeleteForMe}
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-zinc-300 hover:bg-surface-hover hover:text-white transition-colors"
>
<Trash2 size={16} className="text-zinc-400" />
{t('deleteForMe')}
</button>
<button
onClick={handleDeleteForAll}
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-red-400 hover:bg-red-500/10 hover:text-red-300 transition-colors"
>
<Trash2 size={16} />
{chatForDelete?.type === 'personal' && otherMemberName
? `${t('deleteAlsoFor')} ${otherMemberName}`
: t('deleteForAll')}
</button>
</>
) : (
<>
<div className="flex items-center gap-1 px-3 py-2 border-b border-border">
{['👍', '❤️', '😂', '😮', '😢', '🔥'].map((emoji) => (
<button
key={emoji}
onClick={() => handleReaction(emoji)}
className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-surface-hover transition-colors text-lg"
>
{emoji}
</button>
))}
</div>
<button
onClick={handleReply}
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-zinc-300 hover:bg-surface-hover hover:text-white transition-colors"
>
<Reply size={16} />
{quotedText ? t('replyWithQuote') : t('reply')}
</button>
<button
onClick={() => {
setShowContext(false);
onStartSelectionMode?.(message.id);
}}
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-zinc-300 hover:bg-surface-hover hover:text-white transition-colors"
>
<CheckCheck size={16} />
{t('select')}
</button>
<button
onClick={() => {
setShowContext(false);
onForward?.(message.id);
}}
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-zinc-300 hover:bg-surface-hover hover:text-white transition-colors"
>
<Forward size={16} />
{t('forward')}
</button>
<button
onClick={handlePin}
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-zinc-300 hover:bg-surface-hover hover:text-white transition-colors"
>
<Pin size={16} />
{isPinned ? t('unpinMessage') : t('pinMessage')}
</button>
{message.content && (
<button
onClick={handleCopy}
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-zinc-300 hover:bg-surface-hover hover:text-white transition-colors"
>
<Copy size={16} />
{t('copy')}
</button>
)}
{isMine && message.content && (
<button
onClick={handleEdit}
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-zinc-300 hover:bg-surface-hover hover:text-white transition-colors"
>
<Pencil size={16} />
{t('edit')}
</button>
)}
<div className="border-t border-border my-1" />
<button
onClick={() => isFavorites ? handleDeleteForMe() : setDeleteMenuMode(true)}
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-red-400 hover:bg-red-500/10 transition-colors"
>
<Trash2 size={16} />
{t('delete')}
</button>
</>
)}
</motion.div>
)}
</AnimatePresence>,
document.body
)}
<AnimatePresence>
{lightboxData && (
<ImageLightbox
images={media.filter(m => m.type === 'image' || m.type === 'video' || isMediaGif?.(m) || m.type === 'gif').map(m => ({
url: getMediaUrl(m.url),
type: (isMediaGif?.(m) || m.type === 'gif' || (m.type === 'image' && m.url?.toLowerCase().endsWith('.mp4'))) ? 'gif' : m.type
}))}
initialIndex={lightboxData.index}
onClose={() => setLightboxData(null)}
/>
)}
</AnimatePresence>
</>
);
}
export default memo(MessageBubble);