import { createContext, useContext, type ReactNode, useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import { useNavigate } from 'react-router-dom'; import { useWsEvent, useWsStatus, getWsClient, useRoomSubscription } from '@/ws'; import { roomGet, participantList, pinAdd, pinRemove, pinList, threadList } from '@/client/api'; import type { AxiosResponse } from 'axios'; import type { ApiResponseRoomResponse, ApiResponseRoomParticipantListResponse, ApiResponseVecRoomPinResponse, ApiResponseVecRoomThreadResponse, } from '@/client/generated'; import { useCurrentUserQuery } from '@/hooks/useAuth'; import { db } from '@/lib/db'; import { MessageRepository } from '@/lib/db/repository'; import type { Message, Member, ThreadState, PinnedMessage, ActiveAiStream, ReactionGroup, } from './types'; import { useRoomMessages } from './use-room-messages'; import { useAiStreaming } from './use-ai-streaming'; import { mapParticipantToMember } from './utils'; import type { RoomId } from '@/ws/types/core'; function safeGetClient() { try { return getWsClient(); } catch { return null; } } export interface RoomContextValue { wsStatus: ReturnType; /** Room metadata */ currentRoom: { id: string; room_name: string; topic?: string; public: boolean } | null; members: Member[]; pinnedMessages: PinnedMessage[]; threads: ThreadState[]; /** Messages */ messages: Message[]; isHistoryLoaded: boolean; isLoadingMore: boolean; isTransitioningRoom: boolean; nextCursor: number | null; loadHistory: (cursor?: number | null) => Promise; clearMessages: () => void; mergePendingMessages: () => void; /** Optimistic operations */ sendMessage: (content: string, opts?: { contentType?: string; thread?: string; inReplyTo?: string; attachmentIds?: string[] }) => Promise; editMessage: (messageId: string, content: string) => Promise; revokeMessage: (messageId: string) => Promise; /** Typing users: userId -> { username, avatar_url } */ typingUsers: Map; /** AI streaming */ streamingChunks: Map>; activeAiStream: ActiveAiStream | null; cancelAiStream: () => void; getAiList: () => Promise; upsertAi: (config: Record) => Promise; deleteAi: (agentId: string) => void; /** Pin management */ addPin: (messageId: string) => Promise; removePin: (messageId: string) => Promise; /** Member state */ updateDoNotDisturb: (dnd: boolean) => Promise; updateCustomStatus: (emoji: string, text: string, expires_at?: string) => Promise; /** Voice/Video */ voiceJoin: () => void; voiceLeave: () => void; /** General */ searchMessages: (query: string, opts?: Record) => Promise; createInvite: (opts?: Record) => Promise; updatePresence: (status: string) => void; grantAccess: (targetUserId: string, role: string) => void; banUser: (userId: string, reason?: string) => void; createThread: (parentSeq: number) => Promise | undefined; /** UI state */ setCurrentRoom: (room: { id: string; room_name: string; topic?: string; public: boolean } | null) => void; setMembers: React.Dispatch>; setPinnedMessages: React.Dispatch>; setThreads: React.Dispatch>; } const RoomContext = createContext(null); interface RoomProviderProps { roomId: RoomId | null; projectName?: string; children: ReactNode; } export function RoomProvider({ roomId, projectName, children }: RoomProviderProps) { const navigate = useNavigate(); const wsStatus = useWsStatus(); useRoomSubscription(roomId); const { data: user } = useCurrentUserQuery(); const currentUserId = user?.uid ?? null; // ── Messages ── const { messages, isHistoryLoaded, isLoadingMore, isTransitioningRoom, nextCursor, clearMessages, loadHistory, triggerCatchupSync, sendMessage: optimisticSend, editMessage: optimisticEdit, revokeMessage: optimisticRevoke, handleNewMessage, handleEditedMessage, handleRevokedMessage, handleReactionUpdate, mergePendingMessages, } = useRoomMessages(roomId); // ── AI streaming ── const { streamingChunks, activeAiStream, setActiveAiStream, insertChunk, finalizeStream, cancelStream, cleanup: cleanupStream, startTimer, clearTimer, } = useAiStreaming(); // ── Sync Read Receipts ── const lastReadSeqRef = useRef(0); const syncReadReceipt = useCallback(() => { if (!roomId || messages.length === 0) return; const latestSeq = Math.max(...messages.map(m => m.seq)); if (latestSeq > lastReadSeqRef.current && document.visibilityState === 'visible') { const client = safeGetClient(); if (client) { client.sendReadReceipt(roomId, latestSeq); lastReadSeqRef.current = latestSeq; } } }, [roomId, messages]); useEffect(() => { syncReadReceipt(); const onVisibilityChange = () => syncReadReceipt(); document.addEventListener('visibilitychange', onVisibilityChange); return () => document.removeEventListener('visibilitychange', onVisibilityChange); }, [syncReadReceipt]); // ── Room metadata ── const [currentRoom, setCurrentRoom] = useState<{ id: string; room_name: string; topic?: string; public: boolean } | null>(null); const [members, setMembers] = useState([]); const [pinnedMessages, setPinnedMessages] = useState([]); const [threads, setThreads] = useState([]); const [typingUsers, setTypingUsers] = useState>(new Map()); const typingTimersRef = useRef>>(new Map()); // ── Exposed actions ── const getAiList = useCallback(async () => { const client = safeGetClient(); if (client && roomId) return client.getAiList(roomId); return null; }, [roomId]); const upsertAi = useCallback(async (config: Record) => { const client = safeGetClient(); if (client && roomId) return client.upsertAi(roomId, config); return null; }, [roomId]); const deleteAi = useCallback((agentId: string) => { const client = safeGetClient(); if (client && roomId) client.deleteAi(roomId, agentId); }, [roomId]); const addPin = useCallback(async (messageId: string) => { if (!roomId) return; const res = await pinAdd(roomId, messageId); const pin = res.data?.data; if (!pin) return; setPinnedMessages((prev) => (prev.some((p) => p.message === pin.message) ? prev : [...prev, pin])); }, [roomId]); const removePin = useCallback(async (messageId: string) => { if (!roomId) return; await pinRemove(roomId, messageId); setPinnedMessages((prev) => prev.filter((p) => p.message !== messageId)); }, [roomId]); const updateDoNotDisturb = useCallback(async (dnd: boolean) => { const client = safeGetClient(); if (client && roomId) { client.emitRaw('state_update_dnd', { room: roomId, do_not_disturb: dnd }); setMembers(prev => prev.map(m => m.uid === currentUserId ? { ...m, do_not_disturb: dnd } : m)); } }, [roomId, currentUserId]); const updateCustomStatus = useCallback(async (emoji: string, text: string, expires_at?: string) => { const client = safeGetClient(); if (client) client.emitRaw('custom_status_update', { emoji, text, expires_at: expires_at ?? null }); }, []); const searchMessages = useCallback(async (query: string, opts?: Record) => { const client = safeGetClient(); if (client) return client.search(query, { room: roomId, ...opts }); return null; }, [roomId]); const voiceJoin = useCallback(() => { const client = safeGetClient(); if (client && roomId) client.emitRaw('voice_join', { room: roomId }); }, [roomId]); const voiceLeave = useCallback(() => { const client = safeGetClient(); if (client && roomId) client.emitRaw('voice_leave', { room: roomId }); }, [roomId]); const createInvite = useCallback(async (options: Record) => { const client = safeGetClient(); if (client && roomId) client.emitRaw('invite_create', { room: roomId, ...options }); }, [roomId]); const updatePresence = useCallback((status: string) => { const client = safeGetClient(); if (client) client.updatePresence(status); }, []); const grantAccess = useCallback((targetUserId: string, role: string) => { const client = safeGetClient(); if (client && roomId) client.grantAccess(roomId, targetUserId, role); }, [roomId]); const banUser = useCallback((userId: string, reason?: string) => { const client = safeGetClient(); if (client && roomId) client.banUser(roomId, userId, reason); }, [roomId]); const createThread = useCallback((parentSeq: number) => { const client = safeGetClient(); if (client && roomId) return client.createThread(roomId, parentSeq); }, [roomId]); // ── Room switch cleanup ── // Reset state during render when roomId changes const [prevRoomId, setPrevRoomId] = useState(roomId); if (roomId !== prevRoomId) { setPrevRoomId(roomId); setCurrentRoom(null); setMembers([]); setPinnedMessages([]); setThreads([]); setTypingUsers(new Map()); } // Imperative cleanup when roomId changes useEffect(() => { if (!roomId) return; clearMessages(); cleanupStream(); mergePendingMessages(); }, [roomId, clearMessages, cleanupStream, mergePendingMessages]); // ── Load room info ── useEffect(() => { if (!roomId) return; const load = async () => { try { const [roomRes, membersRes, pinsRes, threadsRes] = await Promise.allSettled([ roomGet(roomId), participantList(roomId), pinList(roomId), threadList(roomId), ]); if (roomRes.status === 'fulfilled') { const data = (roomRes.value as AxiosResponse).data?.data; if (data) { setCurrentRoom({ id: data.id, room_name: data.room_name, public: data.public }); // Update presence to online when joining a room updatePresence('online'); } } if (membersRes.status === 'fulfilled') { const membersData = (membersRes.value as AxiosResponse).data?.data; const participants = membersData?.participants ?? []; setMembers(participants.map((p) => mapParticipantToMember(p, 'online'))); } if (pinsRes.status === 'fulfilled') { setPinnedMessages((pinsRes.value as AxiosResponse).data?.data ?? []); } if (threadsRes.status === 'fulfilled') { const threadData = (threadsRes.value as AxiosResponse).data?.data ?? []; const mapped: ThreadState[] = threadData.map((t) => ({ ...t, messages: [], isOpen: false, })); setThreads(mapped); } } catch (err) { console.error('[RoomProvider] failed to load room info:', err); } }; load(); loadHistory().then(() => triggerCatchupSync()); }, [roomId, loadHistory, triggerCatchupSync, updatePresence]); // ── WS: message events ── useWsEvent('message_new', (event) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any handleNewMessage(event as any); }); useWsEvent('message_edited', (event) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any handleEditedMessage(event as any); }); useWsEvent('message_revoked', (event) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any handleRevokedMessage(event as any); }); // ── WS: AI streaming ── useWsEvent('message_stream_start', async (event) => { if (event.room_id !== roomId) return; const { message_id, display_name, sse_url } = event.data; const existing = await db.messages.get(message_id); if (existing) return; const streamMsg: Message = { id: message_id, seq: Date.now(), room: roomId, sender_type: 'ai', sender_id: null, display_name, content: '', content_type: 'text/markdown', thinking_content: null, send_at: new Date().toISOString(), thread: null, in_reply_to: null, is_streaming: true, _localReactions: [], }; await MessageRepository.saveMessages(roomId, [streamMsg]); // Open EventSource for SSE chunk delivery. const client = getWsClient(); if (client && sse_url) { client.connectEventSource(message_id, sse_url); } }); useWsEvent('message_stream_chunk', async (event) => { if (event.room_id !== roomId) return; const { message_id, content, seq, chunk_type } = event.data; const isToolCall = chunk_type === 'tool_call' || chunk_type === 'tool_result'; insertChunk(message_id, chunk_type ?? undefined, content, seq); clearTimer(message_id); startTimer(message_id, async () => { finalizeStream(message_id, content || '[Stream timed out]', null); await db.messages.update(message_id, { is_streaming: false, content: content || '[Stream timed out]' }); }); if (!isToolCall && event.data.display_name) { setActiveAiStream({ message_id, display_name: event.data.display_name }); } const msg = await db.messages.get(message_id); if (msg) { await db.messages.update(message_id, { content: (msg.content || '') + content }); } }); useWsEvent('message_stream_done', async (event) => { if (event.room_id !== roomId) return; const { message_id, content, thinking_content } = event.data; const finalized = finalizeStream(message_id, content, thinking_content); await db.messages.update(message_id, { content: finalized.content, thinking_content: finalized.thinking_content, is_streaming: false }); const client = getWsClient?.(); if (client) client.closeEventSource(message_id); }); // ── WS: Reaction ── useWsEvent('reaction_batch_updated', (event) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any handleReactionUpdate(event as any); }); useWsEvent('reaction_added', async (event) => { if (event.room_id !== roomId) return; const { message: messageId, emoji, user: userId } = event.data; const m = await db.messages.get(messageId); if (!m) return; const reactions = [...(m._localReactions || [])]; const existing = reactions.find((r) => r.emoji === emoji); if (existing && !existing.users.includes(userId)) { existing.count++; existing.users.push(userId); } else if (!existing) { reactions.push({ emoji, count: 1, reacted_by_me: currentUserId === userId, users: [userId] }); } await db.messages.update(messageId, { _localReactions: reactions }); }); useWsEvent('reaction_removed', async (event) => { if (event.room_id !== roomId) return; const { message: messageId, emoji, user: userId } = event.data; const m = await db.messages.get(messageId); if (!m) return; const reactions = (m._localReactions || []) .map((r: ReactionGroup) => r.emoji !== emoji ? r : { ...r, count: r.count - 1, users: r.users.filter((u: string) => u !== userId) }) .filter((r: ReactionGroup) => r.count > 0); await db.messages.update(messageId, { _localReactions: reactions }); }); // ── WS: Member events ── useWsEvent('member_joined', (event) => { if (event.room_id !== roomId) return; const { user, username } = event.data; setMembers((prev: Member[]) => { if (prev.some((m) => m.uid === user)) return prev; return [...prev, { uid: user, username, avatar_url: null, project_role: 'member', is_room_owner: false, last_read_seq: null, do_not_disturb: false, presence: 'online' }]; }); }); useWsEvent('member_removed', (event) => { if (event.room_id !== roomId) return; setMembers((prev: Member[]) => prev.filter((m) => m.uid !== event.data.user)); }); useWsEvent('presence_changed', (event) => { const { user, status } = event.data; setMembers((prev: Member[]) => prev.map((m) => (m.uid === user ? { ...m, presence: status } : m))); }); // ── WS: Pin events ── useWsEvent('pin_added', (event) => { if (event.room_id !== roomId) return; const { room, message, pinned_by, pinned_at } = event.data; setPinnedMessages((prev: PinnedMessage[]) => ( prev.some((p) => p.message === message) ? prev : [...prev, { room, message, pinned_by, pinned_at }] )); }); useWsEvent('pin_removed', (event) => { if (event.room_id !== roomId) return; setPinnedMessages((prev: PinnedMessage[]) => prev.filter((p) => p.message !== event.data.message)); }); // ── WS: Room events ── useWsEvent('room_topic_updated', (event) => { if (event.room_id !== roomId) return; setCurrentRoom((prev) => prev ? { ...prev, topic: event.data.new_topic ?? prev.topic } : prev); }); useWsEvent('room_renamed', (event) => { if (event.room_id !== roomId) return; setCurrentRoom((prev) => prev ? { ...prev, room_name: event.data.new_name } : prev); }); useWsEvent('room_deleted', (event) => { if (event.room_id !== roomId) return; navigate(`/${projectName}/channel`, { replace: true }); }); useWsEvent('room_settings_updated', (event) => { if (event.room_id !== roomId) return; setCurrentRoom((prev) => prev ? { ...prev, public: (event.data as { public_status?: boolean }).public_status ?? prev.public } : prev); }); // ── WS: Thread events ── useWsEvent('thread_created', (event) => { if (event.room_id !== roomId) return; setThreads((prev) => { if (prev.some((t) => t.id === event.data.id)) return prev; return [...prev, { ...event.data, messages: [], isOpen: false, last_message_at: event.data.created_at, last_message_preview: null } as ThreadState]; }); }); useWsEvent('thread_updated', (event) => { if (event.room_id !== roomId) return; setThreads((prev) => prev.map((t) => (t.id === event.data.id ? { ...t, ...event.data } : t))); }); useWsEvent('thread_resolved', (event) => { if (event.room_id !== roomId) return; setThreads((prev) => prev.filter((t) => t.id !== event.data.id)); }); useWsEvent('thread_archived', (event) => { if (event.room_id !== roomId) return; setThreads((prev) => prev.filter((t) => t.id !== event.data.id)); }); // ── WS: Draft events ── useWsEvent('draft_saved', (event) => { if (event.room_id !== roomId) return; // Local input state is handled by ChannelPage, but we could sync across tabs here if needed }); useWsEvent('draft_cleared', (event) => { if (event.room_id !== roomId) return; }); // ── WS: Other Full Coverage Listeners (No-op or Logging) ── useWsEvent('thread_participant_joined', (e) => console.debug('thread_participant_joined', e)); useWsEvent('thread_participant_left', (e) => console.debug('thread_participant_left', e)); useWsEvent('category_created', (e) => console.debug('category_created', e)); useWsEvent('category_updated', (e) => console.debug('category_updated', e)); useWsEvent('category_deleted', (e) => console.debug('category_deleted', e)); useWsEvent('project_room_created', (e) => console.debug('project_room_created', e)); useWsEvent('project_room_deleted', (e) => console.debug('project_room_deleted', e)); useWsEvent('project_room_renamed', (e) => console.debug('project_room_renamed', e)); useWsEvent('project_room_moved', (e) => console.debug('project_room_moved', e)); useWsEvent('room_moved', (e) => console.debug('room_moved', e)); useWsEvent('invite_created', (e) => console.debug('invite_created', e)); useWsEvent('invite_accepted', (e) => console.debug('invite_accepted', e)); useWsEvent('invite_rejected', (e) => console.debug('invite_rejected', e)); useWsEvent('invite_revoked', (e) => console.debug('invite_revoked', e)); useWsEvent('attachment_uploaded', (e) => console.debug('attachment_uploaded', e)); useWsEvent('attachment_thumbnail_generated', (e) => console.debug('attachment_thumbnail_generated', e)); useWsEvent('attachment_deleted', (e) => console.debug('attachment_deleted', e)); useWsEvent('user_banned', (e) => console.debug('user_banned', e)); useWsEvent('user_unbanned', (e) => console.debug('user_unbanned', e)); useWsEvent('ai_agent_joined', (e) => console.debug('ai_agent_joined', e)); useWsEvent('ai_agent_left', (e) => console.debug('ai_agent_left', e)); useWsEvent('ai_agent_status_changed', (e) => console.debug('ai_agent_status_changed', e)); useWsEvent('voice_channel_joined', (e) => console.debug('voice_channel_joined', e)); useWsEvent('voice_channel_left', (e) => console.debug('voice_channel_left', e)); useWsEvent('voice_mute_updated', (e) => console.debug('voice_mute_updated', e)); useWsEvent('voice_deaf_updated', (e) => console.debug('voice_deaf_updated', e)); useWsEvent('screen_share_started', (e) => console.debug('screen_share_started', e)); useWsEvent('screen_share_stopped', (e) => console.debug('screen_share_stopped', e)); useWsEvent('speaking_started', (e) => console.debug('speaking_started', e)); useWsEvent('speaking_stopped', (e) => console.debug('speaking_stopped', e)); useWsEvent('custom_status_updated', (e) => console.debug('custom_status_updated', e)); useWsEvent('search_result', (e) => console.debug('search_result', e)); // ── WS: Typing indicator ── useWsEvent('typing_start', (event) => { if (event.room_id !== roomId) return; const { user, username, avatar_url } = event.data; if (currentUserId && user === currentUserId) return; const existing = typingTimersRef.current.get(user); if (existing) clearTimeout(existing); setTypingUsers((prev) => { const next = new Map(prev); next.set(user, { user_id: user, username, avatar_url: avatar_url ?? null }); return next; }); typingTimersRef.current.set(user, setTimeout(() => { setTypingUsers((prev) => { const next = new Map(prev); next.delete(user); return next; }); typingTimersRef.current.delete(user); }, 4000)); }); useWsEvent('typing_stop', (event) => { if (event.room_id !== roomId) return; const timer = typingTimersRef.current.get(event.data.user); if (timer) { clearTimeout(timer); typingTimersRef.current.delete(event.data.user); } setTypingUsers((prev) => { const next = new Map(prev); next.delete(event.data.user); return next; }); }); // ── Cancel AI stream ── const cancelAiStream = useCallback(() => { const client = safeGetClient(); if (client && roomId) { client.emitRaw('ai_stop', { room: roomId }); } cancelStream(); }, [roomId, cancelStream]); // ── Context value ── const value = useMemo(() => ({ wsStatus, currentRoom, members, pinnedMessages, threads, messages, isHistoryLoaded, isLoadingMore, isTransitioningRoom, nextCursor, loadHistory, clearMessages, mergePendingMessages, sendMessage: optimisticSend, editMessage: optimisticEdit, revokeMessage: optimisticRevoke, typingUsers, streamingChunks, activeAiStream, cancelAiStream, getAiList, upsertAi, deleteAi, addPin, removePin, updateDoNotDisturb, updateCustomStatus, searchMessages, voiceJoin, voiceLeave, createInvite, updatePresence, grantAccess, banUser, createThread, setCurrentRoom, setMembers, setPinnedMessages, setThreads, }), [ wsStatus, currentRoom, members, pinnedMessages, threads, messages, isHistoryLoaded, isLoadingMore, isTransitioningRoom, nextCursor, loadHistory, clearMessages, mergePendingMessages, optimisticSend, optimisticEdit, optimisticRevoke, typingUsers, streamingChunks, activeAiStream, cancelAiStream, getAiList, upsertAi, deleteAi, addPin, removePin, updateDoNotDisturb, updateCustomStatus, searchMessages, voiceJoin, voiceLeave, createInvite, updatePresence, grantAccess, banUser, createThread, ]); return {children}; } export function useRoom(): RoomContextValue { const ctx = useContext(RoomContext); if (!ctx) throw new Error('useRoom must be used within RoomProvider'); return ctx; } export function useOptionalRoom(): RoomContextValue | null { return useContext(RoomContext); }