1200 lines
37 KiB
TypeScript
1200 lines
37 KiB
TypeScript
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<void>;
|
|
disconnectWs: () => void;
|
|
|
|
rooms: RoomWithCategory[];
|
|
roomsLoading: boolean;
|
|
roomsError: Error | null;
|
|
refreshRooms: () => Promise<void>;
|
|
|
|
categories: RoomCategoryResponse[];
|
|
categoriesLoading: boolean;
|
|
refreshCategories: () => Promise<void>;
|
|
createCategory: (name: string, position?: number) => Promise<WsRoomCategoryResponse>;
|
|
updateCategory: (id: string, name?: string, position?: number) => Promise<void>;
|
|
deleteCategory: (id: string) => Promise<void>;
|
|
|
|
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<void>;
|
|
editMessage: (messageId: string, content: string) => Promise<void>;
|
|
revokeMessage: (messageId: string) => Promise<void>;
|
|
updateReadSeq: (seq: number) => Promise<void>;
|
|
|
|
members: RoomMemberResponse[];
|
|
membersLoading: boolean;
|
|
refreshMembers: () => Promise<void>;
|
|
addMember: (userId: string, role?: string) => Promise<void>;
|
|
removeMember: (userId: string) => Promise<void>;
|
|
updateMemberRole: (userId: string, role: string) => Promise<void>;
|
|
|
|
pins: RoomPinResponse[];
|
|
pinsLoading: boolean;
|
|
refreshPins: () => Promise<void>;
|
|
pinMessage: (messageId: string) => Promise<void>;
|
|
unpinMessage: (messageId: string) => Promise<void>;
|
|
|
|
threads: RoomThreadResponse[];
|
|
refreshThreads: () => Promise<void>;
|
|
createThread: (parentSeq: number) => Promise<RoomThreadResponse>;
|
|
|
|
createRoom: (name: string, isPublic: boolean, categoryId?: string) => Promise<RoomResponse>;
|
|
updateRoom: (roomId: string, name?: string, isPublic?: boolean, categoryId?: string) => Promise<void>;
|
|
deleteRoom: (roomId: string) => Promise<void>;
|
|
streamingMessages: Map<string, string>;
|
|
}
|
|
|
|
const RoomContext = createContext<RoomContextValue | null>(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<string | null>(initialRoomId);
|
|
const [wsClient, setWsClient] = useState<RoomWsClient | null>(null);
|
|
const wsClientRef = useRef<RoomWsClient | null>(null);
|
|
const activeRoomIdRef = useRef<string | null>(activeRoomId);
|
|
const [wsStatus, setWsStatus] = useState<RoomWsStatus>('idle');
|
|
const [wsError, setWsError] = useState<string | null>(null);
|
|
const [wsToken, setWsToken] = useState<string | null>(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<RoomWithCategory[]>([]);
|
|
const [roomsLoading, setRoomsLoading] = useState(false);
|
|
const [roomsError, setRoomsError] = useState<Error | null>(null);
|
|
|
|
const [categories, setCategories] = useState<RoomCategoryResponse[]>([]);
|
|
const [categoriesLoading, setCategoriesLoading] = useState(false);
|
|
|
|
const [activeRoom, setActiveRoomState] = useState<RoomResponse | null>(null);
|
|
|
|
const [messages, setMessages] = useState<MessageWithMeta[]>([]);
|
|
const [isHistoryLoaded, setIsHistoryLoaded] = useState(false);
|
|
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
|
const [nextCursor, setNextCursor] = useState<number | null>(null);
|
|
|
|
const loadMessagesAbortRef = useRef<AbortController | null>(null);
|
|
|
|
const prevRoomIdRef = useRef<string | null>(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<void>) | 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<string, import('@/lib/room-ws-client').ReactionItem[]>();
|
|
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<RoomMemberResponse[]>([]);
|
|
const [membersLoading, setMembersLoading] = useState(false);
|
|
|
|
const [pins, setPins] = useState<RoomPinResponse[]>([]);
|
|
const [pinsLoading, setPinsLoading] = useState(false);
|
|
|
|
const [threads, setThreads] = useState<RoomThreadResponse[]>([]);
|
|
|
|
const [streamingContent, setStreamingContent] = useState<Map<string, string>>(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<RoomContextValue>(
|
|
() => ({
|
|
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 <RoomContext.Provider value={value}>{children}</RoomContext.Provider>;
|
|
}
|
|
|
|
export function useRoom() {
|
|
const ctx = useContext(RoomContext);
|
|
if (!ctx) throw new Error('useRoom must be used within RoomProvider');
|
|
return ctx;
|
|
}
|