1470 lines
64 KiB
TypeScript
1470 lines
64 KiB
TypeScript
import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
|
||
import { motion, AnimatePresence } from 'framer-motion';
|
||
import {
|
||
Phone,
|
||
Video,
|
||
MoreVertical,
|
||
Search,
|
||
X,
|
||
ArrowDown,
|
||
Trash2,
|
||
UserPlus,
|
||
Bell,
|
||
BellOff,
|
||
Settings,
|
||
Eraser,
|
||
Pin,
|
||
Forward,
|
||
Bookmark,
|
||
ArrowLeft,
|
||
MessagesSquare,
|
||
Pencil,
|
||
} from 'lucide-react';
|
||
import { useChatStore } from '../../application/chatStore';
|
||
import { httpClient } from '../../../../core/infrastructure/httpClient';
|
||
import { useAuthStore } from '../../../auth/application/authStore';
|
||
import { ChatApi } from '../../infrastructure/chatApi';
|
||
import { getSocket } from '../../../../core/infrastructure/socket';
|
||
import { isChatMuted, toggleMuteChat } from '../../../../core/utils/sounds';
|
||
import { useLang } from '../../../../core/infrastructure/i18n';
|
||
import { formatLastSeen, getInitials, generateAvatarColor } from '../../../../core/utils/utils';
|
||
import type { UserBasic, Message } from '../../../../core/domain/types';
|
||
import MessageBubble from './MessageBubble';
|
||
import MessageInput from './MessageInput';
|
||
import TypingIndicator from './TypingIndicator';
|
||
import UserProfile from '../../../users/presentation/components/UserProfile';
|
||
import GroupSettings from './GroupSettings';
|
||
import ForwardModal from './ForwardModal';
|
||
import ConfirmModal from '../../../../core/presentation/components/ui/ConfirmModal';
|
||
import Avatar from '../../../../core/presentation/components/ui/Avatar';
|
||
import { useThemeStore } from '../../../../core/application/stores/themeStore';
|
||
|
||
export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCall?: (targetUser: UserBasic, type: 'voice' | 'video') => void; onStartGroupCall?: (chatId: string, chatName: string, type: 'voice' | 'video') => void }) {
|
||
const { user, config } = useAuthStore();
|
||
const { t, lang } = useLang();
|
||
const { chatTheme } = useThemeStore();
|
||
const {
|
||
activeChat,
|
||
chats,
|
||
messages,
|
||
typingUsers,
|
||
pinnedMessages,
|
||
isLoadingMessages,
|
||
hasMoreMessages,
|
||
loadMessages,
|
||
setActiveChat,
|
||
loadChats,
|
||
} = useChatStore();
|
||
|
||
const [showTopMenu, setShowTopMenu] = useState(false);
|
||
const [showSearch, setShowSearch] = useState(false);
|
||
const [searchText, setSearchText] = useState('');
|
||
const [searchResults, setSearchResults] = useState<Message[]>([]);
|
||
const [profileUserId, setProfileUserId] = useState<string | null>(null);
|
||
const [showGroupSettings, setShowGroupSettings] = useState(false);
|
||
const [showScrollDown, setShowScrollDown] = useState(false);
|
||
const [stickyDate, setStickyDate] = useState<string | null>(null);
|
||
const [showStickyDate, setShowStickyDate] = useState(false);
|
||
const stickyDateTimerRef = useRef<any>(null);
|
||
const [muted, setMuted] = useState(false);
|
||
|
||
const [selectionMode, setSelectionMode] = useState(false);
|
||
const [selectedMessages, setSelectedMessages] = useState<Set<string>>(new Set());
|
||
const [showForwardModal, setShowForwardModal] = useState(false);
|
||
const [showDeleteMenu, setShowDeleteMenu] = useState(false);
|
||
const [confirmAction, setConfirmAction] = useState<{ message: string; action: () => void } | null>(null);
|
||
const [scrollReady, setScrollReady] = useState(false);
|
||
const [activeGroupCallParticipants, setActiveGroupCallParticipants] = useState<string[]>([]);
|
||
|
||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||
|
||
const handleJumpToMessage = async (msgId: string, sequenceId?: number) => {
|
||
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;
|
||
};
|
||
|
||
if (tryScroll()) return;
|
||
if (!activeChat) return;
|
||
|
||
const NotificationStore = await import('../../../../core/application/stores/notificationStore');
|
||
NotificationStore.useNotificationStore.getState().addNotification('info', (t as any)('searchingHistory') || 'Searching message in history...');
|
||
|
||
const chatStore = useChatStore.getState();
|
||
|
||
if (sequenceId !== undefined) {
|
||
await chatStore.jumpToMessage(activeChat, sequenceId);
|
||
// Wait a bit for React to render
|
||
setTimeout(() => {
|
||
if (!tryScroll()) {
|
||
NotificationStore.useNotificationStore.getState().addNotification('warning', (t as any)('messageNotFound') || 'Message not found');
|
||
}
|
||
}, 300);
|
||
} else {
|
||
// Old fallback loop if no sequenceId
|
||
let found = false;
|
||
for (let i = 0; i < 50; i++) {
|
||
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', (t as any)('messageNotFound') || 'Message not found');
|
||
}
|
||
}
|
||
};
|
||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||
const topMenuRef = useRef<HTMLDivElement>(null);
|
||
const deleteMenuRef = useRef<HTMLDivElement>(null);
|
||
const chatViewRef = useRef<HTMLDivElement>(null);
|
||
|
||
const chat = chats.find((c) => c.id === activeChat);
|
||
const allChatMessages = activeChat ? messages[activeChat] || [] : [];
|
||
// Filter out deleted messages to prevent layout shifts
|
||
const chatMessages = allChatMessages.filter(m => !m.isDeleted);
|
||
const chatPinnedMessages = activeChat ? pinnedMessages[activeChat] || [] : [];
|
||
const [pinnedIndex, setPinnedIndex] = useState(0);
|
||
|
||
const [importStatus, setImportStatus] = useState<{ processed: number, total: number, status: string } | null>(null);
|
||
const isAtBottomRef = useRef(false);
|
||
|
||
useEffect(() => {
|
||
if (!chat?.isImporting || !chat?.importJobId) {
|
||
setImportStatus(null);
|
||
return;
|
||
}
|
||
|
||
const poll = async () => {
|
||
try {
|
||
const data = await httpClient.request<any>(`/import/telegram/status/${chat.importJobId}`);
|
||
setImportStatus({ processed: data.processedMessages, total: data.totalMessages, status: data.status });
|
||
if (data.status === 'Completed' || data.status === 'Failed') {
|
||
setImportStatus(null);
|
||
loadChats();
|
||
}
|
||
} catch (e: any) {
|
||
if (e.status === 404) {
|
||
console.warn('Import job not found');
|
||
} else {
|
||
console.error('Failed to poll status', e);
|
||
}
|
||
}
|
||
};
|
||
|
||
poll();
|
||
const interval = setInterval(poll, 1000);
|
||
return () => clearInterval(interval);
|
||
}, [chat?.isImporting, chat?.importJobId]);
|
||
|
||
// Количество непрочитанных сообщений (для бейджика)
|
||
const unreadCount = chatMessages.filter(
|
||
(m) => m.senderId !== user?.id && !m.readBy?.some((r) => r.userId === user?.id)
|
||
).length;
|
||
|
||
const otherMember = chat?.members.find((m) => m.user.id !== user?.id);
|
||
const isFavorites = chat?.type === 'favorites';
|
||
const chatName = isFavorites
|
||
? t('favorites')
|
||
: chat?.type === 'personal'
|
||
? otherMember?.user.displayName || otherMember?.user.userName || otherMember?.user.username || t('chat')
|
||
: chat?.name || t('group');
|
||
const chatAvatar = isFavorites
|
||
? null
|
||
: chat?.type === 'personal'
|
||
? otherMember?.user.avatarUrl || otherMember?.user.avatar || null
|
||
: chat?.avatarUrl || chat?.avatar || null;
|
||
const isOnline = chat?.type === 'personal' && otherMember?.user.isOnline;
|
||
|
||
const typingInChat = typingUsers.filter((t) => t.chatId === activeChat && t.userId !== user?.id);
|
||
|
||
// 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 });
|
||
|
||
// 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(
|
||
(m) => m.senderId !== user?.id && !m.readBy?.some((r) => r.userId === user?.id)
|
||
);
|
||
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 initialScrollChatId = useRef<string | null>(null);
|
||
|
||
// Load muted state
|
||
useEffect(() => {
|
||
if (activeChat) {
|
||
setMuted(isChatMuted(activeChat));
|
||
setActiveGroupCallParticipants([]);
|
||
}
|
||
}, [activeChat]);
|
||
|
||
// Listen for active group calls
|
||
useEffect(() => {
|
||
const socket = getSocket();
|
||
if (!socket) return;
|
||
const handler = (data: { chatId: string; participants: string[] }) => {
|
||
if (data.chatId === activeChat) {
|
||
setActiveGroupCallParticipants(data.participants.filter(p => p !== user?.id));
|
||
}
|
||
};
|
||
socket.on('group_call_active', handler);
|
||
// Request current status when opening a group chat
|
||
if (activeChat && chat?.type === 'group') {
|
||
socket.emit('get_group_call_status', { chatId: activeChat });
|
||
}
|
||
return () => { socket.off('group_call_active', handler); };
|
||
}, [activeChat, user?.id, chat?.type]);
|
||
|
||
// Close top menu on click outside
|
||
useEffect(() => {
|
||
if (!showTopMenu) return;
|
||
const handleClick = (e: MouseEvent) => {
|
||
if (topMenuRef.current && !topMenuRef.current.contains(e.target as Node)) {
|
||
setShowTopMenu(false);
|
||
}
|
||
};
|
||
// Use setTimeout to avoid the same click that opened the menu from closing it
|
||
const timer = setTimeout(() => document.addEventListener('click', handleClick), 0);
|
||
return () => {
|
||
clearTimeout(timer);
|
||
document.removeEventListener('click', handleClick);
|
||
};
|
||
}, [showTopMenu]);
|
||
|
||
// Close delete menu on click outside
|
||
useEffect(() => {
|
||
if (!showDeleteMenu) return;
|
||
const handleClick = (e: MouseEvent) => {
|
||
if (deleteMenuRef.current && !deleteMenuRef.current.contains(e.target as Node)) {
|
||
setShowDeleteMenu(false);
|
||
}
|
||
};
|
||
const timer = setTimeout(() => document.addEventListener('click', handleClick), 0);
|
||
return () => {
|
||
clearTimeout(timer);
|
||
document.removeEventListener('click', handleClick);
|
||
};
|
||
}, [showDeleteMenu]);
|
||
|
||
// Прокрутка вниз
|
||
const scrollToBottom = useCallback((smooth = true) => {
|
||
if (messagesEndRef.current) {
|
||
messagesEndRef.current.scrollIntoView({ behavior: smooth ? 'smooth' : 'instant', block: 'end' });
|
||
} else if (scrollContainerRef.current) {
|
||
scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight;
|
||
}
|
||
}, []);
|
||
|
||
const isInitializingRef = useRef(false);
|
||
const isScrollingToBottomRef = useRef(false);
|
||
const scrollTimeoutRef = useRef<any>(null);
|
||
const prevChatIdRef = useRef<string | null>(activeChat);
|
||
|
||
// 1. СОХРАНЕНИЕ ПОЗИЦИИ (ЯКОРНОЕ ПО MESSAGE ID)
|
||
const saveScrollPosition = useCallback((targetChatId?: string) => {
|
||
const container = scrollContainerRef.current;
|
||
const chatId = targetChatId || activeChat;
|
||
|
||
if (!container || !chatId || !scrollReady || isInitializingRef.current || isScrollingToBottomRef.current) return;
|
||
|
||
// Проверка: сообщения в стейте должны быть от целевого чата
|
||
if (chatMessages.length > 0 && chatMessages[0].chatId !== chatId) return;
|
||
|
||
const isAtBottomNow = container.scrollHeight - container.scrollTop - container.clientHeight < 40;
|
||
isAtBottomRef.current = isAtBottomNow;
|
||
|
||
if (isAtBottomNow) {
|
||
localStorage.setItem(`chat_at_bottom_${chatId}`, 'true');
|
||
localStorage.removeItem(`chat_anchor_${chatId}`);
|
||
return;
|
||
}
|
||
|
||
const messageElements = container.querySelectorAll('[data-message-id]');
|
||
if (messageElements.length === 0) return;
|
||
|
||
let anchor = null;
|
||
const containerRect = container.getBoundingClientRect();
|
||
|
||
// Находим первое сообщение, которое пересекает верхнюю границу видимости
|
||
for (const el of messageElements) {
|
||
const rect = el.getBoundingClientRect();
|
||
if (rect.bottom > containerRect.top) {
|
||
anchor = {
|
||
id: el.getAttribute('data-message-id'),
|
||
offset: rect.top - containerRect.top
|
||
};
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (anchor && anchor.id) {
|
||
localStorage.setItem(`chat_anchor_${chatId}`, JSON.stringify(anchor));
|
||
localStorage.removeItem(`chat_at_bottom_${chatId}`);
|
||
}
|
||
}, [activeChat, scrollReady, chatMessages]);
|
||
|
||
// 2. ВОССТАНОВЛЕНИЕ ПОЗИЦИИ
|
||
const restoreScrollPosition = useCallback(() => {
|
||
const container = scrollContainerRef.current;
|
||
if (!container || !activeChat) return false;
|
||
|
||
if (chatMessages.length > 0 && chatMessages[0].chatId !== activeChat) return false;
|
||
if (chatMessages.length === 0) return true;
|
||
|
||
// ПРИОРИТЕТ 1: Если чат внизу (после второго клика или скролла)
|
||
if (localStorage.getItem(`chat_at_bottom_${activeChat}`) === 'true') {
|
||
container.scrollTop = container.scrollHeight;
|
||
isAtBottomRef.current = true;
|
||
return true;
|
||
}
|
||
|
||
// ПРИОРИТЕТ 2: Восстановление по якорю сообщения
|
||
const saved = localStorage.getItem(`chat_anchor_${activeChat}`);
|
||
if (saved) {
|
||
try {
|
||
const { id, offset } = JSON.parse(saved);
|
||
let el = container.querySelector(`[data-message-id="${id}"]`) as HTMLElement;
|
||
|
||
// Поиск ближайшего, если точное сообщение еще не загружено
|
||
if (!el) {
|
||
const msgIndex = chatMessages.findIndex(m => m.id === id);
|
||
if (msgIndex !== -1) {
|
||
for (let i = msgIndex; i < chatMessages.length; i++) {
|
||
const nextEl = container.querySelector(`[data-message-id="${chatMessages[i].id}"]`) as HTMLElement;
|
||
if (nextEl) { el = nextEl; break; }
|
||
}
|
||
}
|
||
}
|
||
|
||
if (el) {
|
||
container.scrollTop = el.offsetTop - offset;
|
||
return true;
|
||
}
|
||
} catch (e) { console.error('Anchor restoration failed', e); }
|
||
}
|
||
|
||
// ПРИОРИТЕТ 3: Непрочитанные или низ
|
||
const firstUnread = chatMessages.find(m => m.senderId !== user?.id && !m.readBy?.some(r => r.userId === user?.id));
|
||
if (firstUnread) {
|
||
const el = document.getElementById(`msg-${firstUnread.id}`) || document.getElementById('unread-divider');
|
||
if (el) {
|
||
container.scrollTop = (el as HTMLElement).offsetTop - 80;
|
||
return true;
|
||
}
|
||
}
|
||
|
||
container.scrollTop = container.scrollHeight;
|
||
isAtBottomRef.current = true;
|
||
return true;
|
||
}, [activeChat, chatMessages, user?.id]);
|
||
|
||
// 3. ОБЗЕРВЕР И УПРАВЛЕНИЕ ЖИЗНЕННЫМ ЦИКЛОМ
|
||
useEffect(() => {
|
||
if (isLoadingMessages || !scrollContainerRef.current || !activeChat) return;
|
||
const container = scrollContainerRef.current;
|
||
|
||
const observer = new ResizeObserver(() => {
|
||
if (isInitializingRef.current) return;
|
||
|
||
if (!scrollReady) {
|
||
if (restoreScrollPosition()) {
|
||
setScrollReady(true);
|
||
isInitializingRef.current = false;
|
||
}
|
||
} else if (isAtBottomRef.current) {
|
||
// Удержание внизу при росте контента
|
||
container.scrollTop = container.scrollHeight;
|
||
}
|
||
});
|
||
|
||
const messagesDiv = container.querySelector('.space-y-1');
|
||
if (messagesDiv) observer.observe(messagesDiv);
|
||
|
||
// Попытка восстановления при появлении правильных сообщений
|
||
if (chatMessages.length > 0 && chatMessages[0].chatId === activeChat && !scrollReady) {
|
||
if (restoreScrollPosition()) {
|
||
setScrollReady(true);
|
||
isInitializingRef.current = false;
|
||
}
|
||
}
|
||
|
||
return () => observer.disconnect();
|
||
}, [activeChat, isLoadingMessages, chatMessages, restoreScrollPosition, scrollReady]);
|
||
|
||
// 4. ПЕРЕКЛЮЧЕНИЕ ЧАТОВ (СИНХРОННОЕ)
|
||
useLayoutEffect(() => {
|
||
if (activeChat !== prevChatIdRef.current) {
|
||
isInitializingRef.current = true;
|
||
isScrollingToBottomRef.current = false;
|
||
|
||
// Сохраняем позицию старого чата
|
||
if (prevChatIdRef.current && scrollContainerRef.current && scrollReady) {
|
||
saveScrollPosition(prevChatIdRef.current);
|
||
}
|
||
|
||
setScrollReady(false);
|
||
prevChatIdRef.current = activeChat;
|
||
|
||
if (scrollContainerRef.current) {
|
||
scrollContainerRef.current.scrollTop = 0;
|
||
}
|
||
|
||
const timer = setTimeout(() => {
|
||
isInitializingRef.current = false;
|
||
}, 600);
|
||
|
||
return () => clearTimeout(timer);
|
||
}
|
||
}, [activeChat, saveScrollPosition, scrollReady]);
|
||
|
||
const checkScrollPosition = useCallback(() => {
|
||
const container = scrollContainerRef.current;
|
||
if (!container || isInitializingRef.current) return;
|
||
const isNearBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 300;
|
||
setShowScrollDown(!isNearBottom);
|
||
}, []);
|
||
|
||
const lastScrollTopRef = useRef(0);
|
||
const handleScroll = () => {
|
||
if (isInitializingRef.current || isScrollingToBottomRef.current) return;
|
||
|
||
checkScrollPosition();
|
||
const container = scrollContainerRef.current;
|
||
if (container && activeChat) {
|
||
// Synchronous "at bottom" check to prevent ResizeObserver from fighting the user
|
||
const isAtBottomNow = container.scrollHeight - container.scrollTop - container.clientHeight < 40;
|
||
isAtBottomRef.current = isAtBottomNow;
|
||
|
||
if (scrollTimeoutRef.current) clearTimeout(scrollTimeoutRef.current);
|
||
scrollTimeoutRef.current = setTimeout(() => saveScrollPosition(), 150);
|
||
|
||
const st = container.scrollTop;
|
||
const isScrollingUp = st < lastScrollTopRef.current;
|
||
const stChanged = st !== lastScrollTopRef.current;
|
||
|
||
// Sticky Date Header Logic - Telegram style
|
||
if (st > 100 && stChanged) {
|
||
// Показываем плашку только при прокрутке или если она уже активна
|
||
// При прокрутке вверх она должна быть видна всегда
|
||
if (isScrollingUp) {
|
||
setShowStickyDate(true);
|
||
if (stickyDateTimerRef.current) clearTimeout(stickyDateTimerRef.current);
|
||
// Таймер на 2 сек запустится только после остановки скролла
|
||
stickyDateTimerRef.current = setTimeout(() => setShowStickyDate(false), 2000);
|
||
} else {
|
||
// При прокрутке вниз плашка обычно скрывается быстрее
|
||
if (stickyDateTimerRef.current) {
|
||
clearTimeout(stickyDateTimerRef.current);
|
||
stickyDateTimerRef.current = setTimeout(() => setShowStickyDate(false), 800);
|
||
}
|
||
}
|
||
} else if (st <= 100) {
|
||
setShowStickyDate(false);
|
||
if (stickyDateTimerRef.current) clearTimeout(stickyDateTimerRef.current);
|
||
}
|
||
|
||
lastScrollTopRef.current = st;
|
||
|
||
const containerRect = container.getBoundingClientRect();
|
||
const messageElements = container.querySelectorAll('[data-message-id]');
|
||
|
||
let currentTopMsgId = null;
|
||
for (const el of messageElements) {
|
||
const rect = el.getBoundingClientRect();
|
||
if (rect.top >= containerRect.top) {
|
||
currentTopMsgId = el.getAttribute('data-message-id');
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (currentTopMsgId) {
|
||
const msg = chatMessages.find(m => m.id === currentTopMsgId);
|
||
if (msg) {
|
||
const dateStr = new Date(msg.createdAt).toLocaleDateString(lang === 'ru' ? 'ru' : 'en', {
|
||
day: 'numeric',
|
||
month: 'long',
|
||
});
|
||
if (dateStr !== stickyDate) setStickyDate(dateStr);
|
||
}
|
||
}
|
||
|
||
if (container.scrollTop < 100 && hasMoreMessages[activeChat] && !isLoadingMessages) {
|
||
useChatStore.getState().loadMessages(activeChat, false, true);
|
||
}
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
const handleScrollEvent = (e: any) => {
|
||
if (e.detail?.chatId === activeChat) {
|
||
// Принудительный сброс режима (Второй Клик)
|
||
localStorage.setItem(`chat_at_bottom_${activeChat}`, 'true');
|
||
localStorage.removeItem(`chat_anchor_${activeChat}`);
|
||
|
||
isScrollingToBottomRef.current = true;
|
||
if (scrollContainerRef.current) {
|
||
scrollContainerRef.current.scrollTo({
|
||
top: scrollContainerRef.current.scrollHeight,
|
||
behavior: 'smooth'
|
||
});
|
||
}
|
||
|
||
setTimeout(() => {
|
||
isScrollingToBottomRef.current = false;
|
||
}, 1000);
|
||
}
|
||
};
|
||
window.addEventListener('CHAT_SCROLL_TO_BOTTOM', handleScrollEvent);
|
||
return () => window.removeEventListener('CHAT_SCROLL_TO_BOTTOM', handleScrollEvent);
|
||
}, [activeChat]);
|
||
|
||
// Read receipts using IntersectionObserver
|
||
const sentReadIdsRef = useRef<Set<string>>(new Set());
|
||
const observerRef = useRef<IntersectionObserver | null>(null);
|
||
|
||
useEffect(() => {
|
||
if (!activeChat || !user?.id) return;
|
||
|
||
if (observerRef.current) observerRef.current.disconnect();
|
||
sentReadIdsRef.current.clear();
|
||
|
||
const socket = getSocket();
|
||
if (!socket) return;
|
||
|
||
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');
|
||
const seqIdAttr = entry.target.getAttribute('data-sequence-id');
|
||
if (msgId && seqIdAttr && !sentReadIdsRef.current.has(msgId)) {
|
||
newlyReadIds.push(msgId);
|
||
sentReadIdsRef.current.add(msgId);
|
||
observer.unobserve(entry.target);
|
||
|
||
const seqId = parseInt(seqIdAttr, 10);
|
||
if (seqId > highestSequenceId) {
|
||
highestSequenceId = seqId;
|
||
highestMsgId = msgId;
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
if (newlyReadIds.length > 0 && highestMsgId) {
|
||
socket.emit('read_messages', {
|
||
chatId: activeChat,
|
||
lastReadMessageId: highestMsgId,
|
||
lastReadSequenceId: highestSequenceId,
|
||
});
|
||
useChatStore.getState().markRead(activeChat, user.id, highestSequenceId);
|
||
}
|
||
},
|
||
{
|
||
root: scrollContainerRef.current,
|
||
threshold: 0.1,
|
||
rootMargin: '0px',
|
||
}
|
||
);
|
||
|
||
observerRef.current = observer;
|
||
|
||
const observeUnread = () => {
|
||
if (!scrollContainerRef.current) return;
|
||
const unreadElements = scrollContainerRef.current.querySelectorAll('.unread-detector');
|
||
unreadElements.forEach((el: Element) => {
|
||
const id = el.getAttribute('data-message-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);
|
||
}
|
||
}
|
||
});
|
||
};
|
||
|
||
setTimeout(observeUnread, 100);
|
||
return () => observer.disconnect();
|
||
}, [activeChat, user?.id]);
|
||
|
||
useEffect(() => {
|
||
if (observerRef.current && scrollContainerRef.current) {
|
||
const unreadElements = scrollContainerRef.current.querySelectorAll('.unread-detector');
|
||
unreadElements.forEach((el: Element) => {
|
||
const id = el.getAttribute('data-message-id');
|
||
if (id && !sentReadIdsRef.current.has(id)) {
|
||
observerRef.current?.observe(el);
|
||
}
|
||
});
|
||
}
|
||
}, [chatMessages, scrollReady]);
|
||
|
||
useEffect(() => {
|
||
if (scrollReady) {
|
||
checkScrollPosition();
|
||
}
|
||
}, [chatMessages.length, scrollReady, checkScrollPosition]);
|
||
|
||
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
||
if (!chatViewRef.current) return;
|
||
const { left, top } = chatViewRef.current.getBoundingClientRect();
|
||
chatViewRef.current.style.setProperty('--mouse-x', `${e.clientX - left}px`);
|
||
chatViewRef.current.style.setProperty('--mouse-y', `${e.clientY - top}px`);
|
||
};
|
||
|
||
// Поиск сообщений
|
||
useEffect(() => {
|
||
if (!searchText.trim() || !activeChat) {
|
||
setSearchResults([]);
|
||
return;
|
||
}
|
||
const timer = setTimeout(async () => {
|
||
try {
|
||
const results = await ChatApi.searchMessages(searchText, activeChat);
|
||
setSearchResults(results);
|
||
} catch (e) {
|
||
console.error(e);
|
||
}
|
||
}, 300);
|
||
return () => clearTimeout(timer);
|
||
}, [searchText, activeChat]);
|
||
|
||
const openSearch = () => {
|
||
setShowSearch(true);
|
||
setShowTopMenu(false);
|
||
setTimeout(() => searchInputRef.current?.focus(), 100);
|
||
};
|
||
|
||
if (!activeChat || !chat) {
|
||
return (
|
||
<section className="flex-1 h-full flex flex-col items-center justify-center bg-[#010101] relative overflow-hidden">
|
||
{/* Background Knot Texture */}
|
||
<div className="absolute inset-0 opacity-[0.04] pointer-events-none flex items-center justify-center">
|
||
<span className="material-symbols-outlined text-white text-[500px] select-none">cloud_download</span>
|
||
</div>
|
||
|
||
{/* Chat Empty State View */}
|
||
<div className="flex-1 flex flex-col items-center justify-center z-10 p-12 text-center relative">
|
||
<motion.div
|
||
initial={{ scale: 0.8, opacity: 0 }}
|
||
animate={{ scale: 1, opacity: 1 }}
|
||
transition={{ duration: 0.8, ease: [0.16, 1, 0.3, 1] }}
|
||
className="w-24 h-24 mb-6 rounded-[2rem] bg-[#1c1c1c] flex items-center justify-center shadow-[0_0_80px_rgba(48,150,229,0.15)] border border-white/5"
|
||
>
|
||
<span className="material-symbols-outlined text-[#3096e5] text-5xl select-none" style={{ fontVariationSettings: "'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 48" }}>
|
||
forum
|
||
</span>
|
||
</motion.div>
|
||
|
||
<h2 className="text-3xl font-black text-[#e5e2e1] tracking-tight mb-2 leading-tight">
|
||
{t('selectChatTitle')}
|
||
</h2>
|
||
|
||
<p className="text-sm font-medium text-[#c1c6d7] max-w-sm leading-relaxed opacity-40 px-4">
|
||
{t('selectChatSubtext')}
|
||
</p>
|
||
|
||
<motion.button
|
||
initial={{ y: 20, opacity: 0 }}
|
||
animate={{ y: 0, opacity: 1 }}
|
||
transition={{ delay: 0.2 }}
|
||
onClick={() => window.dispatchEvent(new CustomEvent('OPEN_NEW_CHAT'))}
|
||
className="mt-10 px-9 py-3.5 bg-gradient-to-tr from-[#3096e5] to-[#9acbff] text-black font-extrabold text-base rounded-full shadow-[0_15px_40px_rgba(48,150,229,0.2)] hover:scale-105 active:scale-95 transition-all flex items-center gap-2.5"
|
||
>
|
||
<span className="material-symbols-outlined text-black font-bold text-xl">edit</span>
|
||
{t('newMessage')}
|
||
</motion.button>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
const handleToggleSelect = (msgId: string) => {
|
||
const newMap = new Set(selectedMessages);
|
||
if (newMap.has(msgId)) {
|
||
newMap.delete(msgId);
|
||
if (newMap.size === 0) setSelectionMode(false);
|
||
} else {
|
||
newMap.add(msgId);
|
||
}
|
||
setSelectedMessages(newMap);
|
||
};
|
||
|
||
const handleStartSelection = (msgId: string) => {
|
||
setSelectionMode(true);
|
||
setSelectedMessages(new Set([msgId]));
|
||
};
|
||
|
||
const handleForward = (targetChatId: string) => {
|
||
const socket = getSocket();
|
||
if (!socket || !activeChat) return;
|
||
|
||
const messagesToForward = Array.from(selectedMessages)
|
||
.map(id => chatMessages.find(m => m.id === id))
|
||
.filter(Boolean)
|
||
.sort((a, b) => new Date(a!.createdAt).getTime() - new Date(b!.createdAt).getTime());
|
||
|
||
messagesToForward.forEach(msg => {
|
||
socket.emit('send_message', {
|
||
chatId: targetChatId,
|
||
content: msg?.content,
|
||
type: msg?.type,
|
||
forwardedFromId: msg?.forwardedFromId || msg?.sender.id,
|
||
attachments: msg?.media?.map(m => ({
|
||
type: m.type,
|
||
url: m.url,
|
||
fileName: m.filename,
|
||
fileSize: m.size
|
||
})) || [],
|
||
});
|
||
});
|
||
|
||
setSelectionMode(false);
|
||
setSelectedMessages(new Set());
|
||
setShowForwardModal(false);
|
||
setActiveChat(targetChatId);
|
||
useChatStore.getState().loadMessages(targetChatId);
|
||
};
|
||
|
||
const handleBulkDelete = (deleteForAll: boolean) => {
|
||
const socket = getSocket();
|
||
if (!socket || !activeChat) return;
|
||
|
||
const ids = Array.from(selectedMessages);
|
||
socket.emit('delete_messages', {
|
||
messageIds: ids,
|
||
chatId: activeChat,
|
||
deleteForAll,
|
||
});
|
||
|
||
// Optimistic local removal
|
||
if (!deleteForAll) {
|
||
useChatStore.getState().hideMessages(ids, activeChat);
|
||
}
|
||
|
||
setSelectionMode(false);
|
||
setSelectedMessages(new Set());
|
||
setShowDeleteMenu(false);
|
||
};
|
||
|
||
return (
|
||
<div
|
||
ref={chatViewRef}
|
||
onMouseMove={handleMouseMove}
|
||
className={`flex-1 flex flex-col h-full overflow-hidden bg-surface-container-lowest relative z-0 chat-theme-${chatTheme} transition-colors duration-500`}
|
||
>
|
||
<div className="absolute inset-0 pointer-events-none bg-gradient-to-b from-primary/5 to-transparent h-32 opacity-30" />
|
||
{selectionMode ? (
|
||
<div className="h-[76px] flex items-center justify-between px-6 bg-surface-container-highest/80 backdrop-blur-xl z-20 flex-shrink-0 animate-in slide-in-from-top-2 border-none">
|
||
<div className="flex items-center gap-4 text-on-surface">
|
||
<button onClick={() => { setSelectionMode(false); setSelectedMessages(new Set()); }} className="p-2 -ml-2 rounded-xl hover:bg-on-surface/10 transition slide-on-ice">
|
||
<span className="material-symbols-outlined">close</span>
|
||
</button>
|
||
<span className="text-lg font-bold font-headline">{selectedMessages.size} {t('selected')}</span>
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<div className="relative" ref={deleteMenuRef}>
|
||
<button
|
||
disabled={selectedMessages.size === 0}
|
||
onClick={() => isFavorites ? handleBulkDelete(false) : setShowDeleteMenu(!showDeleteMenu)}
|
||
className="flex items-center gap-2 px-4 py-2 bg-red-500/90 text-white font-medium rounded-xl hover:bg-red-600 transition-colors disabled:opacity-50"
|
||
>
|
||
<Trash2 size={18} />
|
||
{t('delete')}
|
||
</button>
|
||
<AnimatePresence>
|
||
{showDeleteMenu && (
|
||
<motion.div
|
||
initial={{ opacity: 0, scale: 0.95, y: -5 }}
|
||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||
exit={{ opacity: 0, scale: 0.95, y: -5 }}
|
||
transition={{ duration: 0.15 }}
|
||
className="absolute right-0 top-full mt-2 w-56 rounded-2xl bg-[#1a1a1a] shadow-[0_20px_50px_rgba(0,0,0,0.5)] z-50 py-1.5 ring-1 ring-white/10 overflow-hidden"
|
||
>
|
||
<button
|
||
onClick={() => handleBulkDelete(false)}
|
||
className="flex items-center gap-3 w-full px-4 py-3 text-sm text-zinc-300 hover:bg-surface-hover hover:text-white transition-colors"
|
||
>
|
||
<Trash2 size={16} className="text-zinc-400" />
|
||
{t('deleteForMe')}
|
||
</button>
|
||
<div className="border-t border-border/30 mx-3" />
|
||
<button
|
||
onClick={() => handleBulkDelete(true)}
|
||
className="flex items-center gap-3 w-full px-4 py-3 text-sm text-red-400 hover:bg-red-500/10 hover:text-red-300 transition-colors"
|
||
>
|
||
<Trash2 size={16} className="text-red-400" />
|
||
{t('deleteForAll')}
|
||
</button>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
</div>
|
||
<button
|
||
disabled={selectedMessages.size === 0}
|
||
onClick={() => setShowForwardModal(true)}
|
||
className="flex items-center gap-2 px-4 py-2 bg-white text-black font-medium rounded-xl hover:bg-zinc-200 transition-colors disabled:opacity-50"
|
||
>
|
||
<Forward size={18} />
|
||
{t('forward')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="h-[76px] flex items-center justify-between px-6 bg-surface-container-lowest/40 backdrop-blur-xl z-20 flex-shrink-0 gap-2 border-none">
|
||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||
<button
|
||
onClick={() => setActiveChat(null)}
|
||
className="lg:hidden p-2 -ml-1 rounded-xl hover:bg-surface-container-highest/30 text-on-surface-variant transition-colors"
|
||
>
|
||
<span className="material-symbols-outlined">arrow_back</span>
|
||
</button>
|
||
<div
|
||
className="flex items-center gap-4 min-w-0 flex-1 group cursor-pointer"
|
||
onClick={() => {
|
||
if (chat.type === 'personal' && otherMember) {
|
||
setProfileUserId(otherMember.user.id);
|
||
} else if (chat.type === 'group') {
|
||
setShowGroupSettings(true);
|
||
}
|
||
}}
|
||
>
|
||
<div className="relative flex-shrink-0 transform transition-all duration-500 group-hover:scale-105 active:scale-95">
|
||
{isFavorites ? (
|
||
<div className="w-11 h-11 rounded-2xl bg-gradient-to-br from-primary to-primary-container flex items-center justify-center shadow-lg shadow-primary/10 border-2 border-outline-variant/10">
|
||
<span className="material-symbols-outlined text-on-primary-container text-[20px]">bookmark</span>
|
||
</div>
|
||
) : (
|
||
<Avatar
|
||
src={chatAvatar}
|
||
name={chatName}
|
||
size="md"
|
||
online={isOnline ? true : undefined}
|
||
className="avatar-knot-container group-hover:border-primary/30"
|
||
/>
|
||
)}
|
||
</div>
|
||
<div className="min-w-0 text-left">
|
||
<h3 className="text-lg font-bold font-headline text-on-surface truncate group-hover:text-primary transition-colors leading-none mb-1">{chatName}</h3>
|
||
<p className="text-[11px] font-bold uppercase tracking-widest text-on-surface-variant/50">
|
||
{isFavorites
|
||
? t('favoritesDescription')
|
||
: typingInChat.length > 0
|
||
? <span className="text-primary font-black animate-pulse">{t('typing')}</span>
|
||
: isOnline
|
||
? <span className="text-success">{t('online')}</span>
|
||
: chat.type === 'personal' && otherMember?.user.lastSeen
|
||
? `${formatLastSeen(otherMember.user.lastSeen, lang)}`
|
||
: chat.type === 'group'
|
||
? `${chat.members.length} ${t('members')}`
|
||
: ''}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-1.5 ml-4">
|
||
<AnimatePresence>
|
||
{showSearch && (
|
||
<motion.div
|
||
initial={{ width: 0, opacity: 0 }}
|
||
animate={{ width: 200, opacity: 1 }}
|
||
exit={{ width: 0, opacity: 0 }}
|
||
className="overflow-hidden"
|
||
>
|
||
<input
|
||
ref={searchInputRef}
|
||
type="text"
|
||
placeholder={t('searchMessages')}
|
||
value={searchText}
|
||
onChange={(e) => setSearchText(e.target.value)}
|
||
className="w-full px-4 py-2 rounded-xl bg-surface-container text-sm text-on-surface placeholder-on-surface-variant/30 border-none focus:ring-2 focus:ring-primary/20"
|
||
/>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
|
||
<button
|
||
onClick={() => {
|
||
if (showSearch) {
|
||
setShowSearch(false);
|
||
setSearchText('');
|
||
setSearchResults([]);
|
||
} else {
|
||
openSearch();
|
||
}
|
||
}}
|
||
className="hidden lg:flex p-2 rounded-xl hover:bg-surface-container-highest/40 transition-all duration-300 text-on-surface-variant hover:text-primary slide-on-ice"
|
||
>
|
||
{showSearch ? <span className="material-symbols-outlined">close</span> : <span className="material-symbols-outlined">search</span>}
|
||
</button>
|
||
|
||
{!isFavorites && config?.webRtc?.enabled && (
|
||
<div className="hidden lg:flex items-center gap-1.5">
|
||
<button
|
||
onClick={() => {
|
||
if (chat.type === 'personal' && otherMember) {
|
||
onStartCall?.(otherMember.user, 'voice');
|
||
} else if (chat.type === 'group') {
|
||
onStartGroupCall?.(chat.id, chat.name || 'Group', 'voice');
|
||
}
|
||
}}
|
||
className="p-2 rounded-xl hover:bg-surface-container-highest/40 transition-all duration-300 text-on-surface-variant hover:text-primary slide-on-ice" title={t('call')}>
|
||
<span className="material-symbols-outlined">call</span>
|
||
</button>
|
||
{config?.webRtc?.enableVideoCalls && (
|
||
<button
|
||
onClick={() => {
|
||
if (chat.type === 'personal' && otherMember) {
|
||
onStartCall?.(otherMember.user, 'video');
|
||
} else if (chat.type === 'group') {
|
||
onStartGroupCall?.(chat.id, chat.name || 'Group', 'video');
|
||
}
|
||
}}
|
||
className="p-2 rounded-xl hover:bg-surface-container-highest/40 transition-all duration-300 text-on-surface-variant hover:text-primary slide-on-ice" title={t('videoCall')}>
|
||
<span className="material-symbols-outlined">videocam</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<div className="relative" ref={topMenuRef}>
|
||
<button
|
||
onClick={() => setShowTopMenu(!showTopMenu)}
|
||
className="p-2 rounded-xl hover:bg-surface-container-highest/40 transition-all duration-300 text-on-surface-variant hover:text-primary slide-on-ice"
|
||
>
|
||
<span className="material-symbols-outlined">more_vert</span>
|
||
</button>
|
||
<AnimatePresence>
|
||
{showTopMenu && (
|
||
<motion.div
|
||
initial={{ opacity: 0, scale: 0.95, y: -5 }}
|
||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||
exit={{ opacity: 0, scale: 0.95, y: -5 }}
|
||
transition={{ duration: 0.15 }}
|
||
className="absolute right-0 top-full mt-2 w-56 rounded-2xl bg-[#1a1a1a] shadow-[0_20px_50px_rgba(0,0,0,0.5)] z-50 py-1.5 ring-1 ring-white/10"
|
||
>
|
||
<button
|
||
onClick={openSearch}
|
||
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-zinc-300 hover:bg-surface-hover hover:text-white transition-colors"
|
||
>
|
||
<Search size={16} />
|
||
{t('searchMessages')}
|
||
</button>
|
||
{!isFavorites && config?.webRtc?.enabled && (
|
||
<>
|
||
<button
|
||
onClick={() => {
|
||
setShowTopMenu(false);
|
||
if (chat.type === 'personal' && otherMember) {
|
||
onStartCall?.(otherMember.user, 'voice');
|
||
} else if (chat.type === 'group') {
|
||
onStartGroupCall?.(chat.id, chat.name || 'Group', 'voice');
|
||
}
|
||
}}
|
||
className="lg:hidden flex items-center gap-3 w-full px-4 py-2.5 text-sm text-zinc-300 hover:bg-surface-hover hover:text-white transition-colors"
|
||
>
|
||
<Phone size={16} />
|
||
{t('call')}
|
||
</button>
|
||
{config?.webRtc?.enableVideoCalls && (
|
||
<button
|
||
onClick={() => {
|
||
setShowTopMenu(false);
|
||
if (chat.type === 'personal' && otherMember) {
|
||
onStartCall?.(otherMember.user, 'video');
|
||
} else if (chat.type === 'group') {
|
||
onStartGroupCall?.(chat.id, chat.name || 'Group', 'video');
|
||
}
|
||
}}
|
||
className="lg:hidden flex items-center gap-3 w-full px-4 py-2.5 text-sm text-zinc-300 hover:bg-surface-hover hover:text-white transition-colors"
|
||
>
|
||
<Video size={16} />
|
||
{t('videoCall')}
|
||
</button>
|
||
)}
|
||
</>
|
||
)}
|
||
{!isFavorites && chat.type === 'personal' && otherMember && (
|
||
<button
|
||
onClick={() => {
|
||
setShowTopMenu(false);
|
||
setProfileUserId(otherMember.user.id);
|
||
}}
|
||
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-zinc-300 hover:bg-surface-hover hover:text-white transition-colors"
|
||
>
|
||
<UserPlus size={16} />
|
||
{t('userProfile')}
|
||
</button>
|
||
)}
|
||
{!isFavorites && (
|
||
<button
|
||
onClick={() => {
|
||
if (activeChat) {
|
||
const nowMuted = toggleMuteChat(activeChat);
|
||
setMuted(nowMuted);
|
||
}
|
||
}}
|
||
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-zinc-300 hover:bg-surface-hover hover:text-white transition-colors"
|
||
>
|
||
{muted ? <Bell size={16} /> : <BellOff size={16} />}
|
||
{muted ? t('enableSound') : t('disableSound')}
|
||
</button>
|
||
)}
|
||
{!isFavorites && chat.type === 'group' && (
|
||
<button
|
||
onClick={() => {
|
||
setShowTopMenu(false);
|
||
setShowGroupSettings(true);
|
||
}}
|
||
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-zinc-300 hover:bg-surface-hover hover:text-white transition-colors"
|
||
>
|
||
<Settings size={16} />
|
||
{t('groupSettings')}
|
||
</button>
|
||
)}
|
||
<div className="border-t border-white/5 my-1" />
|
||
<button
|
||
onClick={() => {
|
||
setShowTopMenu(false);
|
||
if (activeChat) {
|
||
setConfirmAction({
|
||
message: isFavorites ? t('clearHistoryConfirm') : t('clearChatConfirm'),
|
||
action: async () => {
|
||
try {
|
||
await ChatApi.clearChat(activeChat);
|
||
useChatStore.getState().clearMessages(activeChat);
|
||
const NotificationStore = await import('../../../../core/application/stores/notificationStore');
|
||
NotificationStore.useNotificationStore.getState().addNotification('success', t('chatCleared'));
|
||
} catch (e) {
|
||
console.error(e);
|
||
}
|
||
},
|
||
});
|
||
}
|
||
}}
|
||
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-zinc-300 hover:bg-surface-hover hover:text-white transition-colors"
|
||
>
|
||
<Eraser size={16} />
|
||
{isFavorites ? t('clearHistory') : t('clearChat')}
|
||
</button>
|
||
{!isFavorites && (
|
||
<button
|
||
onClick={() => {
|
||
setShowTopMenu(false);
|
||
if (activeChat) {
|
||
setConfirmAction({
|
||
message: t('deleteChatConfirm'),
|
||
action: async () => {
|
||
try {
|
||
await ChatApi.deleteChat(activeChat);
|
||
useChatStore.getState().removeChat(activeChat);
|
||
} catch (e) {
|
||
console.error(e);
|
||
}
|
||
},
|
||
});
|
||
}
|
||
}}
|
||
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-red-400 hover:bg-red-500/10 transition-colors"
|
||
>
|
||
<Trash2 size={16} />
|
||
{t('deleteChat')}
|
||
</button>
|
||
)}
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Результаты поиска */}
|
||
<AnimatePresence>
|
||
{showSearch && searchResults.length > 0 && (
|
||
<motion.div
|
||
initial={{ height: 0, opacity: 0 }}
|
||
animate={{ height: 'auto', opacity: 1 }}
|
||
exit={{ height: 0, opacity: 0 }}
|
||
className="absolute top-14 left-0 right-0 z-20 max-h-60 overflow-y-auto glass-strong border-b border-border"
|
||
>
|
||
{searchResults.map((msg) => (
|
||
<div
|
||
key={msg.id}
|
||
className="px-4 py-2 hover:bg-surface-hover cursor-pointer border-b border-border/50 last:border-0"
|
||
onClick={() => {
|
||
// Scroll to message
|
||
const el = document.getElementById(`msg-${msg.id}`);
|
||
if (el) {
|
||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
el.classList.add('highlight-message');
|
||
setTimeout(() => el.classList.remove('highlight-message'), 5000);
|
||
}
|
||
setShowSearch(false);
|
||
setSearchText('');
|
||
setSearchResults([]);
|
||
}}
|
||
>
|
||
<div className="flex items-center gap-2 mb-0.5">
|
||
<span className="text-xs font-medium text-knot-400">
|
||
{msg.sender?.displayName || msg.sender?.username}
|
||
</span>
|
||
<span className="text-xs text-zinc-600">
|
||
{new Date(msg.createdAt).toLocaleDateString(lang === 'ru' ? 'ru' : 'en')}
|
||
</span>
|
||
</div>
|
||
<p className="text-sm text-zinc-300 truncate">{msg.content}</p>
|
||
</div>
|
||
))}
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
|
||
{chat.isImporting ? (
|
||
<div className="flex-1 flex flex-col items-center justify-center p-12 text-center bg-surface-container-lowest relative overflow-hidden">
|
||
<div className="absolute inset-0 opacity-[0.02] pointer-events-none flex items-center justify-center">
|
||
<span className="material-symbols-outlined text-white text-[500px] select-none">cloud_download</span>
|
||
</div>
|
||
|
||
<motion.div
|
||
initial={{ scale: 0.9, opacity: 0, y: 20 }}
|
||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||
className="z-10 w-full max-w-md p-8 rounded-[2.5rem] bg-surface-container-low border border-outline/10 shadow-2xl backdrop-blur-3xl"
|
||
>
|
||
<div className="w-20 h-20 mx-auto mb-6 rounded-3xl bg-primary/10 flex items-center justify-center shadow-lg shadow-primary/5">
|
||
<span className="material-symbols-outlined text-primary text-4xl animate-bounce">downloading</span>
|
||
</div>
|
||
|
||
<h2 className="text-2xl font-black text-on-surface tracking-tight mb-2">
|
||
Идет импорт истории
|
||
</h2>
|
||
<p className="text-on-surface-variant text-sm font-medium mb-8 opacity-60 leading-relaxed">
|
||
Мы переносим ваши сообщения и медиафайлы из Telegram. Это займет некоторое время.
|
||
</p>
|
||
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between mb-2 px-1">
|
||
<span className="text-xs font-black uppercase tracking-widest text-primary">
|
||
{importStatus?.status === 'Processing' ? 'Обработка' :
|
||
importStatus?.status === 'Queued' ? 'В очереди' : 'Загрузка'}
|
||
</span>
|
||
<span className="text-xs font-black text-on-surface tabular-nums">
|
||
{importStatus?.processed || 0} / {importStatus?.total || 0}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="h-3 w-full bg-surface-container-highest rounded-full overflow-hidden border border-outline/10 p-0.5">
|
||
<motion.div
|
||
initial={{ width: 0 }}
|
||
animate={{ width: `${Math.min(100, Math.round(((importStatus?.processed || 0) / (importStatus?.total || 1)) * 100))}%` }}
|
||
transition={{ type: 'spring', damping: 20 }}
|
||
className="h-full bg-linear-to-r from-primary to-primary-container rounded-full shadow-[0_0_20px_rgba(48,150,229,0.3)] transition-all duration-300"
|
||
/>
|
||
</div>
|
||
|
||
<p className="text-[11px] font-bold text-on-surface-variant/40 uppercase tracking-[0.2em] pt-4">
|
||
Чат станет доступен автоматически
|
||
</p>
|
||
</div>
|
||
</motion.div>
|
||
</div>
|
||
) : (
|
||
<>
|
||
{chat?.type === 'group' && config?.webRtc?.enabled && activeGroupCallParticipants.length > 0 && (
|
||
<button
|
||
onClick={() => onStartGroupCall?.(chat.id, chat.name || 'Group', 'voice')}
|
||
className="flex items-center gap-3 px-4 py-2.5 border-b border-outline/10 bg-emerald-500/10 hover:bg-emerald-500/20 transition-colors text-left w-full flex-shrink-0"
|
||
>
|
||
<div className="w-8 h-8 rounded-full bg-emerald-500/20 flex items-center justify-center">
|
||
<Phone size={14} className="text-emerald-400" />
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<p className="text-xs font-medium text-emerald-400">{t('activeCall')}</p>
|
||
<p className="text-sm text-zinc-300">{activeGroupCallParticipants.length} {t('participants')}</p>
|
||
</div>
|
||
<span className="text-xs text-emerald-400 font-medium px-3 py-1 rounded-full bg-emerald-500/20">{t('joinCall')}</span>
|
||
</button>
|
||
)}
|
||
|
||
{chatPinnedMessages.length > 0 && (
|
||
<div className="flex-shrink-0 flex items-center gap-0 border-b border-outline/10 bg-surface-container-high/60 hover:bg-surface-container-high transition-colors overflow-hidden h-[54px] relative">
|
||
{/* Cycling progress indicator for multiple pins */}
|
||
{chatPinnedMessages.length > 1 && (
|
||
<div className="absolute left-1 top-1.5 bottom-1.5 w-0.5 rounded-full bg-white/5 flex flex-col gap-0.5 overflow-hidden">
|
||
{chatPinnedMessages.map((_, idx) => (
|
||
<div
|
||
key={idx}
|
||
className={`flex-1 transition-colors duration-300 ${idx === pinnedIndex % chatPinnedMessages.length ? 'bg-primary' : 'bg-primary/20'}`}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<button
|
||
onClick={() => {
|
||
const currentPin = chatPinnedMessages[pinnedIndex % chatPinnedMessages.length];
|
||
if (!currentPin) return;
|
||
handleJumpToMessage(currentPin.id, currentPin.sequenceId);
|
||
if (chatPinnedMessages.length > 1) {
|
||
setPinnedIndex(prev => (prev + 1) % chatPinnedMessages.length);
|
||
}
|
||
}}
|
||
className={`flex-1 flex items-center gap-3 px-4 py-2 text-left h-full ${chatPinnedMessages.length > 1 ? 'ml-1.5' : ''}`}
|
||
>
|
||
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||
<Pin size={14} className="text-primary rotate-45" />
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<p className="text-[11px] font-black text-primary uppercase tracking-wider">
|
||
{t('pinnedMessage')} {chatPinnedMessages.length > 1 ? `#${(pinnedIndex % chatPinnedMessages.length) + 1}` : ''}
|
||
</p>
|
||
<p className="text-sm text-zinc-300 truncate font-medium">
|
||
{chatPinnedMessages[pinnedIndex % chatPinnedMessages.length]?.content ||
|
||
(chatPinnedMessages[pinnedIndex % chatPinnedMessages.length]?.media?.length > 0 ? t('media') : '...')}
|
||
</p>
|
||
</div>
|
||
</button>
|
||
|
||
<div className="flex items-center px-2">
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
const socket = getSocket();
|
||
const currentPin = chatPinnedMessages[pinnedIndex % chatPinnedMessages.length];
|
||
if (socket && activeChat && currentPin) {
|
||
socket.emit('unpin_message', { messageId: currentPin.id, chatId: activeChat });
|
||
}
|
||
}}
|
||
className="w-8 h-8 rounded-full flex items-center justify-center text-zinc-500 hover:text-white hover:bg-white/5 transition-all"
|
||
title={t('unpin' as any) || 'Открепить'}
|
||
>
|
||
<X size={16} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="relative flex-1 flex flex-col overflow-hidden">
|
||
<AnimatePresence mode="wait">
|
||
{showStickyDate && stickyDate && (
|
||
<motion.div
|
||
initial={{ opacity: 0, scale: 0.9, y: -20 }}
|
||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||
exit={{ opacity: 0, scale: 0.9, y: -20 }}
|
||
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||
key="sticky-date"
|
||
className="absolute top-6 left-1/2 -translate-x-1/2 z-[999] pointer-events-none"
|
||
>
|
||
<span className="px-4 py-1.5 rounded-full text-[11px] font-black uppercase tracking-widest text-white bg-black/60 backdrop-blur-xl shadow-[0_10px_30px_rgba(0,0,0,0.5)] border border-white/10 ring-2 ring-black/20 whitespace-nowrap">
|
||
{stickyDate}
|
||
</span>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
|
||
<div
|
||
ref={scrollContainerRef}
|
||
onScroll={handleScroll}
|
||
className={`flex-1 overflow-y-auto overflow-x-hidden px-6 pt-6 pb-2 relative z-10 scroll-smooth-container ${!scrollReady && !isLoadingMessages && chatMessages.length > 0 ? 'invisible' : ''}`}
|
||
>
|
||
{isLoadingMessages && chatMessages.length === 0 ? (
|
||
<div className="flex justify-center py-8">
|
||
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||
</div>
|
||
) : chatMessages.length === 0 ? (
|
||
<div className="flex items-center justify-center h-full">
|
||
<div className="flex flex-col items-center gap-4 opacity-30 select-none">
|
||
<MessagesSquare size={64} className="text-on-surface-variant" />
|
||
<p className="text-sm font-bold uppercase tracking-widest text-on-surface-variant">{t('noMessages')}</p>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-1 max-w-3xl mx-auto">
|
||
{isLoadingMessages && (
|
||
<div className="flex justify-center py-4">
|
||
<div className="w-5 h-5 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||
</div>
|
||
)}
|
||
{chatMessages.map((msg, i) => {
|
||
const prevMsg = i > 0 ? chatMessages[i - 1] : null;
|
||
const showAvatar = !prevMsg || prevMsg.senderId !== msg.senderId;
|
||
const showDate =
|
||
!prevMsg ||
|
||
new Date(msg.createdAt).toDateString() !== new Date(prevMsg.createdAt).toDateString();
|
||
|
||
const isFirstUnread = firstUnreadId === msg.id;
|
||
|
||
return (
|
||
<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 && (
|
||
<div id="unread-divider" className="flex items-center justify-center my-4 opacity-80 select-none">
|
||
<div className="flex-1 h-px bg-outline/20"></div>
|
||
<span className="px-4 text-[11px] font-semibold tracking-wider uppercase text-zinc-400">
|
||
{t('unreadMessages')}
|
||
</span>
|
||
<div className="flex-1 h-px bg-outline/20"></div>
|
||
</div>
|
||
)}
|
||
{showDate && (
|
||
<div className="flex justify-center my-4">
|
||
<span className="px-3 py-1 rounded-full text-xs text-zinc-400 glass-effect">
|
||
{new Date(msg.createdAt).toLocaleDateString(lang === 'ru' ? 'ru' : 'en', {
|
||
day: 'numeric',
|
||
month: 'long',
|
||
})}
|
||
</span>
|
||
</div>
|
||
)}
|
||
<MessageBubble
|
||
message={msg}
|
||
isMine={msg.senderId === user?.id}
|
||
showAvatar={showAvatar}
|
||
onViewProfile={(userId) => setProfileUserId(userId)}
|
||
selectionMode={selectionMode}
|
||
isSelected={selectedMessages.has(msg.id)}
|
||
onToggleSelect={handleToggleSelect}
|
||
onStartSelectionMode={handleStartSelection}
|
||
onForward={(id) => {
|
||
setSelectedMessages(new Set([id]));
|
||
setShowForwardModal(true);
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
})}
|
||
<div ref={messagesEndRef} className="h-4" />
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<AnimatePresence>
|
||
{showScrollDown && (
|
||
<motion.button
|
||
initial={{ scale: 0.5, opacity: 0, y: 20 }}
|
||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||
exit={{ scale: 0.5, opacity: 0, y: 20 }}
|
||
whileHover={{ scale: 1.1 }}
|
||
whileTap={{ scale: 0.9 }}
|
||
onClick={() => scrollToBottom(true)}
|
||
className="absolute bottom-8 right-8 w-14 h-14 rounded-2xl bg-primary text-on-primary shadow-2xl flex items-center justify-center z-20 hover:shadow-primary/20 transition-all"
|
||
>
|
||
<ArrowDown size={28} />
|
||
{unreadCount > 0 && (
|
||
<span className="absolute -top-2 -right-2 min-w-[24px] h-6 px-1.5 rounded-full bg-error text-on-error text-[12px] font-black flex items-center justify-center shadow-lg border-2 border-surface-container-lowest">
|
||
{unreadCount > 99 ? '99+' : unreadCount}
|
||
</span>
|
||
)}
|
||
</motion.button>
|
||
)}
|
||
</AnimatePresence>
|
||
</div>
|
||
|
||
<footer className="flex-shrink-0 bg-surface-container-lowest/40 backdrop-blur-xl border-t border-white/5 safe-area-bottom relative z-50">
|
||
<MessageInput chatId={activeChat} />
|
||
</footer>
|
||
</>
|
||
)}
|
||
|
||
{/* Typing indicator is already shown in the header, removed from here to prevent layout jumping */}
|
||
|
||
{(() => {
|
||
return (
|
||
<>
|
||
<AnimatePresence>
|
||
{profileUserId && (
|
||
<UserProfile
|
||
userId={profileUserId}
|
||
chatId={activeChat || undefined}
|
||
onClose={() => setProfileUserId(null)}
|
||
onGoToMessage={(msgId: any) => { 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 && (
|
||
<ForwardModal
|
||
onClose={() => setShowForwardModal(false)}
|
||
onForward={handleForward}
|
||
/>
|
||
)}
|
||
</AnimatePresence>
|
||
|
||
<ConfirmModal
|
||
open={!!confirmAction}
|
||
message={confirmAction?.message || ''}
|
||
onConfirm={() => {
|
||
confirmAction?.action();
|
||
setConfirmAction(null);
|
||
}}
|
||
onCancel={() => setConfirmAction(null)}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|