import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import Sidebar from '../components/Sidebar' import ChatWindow from '../components/ChatWindow' import NewChatDialog from '../components/NewChatDialog' import SearchDialog from '../components/SearchDialog' import { api } from '../lib/api' import { ensurePushSubscription } from '../lib/push' import { useAuth } from '../auth/AuthContext' import { useConversations } from '../hooks/useConversations' import { useMessages } from '../hooks/useMessages' import { useRealtime } from '../hooks/useRealtime' import type { ClientMessage, Media, Member, 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 [searchOpen, setSearchOpen] = useState(false) const [focusTick, setFocusTick] = useState(0) const [membersVersion, setMembersVersion] = useState(0) const { conversations, loading, error, reload, add, remove, patch } = useConversations() const { messages, loading: messagesLoading, loadingOlder, hasMore, error: messagesError, loadOlder, upsert, patchMessage, addOptimistic, markSending, failOptimistic, markReadUpTo, } = useMessages(selectedId, user.id) const selectedIdRef = useRef(selectedId) const conversationsRef = useRef(conversations) const typingTimers = useRef(new Map()) const membersLoadedRef = useRef(new Set()) const membersInFlightRef = useRef(new Set()) const mountedRef = useRef(true) useEffect(() => { mountedRef.current = true return () => { mountedRef.current = false } }, []) 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(() => { for (const conversation of conversations) { if (membersLoadedRef.current.has(conversation.id) || membersInFlightRef.current.has(conversation.id)) { continue } membersInFlightRef.current.add(conversation.id) api .listMembers(conversation.id) .then((members) => { membersLoadedRef.current.add(conversation.id) if (!mountedRef.current) return members.forEach((member) => rememberUser(member.user)) if (conversation.type !== 'group') { const others = members.filter((member) => member.user.id !== user.id) if (others.length === 1) { const peerId = others[0].user.id setLearnedPeers((prev) => prev[conversation.id] === peerId ? prev : { ...prev, [conversation.id]: peerId }, ) } } }) .catch(() => {}) .finally(() => { membersInFlightRef.current.delete(conversation.id) }) } }, [conversations, rememberUser, user.id]) useEffect(() => { void ensurePushSubscription() }, []) 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, reactions, media } = event.data const message: Message = { id: message_id, conversation_id, sender_id, body, created_at, reactions: reactions ?? [], media: media ?? null, } 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 } case 'reaction.added': { const { conversation_id, message_id, user_id, emoji } = event.data if (conversation_id !== selectedIdRef.current) break patchMessage(message_id, (message) => { if (message.reactions.some((reaction) => reaction.emoji === emoji && reaction.user_id === user_id)) { return message } return { ...message, reactions: [...message.reactions, { emoji, user_id }] } }) break } case 'reaction.removed': { const { conversation_id, message_id, user_id, emoji } = event.data if (conversation_id !== selectedIdRef.current) break patchMessage(message_id, (message) => ({ ...message, reactions: message.reactions.filter( (reaction) => !(reaction.emoji === emoji && reaction.user_id === user_id), ), })) break } case 'member.added': case 'member.removed': { if (event.data.conversation_id === selectedIdRef.current) { setMembersVersion((version) => version + 1) } break } case 'conversation.added': { void reload() break } case 'conversation.removed': { const { conversation_id } = event.data remove(conversation_id) if (selectedIdRef.current === conversation_id) setSelectedId(null) break } } }, [markReadUpTo, patch, patchMessage, reload, remove, upsert, user.id], ) const realtime = useRealtime(handleEvent) const sendMessage = useCallback( async (conversationId: number, body: string, media: Media | null) => { const text = body.trim() if (!text && !media) return const tempId = crypto.randomUUID() const optimistic: ClientMessage = { id: -Date.now(), conversation_id: conversationId, sender_id: user.id, body: text, reactions: [], media, 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, media?.id) 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, message.media?.id) 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 handleReaction = useCallback( async (message: ClientMessage, emoji: string) => { if (message.id <= 0) return const mine = message.reactions.some((reaction) => reaction.emoji === emoji && reaction.user_id === user.id) try { const updated = mine ? await api.removeReaction(message.conversation_id, message.id, emoji) : await api.addReaction(message.conversation_id, message.id, emoji) patchMessage(message.id, (current) => ({ ...current, ...updated, media: updated.media ?? current.media, reactions: updated.reactions, })) } catch { return } }, [patchMessage, user.id], ) 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 handleCreateGroup = useCallback( async (name: string, memberIds: number[]) => { const conversation = await api.createGroup(name, memberIds) add(conversation) setSelectedId(conversation.id) setMembersVersion((version) => version + 1) }, [add], ) const handleMembersLoaded = useCallback( (conversationId: number, members: Member[]) => { membersLoadedRef.current.add(conversationId) members.forEach((member) => rememberUser(member.user)) const conversation = conversationsRef.current.find((item) => item.id === conversationId) if (conversation && conversation.type !== 'group') { const others = members.filter((member) => member.user.id !== user.id) if (others.length === 1) { const peerId = others[0].user.id setLearnedPeers((prev) => prev[conversationId] === peerId ? prev : { ...prev, [conversationId]: peerId }, ) } } }, [rememberUser, user.id], ) 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 (
Vibe — Мессенджер
setDialogOpen(true)} onSearch={() => setSearchOpen(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} membersVersion={membersVersion} onBack={() => setSelectedId(null)} onSend={sendMessage} onTyping={sendTyping} onLoadOlder={() => void loadOlder()} onRetry={resendMessage} onReact={(message, emoji) => void handleReaction(message, emoji)} onMembersChanged={() => setMembersVersion((version) => version + 1)} onMembersLoaded={handleMembersLoaded} />
{dialogOpen && ( setDialogOpen(false)} onStartChat={handleStartChat} onCreateGroup={handleCreateGroup} onContactsChanged={() => void loadContacts()} /> )} {searchOpen && ( setSearchOpen(false)} onSelect={handleSelect} conversations={conversations} peers={peers} directory={directory} currentUser={user} /> )}
) }