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 } 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 [chats, setChats] = useState(cachedChats); // Initialize with cached data const [loading, setLoading] = useState(cachedChats.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 // 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 (cachedChats.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, 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 message: Message = JSON.parse(event.data); console.log('Received message via WebSocket:', message); // Add or update message in current chat if it belongs to it if (selectedChat && message.chat_id === selectedChat.chat_id) { const isNewMessage = !messages.some(m => m.id === message.id); setMessages(prev => { // Check if message already exists (update it if edited) const existingIndex = prev.findIndex(m => m.id === message.id); if (existingIndex !== -1) { // Update existing message const updated = [...prev]; updated[existingIndex] = message; return updated; } // Add new message return [...prev, message]; }); // Handle scroll and unread counter for new messages if (isNewMessage) { // Use ref to check current position synchronously setTimeout(() => { if (isAtBottomRef.current) { // Auto-scroll if at bottom scrollToBottom(true); } else { // Increment unread counter if not at bottom 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); // Send message with chat_id (если чат существует) или recipient_id (если новый чат) const newMessage = await chatService.sendMessage({ content: messageText.trim(), chat_id: selectedChat?.chat_id, recipient_id: selectedChat?.user_id, }); // Add message to list setMessages(prev => [...prev, newMessage]); // Scroll to bottom after sending setTimeout(() => { scrollToBottom(true); }, 100); // Clear input setMessageText(''); // Keep input focused messageInputRef.current?.focus(); // Update chat list with new last message const updatedChats = chats.map(chat => chat.chat_id === selectedChat?.chat_id ? { ...chat, last_message: messageText.trim() } : chat ); setChats(updatedChats); setChatsCache(updatedChats); // Update cache // Update cache for specific chat if (selectedChat?.chat_id) { updateChatCache(selectedChat.chat_id, { last_message: messageText.trim() }); } } catch (err: any) { console.error('Failed to send message:', err); alert('Не удалось отправить сообщение'); } finally { setSendingMessage(false); } }; 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(); } }; 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 */}
Новый чат
{/* 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}

) : (

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

)}
)) )}
{/* Footer Info */}

Aether Chat v1.0

{/* 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 button - показываем только для своих сообщений */} {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 && ( {unreadCount > 0 && ( {unreadCount > 99 ? '99+' : unreadCount} )} )} {/* Message Input */}
setMessageText(e.target.value)} onKeyPress={handleKeyPress} placeholder="Написать сообщение..." disabled={sendingMessage} className="flex-1 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', }} 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' }} > Закрыть
)}
); }