import { useAuthStore } from '../store/authStore'; import { useChatStore } from '../store/chatStore'; import { useNavigate, useParams } from 'react-router-dom'; import { motion, AnimatePresence } from 'framer-motion'; import { MessageSquarePlus, Settings, User, Send, ArrowLeft, X, Pencil, Check, ArrowDown, Trash2 } from 'lucide-react'; import miniLogo from '../assets/mini-logo.png'; import VerificationBanner from '../components/common/VerificationBanner'; import { useEffect, useState, useRef } from 'react'; import chatService, { type Chat, type Message } from '../services/chatService'; import { userService, type User as UserType } from '../services/userService'; export default function ChatPage() { const user = useAuthStore((state) => state.user); const navigate = useNavigate(); const { chatId } = useParams<{ chatId: string }>(); // Chat store with cache const cachedChats = useChatStore((state) => state.chats); const setChatsCache = useChatStore((state) => state.setChats); const updateChatCache = useChatStore((state) => state.updateChat); const safeCachedChats = Array.isArray(cachedChats) ? cachedChats : []; const [chats, setChats] = useState(safeCachedChats); // Initialize with cached data const [loading, setLoading] = useState(safeCachedChats.length === 0); // Don't show loading if we have cache const [error, setError] = useState(null); // Selected chat state const [selectedChat, setSelectedChat] = useState(null); const [messages, setMessages] = useState([]); const [messagesLoading, setMessagesLoading] = useState(false); const [messagesError, setMessagesError] = useState(null); const [hasMoreMessages, setHasMoreMessages] = useState(true); const [loadingMore, setLoadingMore] = useState(false); // User profile modal state const [viewingUser, setViewingUser] = useState(null); const [_userProfileLoading, setUserProfileLoading] = useState(false); // Message input state const [messageText, setMessageText] = useState(''); const [sendingMessage, setSendingMessage] = useState(false); // Message editing state const [editingMessageId, setEditingMessageId] = useState(null); const [editingMessageText, setEditingMessageText] = useState(''); // Unread messages counter const [unreadCount, setUnreadCount] = useState(0); const [showScrollButton, setShowScrollButton] = useState(false); const isAtBottomRef = useRef(true); // Track if user is at bottom // New chat / user search state const [showNewChatModal, setShowNewChatModal] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [searchResults, setSearchResults] = useState([]); const [searchLoading, setSearchLoading] = useState(false); const searchTimeoutRef = useRef(null); // Ref for auto-scroll to bottom const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); // WebSocket ref const wsRef = useRef(null); // Input ref for auto-focus const messageInputRef = useRef(null); useEffect(() => { const loadChats = async () => { try { if (safeCachedChats.length === 0) { setLoading(true); } setError(null); const data = await chatService.getChats(0, 50); setChats(data); setChatsCache(data); // If chatId in URL, select that chat (messages will load in next useEffect) if (chatId && data.length > 0) { const chat = data.find(c => c.chat_id === chatId); if (chat) { setSelectedChat(chat); } } } catch (err: any) { setError(err.response?.data?.detail || 'Ошибка загрузки чатов'); console.error('Failed to load chats:', err); } finally { setLoading(false); } }; loadChats(); }, [chatId, safeCachedChats.length, setChatsCache]); useEffect(() => { if (selectedChat) { // Reset messages and load from beginning setMessages([]); setHasMoreMessages(true); setUnreadCount(0); setShowScrollButton(false); isAtBottomRef.current = true; loadMessages(selectedChat.chat_id, true); // Auto-focus input when chat is selected setTimeout(() => { messageInputRef.current?.focus(); }, 100); } }, [selectedChat?.chat_id]); // WebSocket connection useEffect(() => { const connectWebSocket = () => { // Get token from cookies for WebSocket auth const wsUrl = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1'; const wsProtocol = wsUrl.startsWith('https') ? 'wss' : 'ws'; const wsBase = wsUrl.replace('http://', '').replace('https://', ''); const ws = new WebSocket(`${wsProtocol}://${wsBase}/chats/ws`); ws.onopen = () => { console.log('WebSocket connected'); }; ws.onmessage = (event) => { try { const data = JSON.parse(event.data); console.log('Received WebSocket data:', data); // Handle message deletion if (data.type === 'del' && data.message_id) { setMessages(prev => prev.filter(m => m.id !== data.message_id)); return; } // Handle regular message (add/update) const message: Message = data; // Add or update message in current chat if it belongs to it if (selectedChat && message.chat_id === selectedChat.chat_id) { setMessages(prev => { // Check if this is replacing a temporary message (sent by current user) const tempMessageIndex = prev.findIndex(m => m.id.startsWith('temp-') && m.sender_id === message.sender_id && m.content === message.content); // Check if message already exists const existingIndex = prev.findIndex(m => m.id === message.id); if (tempMessageIndex !== -1) { // Replace temporary message with real one const updated = [...prev]; updated[tempMessageIndex] = message; return updated; } else if (existingIndex !== -1) { // Update existing message (edit case) const updated = [...prev]; updated[existingIndex] = message; return updated; } else { // Add new message from other user return [...prev, message]; } }); // Handle scroll and unread counter for new messages const isOwnMessage = message.sender_id === user?.id; // Use ref to check current position synchronously setTimeout(() => { if (isOwnMessage || isAtBottomRef.current) { // Auto-scroll if it's our message or if at bottom scrollToBottom(true); } else { // Increment unread counter if not at bottom and not our message setUnreadCount(prev => prev + 1); setShowScrollButton(true); } }, 100); } // Update chat list with new last message setChats(prevChats => prevChats.map(chat => chat.chat_id === message.chat_id ? { ...chat, last_message: message.content } : chat ) ); // Update cache updateChatCache(message.chat_id, { last_message: message.content }); } catch (error) { console.error('Error parsing WebSocket message:', error); } }; ws.onerror = (error) => { console.error('WebSocket error:', error); }; ws.onclose = () => { console.log('WebSocket disconnected'); // Attempt to reconnect after 3 seconds setTimeout(() => { if (wsRef.current === ws) { connectWebSocket(); } }, 3000); }; wsRef.current = ws; }; connectWebSocket(); // Cleanup on unmount return () => { if (wsRef.current) { wsRef.current.close(); wsRef.current = null; } }; }, [selectedChat, updateChatCache]); // Handle Escape key to close chat useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { if (viewingUser) { setViewingUser(null); } else if (selectedChat) { handleBackToChats(); } } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [selectedChat, viewingUser]); const loadMessages = async (chatId: string, isInitial: boolean = false) => { try { if (isInitial) { setMessagesLoading(true); } else { setLoadingMore(true); } setMessagesError(null); const offset = isInitial ? 0 : messages.length; // Save scroll position before loading more const scrollContainer = messagesContainerRef.current; const previousScrollHeight = scrollContainer?.scrollHeight || 0; const data = await chatService.getChatMessages(chatId, offset, 50); if (data.length < 50) { setHasMoreMessages(false); } // Backend returns messages in DESC order (newest first), reverse to show oldest first const sortedData = [...data].reverse(); if (isInitial) { setMessages(sortedData); // Scroll to bottom on initial load setTimeout(() => { scrollToBottom(false); checkScrollPosition(); }, 100); } else { // Prepend old messages to the beginning setMessages(prev => [...sortedData, ...prev]); // Restore scroll position after render setTimeout(() => { if (scrollContainer) { const newScrollHeight = scrollContainer.scrollHeight; scrollContainer.scrollTop = newScrollHeight - previousScrollHeight; } }, 0); } } catch (err: any) { setMessagesError(err.response?.data?.detail || 'Ошибка загрузки сообщений'); console.error('Failed to load messages:', err); } finally { setMessagesLoading(false); setLoadingMore(false); } }; const checkScrollPosition = () => { const element = messagesContainerRef.current; if (!element) return; const threshold = 150; // pixels from bottom const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight; const atBottom = distanceFromBottom < threshold; isAtBottomRef.current = atBottom; setShowScrollButton(!atBottom); if (atBottom) { setUnreadCount(0); } }; const scrollToBottom = (smooth: boolean = true) => { if (messagesEndRef.current) { messagesEndRef.current.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto' }); setUnreadCount(0); setShowScrollButton(false); isAtBottomRef.current = true; } }; const handleScroll = (e: React.UIEvent) => { const element = e.currentTarget; // Update scroll position tracking checkScrollPosition(); // If scrolled to top and has more messages if (element.scrollTop === 0 && hasMoreMessages && !loadingMore && selectedChat) { loadMessages(selectedChat.chat_id, false); } }; const handleChatClick = (chat: Chat) => { setSelectedChat(chat); navigate(`/chat/${chat.chat_id}`); }; const handleBackToChats = () => { setSelectedChat(null); setMessages([]); navigate('/chat'); }; const handleViewUserProfile = async (userId: number) => { try { setUserProfileLoading(true); const userData = await userService.getUserById(userId); setViewingUser(userData); } catch (err: any) { console.error('Failed to load user profile:', err); } finally { setUserProfileLoading(false); } }; const handleSendMessage = async () => { if (!messageText.trim() || sendingMessage) return; try { setSendingMessage(true); const messageContent = messageText.trim(); // Create a temporary message to show immediately (optimistic update) const tempMessage: Message = { id: `temp-${Date.now()}`, // Temporary ID sender_id: user?.id || 0, chat_id: selectedChat?.chat_id || '', content: messageContent, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), }; // Add message to UI immediately setMessages(prev => [...prev, tempMessage]); // Send message with chat_id (если чат существует) или recipient_id (если новый чат) const sentMessage = await chatService.sendMessage({ content: messageContent, chat_id: selectedChat?.chat_id, recipient_id: selectedChat?.user_id, }); // Replace temporary message with real one from backend setMessages(prev => prev.map(m => m.id === tempMessage.id ? sentMessage : m) ); // Clear input setMessageText(''); // Update chat list with new last message const updatedChats = chats.map(chat => chat.chat_id === selectedChat?.chat_id ? { ...chat, last_message: messageContent } : chat ); setChats(updatedChats); setChatsCache(updatedChats); // Update cache // Update cache for specific chat if (selectedChat?.chat_id) { updateChatCache(selectedChat.chat_id, { last_message: messageContent }); } // Auto-scroll to bottom setTimeout(() => { scrollToBottom(true); }, 100); } catch (err: any) { console.error('Failed to send message:', err); alert('Не удалось отправить сообщение'); // Remove temporary message if sending failed setMessages(prev => prev.filter(m => !m.id.startsWith('temp-'))); } finally { setSendingMessage(false); // Keep input focused after everything is done setTimeout(() => { messageInputRef.current?.focus(); }, 0); } }; const handleKeyPress = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSendMessage(); } }; const handleStartEdit = (message: Message) => { setEditingMessageId(message.id); setEditingMessageText(message.content); }; const handleCancelEdit = () => { setEditingMessageId(null); setEditingMessageText(''); }; const handleSaveEdit = async (messageId: string) => { if (!editingMessageText.trim() || sendingMessage) return; try { setSendingMessage(true); const updatedMessage = await chatService.updateMessage({ id: messageId, content: editingMessageText.trim(), }); // Update message in list setMessages(prev => prev.map(m => m.id === messageId ? updatedMessage : m) ); // Clear editing state setEditingMessageId(null); setEditingMessageText(''); } catch (err: any) { console.error('Failed to update message:', err); alert('Не удалось изменить сообщение'); } finally { setSendingMessage(false); } }; const handleEditKeyPress = (e: React.KeyboardEvent, messageId: string) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSaveEdit(messageId); } else if (e.key === 'Escape') { handleCancelEdit(); } }; const handleDeleteMessage = async (messageId: string) => { if (!confirm('Удалить сообщение?')) return; try { await chatService.deleteMessage(messageId); // Message will be removed via WebSocket } catch (err: any) { console.error('Failed to delete message:', err); alert('Не удалось удалить сообщение'); } }; // User search handlers const handleSearchUsers = async (query: string) => { setSearchQuery(query); // Clear previous timeout if (searchTimeoutRef.current) { clearTimeout(searchTimeoutRef.current); } if (query.trim().length < 2) { setSearchResults([]); setSearchLoading(false); return; } setSearchLoading(true); // Debounce search searchTimeoutRef.current = setTimeout(async () => { try { const results = await userService.searchUsers(query); setSearchResults(results); } catch (err: any) { console.error('Search failed:', err); } finally { setSearchLoading(false); } }, 300); }; const handleStartChatWithUser = async (recipientUser: UserType) => { try { // Send initial message to create chat const message = await chatService.sendMessage({ recipient_id: recipientUser.id, content: `Привет, ${recipientUser.display_name || recipientUser.username}!` }); // Close modal setShowNewChatModal(false); setSearchQuery(''); setSearchResults([]); // Reload chats to see the new one const updatedChats = await chatService.getChats(); setChats(updatedChats); setChatsCache(updatedChats); // Find and select the new chat const newChat = updatedChats.find(c => c.chat_id === message.chat_id ); if (newChat) { setSelectedChat(newChat); navigate(`/chat/${newChat.chat_id}`); } } catch (err: any) { console.error('Failed to start chat:', err); alert('Не удалось начать чат'); } }; return (
{/* Sidebar */}
{/* Header */}

AETHER

{/* Verification Banner */} {user && !user.is_verified && (
)} {/* User Profile Section */}
{/* Avatar */}
navigate('/profile')} title="Перейти в профиль" > {!user?.avatar_url && ( Avatar )}
{/* User Info */}

