gitdataai/src/contexts/room/room-context.tsx
ZhenYi 8702312c32 feat(settings): add AccessSettings and room context updates
Add AccessSettings component for project access management,
update room context with improved state management.
2026-05-14 23:15:16 +08:00

721 lines
25 KiB
TypeScript

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<typeof useWsStatus>;
/** 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<void>;
clearMessages: () => void;
mergePendingMessages: () => void;
/** Optimistic operations */
sendMessage: (content: string, opts?: { contentType?: string; thread?: string; inReplyTo?: string; attachmentIds?: string[] }) => Promise<void>;
editMessage: (messageId: string, content: string) => Promise<void>;
revokeMessage: (messageId: string) => Promise<void>;
/** Typing users: userId -> { username, avatar_url } */
typingUsers: Map<string, { user_id: string; username: string; avatar_url: string | null }>;
/** AI streaming */
streamingChunks: Map<RoomId, Array<{ type: string; content: string; seq?: number }>>;
activeAiStream: ActiveAiStream | null;
cancelAiStream: () => void;
getAiList: () => Promise<unknown>;
upsertAi: (config: Record<string, unknown>) => Promise<unknown>;
deleteAi: (agentId: string) => void;
/** Pin management */
addPin: (messageId: string) => Promise<void>;
removePin: (messageId: string) => Promise<void>;
/** Member state */
updateDoNotDisturb: (dnd: boolean) => Promise<void>;
updateCustomStatus: (emoji: string, text: string, expires_at?: string) => Promise<void>;
/** Voice/Video */
voiceJoin: () => void;
voiceLeave: () => void;
/** General */
searchMessages: (query: string, opts?: Record<string, unknown>) => Promise<unknown>;
createInvite: (opts?: Record<string, unknown>) => Promise<void>;
updatePresence: (status: string) => void;
grantAccess: (targetUserId: string, role: string) => void;
banUser: (userId: string, reason?: string) => void;
createThread: (parentSeq: number) => Promise<unknown> | undefined;
/** UI state */
setCurrentRoom: (room: { id: string; room_name: string; topic?: string; public: boolean } | null) => void;
setMembers: React.Dispatch<React.SetStateAction<Member[]>>;
setPinnedMessages: React.Dispatch<React.SetStateAction<PinnedMessage[]>>;
setThreads: React.Dispatch<React.SetStateAction<ThreadState[]>>;
}
const RoomContext = createContext<RoomContextValue | null>(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<number>(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<Member[]>([]);
const [pinnedMessages, setPinnedMessages] = useState<PinnedMessage[]>([]);
const [threads, setThreads] = useState<ThreadState[]>([]);
const [typingUsers, setTypingUsers] = useState<Map<string, { user_id: string; username: string; avatar_url: string | null }>>(new Map());
const typingTimersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(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<string, unknown>) => {
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<string, unknown>) => {
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<string, unknown>) => {
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<ApiResponseRoomResponse>).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<ApiResponseRoomParticipantListResponse>).data?.data;
const participants = membersData?.participants ?? [];
setMembers(participants.map((p) => mapParticipantToMember(p, 'online')));
}
if (pinsRes.status === 'fulfilled') {
setPinnedMessages((pinsRes.value as AxiosResponse<ApiResponseVecRoomPinResponse>).data?.data ?? []);
}
if (threadsRes.status === 'fulfilled') {
const threadData = (threadsRes.value as AxiosResponse<ApiResponseVecRoomThreadResponse>).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<RoomContextValue>(() => ({
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 <RoomContext.Provider value={value}>{children}</RoomContext.Provider>;
}
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);
}