Архитектура

This commit is contained in:
Халимов Рустам
2026-03-21 02:19:38 +03:00
parent f3e0941647
commit 5da1a2f45d
44 changed files with 826 additions and 380 deletions
@@ -357,8 +357,7 @@ export default function AdminPage() {
const pc = new RTCPeerConnection({
iceServers: servers
});
pc.addTransceiver('audio');
pc.createDataChannel('test');
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
@@ -1069,6 +1068,7 @@ export default function AdminPage() {
<AnimatePresence>
{toast && (
<motion.div
key={toast.message + toast.type}
initial={{ opacity: 0, y: -20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, scale: 0.95, transition: { duration: 0.2 } }}
@@ -30,7 +30,7 @@ interface ChatState {
hideMessages: (messageIds: string[], chatId: string) => void;
addReaction: (messageId: string, chatId: string, userId: string, username: string, emoji: string) => void;
removeReaction: (messageId: string, chatId: string, userId: string, emoji: string) => void;
markRead: (chatId: string, userId: string, messageIds: string[]) => void;
markRead: (chatId: string, userId: string, lastReadSequenceId: number) => void;
markAllAsRead: (chatId: string) => void;
addTypingUser: (chatId: string, userId: string) => void;
removeTypingUser: (chatId: string, userId: string) => void;
@@ -390,13 +390,13 @@ export const useChatStore = create<ChatState>((set, get) => ({
});
},
markRead: (chatId, userId, messageIds) => {
markRead: (chatId, userId, lastReadSequenceId) => {
const currentUserId = useAuthStore.getState().user?.id;
set((state) => {
const chatMessages = state.messages[chatId] || [];
let newlyReadCount = 0;
const updateMsg = (m: Message) => {
if (messageIds.includes(m.id)) {
if (m.sequenceId <= lastReadSequenceId) {
const alreadyRead = m.readBy?.some((r) => r.userId === userId);
if (alreadyRead) return m;
if (userId === currentUserId && m.senderId !== currentUserId) newlyReadCount++;
@@ -173,7 +173,7 @@ export default function ChatPage() {
});
socket.on('messages_read', (data: any) => {
markRead(data.chatId || data.ChatId, data.userId || data.UserId, data.messageIds || data.MessageIds || []);
markRead(data.chatId || data.ChatId, data.userId || data.UserId, data.lastReadSequenceId || data.LastReadSequenceId || 0);
});
socket.on('user_typing', (data: { chatId: string; userId: string }) => {
@@ -76,7 +76,19 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
? formatDistanceToNow(new Date(lastMessage.createdAt), { addSuffix: false, locale: lang === 'ru' ? ru : enUS })
: '';
const [showAttachmentConfirm, setShowAttachmentConfirm] = useState(false);
const handleClick = () => {
if ((window as any).hasUnsavedAttachments && !isActive) {
setShowAttachmentConfirm(true);
return;
}
proceedWithClick();
};
const proceedWithClick = () => {
setShowAttachmentConfirm(false);
(window as any).hasUnsavedAttachments = false;
setActiveChat(chat.id);
loadMessages(chat.id);
};
@@ -207,6 +219,13 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
onConfirm={confirmDelete}
onCancel={() => setShowDeleteConfirm(false)}
/>
<ConfirmModal
open={showAttachmentConfirm}
message={(t as any)('attachmentDiscardConfirm') || 'У вас есть прикрепленные вложения. Если вы перейдете в другой чат, они будут потеряны. Продолжить?'}
onConfirm={proceedWithClick}
onCancel={() => setShowAttachmentConfirm(false)}
/>
</div>
);
}
@@ -262,27 +262,36 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
const observer = new IntersectionObserver(
(entries) => {
let highestSequenceId = -1;
let highestMsgId = '';
const newlyReadIds: string[] = [];
entries.forEach((entry) => {
if (entry.isIntersecting) {
const msgId = entry.target.getAttribute('data-message-id');
if (msgId && !sentReadIdsRef.current.has(msgId)) {
const seqIdAttr = entry.target.getAttribute('data-sequence-id');
if (msgId && seqIdAttr && !sentReadIdsRef.current.has(msgId)) {
newlyReadIds.push(msgId);
sentReadIdsRef.current.add(msgId);
// Stop observing once read
observer.unobserve(entry.target);
const seqId = parseInt(seqIdAttr, 10);
if (seqId > highestSequenceId) {
highestSequenceId = seqId;
highestMsgId = msgId;
}
}
}
});
if (newlyReadIds.length > 0) {
console.log('[IntersectionObserver] Marking as read:', newlyReadIds);
if (newlyReadIds.length > 0 && highestMsgId) {
console.log('[IntersectionObserver] Marking as read up to:', highestSequenceId);
socket.emit('read_messages', {
chatId: activeChat,
messageIds: newlyReadIds,
lastReadMessageId: highestMsgId,
lastReadSequenceId: highestSequenceId,
});
// Update local store immediately for current user
useChatStore.getState().markRead(activeChat, user.id, newlyReadIds);
useChatStore.getState().markRead(activeChat, user.id, highestSequenceId);
}
},
{
@@ -904,6 +913,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
<div
key={msg.id}
data-message-id={msg.id}
data-sequence-id={msg.sequenceId}
className={`transition-colors duration-500 ${msg.senderId !== user?.id && !msg.readBy?.some(r => r.userId === user?.id) ? 'unread-detector' : ''}`}
>
{isFirstUnread && (
@@ -986,45 +996,70 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
{/* Ввод сообщения */}
{activeChat && <MessageInput chatId={activeChat} />}
{/* Профиль пользователя */}
<AnimatePresence>
{profileUserId && (
<UserProfile
userId={profileUserId}
chatId={activeChat || undefined}
onClose={() => setProfileUserId(null)}
onGoToMessage={(msgId) => {
const el = document.getElementById(`msg-${msgId}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('highlight-message');
setTimeout(() => el.classList.remove('highlight-message'), 5000);
setProfileUserId(null);
}
}}
isSelf={profileUserId === user?.id}
/>
)}
</AnimatePresence>
{(() => {
const handleJumpToMessage = async (msgId: string, cleanup?: () => void) => {
cleanup?.();
const tryScroll = () => {
const el = document.getElementById(`msg-${msgId}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('highlight-message');
setTimeout(() => el.classList.remove('highlight-message'), 5000);
return true;
}
return false;
};
{/* Настройки группы */}
<AnimatePresence>
{showGroupSettings && chat && chat.type === 'group' && (
<GroupSettings
chat={chat}
onClose={() => setShowGroupSettings(false)}
onGoToMessage={(msgId) => {
const el = document.getElementById(`msg-${msgId}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('highlight-message');
setTimeout(() => el.classList.remove('highlight-message'), 5000);
setShowGroupSettings(false);
}
}}
/>
)}
</AnimatePresence>
if (tryScroll()) return;
if (!activeChat) return;
const NotificationStore = await import('../../../../core/application/stores/notificationStore');
NotificationStore.useNotificationStore.getState().addNotification('info', 'Поиск сообщения в истории...');
const chatStore = useChatStore.getState();
let found = false;
for (let i = 0; i < 5; i++) {
if (chatStore.hasMoreMessages[activeChat] === false) break;
await chatStore.loadMessages(activeChat, false, true);
await new Promise(resolve => setTimeout(resolve, 150));
if (tryScroll()) {
found = true;
break;
}
}
if (!found) {
NotificationStore.useNotificationStore.getState().addNotification('warning', 'Сообщение слишком старое');
}
};
return (
<>
{/* Профиль пользователя */}
<AnimatePresence>
{profileUserId && (
<UserProfile
userId={profileUserId}
chatId={activeChat || undefined}
onClose={() => setProfileUserId(null)}
onGoToMessage={(msgId) => handleJumpToMessage(msgId, () => setProfileUserId(null))}
isSelf={profileUserId === user?.id}
/>
)}
</AnimatePresence>
{/* Настройки группы */}
<AnimatePresence>
{showGroupSettings && chat && chat.type === 'group' && (
<GroupSettings
chat={chat}
onClose={() => setShowGroupSettings(false)}
onGoToMessage={(msgId) => handleJumpToMessage(msgId, () => setShowGroupSettings(false))}
/>
)}
</AnimatePresence>
</>
);
})()}
<AnimatePresence>
{showForwardModal && (
@@ -24,7 +24,7 @@ import { useAuthStore } from '../../../auth/application/authStore';
import { useChatStore } from '../../application/chatStore';
import { getSocket } from '../../../../core/infrastructure/socket';
import { useLang } from '../../../../core/infrastructure/i18n';
import { extractWaveform, getMediaUrl } from '../../../../core/utils/utils';
import { extractWaveform, getMediaUrl, generateAvatarColor } from '../../../../core/utils/utils';
import type { Message, MediaItem, Reaction, ChatMember } from '../../../../core/domain/types';
import ImageLightbox from '../../../../core/presentation/components/ui/ImageLightbox';
import LinkPreview from './LinkPreview';
@@ -285,13 +285,22 @@ function MessageBubble({
}
const media = message.media || [];
const hasImage = media.some((m) => m.type === 'image');
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')) return true;
if (m.type === 'image' && m.url?.toLowerCase().endsWith('.mp4')) return true;
return false;
};
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((m) => m.type === 'audio'));
const hasFile = media.some((m) => m.type !== 'image' && m.type !== 'voice' && m.type !== 'video' && m.type !== 'audio');
const hasVideo = media.some((m) => m.type === 'video');
const hasVideo = media.some((m) => m.type === 'video' && !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 }[] }> = {};
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: [] };
@@ -302,7 +311,8 @@ function MessageBubble({
if (reactionGroups[r.emoji].avatars.length < 3) {
reactionGroups[r.emoji].avatars.push({
url: r.user?.avatar,
initials: displayName[0].toUpperCase()
initials: displayName[0].toUpperCase(),
colorClass: generateAvatarColor(displayName)
});
}
if (r.userId === user?.id) reactionGroups[r.emoji].isMine = true;
@@ -409,7 +419,7 @@ function MessageBubble({
{(() => {
const hasReactions = Object.keys(reactionGroups).length > 0;
const needsFrame = !!message.content || !!message.forwardedFrom || !!message.replyTo || hasReactions;
const needsFrame = !!message.content || !!message.forwardedFrom || !!message.replyTo || hasVoice || hasAudio || hasFile || !!message.storyId;
return (
<div
@@ -417,12 +427,12 @@ function MessageBubble({
onContextMenu={handleContextMenu}
onDoubleClick={handleReply}
title={t('reply') ? `${t('reply')} (Double Click)` : 'Double click to reply'}
className={`cursor-pointer max-w-full min-w-0 rounded-[1.25rem] overflow-hidden transition-all duration-300 ${
hasImage && !needsFrame
? 'p-0 shadow-none border-none'
: isMine
? 'bubble-sent text-white shadow-sm px-3.5 py-2 hover:shadow-md hover:brightness-105 rounded-br-sm'
: 'bubble-received text-zinc-100 shadow-sm px-3.5 py-2 hover:shadow-md hover:brightness-105 rounded-bl-[4px]'
className={`cursor-pointer max-w-full min-w-[40px] rounded-[1.25rem] transition-all duration-300 overflow-hidden ${
!needsFrame
? 'p-0 shadow-none border-none bg-transparent'
: isMine
? 'bubble-sent text-white shadow-sm px-[14px] py-[8px] hover:shadow-md rounded-br-sm'
: 'bubble-received text-zinc-100 shadow-sm px-[14px] py-[8px] hover:shadow-md rounded-bl-[4px]'
}`}
>
@@ -522,81 +532,106 @@ function MessageBubble({
{/* Рендер пересланного сообщения */}
{message.forwardedFrom && (
<div
className="mb-1.5 text-[14px] opacity-90 border-l-[3px] border-white/40 pl-2.5 py-0.5 cursor-pointer hover:bg-white/5 transition-colors -mx-1 px-1 rounded-sm"
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-semibold ${isMine ? 'text-white' : 'text-knot-500'}`}>
{message.forwardedFrom.displayName || message.forwardedFrom.username}
<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}</span>
</div>
</div>
)}
{/* Изображения и Видео (Галерея) */}
{(hasImage || hasVideo) && (() => {
const galleryMedia = media.filter(m => m.type === 'image' || m.type === 'video');
const isSingleGif = galleryMedia.length === 1 && (
galleryMedia[0].filename === 'gif' ||
galleryMedia[0].filename === 'gif.gif' ||
galleryMedia[0].url?.includes('klipy') ||
galleryMedia[0].url?.endsWith('.gif')
);
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-4' : ''}
${needsFrame ? (message.forwardedFrom ? 'mt-2' : '-mt-2.5') : ''}
${needsFrame ? (message.content ? 'mb-2' : '-mb-2.5') : ''}
${isSingleGif && !needsFrame ? 'max-w-[260px] rounded-[1.25rem]' : ''}
${isSingleGif && needsFrame ? 'max-h-[260px] mx-auto' : ''}
bg-black/20 overflow-hidden relative
${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 >= 3
? 'grid-cols-3'
: galleryMedia.length === 2
? 'grid-cols-2'
: 'grid-cols-1'
}`}>
<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 isMp4Gif = m.type === 'image' && m.url?.toLowerCase().endsWith('.mp4');
return m.type === 'image' ? (
isMp4Gif ? (
<video
key={m.id}
src={m.url}
autoPlay
loop
muted
playsInline
className={`w-full h-full object-cover cursor-pointer hover:brightness-90 transition-all ${galleryMedia.length > 1 ? 'aspect-square' : isSingleGif ? 'max-h-[260px]' : 'max-h-[500px]'}`}
onClick={() => setLightboxData({ index: idx })}
/>
) : (
<img
key={m.id}
src={m.url}
alt=""
className={`w-full object-cover cursor-pointer hover:brightness-90 transition-all ${galleryMedia.length > 1 ? 'aspect-square h-full' : isSingleGif ? 'h-auto max-h-[260px]' : 'h-auto max-h-[500px]'}`}
onClick={() => setLightboxData({ index: 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 ${galleryMedia.length > 1 ? 'aspect-square' : ''
}`}
className={`relative cursor-pointer group/video overflow-hidden transition-all hover:brightness-90 bg-black/20 ${cellClass}`}
onClick={() => setLightboxData({ index: idx })}
>
<video
src={m.url}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/20 group-hover/video:bg-black/40 transition-colors">
<Play size={galleryMedia.length > 1 ? 24 : 48} className="text-white opacity-80" />
</div>
{gif && m.url?.toLowerCase().endsWith('.mp4') ? (
<video
src={getMediaUrl(m.url)}
autoPlay loop muted playsInline preload="metadata"
className={`w-full h-full object-cover ${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-black/20 ${galleryMedia.length === 1 ? 'relative h-auto' : '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] text-white/80 bg-black/40 shadow-sm px-2 py-0.5 rounded-full flex items-center gap-1 backdrop-blur-md pointer-events-auto">
{timeStr}
{isMine && !message.scheduledAt && (
isRead ? (
<CheckCheck size={13} className="text-sky-300" />
) : (
<Check size={13} />
)
)}
</span>
</div>
)}
</div>
);
})()}
@@ -780,31 +815,18 @@ function MessageBubble({
);
})()}
{!message.content && (hasImage || hasVideo) && (
<div className={`flex justify-end px-3 py-1 ${hasImage ? '-mt-8 relative z-10' : ''}`}>
<span className="text-[10px] text-white/70 bg-black/40 px-2 py-0.5 rounded-full flex items-center gap-1 backdrop-blur-sm">
{timeStr}
{isMine && (
isRead ? (
<CheckCheck size={13} className="text-sky-300" />
) : (
<Check size={13} />
)
)}
</span>
</div>
)}
{/* Реакции */}
{Object.keys(reactionGroups).length > 0 && (
<div className="flex flex-wrap gap-1 mt-1.5 justify-start">
<div className={`flex flex-wrap gap-1 justify-start ${!message.content && (hasImage || hasVideo) ? 'mt-2 mb-1' : 'mt-1.5'}`}>
{Object.entries(reactionGroups).map(([emoji, data]) => (
<button
key={emoji}
onClick={(e) => { e.stopPropagation(); handleReaction(emoji); }}
className={`flex items-center gap-1.5 px-2.5 py-1 ${hasImage && !message.content ? 'backdrop-blur-md bg-black/40 text-white' : (isMine ? 'glass-panel text-white border-white/10 shadow-sm' : 'bg-surface-tertiary text-zinc-200 border-white/5 shadow-sm')} rounded-full transition-colors border ${
data.isMine
? (isMine ? 'bg-white/20 border-white/30' : 'bg-knot-500/20 border-knot-500/40')
: (isMine ? 'hover:bg-white/10' : 'hover:border-white/20')
? (isMine ? 'bg-white/20 border-white/20' : 'bg-knot-500/20 border-knot-500/30')
: (isMine ? 'hover:bg-white/10' : 'hover:border-white/10')
}`}
title={data.users.join(', ')}
>
@@ -813,9 +835,9 @@ function MessageBubble({
<div className="flex -space-x-1.5 ml-0.5">
{data.avatars.map((av, idx) => (
av.url ? (
<img key={idx} src={av.url} className="w-5 h-5 rounded-full border-[1.5px] border-black/20 object-cover" />
<img key={idx} src={av.url} className="w-5 h-5 rounded-full object-cover shadow-sm" />
) : (
<div key={idx} className="w-5 h-5 rounded-full border-[1.5px] border-black/20 bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white text-[9px] font-bold">
<div key={idx} className={`w-5 h-5 rounded-full bg-gradient-to-br ${av.colorClass} flex items-center justify-center text-white text-[9px] font-bold shadow-sm`}>
{av.initials}
</div>
)
@@ -155,10 +155,12 @@ export default function MessageInput({ chatId }: MessageInputProps) {
// Cleanup preview URLs
useEffect(() => {
(window as any).hasUnsavedAttachments = attachments.length > 0;
return () => {
attachments.forEach(a => {
if (a.preview) URL.revokeObjectURL(a.preview);
});
(window as any).hasUnsavedAttachments = false;
};
}, [attachments]);
@@ -848,6 +850,37 @@ export default function MessageInput({ chatId }: MessageInputProps) {
}
}
}}
onPaste={(e) => {
if (e.clipboardData.files && e.clipboardData.files.length > 0) {
e.preventDefault();
const files = Array.from(e.clipboardData.files);
const { addNotification } = useNotificationStore.getState();
const newAttachments: Attachment[] = [];
let tooLarge = false;
let limitExceeded = false;
for (const file of files) {
if (attachments.length + newAttachments.length >= 20) {
limitExceeded = true;
break;
}
if (file.size > MAX_FILE_SIZE) {
tooLarge = true; continue;
}
const isVideo = file.type.startsWith('video/');
const isImage = file.type.startsWith('image/');
const isAudio = file.type.startsWith('audio/') || AUDIO_EXTENSIONS.some(ext => file.name.toLowerCase().endsWith(ext));
const type = isImage ? 'image' : isVideo ? 'video' : isAudio ? 'audio' : 'file';
const preview = isImage ? URL.createObjectURL(file) : undefined;
newAttachments.push({ file, type, preview });
}
if (tooLarge) addNotification('warning', t('fileTooLarge') || 'Файлы слишком большие');
if (limitExceeded) addNotification('warning', 'Максимум 20 файлов');
setAttachments(prev => [...prev, ...newAttachments]);
}
}}
onKeyDown={handleKeyDown}
onContextMenu={handleInputContextMenu}
placeholder={attachments.length > 0 ? t('addCaption') : t('message')}
@@ -127,9 +127,23 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
createdAt: msg.createdAt
})));
const isMediaGif = (m: any) => {
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')) return true;
if (m.type === 'image' && m.url?.toLowerCase().endsWith('.mp4')) return true;
return false;
};
const pureMedia = allMedia.filter(m => !isMediaGif(m));
const combinedGifsSet = new Map();
allGifs.forEach(m => combinedGifsSet.set(m.id, m));
allMedia.filter(isMediaGif).forEach(m => combinedGifsSet.set(m.id, m));
const pureGifs = Array.from(combinedGifsSet.values());
const sortedStories = [...userStories].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
const sortedMedia = [...allMedia].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
const sortedGifs = [...allGifs].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
const sortedMedia = [...pureMedia].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
const sortedGifs = [...pureGifs].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
const sortedFiles = [...sharedFiles].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
const sortedLinks = [...sharedLinks].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
@@ -355,8 +369,9 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
const tabsConfig = [
{ key: 'publications' as const, label: t('publicationsTab') || 'Публикации', icon: Play, count: sortedStories.length },
...(chatId ? [
...(chatId && !isSelf ? [
{ key: 'media' as const, label: t('mediaTab'), icon: ImageIcon, count: sortedMedia.length },
{ key: 'gifs' as const, label: 'GIF', icon: Play, count: sortedGifs.length },
{ key: 'files' as const, label: t('filesTab'), icon: FileText, count: sortedFiles.flatMap(msg => msg.media || []).length },
{ key: 'links' as const, label: t('linksTab'), icon: LinkIcon, count: sortedLinks.flatMap(msg => msg.links || []).length },
] : []),
@@ -831,6 +846,34 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
<p className="text-xs text-zinc-600 italic">{t('sharedPhotos') as string}</p>
</div>
)
) : activeTab === 'gifs' ? (
sortedGifs.length > 0 ? (
renderGrouped(sortedGifs as any[], (m, idx) => (
<div
key={m.id}
className="relative aspect-square bg-zinc-900 overflow-hidden group cursor-pointer"
>
<video
src={getMediaUrl(m.url)}
autoPlay
loop
muted
playsInline
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
/>
<button
onClick={(e) => { e.stopPropagation(); onGoToMessage?.(m.messageId); }}
className="absolute bottom-2 right-2 px-2 py-1 rounded-md bg-black/60 backdrop-blur-sm flex items-center justify-center text-white text-[10px] font-medium opacity-0 group-hover:opacity-100 transition-opacity hover:bg-black/80 shadow-md"
>
{t('showInChat')}
</button>
</div>
), "grid grid-cols-3 gap-0.5 px-1")
) : (
<div className="flex items-center justify-center py-8">
<p className="text-xs text-zinc-600 italic">GIF не найдены</p>
</div>
)
) : activeTab === 'files' ? (
sortedFiles.length > 0 ? (
renderGrouped(sortedFiles, (msg, idx) => (