Files
forkmessager/client-web/src/modules/chats/presentation/components/ChatView.tsx
2026-04-20 23:21:50 +03:00

1470 lines
64 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}