995 lines
43 KiB
TypeScript
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);
|