Fix MessageBubble.tsx JSX syntax errors

This commit is contained in:
Халимов Рустам
2026-04-20 23:00:30 +03:00
parent 9bfc5555bc
commit a5d40f28c8
@@ -25,14 +25,13 @@ import {
PhoneIncoming, PhoneIncoming,
PhoneOutgoing, PhoneOutgoing,
BarChart2, BarChart2,
Music,
} from 'lucide-react'; } from 'lucide-react';
import { useAuthStore } from '../../../auth/application/authStore'; import { useAuthStore } from '../../../auth/application/authStore';
import { useChatStore } from '../../application/chatStore'; import { useChatStore } from '../../application/chatStore';
import { getSocket } from '../../../../core/infrastructure/socket'; import { getSocket } from '../../../../core/infrastructure/socket';
import { useLang } from '../../../../core/infrastructure/i18n'; import { useLang } from '../../../../core/infrastructure/i18n';
import { extractWaveform, getMediaUrl, generateAvatarColor, getInitials } from '../../../../core/utils/utils'; 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 type { Message, MediaItem, Reaction, ChatMember } from '../../../../core/domain/types';
import ImageLightbox from '../../../../core/presentation/components/ui/ImageLightbox'; import ImageLightbox from '../../../../core/presentation/components/ui/ImageLightbox';
import LinkPreview from './LinkPreview'; import LinkPreview from './LinkPreview';
import Avatar from '../../../../core/presentation/components/ui/Avatar'; import Avatar from '../../../../core/presentation/components/ui/Avatar';
@@ -79,11 +78,7 @@ function MessageBubble({
const [quotedText, setQuotedText] = useState<string | null>(null); const [quotedText, setQuotedText] = useState<string | null>(null);
// Прочитано // Прочитано
// Для своих сообщений: проверено, есть ли в readBy другие пользователи (получатели) const isRead = message.readBy?.some((r) => r.userId !== user?.id);
// Для чужих сообщений: проверено, есть ли в readBy текущий пользователь
const isRead = isMine
? message.readBy?.some((r) => r.userId !== user?.id) // Кто-то кроме меня прочитал
: message.readBy?.some((r) => r.userId === user?.id); // Я прочитал
const timeStr = new Date(message.createdAt).toLocaleTimeString(lang === 'ru' ? 'ru-RU' : 'en-US', { const timeStr = new Date(message.createdAt).toLocaleTimeString(lang === 'ru' ? 'ru-RU' : 'en-US', {
hour: '2-digit', hour: '2-digit',
@@ -294,7 +289,7 @@ function MessageBubble({
}; };
}, [showContext]); }, [showContext]);
if (message.isDeleted || message.isDeletedForUser) { if (message.isDeleted) {
return null; return null;
} }
@@ -360,13 +355,11 @@ function MessageBubble({
return false; 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 hasImage = media.some((m) => m.type === 'image' || isMediaGif(m));
const hasVoice = message.type === 'voice' || media.some((m) => m.type === 'voice'); const hasVoice = message.type === 'voice' || media.some((m) => m.type === 'voice');
const hasAudio = !hasVoice && (message.type === 'audio' || media.some(isAudioFile)); const hasAudio = !hasVoice && (message.type === 'audio' || media.some((m) => m.type === 'audio'));
const hasVideo = media.some((m) => m.type === 'video' && !isMediaGif(m)); 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 hasFile = media.some((m) => m.type !== 'image' && m.type !== 'voice' && m.type !== 'video' && m.type !== 'audio' && !isMediaGif(m));
const reactionGroups: Record<string, { count: number; users: string[]; isMine: boolean; avatars: { url?: string | null, initials: string, colorClass?: string }[] }> = {}; const reactionGroups: Record<string, { count: number; users: string[]; isMine: boolean; avatars: { url?: string | null, initials: string, colorClass?: string }[] }> = {};
(message.reactions || []).forEach((r) => { (message.reactions || []).forEach((r) => {
@@ -459,7 +452,7 @@ function MessageBubble({
</div> </div>
)} )}
{!isMine && activeChat?.type !== 'personal' && activeChat?.type !== 'favorites' && ( {!isMine && (
<div className="w-8 flex-shrink-0 mr-2 self-end"> <div className="w-8 flex-shrink-0 mr-2 self-end">
{showAvatar ? ( {showAvatar ? (
<button onClick={() => onViewProfile?.(message.senderId)}> <button onClick={() => onViewProfile?.(message.senderId)}>
@@ -476,7 +469,7 @@ function MessageBubble({
)} )}
<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`}> <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 && activeChat?.type !== 'personal' && activeChat?.type !== 'favorites' && ( {!isMine && showAvatar && (
<button <button
className="text-xs font-medium text-knot-400 ml-3 mb-0.5 hover:underline" className="text-xs font-medium text-knot-400 ml-3 mb-0.5 hover:underline"
onClick={() => onViewProfile?.(message.senderId)} onClick={() => onViewProfile?.(message.senderId)}
@@ -495,7 +488,8 @@ function MessageBubble({
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
onDoubleClick={handleReply} onDoubleClick={handleReply}
title={t('reply') ? `${t('reply')} (Double Click)` : 'Double click to reply'} 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 className={`cursor-pointer max-w-full min-w-[60px] transition-all duration-500 overflow-hidden ${
!needsFrame
? 'p-0 shadow-none border-none bg-transparent' ? 'p-0 shadow-none border-none bg-transparent'
: isMine : isMine
? 'bubble-sent px-4 py-3 hover:brightness-110' ? 'bubble-sent px-4 py-3 hover:brightness-110'
@@ -506,7 +500,8 @@ function MessageBubble({
{/* Reply */} {/* Reply */}
{message.replyTo && ( {message.replyTo && (
<div <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' 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) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@@ -567,7 +562,8 @@ function MessageBubble({
{/* Story Reply Quote */} {/* Story Reply Quote */}
{message.storyId && ( {message.storyId && (
<div <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' 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'}`}> <p className={`text-[11px] font-bold uppercase tracking-wider mb-1 ${isMine ? 'text-white/80' : 'text-knot-500/80'}`}>
@@ -614,11 +610,12 @@ function MessageBubble({
return ( return (
<div className={` <div className={`
${needsFrame ? `-mx-[14px] ${message.forwardedFrom || message.replyTo || message.storyId ? 'mt-2' : '-mt-[8px]'} ${message.content || hasVoice || hasAudio || hasFile || message.type === 'poll' ? 'mb-3' : hasReactions ? 'mb-1' : '-mb-[8px]'}` : ''} ${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]' : ''} ${isSingleGif ? 'max-w-[260px]' : ''}
overflow-hidden relative rounded-[1.25rem] 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' <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) => { {galleryMedia.map((m, idx) => {
const gif = isMediaGif(m); const gif = isMediaGif(m);
@@ -703,89 +700,6 @@ function MessageBubble({
</span> </span>
</div> </div>
)} )}
</span>
</div>
)}
</div>
);
})()}
{/* Голосовое - Optimized Kinetic Layout */}
{hasVoice && (
<div className={`flex items-center gap-3 min-w-[200px] py-0.5 ${hasImage || hasVideo || message.forwardedFrom || message.replyTo || message.storyId ? 'mt-3' : ''}`}>
<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] ${hasImage || hasVideo || hasVoice || message.forwardedFrom || message.replyTo || message.storyId ? 'mt-3' : ''}`}>
{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>
); );
})()} })()}
@@ -851,7 +765,7 @@ function MessageBubble({
{/* Аудио (mp3 файлы) */} {/* Аудио (mp3 файлы) */}
{hasAudio && (() => { {hasAudio && (() => {
const audioMedia = media.find(isAudioFile); const audioMedia = media.find((m) => m.type === 'audio');
const formatSize = (bytes?: number | null) => { const formatSize = (bytes?: number | null) => {
if (!bytes) return ""; if (!bytes) return "";
if (bytes < 1024) return bytes + " B"; if (bytes < 1024) return bytes + " B";
@@ -861,18 +775,78 @@ function MessageBubble({
}; };
return ( 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 <a
key={m.id} href={getMediaUrl(audioMedia?.url)}
href={getMediaUrl(m.url)} download={audioMedia?.filename || 'audio'}
download={m.filename || 'file'}
target="_blank" target="_blank"
rel="noopener noreferrer" 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' 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'}`}
} transition-all mb-1 group/file ${hasImage || hasVideo || hasVoice || hasAudio || message.forwardedFrom || message.replyTo || message.storyId ? 'mt-3' : ''}`}
> >
<div className={`w-11 h-11 rounded-xl flex items-center justify-center ${isMine ? 'bg-[#0a0a0a]/10' : 'bg-primary/20' <Download size={12} />
} group-hover/file:scale-110 transition-transform`}> </a>
<FileText size={22} className={isMine ? 'text-[#0a0a0a]' : 'text-primary'} /> </div>
</div>
</div>
</div> </div>
</div> </div>
); );
@@ -881,7 +855,7 @@ function MessageBubble({
{/* Файлы */} {/* Файлы */}
{hasFile && {hasFile &&
media media
.filter((m) => m.type !== 'image' && m.type !== 'voice' && m.type !== 'video' && !isAudioFile(m) && m.type !== 'gif') .filter((m) => m.type !== 'image' && m.type !== 'voice' && m.type !== 'video' && m.type !== 'audio' && m.type !== 'gif')
.map((m) => { .map((m) => {
const formatSize = (bytes?: number | null) => { const formatSize = (bytes?: number | null) => {
if (!bytes) return ""; if (!bytes) return "";
@@ -1057,7 +1031,7 @@ function MessageBubble({
const onlyEmojiRegex = /^[\p{Extended_Pictographic}\s]+$/u; const onlyEmojiRegex = /^[\p{Extended_Pictographic}\s]+$/u;
const isOnlyEmojis = onlyEmojiRegex.test(message.content) && message.content.length <= 15; const isOnlyEmojis = onlyEmojiRegex.test(message.content) && message.content.length <= 15;
return ( return (
<div className={`flex items-end gap-2 text-sm w-full ${hasImage || hasVideo || hasVoice || hasAudio || hasFile || message.forwardedFrom || message.replyTo || message.storyId ? 'mt-3' : ''}`}> <div className="flex items-end gap-2 text-sm w-full">
<div className="flex-1 min-w-0 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'}`}> <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)} {renderFormattedText(message.content)}
@@ -1066,6 +1040,20 @@ function MessageBubble({
<div className="w-full mt-1 mb-1 relative overflow-hidden"> <div className="w-full mt-1 mb-1 relative overflow-hidden">
<LinkPreview url={firstUrl} /> <LinkPreview url={firstUrl} />
</div> </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>
); );
})()} })()}
@@ -1080,7 +1068,8 @@ function MessageBubble({
<button <button
key={emoji} key={emoji}
onClick={(e) => { e.stopPropagation(); handleReaction(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 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-primary/20 border-primary text-white shadow-lg'
: 'bg-[#201F1F] border-white/5 text-zinc-300 hover:bg-[#2a2a2a]' : 'bg-[#201F1F] border-white/5 text-zinc-300 hover:bg-[#2a2a2a]'
} shadow-md group/react`} } shadow-md group/react`}
@@ -1096,7 +1085,7 @@ function MessageBubble({
)} )}
</div> </div>
{isMine && activeChat?.type !== 'personal' && activeChat?.type !== 'favorites' && ( {isMine && (
<div className="w-8 flex-shrink-0 ml-2 self-end"> <div className="w-8 flex-shrink-0 ml-2 self-end">
{showAvatar ? ( {showAvatar ? (
<button onClick={() => onViewProfile?.(message.senderId)}> <button onClick={() => onViewProfile?.(message.senderId)}>