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, MessageSquare, } from 'lucide-react'; import { useChatStore } from '../stores/chatStore'; import { useAuthStore } from '../stores/authStore'; import { api } from '../lib/api'; import { getSocket } from '../lib/socket'; import { isChatMuted, toggleMuteChat } from '../lib/sounds'; import { useLang } from '../lib/i18n'; import { formatLastSeen } from '../lib/utils'; import type { UserBasic, Message } from '../lib/types'; import MessageBubble from './MessageBubble'; import MessageInput from './MessageInput'; import TypingIndicator from './TypingIndicator'; import UserProfile from './UserProfile'; import GroupSettings from './GroupSettings'; import ForwardModal from './ForwardModal'; import ConfirmModal from './ConfirmModal'; import Avatar from './Avatar'; import { useThemeStore } from '../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, setActiveChat, } = 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 [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 messagesContainerRef = useRef(null); 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 pinnedMsg = activeChat ? pinnedMessages[activeChat] : null; // Количество непрочитанных сообщений (для бейджика) 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 || t('chat') : chat?.name || t('group'); const chatAvatar = isFavorites ? null : chat?.type === 'personal' ? otherMember?.user.avatar : chat?.avatar; 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 }); if (activeChat && activeChat !== sessionUnreadRef.current.chatId && !isLoadingMessages) { 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 }; } const firstUnreadId = activeChat === sessionUnreadRef.current.chatId ? sessionUnreadRef.current.msgId : null; const lastObservedMessageIdRef = useRef(null); // Load muted state useEffect(() => { if (activeChat) { setMuted(isChatMuted(activeChat)); setScrollReady(false); setActiveGroupCallParticipants([]); lastObservedMessageIdRef.current = null; } }, [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) => { messagesEndRef.current?.scrollIntoView({ behavior: smooth ? 'smooth' : 'instant', block: 'end' }); }, []); // Первичная прокрутка при открытии чата или после загрузки (layout effect — до отрисовки) useLayoutEffect(() => { if (!isLoadingMessages && messagesContainerRef.current) { const container = messagesContainerRef.current; const unreadId = sessionUnreadRef.current.msgId; if (unreadId && activeChat === sessionUnreadRef.current.chatId) { const dividerEl = document.getElementById('unread-divider'); const unreadEl = document.getElementById(`msg-${unreadId}`); const targetEl = dividerEl || unreadEl; if (targetEl) { // Вычитаем отступ сверху, чтобы начало непрочитанных было под шапкой container.scrollTop = targetEl.offsetTop - 60; } else { container.scrollTop = container.scrollHeight; } } else { container.scrollTop = container.scrollHeight; } setScrollReady(true); } }, [activeChat, isLoadingMessages]); // Scroll on new message arrivals useEffect(() => { if (chatMessages.length > 0) { const lastMsg = chatMessages[chatMessages.length - 1]; const prevId = lastObservedMessageIdRef.current; lastObservedMessageIdRef.current = lastMsg.id; // Scroll ONLY if a genuinely new message was added (not during initial chat load) if (prevId && prevId !== lastMsg.id) { if (lastMsg.senderId === user?.id) { setTimeout(() => scrollToBottom(true), 50); } else { // Если пользователь внизу — прокрутить const container = messagesContainerRef.current; if (container) { const isNearBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 300; if (isNearBottom) setTimeout(() => scrollToBottom(true), 50); } } } } else { lastObservedMessageIdRef.current = null; } }, [chatMessages.length, user?.id, scrollToBottom]); // Read receipts using IntersectionObserver const sentReadIdsRef = useRef>(new Set()); const observerRef = useRef(null); useEffect(() => { if (!activeChat || !user?.id) return; // Cleanup previous observer if (observerRef.current) observerRef.current.disconnect(); sentReadIdsRef.current.clear(); const socket = getSocket(); if (!socket) return; const observer = new IntersectionObserver( (entries) => { const newlyReadIds: string[] = []; entries.forEach((entry) => { if (entry.isIntersecting) { const msgId = entry.target.getAttribute('data-message-id'); if (msgId && !sentReadIdsRef.current.has(msgId)) { newlyReadIds.push(msgId); sentReadIdsRef.current.add(msgId); // Stop observing once read observer.unobserve(entry.target); } } }); if (newlyReadIds.length > 0) { console.log('[IntersectionObserver] Marking as read:', newlyReadIds); socket.emit('read_messages', { chatId: activeChat, messageIds: newlyReadIds, }); // Update local store immediately for current user useChatStore.getState().markRead(activeChat, user.id, newlyReadIds); } }, { root: messagesContainerRef.current, threshold: 0.1, // Message must be 10% visible } ); observerRef.current = observer; // Initial observation of unread messages const observeUnread = () => { if (!messagesContainerRef.current) return; const unreadElements = messagesContainerRef.current.querySelectorAll('.unread-detector'); unreadElements.forEach((el) => { const id = el.getAttribute('data-message-id'); if (id && !sentReadIdsRef.current.has(id)) { observer.observe(el); } }); }; observeUnread(); return () => observer.disconnect(); }, [activeChat, user?.id]); // Re-run observation when messages change (to catch new arrivals) useEffect(() => { if (observerRef.current && !isLoadingMessages) { if (!messagesContainerRef.current) return; const unreadElements = messagesContainerRef.current.querySelectorAll('.unread-detector'); unreadElements.forEach((el) => { const id = el.getAttribute('data-message-id'); if (id && !sentReadIdsRef.current.has(id)) { observerRef.current?.observe(el); } }); } }, [chatMessages.length, isLoadingMessages]); // Scroll detection const checkScrollPosition = useCallback(() => { const container = messagesContainerRef.current; if (!container) return; const isNearBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 300; setShowScrollDown(!isNearBottom); }, []); const handleScroll = () => { checkScrollPosition(); }; useEffect(() => { // Check scroll position when messages change or scroll ready state changes 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 api.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 (
Knot Messenger {t('selectChat')}
); } const initials = chatName .split(' ') .map((w: string) => w[0]) .join('') .slice(0, 2) .toUpperCase(); 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 && (
)}
) : (
{showSearch && ( setSearchText(e.target.value)} className="w-full px-3 py-1.5 rounded-lg bg-surface-tertiary text-sm text-white placeholder-zinc-500 border border-border focus:border-accent" /> )} {!isFavorites && config?.enableCalls && ( <> )}
{showTopMenu && ( {chat.type === 'personal' && otherMember && ( )} {chat.type === 'group' && ( )}
)}
)} {/* Результаты поиска */} {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'), 3000); } setShowSearch(false); setSearchText(''); setSearchResults([]); }} >
{msg.sender?.displayName || msg.sender?.username} {new Date(msg.createdAt).toLocaleDateString(lang === 'ru' ? 'ru' : 'en')}

{msg.content}

))}
)}
{/* Закреплённое сообщение */} {/* Active group call banner */} {chat?.type === 'group' && config?.enableCalls && activeGroupCallParticipants.length > 0 && ( )} {pinnedMsg && ( )} {/* Сообщения */}
0 ? 'invisible' : ''}`} > {isLoadingMessages ? (
) : chatMessages.length === 0 ? (

{t('noMessages')}

) : (
{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-RU' : 'en-US', { 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); }} />
); })}
{/* Empty spacer for the bottom scroll boundary */}
)}
{/* Кнопка прокрутки вниз */} {showScrollDown && ( scrollToBottom()} className="absolute bottom-24 right-5 w-12 h-12 rounded-full bg-surface-secondary/95 backdrop-blur-md border border-border shadow-xl flex items-center justify-center text-zinc-400 hover:text-white hover:bg-surface-hover hover:scale-105 transition-all z-10" > {unreadCount > 0 && ( {unreadCount > 99 ? '99+' : unreadCount} )} )} {/* Typing индикатор */} {typingInChat.length > 0 && (
)} {/* Ввод сообщения */} {activeChat && } {/* Профиль пользователя */} {profileUserId && ( setProfileUserId(null)} onGoToMessage={(msgId) => { const el = document.getElementById(`msg-${msgId}`); if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); el.classList.add('highlight-message'); setTimeout(() => el.classList.remove('highlight-message'), 3000); setProfileUserId(null); } }} isSelf={profileUserId === user?.id} /> )} {/* Настройки группы */} {showGroupSettings && chat && chat.type === 'group' && ( setShowGroupSettings(false)} onGoToMessage={(msgId) => { const el = document.getElementById(`msg-${msgId}`); if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); el.classList.add('highlight-message'); setTimeout(() => el.classList.remove('highlight-message'), 3000); setShowGroupSettings(false); } }} /> )} {showForwardModal && ( setShowForwardModal(false)} onForward={handleForward} /> )} { confirmAction?.action(); setConfirmAction(null); }} onCancel={() => setConfirmAction(null)} />
); }