{user?.display_name || user?.username}

@{user?.username}

{/* Settings Icon */}
{/* Action Button */}
navigate('/profile')} whileTap={{ scale: 0.95 }} className="w-full flex items-center justify-center gap-2 py-2 px-3 rounded-xl font-inter text-sm font-medium transition hover:opacity-80" style={{ backgroundColor: 'var(--bg-input)', color: 'var(--accent-primary)' }} > Профиль
{/* New Chat Button */}
setShowNewChatModal(true)} className="w-full flex items-center justify-center gap-3 py-3 px-4 rounded-2xl font-inter font-semibold shadow-sm hover:shadow-md transition" style={{ backgroundColor: 'var(--accent-primary)', color: 'white' }} > Новый чат
{/* Chats List */}
{loading ? (

Загрузка чатов...

) : error ? (

{error}

) : chats.length === 0 ? (

Пока нет чатов

Начните новый диалог

) : ( chats.map((chat) => ( handleChatClick(chat)} className="p-4 rounded-2xl cursor-pointer transition-all relative overflow-hidden" style={{ backgroundColor: selectedChat?.chat_id === chat.chat_id ? 'var(--accent-primary)' : 'var(--bg-input)', boxShadow: selectedChat?.chat_id === chat.chat_id ? '0 4px 12px rgba(0,0,0,0.1)' : 'none', }} >
{/* Chat Avatar with Online Indicator */}
{!chat.avatar_url && ( Avatar )}
{/* Online indicator */}
{/* Chat Info */}

{chat.display_name}

{/* Time badge */} 12:34
{chat.last_message ? (

{chat.last_message}

) : (

Нет сообщений

)}
)) )}
{/* Main Chat Area */}
{selectedChat ? ( <> {/* Chat Header */}
handleViewUserProfile(selectedChat.user_id)} className="w-12 h-12 flex-shrink-0 flex items-center justify-center cursor-pointer" style={{ backgroundImage: selectedChat.avatar_url ? `url(${selectedChat.avatar_url})` : undefined, backgroundSize: 'cover', backgroundPosition: 'center', borderRadius: selectedChat.avatar_url ? '50%' : '0', }} > {!selectedChat.avatar_url && ( Avatar )}
handleViewUserProfile(selectedChat.user_id)} >

