Архитектура
This commit is contained in:
@@ -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) => (
|
||||
|
||||
Reference in New Issue
Block a user