Add AccessSettings component for project access management, update room context with improved state management.
721 lines
25 KiB
TypeScript
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);
|
|
}
|