Compare commits
7
Commits
7eea8ff6d1
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67d5764f6e | ||
|
|
786eaffb33 | ||
|
|
4940f1212f | ||
|
|
c166f1d186 | ||
|
|
71b3d2b491 | ||
|
|
a5d40f28c8 | ||
|
|
9bfc5555bc |
@@ -4,6 +4,7 @@ using System.Threading;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
using Knot.Contracts.Conversations.Domain;
|
using Knot.Contracts.Conversations.Domain;
|
||||||
|
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||||
using Knot.Modules.Conversations.Infrastructure.SignalR;
|
using Knot.Modules.Conversations.Infrastructure.SignalR;
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
using Knot.Shared.Kernel.Storage;
|
using Knot.Shared.Kernel.Storage;
|
||||||
|
|||||||
@@ -575,7 +575,7 @@ const translations = {
|
|||||||
clearChatConfirm: 'Clear chat history for yourself? The other person will keep their history.',
|
clearChatConfirm: 'Clear chat history for yourself? The other person will keep their history.',
|
||||||
clearHistory: 'Clear history',
|
clearHistory: 'Clear history',
|
||||||
clearHistoryConfirm: 'Clear history?',
|
clearHistoryConfirm: 'Clear history?',
|
||||||
deleteChatConfirm: 'Delete this chat? This action cannot be undone.',
|
deleteChatConfirm: 'Delete this chat? This action cannot be undone. The chat will be removed for all participants.',
|
||||||
deleteGroupChatConfirm: 'Delete this chat? This action cannot be undone. The chat will be removed for all participants.',
|
deleteGroupChatConfirm: 'Delete this chat? This action cannot be undone. The chat will be removed for all participants.',
|
||||||
pinChat: 'Pin chat',
|
pinChat: 'Pin chat',
|
||||||
unpinChat: 'Unpin chat',
|
unpinChat: 'Unpin chat',
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ export default function ChatPage() {
|
|||||||
const [activeTab, setActiveTab] = useState('chats');
|
const [activeTab, setActiveTab] = useState('chats');
|
||||||
const { t } = useLang();
|
const { t } = useLang();
|
||||||
|
|
||||||
|
const activeChat = useChatStore((state) => state.activeChat);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
groupCallOpenRef.current = groupCallOpen;
|
groupCallOpenRef.current = groupCallOpen;
|
||||||
groupCallChatIdRef.current = groupCallChatId;
|
groupCallChatIdRef.current = groupCallChatId;
|
||||||
@@ -335,6 +337,16 @@ export default function ChatPage() {
|
|||||||
};
|
};
|
||||||
}, [user?.id]);
|
}, [user?.id]);
|
||||||
|
|
||||||
|
// Join chat group when activeChat changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeChat) {
|
||||||
|
const socket = getSocket();
|
||||||
|
if (socket) {
|
||||||
|
socket.emit('join_chat', activeChat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [activeChat]);
|
||||||
|
|
||||||
const handleStartCall = (targetUser: UserBasic, type: 'voice' | 'video') => {
|
const handleStartCall = (targetUser: UserBasic, type: 'voice' | 'video') => {
|
||||||
setCallTarget(targetUser);
|
setCallTarget(targetUser);
|
||||||
setCallType(type);
|
setCallType(type);
|
||||||
@@ -371,8 +383,6 @@ export default function ChatPage() {
|
|||||||
setGroupCallOpen(false);
|
setGroupCallOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const activeChat = useChatStore((state) => state.activeChat);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
|
|||||||
@@ -283,11 +283,7 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
|
|||||||
|
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
open={showDeleteConfirm}
|
open={showDeleteConfirm}
|
||||||
message={
|
message={isFavorites ? t('clearHistoryConfirm') : t('deleteChatConfirm')}
|
||||||
isFavorites ? t('clearHistoryConfirm') :
|
|
||||||
chat.type === 'group' ? t('deleteGroupChatConfirm') :
|
|
||||||
t('deleteChatConfirm')
|
|
||||||
}
|
|
||||||
onConfirm={confirmDelete}
|
onConfirm={confirmDelete}
|
||||||
onCancel={() => setShowDeleteConfirm(false)}
|
onCancel={() => setShowDeleteConfirm(false)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -187,12 +187,31 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
// Refs and logic for tracking session's first unread message to show the divider exactly once per load
|
// Refs and logic for tracking session's first unread message to show the divider exactly once per load
|
||||||
const sessionUnreadRef = useRef<{ chatId: string, msgId: string | null }>({ chatId: '', msgId: null });
|
const sessionUnreadRef = useRef<{ chatId: string, msgId: string | null }>({ chatId: '', msgId: null });
|
||||||
|
|
||||||
if (activeChat && activeChat !== sessionUnreadRef.current.chatId && !isLoadingMessages) {
|
// Update sessionUnreadRef when chat changes OR when messages are marked as read
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeChat || isLoadingMessages) return;
|
||||||
|
|
||||||
|
// Reset on chat change
|
||||||
|
if (activeChat !== sessionUnreadRef.current.chatId) {
|
||||||
const firstUnreadMsg = chatMessages.find(
|
const firstUnreadMsg = chatMessages.find(
|
||||||
(m) => m.senderId !== user?.id && !m.readBy?.some((r) => r.userId === user?.id)
|
(m) => m.senderId !== user?.id && !m.readBy?.some((r) => r.userId === user?.id)
|
||||||
);
|
);
|
||||||
sessionUnreadRef.current = { chatId: activeChat, msgId: firstUnreadMsg ? firstUnreadMsg.id : null };
|
sessionUnreadRef.current = { chatId: activeChat, msgId: firstUnreadMsg ? firstUnreadMsg.id : null };
|
||||||
|
} else {
|
||||||
|
// Update if the first unread message was read (msgId no longer exists in unread list)
|
||||||
|
const firstUnreadMsg = chatMessages.find(
|
||||||
|
(m) => m.senderId !== user?.id && !m.readBy?.some((r) => r.userId === user?.id)
|
||||||
|
);
|
||||||
|
if (sessionUnreadRef.current.msgId && !firstUnreadMsg) {
|
||||||
|
// All messages are now read
|
||||||
|
sessionUnreadRef.current.msgId = null;
|
||||||
|
} else if (firstUnreadMsg && sessionUnreadRef.current.msgId !== firstUnreadMsg.id) {
|
||||||
|
// First unread changed (some messages were read)
|
||||||
|
sessionUnreadRef.current.msgId = firstUnreadMsg.id;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}, [activeChat, chatMessages, user?.id, isLoadingMessages]);
|
||||||
|
|
||||||
const firstUnreadId = activeChat === sessionUnreadRef.current.chatId ? sessionUnreadRef.current.msgId : null;
|
const firstUnreadId = activeChat === sessionUnreadRef.current.chatId ? sessionUnreadRef.current.msgId : null;
|
||||||
const initialScrollChatId = useRef<string | null>(null);
|
const initialScrollChatId = useRef<string | null>(null);
|
||||||
|
|
||||||
@@ -573,6 +592,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
{
|
{
|
||||||
root: scrollContainerRef.current,
|
root: scrollContainerRef.current,
|
||||||
threshold: 0.1,
|
threshold: 0.1,
|
||||||
|
rootMargin: '0px',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -584,8 +604,27 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
unreadElements.forEach((el: Element) => {
|
unreadElements.forEach((el: Element) => {
|
||||||
const id = el.getAttribute('data-message-id');
|
const id = el.getAttribute('data-message-id');
|
||||||
if (id && !sentReadIdsRef.current.has(id)) {
|
if (id && !sentReadIdsRef.current.has(id)) {
|
||||||
|
// Check if element is already visible
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const containerRect = scrollContainerRef.current!.getBoundingClientRect();
|
||||||
|
const isVisible = rect.top >= containerRect.top && rect.bottom <= containerRect.bottom;
|
||||||
|
|
||||||
|
if (isVisible) {
|
||||||
|
// Mark as read immediately without waiting for intersection
|
||||||
|
const seqId = parseInt(el.getAttribute('data-sequence-id') || '0', 10);
|
||||||
|
if (seqId > 0) {
|
||||||
|
socket.emit('read_messages', {
|
||||||
|
chatId: activeChat,
|
||||||
|
lastReadMessageId: id,
|
||||||
|
lastReadSequenceId: seqId,
|
||||||
|
});
|
||||||
|
useChatStore.getState().markRead(activeChat, user.id, seqId);
|
||||||
|
sentReadIdsRef.current.add(id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
observer.observe(el);
|
observer.observe(el);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1060,7 +1099,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
setShowTopMenu(false);
|
setShowTopMenu(false);
|
||||||
if (activeChat) {
|
if (activeChat) {
|
||||||
setConfirmAction({
|
setConfirmAction({
|
||||||
message: chat.type === 'group' ? t('deleteGroupChatConfirm') : t('deleteChatConfirm'),
|
message: t('deleteChatConfirm'),
|
||||||
action: async () => {
|
action: async () => {
|
||||||
try {
|
try {
|
||||||
await ChatApi.deleteChat(activeChat);
|
await ChatApi.deleteChat(activeChat);
|
||||||
|
|||||||
@@ -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) => {
|
||||||
@@ -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`}
|
||||||
|
|||||||
Reference in New Issue
Block a user