import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import Sidebar from '../components/Sidebar' import ChatWindow from '../components/ChatWindow' import NewChatDialog from '../components/NewChatDialog' import { api } from '../lib/api' import { useAuth } from '../auth/AuthContext' import { useConversations } from '../hooks/useConversations' import { useMessages } from '../hooks/useMessages' import { useRealtime } from '../hooks/useRealtime' import type { ClientMessage, Message, User, WsEvent } from '../lib/types' interface ChatAppProps { user: User } export default function ChatApp({ user }: ChatAppProps) { const { logout } = useAuth() const [selectedId, setSelectedId] = useState(null) const [directory, setDirectory] = useState>(() => new Map([[user.id, user]])) const [learnedPeers, setLearnedPeers] = useState>({}) const [presence, setPresence] = useState>({}) const [typing, setTyping] = useState>({}) const [dialogOpen, setDialogOpen] = useState(false) const [focusTick, setFocusTick] = useState(0) const { conversations, loading, error, reload, add, patch } = useConversations() const { messages, loading: messagesLoading, loadingOlder, hasMore, error: messagesError, loadOlder, upsert, addOptimistic, markSending, failOptimistic, markReadUpTo, } = useMessages(selectedId, user.id) const selectedIdRef = useRef(selectedId) const conversationsRef = useRef(conversations) const typingTimers = useRef(new Map()) useEffect(() => { selectedIdRef.current = selectedId }, [selectedId]) useEffect(() => { conversationsRef.current = conversations }, [conversations]) const rememberUser = useCallback((next: User) => { setDirectory((prev) => { const existing = prev.get(next.id) if ( existing && existing.display_name === next.display_name && existing.username === next.username && existing.avatar === next.avatar ) { return prev } const map = new Map(prev) map.set(next.id, next) return map }) }, []) const loadContacts = useCallback(async () => { const contacts = await api.listContacts().catch(() => []) contacts.forEach((contact) => rememberUser(contact.user)) }, [rememberUser]) useEffect(() => { void loadContacts() }, [loadContacts]) useEffect(() => { const onFocus = () => setFocusTick((tick) => tick + 1) window.addEventListener('focus', onFocus) return () => window.removeEventListener('focus', onFocus) }, []) const peers = useMemo(() => { const map: Record = { ...learnedPeers } for (const conversation of conversations) { if (map[conversation.id] !== undefined) continue const last = conversation.last_message if (last && last.sender_id !== user.id) map[conversation.id] = last.sender_id } return map }, [learnedPeers, conversations, user.id]) const handleEvent = useCallback( (event: WsEvent) => { switch (event.type) { case 'message.created': { const { temp_id, conversation_id, message_id, sender_id, body, created_at } = event.data const message: Message = { id: message_id, conversation_id, sender_id, body, created_at } if (conversation_id === selectedIdRef.current || sender_id === user.id) { upsert(message, temp_id) } if (!conversationsRef.current.some((conversation) => conversation.id === conversation_id)) { void reload() return } patch(conversation_id, (conversation) => { const mine = sender_id === user.id const open = conversation_id === selectedIdRef.current const unread_count = mine || open ? 0 : (conversation.unread_count ?? 0) + 1 return { ...conversation, last_message: message, unread_count } }) break } case 'typing': { const { conversation_id, user_id, is_typing } = event.data if (user_id === user.id) break const timers = typingTimers.current const existing = timers.get(conversation_id) if (existing !== undefined) { window.clearTimeout(existing) timers.delete(conversation_id) } if (is_typing) { const timer = window.setTimeout(() => { timers.delete(conversation_id) setTyping((prev) => (prev[conversation_id] ? { ...prev, [conversation_id]: false } : prev)) }, 3000) timers.set(conversation_id, timer) } setTyping((prev) => prev[conversation_id] === is_typing ? prev : { ...prev, [conversation_id]: is_typing }, ) break } case 'presence': { const { user_id, status } = event.data const online = status === 'online' setPresence((prev) => (prev[user_id] === online ? prev : { ...prev, [user_id]: online })) break } case 'read': { const { conversation_id, user_id, message_id } = event.data if (user_id === user.id) break if (conversation_id === selectedIdRef.current) markReadUpTo(message_id) break } } }, [markReadUpTo, patch, reload, upsert, user.id], ) const realtime = useRealtime(handleEvent) const sendMessage = useCallback( async (conversationId: number, body: string) => { const text = body.trim() if (!text) return const tempId = crypto.randomUUID() const optimistic: ClientMessage = { id: -Date.now(), conversation_id: conversationId, sender_id: user.id, body: text, created_at: new Date().toISOString(), temp_id: tempId, status: 'sending', } addOptimistic(optimistic) patch(conversationId, (conversation) => ({ ...conversation, last_message: optimistic, unread_count: 0 })) try { const result = await api.sendMessage(conversationId, text, tempId) upsert(result.message, tempId) patch(conversationId, (conversation) => ({ ...conversation, last_message: result.message, unread_count: 0, })) } catch { failOptimistic(tempId) } }, [addOptimistic, failOptimistic, patch, upsert, user.id], ) const resendMessage = useCallback( async (message: ClientMessage) => { const tempId = message.temp_id if (!tempId) return markSending(tempId) try { const result = await api.sendMessage(message.conversation_id, message.body, tempId) upsert(result.message, tempId) patch(message.conversation_id, (conversation) => ({ ...conversation, last_message: result.message, unread_count: 0, })) } catch { failOptimistic(tempId) } }, [failOptimistic, markSending, patch, upsert], ) const sendTyping = useCallback( (conversationId: number, isTyping: boolean) => { realtime.sendTyping(conversationId, isTyping) }, [realtime], ) const handleSelect = useCallback( (id: number) => { setSelectedId(id) patch(id, (conversation) => conversation.unread_count ? { ...conversation, unread_count: 0 } : conversation, ) }, [patch], ) const handleStartChat = useCallback( async (target: User) => { const conversation = await api.createDM(target.id) rememberUser(target) setLearnedPeers((prev) => ({ ...prev, [conversation.id]: target.id })) add(conversation) setSelectedId(conversation.id) void loadContacts() }, [add, loadContacts, rememberUser], ) const lastMessage = messages.length > 0 ? messages[messages.length - 1] : null const lastMessageId = lastMessage && lastMessage.id > 0 ? lastMessage.id : null useEffect(() => { if (selectedId === null || lastMessageId === null) return if (document.hasFocus()) realtime.sendRead(selectedId, lastMessageId) }, [selectedId, lastMessageId, realtime, focusTick]) const peerIsTyping = selectedId !== null && typing[selectedId] === true return (
setDialogOpen(true)} onRetry={() => void reload()} onLogout={() => void logout()} /> conversation.id === selectedId) ?? null} currentUser={user} directory={directory} peers={peers} presence={presence} peerIsTyping={peerIsTyping} messages={messages} loading={messagesLoading} loadingOlder={loadingOlder} hasMore={hasMore} error={messagesError} onBack={() => setSelectedId(null)} onSend={sendMessage} onTyping={sendTyping} onLoadOlder={() => void loadOlder()} onRetry={resendMessage} />
{dialogOpen && ( setDialogOpen(false)} onStartChat={handleStartChat} onContactsChanged={() => void loadContacts()} /> )}
) }