{selectedChat.display_name}

в сети

{/* Messages Area */}
{loadingMore && (
)} {messagesLoading ? (
) : messagesError ? (

{messagesError}

) : messages.length === 0 ? (

Начните общение

Отправьте первое сообщение в этот чат

) : (
{messages.map((message, index) => { const isMyMessage = message.sender_id === user?.id; const prevMessage = index > 0 ? messages[index - 1] : null; const showAvatar = !prevMessage || prevMessage.sender_id !== message.sender_id; const nextMessage = index < messages.length - 1 ? messages[index + 1] : null; const isLastInGroup = !nextMessage || nextMessage.sender_id !== message.sender_id; // Check if date changed from previous message const messageDate = new Date(message.created_at); const prevMessageDate = prevMessage ? new Date(prevMessage.created_at) : null; const showDateDivider = !prevMessageDate || messageDate.toDateString() !== prevMessageDate.toDateString(); return (
{/* Date divider */} {showDateDivider && (
{messageDate.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' })}
)} {/* Avatar for incoming messages */} {!isMyMessage && showAvatar && ( {!selectedChat.avatar_url && ( Avatar )} )} {/* Message bubble */}
{/* Sender name for incoming messages */} {!isMyMessage && showAvatar && ( {selectedChat.display_name} )} {/* Edit and Delete buttons - показываем только для своих сообщений */} {isMyMessage && editingMessageId !== message.id && ( <> )} {/* Message content or edit input */} {editingMessageId === message.id ? (
setEditingMessageText(e.target.value)} onKeyDown={(e) => handleEditKeyPress(e, message.id)} autoFocus className="flex-1 bg-transparent border-b-2 outline-none" style={{ borderColor: isMyMessage ? 'rgba(255,255,255,0.5)' : 'var(--accent-primary)', color: isMyMessage ? 'white' : 'var(--text-primary)' }} />
) : (

{message.content}

)} {/* Message metadata */}
{/* Edited indicator */} {message.is_edited && ( изменено )} {new Date(message.created_at).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })} {/* Read status for my messages */} {isMyMessage && ( )}
{/* Reactions placeholder - можно добавить позже */} {isLastInGroup && false && (
❤️
)}
); })} {/* Invisible element to scroll to */}
)}
{/* Scroll to bottom button with unread counter - OUTSIDE scroll container */} {showScrollButton && ( scrollToBottom(true)} className="absolute bottom-20 md:bottom-24 right-4 md:right-8 p-2.5 md:p-3 rounded-full shadow-xl hover:scale-110 transition-transform" style={{ backgroundColor: 'var(--accent-primary)', color: 'white', zIndex: 50 }} title="Прокрутить вниз" > {unreadCount > 0 && ( {unreadCount > 99 ? '99+' : unreadCount} )} )} {/* Message Input */}
setMessageText(e.target.value)} onKeyPress={handleKeyPress} placeholder="Написать сообщение..." disabled={sendingMessage} className="flex-1 px-3 md:px-4 py-2.5 md:py-3 rounded-2xl font-inter text-sm outline-none transition" style={{ backgroundColor: 'var(--bg-input)', color: 'var(--text-primary)', borderBottom: '2px solid transparent', }} onFocus={(e) => e.target.style.borderBottomColor = 'var(--accent-primary)'} onBlur={(e) => e.target.style.borderBottomColor = 'transparent'} /> {sendingMessage ? (
) : ( )}
) : ( /* Empty State */
Aether Logo

Aether

Выберите существующий чат из списка слева или создайте новый, чтобы начать общение

)}
{/* User Profile Modal */} {viewingUser && ( setViewingUser(null)} > e.stopPropagation()} > {/* Modal Header */}

