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(null); const audioRef = useRef(null); const bubbleRef = useRef(null); const [quotedText, setQuotedText] = useState(null); // Прочитано // Для своих сообщений: проверено, есть ли в readBy другие пользователи (получатели) // Для чужих сообщений: проверено, есть ли в readBy текущий пользователь const isRead = isMine ? message.readBy?.some((r) => r.userId !== user?.id) // Кто-то кроме меня прочитал : 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(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 (

{message.callType === 'video' ? t('videoCall') : t('audioCall')}

{statusText} {message.duration && message.duration > 0 ? `• ${formatDuration(message.duration)}` : ''}
{isPinned && } {timeStr}
{isMine && (
{isRead ? : }
)}
); } 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 = {}; (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 ( e.stopPropagation()} > {part} ); } if (part.startsWith('**') && part.endsWith('**')) return {part.slice(2, -2)}; if (part.startsWith('_') && part.endsWith('_')) return {part.slice(1, -1)}; if (part.startsWith('*') && part.endsWith('*')) return {part.slice(1, -1)}; if (part.startsWith('~') && part.endsWith('~')) return {part.slice(1, -1)}; if (part.startsWith('`') && part.endsWith('`')) { return {part.slice(1, -1)}; } if (part.startsWith('@') && part.length > 1) { const mentionUsername = part.slice(1); return ( { 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} ); } return {part}; }); }; return ( <>
{ if (selectionMode) onToggleSelect?.(message.id); }} onContextMenu={handleContextMenu} > {selectionMode && (
{isSelected &&
}
)} {!isMine && (
{showAvatar ? ( ) : null}
)}
{!isMine && showAvatar && ( )} {(() => { const hasReactions = Object.keys(reactionGroups).length > 0; const needsFrame = !!message.content || !!message.forwardedFrom || !!message.replyTo || hasVoice || hasAudio || hasFile || !!message.storyId; return (
{/* Reply */} {message.replyTo && (
{ 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); } }} >

{message.replyTo.sender?.displayName || message.replyTo.sender?.userName || message.replyTo.sender?.username || ''}

{message.replyTo.isDeleted ? (

{t('messageDeleted')}

) : ( <> {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 (
{m.type === 'image' ? ( isMp4 ? (
); })()}

{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'); })() : '')}

)}
)} {/* Story Reply Quote */} {message.storyId && (

{t('story')}

{message.storyMediaUrl && (
{message.storyMediaType === 'video' ? (
) : message.storyMediaType === 'image' ? ( ) : (
)}
)}

{message.quote}

)} {/* Рендер пересланного сообщения */} {message.forwardedFrom && (
onViewProfile?.(message.forwardedFromId!)} >
{(t('forwardedFrom' as any) === 'forwardedFrom' ? 'Переслано от' : t('forwardedFrom' as any))} {message.forwardedFrom.displayName || message.forwardedFrom.userName || message.forwardedFrom.username || ''}
)} {/* Изображения и Видео (Галерея) */} {(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 (
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 (
setLightboxData({ index: idx })} > {gif ? ( (m.url?.toLowerCase().endsWith('.gif') || m.filename?.toLowerCase().endsWith('.gif')) ? ( ) : (
); })}
{!message.content && (
{isPinned && } {timeStr} {isMine && !message.scheduledAt && ( {isRead ? 'done_all' : 'done'} )}
)}
); })()} {/* Голосовое - Optimized Kinetic Layout */} {hasVoice && (
{isMine && (
{showAvatar ? ( ) : null}
)}
{typeof document !== 'undefined' && createPortal( {showContext && ( e.stopPropagation()} onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); }} > {deleteMenuMode ? ( <>
{t('delete')}
) : ( <>
{['👍', '❤️', '😂', '😮', '😢', '🔥'].map((emoji) => ( ))}
{message.content && ( )} {isMine && message.content && ( )}
)} )} , document.body )} {lightboxData && ( 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)} /> )} ); } export default memo(MessageBubble);