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