Профиль пользователя

{/* Modal Content */}
{/* Avatar */}
{!viewingUser.avatar_url && ( )}
{/* User Info */}

{viewingUser.display_name}

@{viewingUser.username}

{viewingUser.description && (

{viewingUser.description}

)} {viewingUser.birth_day && (
🎂 {new Date(viewingUser.birth_day).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' })}
)}
✉️ {viewingUser.email}
{/* Actions */}
setViewingUser(null)} className="w-full py-3 px-4 rounded-2xl font-inter font-semibold transition hover:opacity-90" style={{ backgroundColor: 'var(--accent-primary)', color: 'white' }} > Закрыть
)}
{/* New Chat Modal */} {showNewChatModal && ( { setShowNewChatModal(false); setSearchQuery(''); setSearchResults([]); }} > e.stopPropagation()} className="w-full max-w-md rounded-3xl shadow-2xl overflow-hidden" style={{ backgroundColor: 'var(--bg-card)' }} > {/* Modal Header */}

Новый чат

{/* Search Input */} handleSearchUsers(e.target.value)} placeholder="Поиск пользователей..." autoFocus className="w-full px-4 py-3 rounded-2xl font-inter text-sm outline-none transition" style={{ backgroundColor: 'var(--bg-input)', color: 'var(--text-primary)', borderBottom: '2px solid transparent', }} />
{/* Search Results */}
{searchLoading ? (

Поиск...

) : searchQuery.trim().length < 2 ? (

Введите имя пользователя для поиска

) : searchResults.length === 0 ? (

Пользователи не найдены

) : (
{searchResults.map((foundUser) => ( handleStartChatWithUser(foundUser)} className="w-full flex items-center gap-3 p-3 rounded-2xl transition hover:shadow-sm" style={{ backgroundColor: 'var(--bg-input)' }} > {/* Avatar */}
{!foundUser.avatar_url && ( Avatar )}
{/* User Info */}

{foundUser.display_name || foundUser.username}

@{foundUser.username}

))}
)}
)}
); }