gitdataai/src/contexts/room-context.tsx

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;
}