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([]); const [profileUserId, setProfileUserId] = useState(null); const [showGroupSettings, setShowGroupSettings] = useState(false); const [showScrollDown, setShowScrollDown] = useState(false); const [stickyDate, setStickyDate] = useState(null); const [showStickyDate, setShowStickyDate] = useState(false); const stickyDateTimerRef = useRef(null); const [muted, setMuted] = useState(false); const [selectionMode, setSelectionMode] = useState(false); const [selectedMessages, setSelectedMessages] = useState>(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([]); const messagesEndRef = useRef(null); const scrollContainerRef = useRef(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(null); const topMenuRef = useRef(null); const deleteMenuRef = useRef(null); const chatViewRef = useRef(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(`/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(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(null); const prevChatIdRef = useRef(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>(new Set()); const observerRef = useRef(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) => { 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 (
{/* Background Knot Texture */}
cloud_download
{/* Chat Empty State View */}
forum

{t('selectChatTitle')}

{t('selectChatSubtext')}

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" > edit {t('newMessage')}
); } 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 (
{selectionMode ? (
{selectedMessages.size} {t('selected')}
{showDeleteMenu && (
)}
) : (
{ if (chat.type === 'personal' && otherMember) { setProfileUserId(otherMember.user.id); } else if (chat.type === 'group') { setShowGroupSettings(true); } }} >
{isFavorites ? (
bookmark
) : ( )}

{chatName}

{isFavorites ? t('favoritesDescription') : typingInChat.length > 0 ? {t('typing')} : isOnline ? {t('online')} : chat.type === 'personal' && otherMember?.user.lastSeen ? `${formatLastSeen(otherMember.user.lastSeen, lang)}` : chat.type === 'group' ? `${chat.members.length} ${t('members')}` : ''}

{showSearch && ( 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" /> )} {!isFavorites && config?.webRtc?.enabled && (
{config?.webRtc?.enableVideoCalls && ( )}
)}
{showTopMenu && ( {!isFavorites && config?.webRtc?.enabled && ( <> {config?.webRtc?.enableVideoCalls && ( )} )} {!isFavorites && chat.type === 'personal' && otherMember && ( )} {!isFavorites && ( )} {!isFavorites && chat.type === 'group' && ( )}
{!isFavorites && ( )} )}
)} {/* Результаты поиска */} {showSearch && searchResults.length > 0 && ( {searchResults.map((msg) => (
{ // 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([]); }} >
{msg.sender?.displayName || msg.sender?.username} {new Date(msg.createdAt).toLocaleDateString(lang === 'ru' ? 'ru' : 'en')}

{msg.content}

))}
)}
{chat.isImporting ? (
cloud_download
downloading

Идет импорт истории

Мы переносим ваши сообщения и медиафайлы из Telegram. Это займет некоторое время.

{importStatus?.status === 'Processing' ? 'Обработка' : importStatus?.status === 'Queued' ? 'В очереди' : 'Загрузка'} {importStatus?.processed || 0} / {importStatus?.total || 0}

Чат станет доступен автоматически

) : ( <> {chat?.type === 'group' && config?.webRtc?.enabled && activeGroupCallParticipants.length > 0 && ( )} {chatPinnedMessages.length > 0 && (
{/* Cycling progress indicator for multiple pins */} {chatPinnedMessages.length > 1 && (
{chatPinnedMessages.map((_, idx) => (
))}
)}
)}
{showStickyDate && stickyDate && ( {stickyDate} )}
0 ? 'invisible' : ''}`} > {isLoadingMessages && chatMessages.length === 0 ? (
) : chatMessages.length === 0 ? (

{t('noMessages')}

) : (
{isLoadingMessages && (
)} {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 (
r.userId === user?.id) ? 'unread-detector' : ''}`} > {isFirstUnread && (
{t('unreadMessages')}
)} {showDate && (
{new Date(msg.createdAt).toLocaleDateString(lang === 'ru' ? 'ru' : 'en', { day: 'numeric', month: 'long', })}
)} setProfileUserId(userId)} selectionMode={selectionMode} isSelected={selectedMessages.has(msg.id)} onToggleSelect={handleToggleSelect} onStartSelectionMode={handleStartSelection} onForward={(id) => { setSelectedMessages(new Set([id])); setShowForwardModal(true); }} />
); })}
)}
{showScrollDown && ( 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" > {unreadCount > 0 && ( {unreadCount > 99 ? '99+' : unreadCount} )} )}
)} {/* Typing indicator is already shown in the header, removed from here to prevent layout jumping */} {(() => { return ( <> {profileUserId && ( setProfileUserId(null)} onGoToMessage={(msgId: any) => { handleJumpToMessage(msgId); setProfileUserId(null); }} isSelf={profileUserId === user?.id} /> )} {showGroupSettings && chat && chat.type === 'group' && ( setShowGroupSettings(false)} onGoToMessage={(msgId) => { handleJumpToMessage(msgId); setShowGroupSettings(false); }} /> )} ); })()} {showForwardModal && ( setShowForwardModal(false)} onForward={handleForward} /> )} { confirmAction?.action(); setConfirmAction(null); }} onCancel={() => setConfirmAction(null)} />
); }