import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'react'; import { useNavigate } from 'react-router-dom'; import { toast } from 'sonner'; import { type RoomCategoryResponse, type RoomMemberResponse, type RoomMessageResponse, type RoomPinResponse, type RoomResponse, type RoomThreadResponse, } from '@/client'; import { createRoomWsClient, type RoomWsClient, type RoomWsStatus, type RoomMessagePayload, type RoomCategoryResponse as WsRoomCategoryResponse, type RoomReactionUpdatedPayload, } from '@/lib/room-ws-client'; import { requestWsToken } from '@/lib/ws-token'; import { useUser } from '@/contexts'; import { saveMessage, saveMessages, loadMessages as loadMessagesFromIdb, deleteMessage as deleteMessageFromIdb, clearRoomMessages, } from '@/lib/storage/indexed-db'; export type { RoomWsStatus, RoomWsClient } from '@/lib/room-ws-client'; export interface ReactionGroup { emoji: string; count: number; reacted_by_me: boolean; users: string[]; } const MAX_MESSAGES_IN_MEMORY = 1000; function handleRoomError(context: string, error: unknown): void { const message = error instanceof Error ? error.message : 'Operation failed'; console.error(`[RoomContext] ${context}:`, error); toast.error(`${context} failed`, { description: message, }); } export type MessageWithMeta = RoomMessageResponse & { thread_id?: string; display_content?: string; is_streaming?: boolean; isOptimisticError?: boolean; /** True for messages sent by the current user that haven't been confirmed by the server */ isOptimistic?: boolean; reactions?: ReactionGroup[]; }; export type RoomWithCategory = RoomResponse & { category_info?: RoomCategoryResponse | null; }; export type UiMessage = MessageWithMeta; function wsMessageToUiMessage(wsMsg: RoomMessagePayload): MessageWithMeta { return { id: wsMsg.id, seq: wsMsg.seq, room: wsMsg.room_id, sender_type: wsMsg.sender_type, sender_id: wsMsg.sender_id, display_name: wsMsg.display_name, thread: wsMsg.thread_id, thread_id: wsMsg.thread_id, content: wsMsg.content, content_type: wsMsg.content_type, send_at: wsMsg.send_at, display_content: wsMsg.content, is_streaming: false, }; } interface RoomContextValue { wsStatus: RoomWsStatus; wsError: string | null; wsClient: RoomWsClient | null; connectWs: () => Promise; disconnectWs: () => void; rooms: RoomWithCategory[]; roomsLoading: boolean; roomsError: Error | null; refreshRooms: () => Promise; categories: RoomCategoryResponse[]; categoriesLoading: boolean; refreshCategories: () => Promise; createCategory: (name: string, position?: number) => Promise; updateCategory: (id: string, name?: string, position?: number) => Promise; deleteCategory: (id: string) => Promise; activeRoom: RoomResponse | null; activeRoomId: string | null; setActiveRoom: (roomId: string | null) => void; messages: MessageWithMeta[]; messagesLoading: boolean; isHistoryLoaded: boolean; isLoadingMore: boolean; isTransitioningRoom: boolean; nextCursor: number | null; loadMore: (cursor?: number | null) => void; sendMessage: (content: string, contentType?: string, inReplyTo?: string) => Promise; editMessage: (messageId: string, content: string) => Promise; revokeMessage: (messageId: string) => Promise; updateReadSeq: (seq: number) => Promise; members: RoomMemberResponse[]; membersLoading: boolean; refreshMembers: () => Promise; addMember: (userId: string, role?: string) => Promise; removeMember: (userId: string) => Promise; updateMemberRole: (userId: string, role: string) => Promise; pins: RoomPinResponse[]; pinsLoading: boolean; refreshPins: () => Promise; pinMessage: (messageId: string) => Promise; unpinMessage: (messageId: string) => Promise; threads: RoomThreadResponse[]; refreshThreads: () => Promise; createThread: (parentSeq: number) => Promise; createRoom: (name: string, isPublic: boolean, categoryId?: string) => Promise; updateRoom: (roomId: string, name?: string, isPublic?: boolean, categoryId?: string) => Promise; deleteRoom: (roomId: string) => Promise; streamingMessages: Map; } const RoomContext = createContext(null); interface RoomProviderProps { projectName: string | null; /** Room to open immediately on mount */ initialRoomId?: string | null; children: ReactNode; } export function RoomProvider({ projectName, initialRoomId = null, children, }: RoomProviderProps) { const { user } = useUser(); const navigate = useNavigate(); const [activeRoomId, setActiveRoomId] = useState(initialRoomId); const [wsClient, setWsClient] = useState(null); const wsClientRef = useRef(null); const activeRoomIdRef = useRef(activeRoomId); const [wsStatus, setWsStatus] = useState('idle'); const [wsError, setWsError] = useState(null); const [wsToken, setWsToken] = useState(null); // Keep ref updated with latest activeRoomId activeRoomIdRef.current = activeRoomId; useEffect(() => { let cancelled = false; const fetchToken = async () => { try { const token = await requestWsToken(); if (!cancelled) { setWsToken(token); } } catch (error) { console.error('[RoomContext] Failed to fetch WS token:', error); // Token fetch is not critical - client can fall back to cookie auth if (!cancelled) { setWsToken(null); } } }; fetchToken(); return () => { cancelled = true; }; }, []); const [rooms, setRooms] = useState([]); const [roomsLoading, setRoomsLoading] = useState(false); const [roomsError, setRoomsError] = useState(null); const [categories, setCategories] = useState([]); const [categoriesLoading, setCategoriesLoading] = useState(false); const [activeRoom, setActiveRoomState] = useState(null); const [messages, setMessages] = useState([]); const [isHistoryLoaded, setIsHistoryLoaded] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false); const [nextCursor, setNextCursor] = useState(null); const loadMessagesAbortRef = useRef(null); const prevRoomIdRef = useRef(null); const [isTransitioningRoom, setIsTransitioningRoom] = useState(false); useEffect(() => { if (prevRoomIdRef.current !== activeRoomId) { const oldRoomId = prevRoomIdRef.current; prevRoomIdRef.current = activeRoomId; loadMessagesAbortRef.current?.abort(); loadMessagesAbortRef.current = null; // Mark as transitioning room, show loading state instead of clearing immediately setIsTransitioningRoom(true); // Immediately clear old room's messages from state to prevent flicker setMessages([]); setIsHistoryLoaded(false); setNextCursor(null); // Clear old room's IDB cache asynchronously (fire and forget) if (oldRoomId) { clearRoomMessages(oldRoomId).catch(() => {}); } } }, [activeRoomId]); const loadMoreRef = useRef<((cursor?: number | null) => Promise) | null>(null); useEffect(() => { const client = wsClientRef.current; if (!activeRoomId || !client) return; const setup = async () => { if (client.getStatus() !== 'open') { await client.connect(); } await client.subscribeRoom(activeRoomId); loadMoreRef.current?.(null); }; setup(); return () => { client.unsubscribeRoom(activeRoomId).catch(() => {}); }; }, [activeRoomId, wsClient]); const loadMore = useCallback( async (cursor?: number | null) => { const client = wsClientRef.current; if (!activeRoomId || !client) return; // Cancel any in-flight request loadMessagesAbortRef.current?.abort(); loadMessagesAbortRef.current = new AbortController(); const abortController = loadMessagesAbortRef.current; setIsLoadingMore(true); try { // Initial load: check IndexedDB first for fast render if (cursor === null || cursor === undefined) { const cached = await loadMessagesFromIdb(activeRoomId); if (cached.length > 0) { setMessages(cached); setIsTransitioningRoom(false); // Derive cursor from IDB data (oldest message's seq = cursor) const minSeq = cached[0].seq; setNextCursor(minSeq > 0 ? minSeq - 1 : null); // If IDB has data, skip API call — WS will push live updates // Still set isLoadingMore to false and return setIsLoadingMore(false); return; } } // Call API (IDB was empty on initial load, or user is loading older history) const resp = await client.messageList(activeRoomId, { beforeSeq: cursor ?? undefined, limit: 50, }); if (abortController.signal.aborted) { return; } const newMessages = resp.messages.map((m) => ({ ...m, thread_id: m.thread, display_content: m.content, is_streaming: false, })); setMessages((prev) => { // Double-check room hasn't changed if (abortController.signal.aborted) { return prev; } // If initial load (cursor=null), replace instead of merge (room switching) if (cursor === null || cursor === undefined) { // Clear transitioning state setIsTransitioningRoom(false); return newMessages; } // loadMore: prepend older messages before existing const existingIds = new Set(prev.map((m) => m.id)); const filtered = newMessages.filter((m) => !existingIds.has(m.id)); let merged = [...filtered, ...prev]; merged.sort((a, b) => a.seq - b.seq); if (merged.length > MAX_MESSAGES_IN_MEMORY) { merged = merged.slice(-MAX_MESSAGES_IN_MEMORY); } return merged; }); // Persist new messages to IndexedDB if (newMessages.length > 0) { saveMessages(activeRoomId, newMessages).catch(() => {}); } if (resp.messages.length < 50) { setIsHistoryLoaded(true); } // messages are in ascending order (oldest first), so [length-1] is newest setNextCursor(resp.messages.length > 0 ? resp.messages[resp.messages.length - 1].seq : null); // Fetch reactions for all loaded messages (backend may not support this yet) const msgIds = newMessages.map((m) => m.id); if (msgIds.length > 0) { try { const reactionResults = await client.reactionListBatch(activeRoomId, msgIds); const reactionMap = new Map(); for (const result of reactionResults) { if (result.reactions.length > 0) { reactionMap.set(result.message_id, result.reactions); } } if (reactionMap.size > 0) { setMessages((prev) => prev.map((m) => reactionMap.has(m.id) ? { ...m, reactions: reactionMap.get(m.id) } : m, ), ); } } catch { // Reactions will be loaded via WebSocket updates if backend supports it } } } catch (error) { // Ignore abort errors if (abortController.signal.aborted) { return; } handleRoomError('Load messages', error); } finally { setIsLoadingMore(false); loadMessagesAbortRef.current = null; } }, [activeRoomId], ); useEffect(() => { loadMoreRef.current = loadMore; }, [loadMore]); const [members, setMembers] = useState([]); const [membersLoading, setMembersLoading] = useState(false); const [pins, setPins] = useState([]); const [pinsLoading, setPinsLoading] = useState(false); const [threads, setThreads] = useState([]); const [streamingContent, setStreamingContent] = useState>(new Map()); useEffect(() => { const baseUrl = import.meta.env.VITE_API_BASE_URL ?? window.location.origin; const client = createRoomWsClient( baseUrl, { onRoomMessage: (payload) => { // Use ref to get current activeRoomId to avoid stale closure if (payload.room_id === activeRoomIdRef.current) { setMessages((prev) => { // Deduplicate by both ID (for normal) and seq (for optimistic replacement) if (prev.some((m) => m.id === payload.id)) { return prev; } // Also check if there's an optimistic message with the same seq that should be replaced const optimisticIdx = prev.findIndex( (m) => m.isOptimistic && m.seq === payload.seq && m.seq !== 0, ); if (optimisticIdx !== -1) { // Replace optimistic message with confirmed one const confirmed: MessageWithMeta = { ...wsMessageToUiMessage(payload), reactions: prev[optimisticIdx].reactions, }; const next = [...prev]; next[optimisticIdx] = confirmed; // Remove optimistic from IDB, save confirmed deleteMessageFromIdb(prev[optimisticIdx].id).catch(() => {}); saveMessage(confirmed).catch(() => {}); return next; } const newMsg = wsMessageToUiMessage(payload); let updated = [...prev, newMsg]; updated.sort((a, b) => a.seq - b.seq); if (updated.length > MAX_MESSAGES_IN_MEMORY) { updated = updated.slice(-MAX_MESSAGES_IN_MEMORY); } return updated; }); // Persist to IndexedDB const msg = wsMessageToUiMessage(payload); saveMessage(msg).catch(() => {}); } }, onAiStreamChunk: (chunk) => { if (chunk.done) { // When streaming is done, update the message content and remove from streaming setStreamingContent((prev) => { const next = new Map(prev); next.delete(chunk.message_id); return next; }); setMessages((prev) => { const updated = prev.map((m) => m.id === chunk.message_id ? { ...m, content: chunk.content, is_streaming: false } : m, ); // Persist final content to IndexedDB const msg = updated.find((m) => m.id === chunk.message_id); if (msg) saveMessage(msg).catch(() => {}); return updated; }); } else { // Accumulate streaming content setStreamingContent((prev) => { const next = new Map(prev); const existing = next.get(chunk.message_id) ?? ''; next.set(chunk.message_id, existing + chunk.content); return next; }); // Create streaming message placeholder if it doesn't exist setMessages((prev) => { if (prev.some((m) => m.id === chunk.message_id)) { return prev; } const newMsg: MessageWithMeta = { id: chunk.message_id, room: chunk.room_id, seq: 0, sender_type: 'ai', content: '', content_type: 'text', send_at: new Date().toISOString(), is_streaming: true, }; return [...prev, newMsg]; }); } }, onRoomReactionUpdated: (payload: RoomReactionUpdatedPayload) => { setMessages((prev) => { const updated = prev.map((m) => m.id === payload.message_id ? { ...m, reactions: payload.reactions } : m, ); // Persist reaction update to IndexedDB const msg = updated.find((m) => m.id === payload.message_id); if (msg) saveMessage(msg).catch(() => {}); return updated; }); }, onMessageEdited: async (payload) => { // The event only contains message_id and edited_at. // Optimistically update edited_at, then fetch the full message from the API. if (payload.room_id !== activeRoomIdRef.current) return; const client = wsClientRef.current; if (!client) return; // Optimistic update: set edited_at immediately setMessages((prev) => { const updated = prev.map((m) => m.id === payload.message_id ? { ...m, edited_at: payload.edited_at } : m, ); const msg = updated.find((m) => m.id === payload.message_id); if (msg) saveMessage(msg).catch(() => {}); return updated; }); // Fetch full updated message from API try { const updatedMsg = await client.messageGet(payload.message_id); if (!updatedMsg) return; setMessages((prev) => { const merged = prev.map((m) => m.id === payload.message_id ? { ...m, content: updatedMsg.content, display_content: updatedMsg.content, edited_at: payload.edited_at, } : m, ); // Persist to IndexedDB const msg = merged.find((m) => m.id === payload.message_id); if (msg) saveMessage(msg).catch(() => {}); return merged; }); } catch { // Silently ignore - the optimistic update already applied } }, onMessageRevoked: async (payload) => { if (payload.room_id !== activeRoomIdRef.current) return; setMessages((prev) => { const updated = prev.map((m) => m.id === payload.message_id ? { ...m, revoked: payload.revoked_at, revoked_by: payload.revoked_by, content: '', display_content: '', } : m, ); // Persist to IndexedDB const msg = updated.find((m) => m.id === payload.message_id); if (msg) saveMessage(msg).catch(() => {}); return updated; }); }, onMessagePinned: async (payload) => { if (payload.room_id !== activeRoomIdRef.current) return; const client = wsClientRef.current; if (!client) return; try { const pins = await client.pinList(payload.room_id); setPins(pins.map((p) => ({ room: p.room, message: p.message, pinned_by: p.pinned_by, pinned_at: p.pinned_at, }))); } catch { // Silently ignore } }, onMessageUnpinned: async (payload) => { if (payload.room_id !== activeRoomIdRef.current) return; setPins((prev) => prev.filter((p) => p.message !== payload.message_id)); }, onStatusChange: (status) => { setWsStatus(status); if (status === 'closed' || status === 'error') { setWsError('Connection lost'); } else { setWsError(null); } }, onError: (error) => { setWsError(error.message); }, }, { wsToken: wsToken ?? undefined }, ); setWsClient(client); wsClientRef.current = client; return () => { client.disconnect(); wsClientRef.current = null; }; }, [wsToken]); useEffect(() => { if (!wsClientRef.current) return; wsClientRef.current.connect().catch((e) => { console.error('[RoomContext] WS connect error:', e); }); }, [wsClient]); const connectWs = useCallback(async () => { const client = wsClientRef.current; if (!client) return; try { await client.connect(); setWsError(null); } catch (e) { setWsError(e instanceof Error ? e.message : 'Failed to connect'); } }, []); const disconnectWs = useCallback(() => { wsClientRef.current?.disconnect(); }, []); const fetchRooms = useCallback(async () => { const client = wsClientRef.current; if (!projectName || !client) { setRooms([]); return; } setRoomsLoading(true); setRoomsError(null); try { const resp = await client.roomList(projectName); setRooms(resp.map((r) => ({ ...r, category_info: null }))); } catch (err) { setRoomsError(err instanceof Error ? err : new Error('Failed to load rooms')); } finally { setRoomsLoading(false); } }, [projectName]); useEffect(() => { fetchRooms(); }, [fetchRooms]); const fetchCategories = useCallback(async () => { const client = wsClientRef.current; if (!projectName || !client) return; setCategoriesLoading(true); try { const resp = await client.categoryList(projectName); setCategories(resp); } catch (error) { handleRoomError('Load categories', error); } finally { setCategoriesLoading(false); } }, [projectName]); useEffect(() => { fetchCategories(); }, [fetchCategories]); const createCategory = useCallback( async (name: string, position?: number) => { const client = wsClientRef.current; if (!projectName || !client) throw new Error('No project'); const cat = await client.categoryCreate(projectName, name, position); setCategories((prev) => [...prev, cat]); return cat; }, [projectName], ); const updateCategory = useCallback( async (id: string, name?: string, position?: number) => { const client = wsClientRef.current; if (!client) return; await client.categoryUpdate(id, { name, position }); setCategories((prev) => prev.map((c) => c.id === id ? { ...c, ...(name !== undefined && { name }), ...(position !== undefined && { position }) } : c, ), ); }, [], ); const deleteCategory = useCallback( async (id: string) => { const client = wsClientRef.current; if (!client) return; await client.categoryDelete(id); setCategories((prev) => prev.filter((c) => c.id !== id)); }, [], ); const setActiveRoom = useCallback( (roomId: string | null) => { setActiveRoomId(roomId); if (roomId) { navigate(`../room/${roomId}`, { replace: true }); } else { navigate('../room', { replace: true }); } }, [navigate], ); useEffect(() => { const client = wsClientRef.current; if (!activeRoomId || !client) { setActiveRoomState(null); return; } client .roomGet(activeRoomId) .then((resp) => resp && setActiveRoomState(resp)) .catch(() => setActiveRoomState(null)); }, [activeRoomId]); const fetchMembers = useCallback(async () => { const client = wsClientRef.current; if (!activeRoomId || !client) { setMembers([]); return; } setMembersLoading(true); try { const resp = await client.memberList(activeRoomId); setMembers(resp.map((m) => ({ ...m, do_not_disturb: false }))); } catch (error) { handleRoomError('Load members', error); setMembers([]); } finally { setMembersLoading(false); } }, [activeRoomId]); useEffect(() => { fetchMembers(); }, [fetchMembers]); const addMember = useCallback( async (userId: string, role = 'member') => { const client = wsClientRef.current; if (!activeRoomId || !client) return; const tempMember: RoomMemberResponse = { room: activeRoomId, user: userId, role, user_info: undefined, do_not_disturb: false, }; setMembers((prev) => [...prev, tempMember]); try { await client.memberAdd(activeRoomId, userId, role); await fetchMembers(); } catch (error) { handleRoomError('Add member', error); setMembers((prev) => prev.filter((m) => m.user !== userId)); } }, [activeRoomId, fetchMembers], ); const removeMember = useCallback( async (userId: string) => { const client = wsClientRef.current; if (!activeRoomId || !client) return; await client.memberRemove(activeRoomId, userId); setMembers((prev) => prev.filter((m) => m.user !== userId)); }, [activeRoomId], ); const updateMemberRole = useCallback( async (userId: string, role: string) => { const client = wsClientRef.current; if (!activeRoomId || !client) return; await client.memberUpdateRole(activeRoomId, userId, role); setMembers((prev) => prev.map((m) => (m.user === userId ? { ...m, role } : m))); }, [activeRoomId], ); const sendMessage = useCallback( async (content: string, contentType = 'text', inReplyTo?: string) => { const client = wsClientRef.current; if (!activeRoomId || !client) return; // Optimistic update: add message immediately so user sees it instantly const optimisticId = `optimistic-${crypto.randomUUID()}`; const optimisticMsg: MessageWithMeta = { id: optimisticId, room: activeRoomId, seq: 0, sender_type: 'member', sender_id: user?.uid ?? null, content, content_type: contentType, send_at: new Date().toISOString(), display_content: content, is_streaming: false, isOptimistic: true, thread: inReplyTo, thread_id: inReplyTo, in_reply_to: inReplyTo, reactions: [], }; setMessages((prev) => [...prev, optimisticMsg]); // Persist optimistic message to IndexedDB so it's not lost on refresh saveMessage(optimisticMsg).catch(() => {}); try { const confirmedMsg = await client.messageCreate(activeRoomId, content, { contentType, inReplyTo, }); // Replace optimistic message with server-confirmed one setMessages((prev) => { const without = prev.filter((m) => m.id !== optimisticId); const confirmed: MessageWithMeta = { ...confirmedMsg, thread_id: confirmedMsg.thread, display_content: confirmedMsg.content, is_streaming: false, isOptimistic: false, reactions: [], }; // Remove optimistic from IDB deleteMessageFromIdb(optimisticId).catch(() => {}); // Save confirmed to IDB saveMessage(confirmed).catch(() => {}); return [...without, confirmed]; }); } catch (err) { // Mark optimistic message as failed setMessages((prev) => prev.map((m) => m.id === optimisticId ? { ...m, isOptimisticError: true } : m, ), ); handleRoomError('Send message', err); } }, [activeRoomId, user], ); const editMessage = useCallback( async (messageId: string, content: string) => { const client = wsClientRef.current; if (!client) return; // Capture original content for rollback on server rejection let rollbackContent: string | null = null; setMessages((prev) => { const msg = prev.find((m) => m.id === messageId); rollbackContent = msg?.content ?? null; return prev.map((m) => m.id === messageId ? { ...m, content, display_content: content } : m, ); }); try { await client.messageUpdate(messageId, content); // Persist updated content to IndexedDB setMessages((prev) => { const msg = prev.find((m) => m.id === messageId); if (msg) saveMessage(msg).catch(() => {}); return prev; }); } catch (err) { // Rollback optimistic update on server rejection if (rollbackContent !== null) { setMessages((prev) => prev.map((m) => m.id === messageId ? { ...m, content: rollbackContent!, display_content: rollbackContent! } : m, ), ); } handleRoomError('Edit message', err); } }, [], ); const revokeMessage = useCallback( async (messageId: string) => { const client = wsClientRef.current; if (!client) return; await client.messageRevoke(messageId); setMessages((prev) => prev.filter((m) => m.id !== messageId)); // Persist to IndexedDB deleteMessageFromIdb(messageId).catch(() => {}); }, [], ); const updateReadSeq = useCallback( async (seq: number) => { const client = wsClientRef.current; if (!activeRoomId || !user || !client) return; try { await client.memberSetReadSeq(activeRoomId, seq); } catch (error) { console.warn('[RoomContext] Failed to update read position:', error); } }, [activeRoomId, user], ); const fetchPins = useCallback(async () => { const client = wsClientRef.current; if (!activeRoomId || !client) { setPins([]); return; } setPinsLoading(true); try { const resp = await client.pinList(activeRoomId); setPins( resp.map((p) => ({ room: p.room, message: p.message, pinned_by: p.pinned_by, pinned_at: p.pinned_at, })), ); } catch (error) { handleRoomError('Load pinned messages', error); setPins([]); } finally { setPinsLoading(false); } }, [activeRoomId]); useEffect(() => { fetchPins(); }, [fetchPins]); const pinMessage = useCallback( async (messageId: string) => { const client = wsClientRef.current; if (!activeRoomId || !client) return; const tempPin: RoomPinResponse = { room: activeRoomId, message: messageId, pinned_by: '', pinned_at: new Date().toISOString(), }; setPins((prev) => [...prev, tempPin]); try { await client.pinAdd(activeRoomId, messageId); await fetchPins(); } catch (error) { handleRoomError('Pin message', error); setPins((prev) => prev.filter((p) => p.message !== messageId)); } }, [activeRoomId, fetchPins], ); const unpinMessage = useCallback( async (messageId: string) => { const client = wsClientRef.current; if (!activeRoomId || !client) return; setPins((prev) => prev.filter((p) => p.message !== messageId)); try { await client.pinRemove(activeRoomId, messageId); } catch (error) { handleRoomError('Unpin message', error); fetchPins(); } }, [activeRoomId, fetchPins], ); const fetchThreads = useCallback(async () => { const client = wsClientRef.current; if (!activeRoomId || !client) { setThreads([]); return; } try { const resp = await client.threadList(activeRoomId); setThreads(resp); } catch (error) { handleRoomError('Load threads', error); setThreads([]); } }, [activeRoomId]); useEffect(() => { fetchThreads(); }, [fetchThreads]); const createThread = useCallback( async (parentSeq: number) => { const client = wsClientRef.current; if (!activeRoomId || !client) throw new Error('No active room'); const thread = await client.threadCreate(activeRoomId, parentSeq); setThreads((prev) => [...prev, thread]); return thread; }, [activeRoomId], ); const createRoom = useCallback( async (name: string, isPublic: boolean, categoryId?: string) => { const client = wsClientRef.current; if (!projectName || !client) throw new Error('No project'); const room = await client.roomCreate(projectName, name, isPublic, categoryId); setRooms((prev) => [...prev, { ...room, category_info: null }]); return room; }, [projectName], ); const updateRoom = useCallback( async (roomId: string, name?: string, isPublic?: boolean, categoryId?: string) => { const client = wsClientRef.current; if (!client) return; // Save previous values for rollback (use functional update to avoid closure issues) let rollbackRooms: RoomWithCategory[] | null = null; let rollbackActiveRoom: RoomResponse | null = null; // Capture current state for rollback setRooms((prev) => { rollbackRooms = prev; return prev.map((r) => r.id === roomId ? { ...r, ...(name !== undefined && { room_name: name }), ...(isPublic !== undefined && { public: isPublic }), ...(categoryId !== undefined && { category: categoryId }), } : r, ); }); if (activeRoomId === roomId) { setActiveRoomState((prev) => { rollbackActiveRoom = prev; return prev ? { ...prev, ...(name !== undefined && { room_name: name }), ...(isPublic !== undefined && { public: isPublic }), ...(categoryId !== undefined && { category: categoryId }), } : prev; }); } try { await client.roomUpdate(roomId, { roomName: name, isPublic, categoryId }); } catch (error) { handleRoomError('Update room', error); // Use captured snapshot for rollback, not the old value in closure if (rollbackRooms) { setRooms(rollbackRooms); } if (activeRoomId === roomId && rollbackActiveRoom) { setActiveRoomState(rollbackActiveRoom); } } }, [activeRoomId], ); const deleteRoom = useCallback( async (roomId: string) => { const client = wsClientRef.current; if (!client) return; await client.roomDelete(roomId); setRooms((prev) => prev.filter((r) => r.id !== roomId)); if (activeRoomId === roomId) { setActiveRoomId(null); setActiveRoom(null); } }, [activeRoomId], ); const value = useMemo( () => ({ wsStatus, wsError, wsClient: wsClientRef.current, connectWs, disconnectWs, rooms, roomsLoading, roomsError, refreshRooms: fetchRooms, categories, categoriesLoading, refreshCategories: fetchCategories, createCategory, updateCategory, deleteCategory, activeRoom, activeRoomId, setActiveRoom, messages, messagesLoading: false, isHistoryLoaded, isLoadingMore, isTransitioningRoom, nextCursor, loadMore, sendMessage, editMessage, revokeMessage, updateReadSeq, members, membersLoading, refreshMembers: fetchMembers, addMember, removeMember, updateMemberRole, pins, pinsLoading, refreshPins: fetchPins, pinMessage, unpinMessage, threads, refreshThreads: fetchThreads, createThread, createRoom, updateRoom, deleteRoom, streamingMessages: streamingContent, }), [ wsStatus, wsError, connectWs, disconnectWs, wsClientRef.current, rooms, roomsLoading, roomsError, fetchRooms, categories, categoriesLoading, fetchCategories, createCategory, updateCategory, deleteCategory, activeRoom, activeRoomId, setActiveRoom, messages, isHistoryLoaded, isLoadingMore, isTransitioningRoom, nextCursor, loadMore, sendMessage, editMessage, revokeMessage, updateReadSeq, members, membersLoading, fetchMembers, addMember, removeMember, updateMemberRole, pins, pinsLoading, fetchPins, pinMessage, unpinMessage, threads, fetchThreads, createThread, createRoom, updateRoom, deleteRoom, streamingContent, ], ); return {children}; } export function useRoom() { const ctx = useContext(RoomContext); if (!ctx) throw new Error('useRoom must be used within RoomProvider'); return ctx; }