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, } 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 } from '../../../../core/utils/utils'; import type { Message, MediaItem, Reaction, ChatMember } from '../../../../core/domain/types'; import ImageLightbox from '../../../../core/presentation/components/ui/ImageLightbox'; import LinkPreview from './LinkPreview'; 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 [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); // Прочитано 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 otherMemberName = chatForDelete?.type === 'personal' ? chatForDelete.members.find(m => m.user.id !== user?.id)?.user.displayName || chatForDelete.members.find(m => m.user.id !== user?.id)?.user.username || '' : ''; const isPinned = pinnedMessages[message.chatId]?.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) { return null; } const media = message.media || []; const hasImage = media.some((m) => m.type === 'image'); const hasVoice = message.type === 'voice' || media.some((m) => m.type === 'voice'); const hasAudio = !hasVoice && (message.type === 'audio' || media.some((m) => m.type === 'audio')); const hasFile = media.some((m) => m.type !== 'image' && m.type !== 'voice' && m.type !== 'video' && m.type !== 'audio'); const hasVideo = media.some((m) => m.type === 'video'); 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 || '?'; reactionGroups[r.emoji].users.push(displayName); if (reactionGroups[r.emoji].avatars.length < 3) { reactionGroups[r.emoji].avatars.push({ url: r.user?.avatar, initials: displayName[0].toUpperCase() }); } if (r.userId === user?.id) reactionGroups[r.emoji].isMine = true; }); const senderName = message.sender?.displayName || message.sender?.username || ''; const senderAvatar = 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 && ( )}
{/* 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.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!)} >
{message.forwardedFrom.displayName || message.forwardedFrom.username}
)} {/* Изображения и Видео (Галерея) */} {(hasImage || hasVideo) && (() => { const galleryMedia = media.filter(m => m.type === 'image' || m.type === 'video'); const isSingleGif = galleryMedia.length === 1 && ( galleryMedia[0].filename === 'gif' || galleryMedia[0].filename === 'gif.gif' || galleryMedia[0].url?.includes('klipy') || galleryMedia[0].url?.endsWith('.gif') ); return (
= 3 ? 'grid-cols-3' : galleryMedia.length === 2 ? 'grid-cols-2' : 'grid-cols-1' }`}> {galleryMedia.map((m, idx) => { const isMp4Gif = m.type === 'image' && m.url?.toLowerCase().endsWith('.mp4'); return m.type === 'image' ? ( isMp4Gif ? (
); })()} {/* Голосовое */} {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').map(m => ({ url: m.url, type: m.type === 'image' && m.url?.toLowerCase().endsWith('.mp4') ? 'video' : m.type }))} initialIndex={lightboxData.index} onClose={() => setLightboxData(null)} /> )} ); } export default memo(MessageBubble);