diff --git a/client-web/src/modules/chats/presentation/components/MessageBubble.tsx b/client-web/src/modules/chats/presentation/components/MessageBubble.tsx index 86fed6d..7ba4ba9 100644 --- a/client-web/src/modules/chats/presentation/components/MessageBubble.tsx +++ b/client-web/src/modules/chats/presentation/components/MessageBubble.tsx @@ -25,14 +25,13 @@ import { 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 type { Message, MediaItem, Reaction, 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'; @@ -79,11 +78,7 @@ function MessageBubble({ const [quotedText, setQuotedText] = useState(null); // Прочитано - // Для своих сообщений: проверено, есть ли в readBy другие пользователи (получатели) - // Для чужих сообщений: проверено, есть ли в readBy текущий пользователь - const isRead = isMine - ? message.readBy?.some((r) => r.userId !== user?.id) // Кто-то кроме меня прочитал - : message.readBy?.some((r) => r.userId === user?.id); // Я прочитал + 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', @@ -294,64 +289,64 @@ function MessageBubble({ }; }, [showContext]); - if (message.isDeleted || message.isDeletedForUser) { + if (message.isDeleted) { 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 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 (
-
- -
+ +
- -
+ +
-
-

- {message.callType === 'video' ? t('videoCall') : t('audioCall')} -

-
- - {statusText} {message.duration && message.duration > 0 ? `• ${formatDuration(message.duration)}` : ''} - -
-
- -
-
- {isPinned && } - {timeStr} -
- {isMine && ( -
- {isRead ? : } +
+

+ {message.callType === 'video' ? t('videoCall') : t('audioCall')} +

+
+ + {statusText} {message.duration && message.duration > 0 ? `• ${formatDuration(message.duration)}` : ''} +
- )} -
+
-
+
+
+ {isPinned && } + {timeStr} +
+ {isMine && ( +
+ {isRead ? : } +
+ )} +
+ +
); } 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; @@ -360,13 +355,11 @@ function MessageBubble({ 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 hasAudio = !hasVoice && (message.type === 'audio' || media.some((m) => m.type === 'audio')); 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 = {}; (message.reactions || []).forEach((r) => { @@ -459,7 +452,7 @@ function MessageBubble({
)} - {!isMine && activeChat?.type !== 'personal' && activeChat?.type !== 'favorites' && ( + {!isMine && (
{showAvatar ? (
-
{ - 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 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 (
); })}
-
- +
+ {isPlaying ? formatDuration(audioRef.current?.currentTime || 0) - : formatDuration(audioDuration || message.media?.find((m) => m.type === 'voice')?.duration || 0)} + : (typeof audioMedia?.duration === 'number' + ? formatDuration(audioMedia.duration) + : (audioMedia?.duration || formatDuration(audioDuration || 0)))} +
+ {formatSize(audioMedia?.size)} + + + +
- )} +
+ ); + })()} - {/* Аудио (mp3 файлы) */} - {hasAudio && (() => { - const audioMedia = media.find(isAudioFile); + {/* Файлы */} + {hasFile && + media + .filter((m) => m.type !== 'image' && m.type !== 'voice' && m.type !== 'video' && m.type !== 'audio' && m.type !== 'gif') + .map((m) => { const formatSize = (bytes?: number | null) => { if (!bytes) return ""; if (bytes < 1024) return bytes + " B"; @@ -859,7 +864,6 @@ function MessageBubble({ if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + " MB"; return (bytes / (1024 * 1024 * 1024)).toFixed(1) + " GB"; }; - return (
-
- ); - })()} - - {/* Файлы */} - {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 ( - -
- -
-
-

{m.filename || t('fileLabel')}

-

- {formatSize(m.size) || t('download')} -

-
- -
- ); - })} - - {/* Опрос */} - {message.type === 'poll' && message.pollOptions && ( -
-
-

- - {message.content} -

-
-

- {message.pollIsMultipleChoice ? t('multipleAnswers') : t('singleAnswer')} +

+

{m.filename || t('fileLabel')}

+

+ {formatSize(m.size) || t('download')}

- {message.pollIsAnonymous && ( - - {t('anonymous')} - - )}
-
+ + + ); + })} -
- {(() => { - 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); + {/* Опрос */} + {message.type === 'poll' && message.pollOptions && ( +
+
+

+ + {message.content} +

+
+

+ {message.pollIsMultipleChoice ? t('multipleAnswers') : t('singleAnswer')} +

+ {message.pollIsAnonymous && ( + + {t('anonymous')} + + )} +
+
- 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; +
+ {(() => { + 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 ( - - ); - }); - })()} -
- {(() => { - 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) { +
+
+
+
+ )} +
+ + )} + + ); + }); + })()} +
+ {(() => { + 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
ВСЕГО ПРОГОЛОСОВАЛО: {totalVotes}
- } - return null; - })()} -
- )} + } + return null; + })()} + + )} {/* Текст */} {message.content && message.type !== 'poll' && (() => { const onlyEmojiRegex = /^[\p{Extended_Pictographic}\s]+$/u; const isOnlyEmojis = onlyEmojiRegex.test(message.content) && message.content.length <= 15; return ( -
+

{renderFormattedText(message.content)} @@ -1066,12 +1040,26 @@ function MessageBubble({

- ); - })()} - - + )} +
+ + {message.isEdited && {t('edited')}} + {message.scheduledAt && schedule} + {isPinned && } + {timeStr} + {isMine && !message.scheduledAt && ( + + {isRead ? 'done_all' : 'done'} + + )} +
- ); + ); + })()} + + +
+ ); })()} {Object.keys(reactionGroups).length > 0 && ( @@ -1080,10 +1068,11 @@ function MessageBubble({