Files
forkmessager/client-web/src/modules/chats/presentation/components/MessageBubble.tsx
Халимов Рустам 4384233aa5 Reorganize web folder structurally
2026-03-19 22:24:21 +03:00

995 lines
43 KiB
TypeScript

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<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 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<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) {
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<string, { count: number; users: string[]; isMine: boolean; avatars: { url?: string | null, initials: 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 || '?';
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 (
<a
key={i}
href={part}
target="_blank"
rel="noopener noreferrer"
className="text-sky-400 hover:underline"
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-full object-cover" />
) : (
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white text-xs font-semibold">
{senderName[0]?.toUpperCase() || '?'}
</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>
)}
<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-0 rounded-[1.25rem] overflow-hidden transition-all duration-300 ${
hasImage && !message.content && !message.forwardedFrom && !message.replyTo
? 'p-0 shadow-none border-none'
: isMine
? 'bubble-sent text-white shadow-sm px-3.5 py-2 hover:shadow-md hover:brightness-105 rounded-br-sm'
: 'bubble-received text-zinc-100 shadow-sm px-3.5 py-2 hover:shadow-md hover:brightness-105 rounded-bl-[4px]'
}`}
>
{/* Reply */}
{message.replyTo && (
<div
className={`mb-1.5 pl-2.5 py-0.5 border-l-[3px] cursor-pointer 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'
}`}
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-[13.5px] font-semibold mb-0.5 truncate ${isMine ? 'text-white' : 'text-knot-500'}`}>
{message.replyTo.sender?.displayName || message.replyTo.sender?.username}
</p>
<div className="flex items-center gap-1.5">
{message.replyTo.isDeleted ? (
<p className="text-[13px] text-white/50 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={m.url} className="w-full h-full object-cover" muted playsInline />
) : (
<img src={m.url} className="w-full h-full object-cover" alt="" />
)
) : m.type === 'video' ? (
<>
<video src={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-2 break-words whitespace-pre-wrap ${isMine ? 'text-white/80' : 'text-zinc-600 dark:text-zinc-300'}`}>
{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-white/80' : 'text-zinc-600 dark:text-zinc-300'}`}>
{message.quote}
</p>
</div>
</div>
)}
{/* Рендер пересланного сообщения */}
{message.forwardedFrom && (
<div
className="mb-1.5 text-[14px] opacity-90 border-l-[3px] border-white/40 pl-2.5 py-0.5 cursor-pointer hover:bg-white/5 transition-colors -mx-1 px-1 rounded-sm"
onClick={() => onViewProfile?.(message.forwardedFromId!)}
>
<div className={`font-semibold ${isMine ? 'text-white' : 'text-knot-500'}`}>
{message.forwardedFrom.displayName || message.forwardedFrom.username}
</div>
</div>
)}
{/* Изображения и Видео (Галерея) */}
{(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 (
<div className={`
${(message.content || message.forwardedFrom) ? '-mx-4' : ''}
${(message.content || message.forwardedFrom) ? (message.forwardedFrom ? 'mt-2' : '-mt-2.5') : ''}
${(message.content || message.forwardedFrom) ? (message.content ? 'mb-2' : '-mb-2.5') : ''}
${isSingleGif && !(message.content || message.forwardedFrom) ? 'max-w-[260px] rounded-[1.25rem]' : ''}
${isSingleGif && (message.content || message.forwardedFrom) ? 'max-h-[260px] mx-auto' : ''}
bg-black/20 overflow-hidden relative
`}>
<div className={`grid gap-[2px] ${galleryMedia.length >= 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 ? (
<video
key={m.id}
src={m.url}
autoPlay
loop
muted
playsInline
className={`w-full h-full object-cover cursor-pointer hover:brightness-90 transition-all ${galleryMedia.length > 1 ? 'aspect-square' : isSingleGif ? 'max-h-[260px]' : 'max-h-[500px]'}`}
onClick={() => setLightboxData({ index: idx })}
/>
) : (
<img
key={m.id}
src={m.url}
alt=""
className={`w-full h-full object-cover cursor-pointer hover:brightness-90 transition-all ${galleryMedia.length > 1 ? 'aspect-square' : isSingleGif ? 'max-h-[260px]' : 'max-h-[500px]'}`}
onClick={() => setLightboxData({ index: idx })}
/>
)
) : (
<div
key={m.id}
className={`relative cursor-pointer group/video ${galleryMedia.length > 1 ? 'aspect-square' : ''
}`}
onClick={() => setLightboxData({ index: idx })}
>
<video
src={m.url}
className="w-full h-full object-cover"
/>
<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>
</div>
);
})}
</div>
</div>
);
})()}
{/* Голосовое */}
{hasVoice && (
<div className="flex items-center gap-3 min-w-[200px]">
<audio
ref={audioRef}
src={media.find((m) => m.type === 'voice')?.url}
preload="auto"
onError={(e) => console.error('Audio load error:', e)}
/>
<button
onClick={toggleAudio}
className={`w-9 h-9 rounded-full flex items-center justify-center flex-shrink-0 ${isMine ? 'bg-white/20 hover:bg-white/30' : 'bg-knot-500/20 hover:bg-knot-500/30'
} transition-colors`}
>
{isPlaying ? (
<Pause size={16} className={isMine ? 'text-white' : 'text-knot-400'} />
) : (
<Play size={16} className={`${isMine ? 'text-white' : 'text-knot-400'} ml-0.5`} />
)}
</button>
<div className="flex-1 min-w-0">
<div
className="flex items-end 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 * 100);
const progress = audioProgress / 100;
const barProgress = i / 28;
const isActive = barProgress < progress;
return (
<div
key={i}
className={`flex-1 rounded-full transition-colors duration-150 ${isActive
? isMine ? 'bg-white/80' : 'bg-knot-400'
: isMine ? 'bg-white/20' : 'bg-white/10'
}`}
style={{ height: `${barHeight}%` }}
/>
);
})}
</div>
<span className={`text-xs mt-0.5 block ${isMine ? 'text-white/60' : 'text-zinc-500'}`}>
{isPlaying
? formatDuration(audioRef.current?.currentTime || 0)
: formatDuration(audioDuration || message.media?.find((m) => m.type === 'voice')?.duration || 0)}
</span>
</div>
</div>
)}
{/* Аудио (mp3 файлы) */}
{hasAudio && (() => {
const audioMedia = media.find((m) => m.type === 'audio');
return (
<div className="min-w-[220px]">
{audioMedia?.filename && (
<div className="flex items-center gap-2 mb-2">
<Volume2 size={14} className={isMine ? 'text-white/60' : 'text-knot-400'} />
<span className={`text-xs truncate ${isMine ? 'text-white/70' : 'text-zinc-400'}`}>{audioMedia.filename}</span>
</div>
)}
<div className="flex items-center gap-3">
<audio
ref={audioRef}
src={audioMedia?.url}
preload="auto"
onError={(e) => console.error('Audio load error:', e)}
/>
<button
onClick={toggleAudio}
className={`w-9 h-9 rounded-full flex items-center justify-center flex-shrink-0 ${isMine ? 'bg-white/20 hover:bg-white/30' : 'bg-knot-500/20 hover:bg-knot-500/30'
} transition-colors`}
>
{isPlaying ? (
<Pause size={16} className={isMine ? 'text-white' : 'text-knot-400'} />
) : (
<Play size={16} className={`${isMine ? 'text-white' : 'text-knot-400'} ml-0.5`} />
)}
</button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-[2px] h-6">
{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-colors duration-150 ${isActive
? isMine ? 'bg-white/80' : 'bg-knot-400'
: isMine ? 'bg-white/20' : 'bg-white/10'
}`}
style={{ height: `${barHeight}%` }}
/>
);
})}
</div>
<span className={`text-xs mt-0.5 block ${isMine ? 'text-white/60' : 'text-zinc-500'}`}>
{isPlaying
? formatDuration(audioRef.current?.currentTime || 0)
: formatDuration(audioDuration || 0)}
</span>
</div>
</div>
</div>
);
})()}
{/* Файлы */}
{hasFile &&
media
.filter((m) => m.type !== 'image' && m.type !== 'voice' && m.type !== 'video' && m.type !== 'audio')
.map((m) => (
<a
key={m.id}
href={m.url}
download={m.filename || 'file'}
target="_blank"
rel="noopener noreferrer"
className={`flex items-center gap-3 p-2 rounded-xl ${isMine ? 'bg-white/10 hover:bg-white/15' : 'bg-surface-tertiary hover:bg-surface-hover'
} transition-colors mb-1`}
>
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${isMine ? 'bg-white/20' : 'bg-knot-500/20'
}`}>
<FileText size={20} className={isMine ? 'text-white' : 'text-knot-400'} />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm truncate">{m.filename || t('fileLabel')}</p>
<p className={`text-xs ${isMine ? 'text-white/50' : 'text-zinc-500'}`}>
{m.size ? `${(m.size / 1024).toFixed(1)} ${t('kb')}` : t('download')}
</p>
</div>
<Download size={16} className={isMine ? 'text-white/50' : 'text-zinc-500'} />
</a>
))}
{/* Текст */}
{message.content && (() => {
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' : ''}`}>
{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-[10.5px] flex-shrink-0 flex items-center gap-0.5 self-end float-right leading-none ${isOnlyEmojis ? '-mb-1' : 'mb-0.5'} ${isMine ? 'text-white/60' : 'text-zinc-500'
}`}>
{message.isEdited && <span className="mr-0.5">{t('edited')}</span>}
{message.scheduledAt && <Clock size={11} className="text-amber-400 mr-0.5" />}
{timeStr}
{isMine && !message.scheduledAt && (
isRead ? (
<CheckCheck size={14} className="text-sky-300 ml-0.5" />
) : (
<Check size={14} className="ml-0.5" />
)
)}
</span>
</div>
);
})()}
{!message.content && (hasImage || hasVideo) && (
<div className={`flex justify-end px-3 py-1 ${hasImage ? '-mt-8 relative z-10' : ''}`}>
<span className="text-[10px] text-white/70 bg-black/40 px-2 py-0.5 rounded-full flex items-center gap-1 backdrop-blur-sm">
{timeStr}
{isMine && (
isRead ? (
<CheckCheck size={13} className="text-sky-300" />
) : (
<Check size={13} />
)
)}
</span>
</div>
)}
{/* Реакции */}
{Object.keys(reactionGroups).length > 0 && (
<div className={`flex flex-wrap gap-1 mt-1.5 ${isMine ? 'justify-end' : 'justify-start'}`}>
{Object.entries(reactionGroups).map(([emoji, data]) => (
<button
key={emoji}
onClick={(e) => { e.stopPropagation(); handleReaction(emoji); }}
className={`flex items-center gap-1.5 px-2.5 py-1 ${hasImage && !message.content ? 'backdrop-blur-md bg-black/40 text-white' : (isMine ? 'glass-panel text-white border-white/10 shadow-sm' : 'bg-surface-tertiary text-zinc-200 border-white/5 shadow-sm')} rounded-full transition-colors border ${
data.isMine
? (isMine ? 'bg-white/20 border-white/30' : 'bg-knot-500/20 border-knot-500/40')
: (isMine ? 'hover:bg-white/10' : 'hover:border-white/20')
}`}
title={data.users.join(', ')}
>
<span className="text-[17px] leading-none">{emoji}</span>
{(data.avatars && data.avatars.length > 0) ? (
<div className="flex -space-x-1.5 ml-0.5">
{data.avatars.map((av, idx) => (
av.url ? (
<img key={idx} src={av.url} className="w-5 h-5 rounded-full border-[1.5px] border-black/20 object-cover" />
) : (
<div key={idx} className="w-5 h-5 rounded-full border-[1.5px] border-black/20 bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white text-[9px] font-bold">
{av.initials}
</div>
)
))}
</div>
) : (
<span className="text-[12px] font-medium opacity-80 tabular-nums">{data.count}</span>
)}
{data.count > 1 && data.avatars && data.avatars.length > 0 && (
<span className="text-[12px] font-bold opacity-80 tabular-nums ml-1.5 mr-0.5">{data.count}</span>
)}
</button>
))}
</div>
)}
</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-full object-cover" />
) : (
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white text-xs font-semibold">
{senderName[0]?.toUpperCase() || '?'}
</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] glass-strong shadow-2xl 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={() => 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').map(m => ({ url: m.url, type: m.type === 'image' && m.url?.toLowerCase().endsWith('.mp4') ? 'video' : m.type }))}
initialIndex={lightboxData.index}
onClose={() => setLightboxData(null)}
/>
)}
</AnimatePresence>
</>
);
}
export default memo(MessageBubble);