feat(room-ui): typing indicator, quick reactions, message grouping, @here/@channel, drag-drop categories, REST category loading
- DiscordChatPanel: typing indicator with animated dots and user names - MessageActions: quick emoji bar (👍❤️😂🎉😮) on hover - MessageList: group consecutive messages from same sender within 5min - MessageInput/IMEditor: @here/@channel special mention suggestions - DiscordChannelSidebar: useDroppable on category headers for drag-drop, empty categories now render, rooms/categories loaded via REST API - room-context: typingUsers state, REST roomList/categoryList, category merge into rooms
This commit is contained in:
parent
59640c6f44
commit
6aca08b8ab
@ -13,6 +13,7 @@ import {
|
|||||||
PointerSensor,
|
PointerSensor,
|
||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
|
useDroppable,
|
||||||
type DragEndEvent,
|
type DragEndEvent,
|
||||||
type UniqueIdentifier,
|
type UniqueIdentifier,
|
||||||
} from '@dnd-kit/core';
|
} from '@dnd-kit/core';
|
||||||
@ -141,6 +142,9 @@ const ChannelGroup = memo(function ChannelGroup({
|
|||||||
}) {
|
}) {
|
||||||
const ids: UniqueIdentifier[] = rooms.map((r) => `${DRAG_PREFIX}${r.id}`);
|
const ids: UniqueIdentifier[] = rooms.map((r) => `${DRAG_PREFIX}${r.id}`);
|
||||||
|
|
||||||
|
// Make the category header a droppable zone so rooms can be dragged onto it
|
||||||
|
const { setNodeRef: setHeaderRef, isOver: isOverHeader } = useDroppable({ id: categoryName });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="discord-channel-category"
|
className="discord-channel-category"
|
||||||
@ -148,7 +152,8 @@ const ChannelGroup = memo(function ChannelGroup({
|
|||||||
onDrop={canReceiveDrops ? () => undefined /* handled by DnD */ : undefined}
|
onDrop={canReceiveDrops ? () => undefined /* handled by DnD */ : undefined}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
className={cn('discord-channel-category-header w-full', isCollapsed && 'collapsed')}
|
ref={setHeaderRef}
|
||||||
|
className={cn('discord-channel-category-header w-full', isCollapsed && 'collapsed', isOverHeader && 'ring-1 ring-accent')}
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
title={isCollapsed ? 'Expand' : 'Collapse'}
|
title={isCollapsed ? 'Expand' : 'Collapse'}
|
||||||
>
|
>
|
||||||
@ -309,25 +314,30 @@ export const DiscordChannelSidebar = memo(function DiscordChannelSidebar({
|
|||||||
[onMoveRoomToCategory],
|
[onMoveRoomToCategory],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Group rooms by category
|
// Group rooms by category — empty categories still show as collapsible groups
|
||||||
const uncategorized = useMemo(
|
const uncategorized = useMemo(
|
||||||
() => rooms.filter((r) => !r.category_info?.name),
|
() => rooms.filter((r) => !r.category),
|
||||||
[rooms],
|
[rooms],
|
||||||
);
|
);
|
||||||
const categorized = useMemo(
|
const categorized = useMemo(
|
||||||
() => rooms.filter((r) => r.category_info?.name),
|
() => rooms.filter((r) => r.category),
|
||||||
[rooms],
|
[rooms],
|
||||||
);
|
);
|
||||||
|
|
||||||
const categoryMap = useMemo(() => {
|
const categoryMap = useMemo(() => {
|
||||||
|
// Start with ALL categories (including empty ones), then merge in rooms
|
||||||
const map = new Map<CatName, RoomWithCategory[]>();
|
const map = new Map<CatName, RoomWithCategory[]>();
|
||||||
|
for (const cat of categories) {
|
||||||
|
if (!map.has(cat.name)) map.set(cat.name, []);
|
||||||
|
}
|
||||||
for (const room of categorized) {
|
for (const room of categorized) {
|
||||||
const name = room.category_info!.name;
|
const catName = room.category_info?.name;
|
||||||
if (!map.has(name)) map.set(name, []);
|
if (catName && map.has(catName)) {
|
||||||
map.get(name)!.push(room);
|
map.get(catName)!.push(room);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}, [categorized]);
|
}, [categorized, categories]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="discord-channel-sidebar flex flex-col h-full">
|
<div className="discord-channel-sidebar flex flex-col h-full">
|
||||||
|
|||||||
@ -59,6 +59,7 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete, onToggleCha
|
|||||||
refreshThreads,
|
refreshThreads,
|
||||||
roomAiConfigs,
|
roomAiConfigs,
|
||||||
presence,
|
presence,
|
||||||
|
typingUsers,
|
||||||
} = useRoom();
|
} = useRoom();
|
||||||
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
@ -352,6 +353,35 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete, onToggleCha
|
|||||||
onCreateThread={handleCreateThread}
|
onCreateThread={handleCreateThread}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Typing indicator — show who is typing */}
|
||||||
|
{(() => {
|
||||||
|
const roomTyping = typingUsers?.[room.id] ?? {};
|
||||||
|
const typingList = Object.entries(roomTyping);
|
||||||
|
if (typingList.length === 0) return null;
|
||||||
|
const names = typingList.map(([, v]) => v.username);
|
||||||
|
const label = names.length === 1
|
||||||
|
? `${names[0]} is typing...`
|
||||||
|
: names.length === 2
|
||||||
|
? `${names[0]} and ${names[1]} are typing...`
|
||||||
|
: `${names[0]} and ${names.length - 1} others are typing...`;
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-1 text-xs text-muted-foreground animate-pulse flex items-center gap-1.5">
|
||||||
|
<span className="flex gap-0.5">
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="w-1.5 h-1.5 rounded-full bg-muted-foreground"
|
||||||
|
style={{
|
||||||
|
animation: `typing-bounce 1.2s infinite ${i * 0.2}s`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
<MessageInput
|
<MessageInput
|
||||||
ref={messageInputRef}
|
ref={messageInputRef}
|
||||||
roomName={room.room_name ?? 'room'}
|
roomName={room.room_name ?? 'room'}
|
||||||
|
|||||||
@ -13,6 +13,8 @@ import type { MessageWithMeta } from '@/contexts';
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
const QUICK_EMOJIS = ['👍', '❤️', '😂', '🎉', '😮'];
|
||||||
|
|
||||||
interface MessageActionsProps {
|
interface MessageActionsProps {
|
||||||
message: MessageWithMeta;
|
message: MessageWithMeta;
|
||||||
isOwner: boolean;
|
isOwner: boolean;
|
||||||
@ -52,7 +54,26 @@ export function MessageActions({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
|
<div className="flex items-start gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
{/* Add reaction */}
|
{/* Quick reaction bar — Slack-style hover reveal */}
|
||||||
|
{QUICK_EMOJIS.map((emoji) => {
|
||||||
|
const reacted = message.reactions?.find((r) => r.emoji === emoji)?.reacted_by_me;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={emoji}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleReaction(emoji)}
|
||||||
|
className={`size-6 p-0 text-sm hover:bg-accent transition-transform hover:scale-125 ${
|
||||||
|
reacted ? 'bg-accent' : ''
|
||||||
|
}`}
|
||||||
|
title={emoji}
|
||||||
|
>
|
||||||
|
{emoji}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Add more reaction — opens full picker */}
|
||||||
<Popover open={reactionPickerOpen} onOpenChange={setReactionPickerOpen}>
|
<Popover open={reactionPickerOpen} onOpenChange={setReactionPickerOpen}>
|
||||||
<PopoverTrigger
|
<PopoverTrigger
|
||||||
render={
|
render={
|
||||||
|
|||||||
@ -53,6 +53,22 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
|
|||||||
{ id: 'code-review', label: '/code-review', description: 'Request AI code review', type: 'command' as const },
|
{ id: 'code-review', label: '/code-review', description: 'Request AI code review', type: 'command' as const },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Special mention items — @here (online), @channel (all members)
|
||||||
|
const SPECIAL_MENTIONS = [
|
||||||
|
{
|
||||||
|
id: '__here__',
|
||||||
|
label: 'here',
|
||||||
|
description: 'Notify online members',
|
||||||
|
type: 'special_here' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '__channel__',
|
||||||
|
label: 'channel',
|
||||||
|
description: 'Notify all members',
|
||||||
|
type: 'special_channel' as const,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
// Transform room data into MentionItems — memoized to prevent IMEditor re-creation
|
// Transform room data into MentionItems — memoized to prevent IMEditor re-creation
|
||||||
const mentionItems = useMemo(() => ({
|
const mentionItems = useMemo(() => ({
|
||||||
users: members.map((m) => ({
|
users: members.map((m) => ({
|
||||||
@ -64,6 +80,7 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
|
|||||||
channels: [] as { id: string; label: string; type: 'channel'; avatar?: string }[],
|
channels: [] as { id: string; label: string; type: 'channel'; avatar?: string }[],
|
||||||
ai: [] as { id: string; label: string; type: 'ai'; avatar?: string }[],
|
ai: [] as { id: string; label: string; type: 'ai'; avatar?: string }[],
|
||||||
commands: SLASH_COMMANDS,
|
commands: SLASH_COMMANDS,
|
||||||
|
specialMentions: SPECIAL_MENTIONS,
|
||||||
}), [members]);
|
}), [members]);
|
||||||
|
|
||||||
// File upload handler — POST to /rooms/{room_id}/upload
|
// File upload handler — POST to /rooms/{room_id}/upload
|
||||||
|
|||||||
@ -108,10 +108,13 @@ export const MessageList = memo(function MessageList({
|
|||||||
const result: MessageRow[] = [];
|
const result: MessageRow[] = [];
|
||||||
let lastDateKey: string | null = null;
|
let lastDateKey: string | null = null;
|
||||||
let lastSenderKey: string | null = null;
|
let lastSenderKey: string | null = null;
|
||||||
|
let lastMessageTime: number | null = null;
|
||||||
|
const GROUP_GAP_MS = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
for (const message of messages) {
|
for (const message of messages) {
|
||||||
const dateKey = getDateKey(message.send_at);
|
const dateKey = getDateKey(message.send_at);
|
||||||
const senderKey = getSenderKey(message);
|
const senderKey = getSenderKey(message);
|
||||||
|
const msgTime = new Date(message.send_at).getTime();
|
||||||
|
|
||||||
if (dateKey !== lastDateKey) {
|
if (dateKey !== lastDateKey) {
|
||||||
result.push({
|
result.push({
|
||||||
@ -121,9 +124,14 @@ export const MessageList = memo(function MessageList({
|
|||||||
});
|
});
|
||||||
lastDateKey = dateKey;
|
lastDateKey = dateKey;
|
||||||
lastSenderKey = null;
|
lastSenderKey = null;
|
||||||
|
lastMessageTime = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const grouped = senderKey === lastSenderKey;
|
// Group if: same sender AND within 5-minute gap (Discord-style)
|
||||||
|
const sameSender = senderKey === lastSenderKey;
|
||||||
|
const withinTimeGap = lastMessageTime !== null && (msgTime - lastMessageTime) < GROUP_GAP_MS;
|
||||||
|
const grouped = sameSender && withinTimeGap;
|
||||||
|
|
||||||
result.push({
|
result.push({
|
||||||
type: 'message',
|
type: 'message',
|
||||||
message,
|
message,
|
||||||
@ -132,6 +140,7 @@ export const MessageList = memo(function MessageList({
|
|||||||
key: message.id,
|
key: message.id,
|
||||||
});
|
});
|
||||||
lastSenderKey = senderKey;
|
lastSenderKey = senderKey;
|
||||||
|
lastMessageTime = msgTime;
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}, [messages, replyMap]);
|
}, [messages, replyMap]);
|
||||||
|
|||||||
@ -26,6 +26,7 @@ export interface IMEditorProps {
|
|||||||
channels: MentionItem[];
|
channels: MentionItem[];
|
||||||
ai: MentionItem[];
|
ai: MentionItem[];
|
||||||
commands: MentionItem[];
|
commands: MentionItem[];
|
||||||
|
specialMentions?: MentionItem[];
|
||||||
};
|
};
|
||||||
onUploadFile?: (file: File) => Promise<{ id: string; url: string }>;
|
onUploadFile?: (file: File) => Promise<{ id: string; url: string }>;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
@ -185,6 +186,10 @@ function MentionDropdown({
|
|||||||
p: Palette;
|
p: Palette;
|
||||||
query: string;
|
query: string;
|
||||||
}) {
|
}) {
|
||||||
|
const SPECIAL_TYPES = ['special_here', 'special_channel'];
|
||||||
|
const specialItems = items.filter((item) => SPECIAL_TYPES.includes(item.type));
|
||||||
|
const regularItems = items.filter((item) => !SPECIAL_TYPES.includes(item.type));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="absolute left-0 z-50 overflow-hidden"
|
className="absolute left-0 z-50 overflow-hidden"
|
||||||
@ -203,14 +208,49 @@ function MentionDropdown({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="py-1 max-h-60 overflow-y-auto">
|
<div className="py-1 max-h-60 overflow-y-auto">
|
||||||
{items.map((item, i) => {
|
{/* Special mentions section */}
|
||||||
|
{specialItems.length > 0 && regularItems.length > 0 && (
|
||||||
|
<div className="px-3 py-1 text-[10px] font-semibold uppercase tracking-wide" style={{color: p.textSubtle}}>
|
||||||
|
Notify
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{specialItems.map((item) => {
|
||||||
|
const realIndex = items.indexOf(item);
|
||||||
|
const icon = item.type === 'special_here' ? '📍' : '📢';
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => onSelect(item)}
|
||||||
|
className="w-full flex items-center gap-3 px-3 py-2.5 transition-colors text-left cursor-pointer"
|
||||||
|
style={{background: realIndex === selectedIndex ? p.popupSelected : 'transparent'}}
|
||||||
|
>
|
||||||
|
<span className="w-7 h-7 rounded-full shrink-0 flex items-center justify-center text-base">
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 truncate text-sm font-medium" style={{color: p.text}}>
|
||||||
|
@{item.label}
|
||||||
|
</span>
|
||||||
|
{item.description && (
|
||||||
|
<span className="text-[10px] text-muted-foreground mr-1">
|
||||||
|
{item.description}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{specialItems.length > 0 && regularItems.length > 0 && (
|
||||||
|
<div className="mx-3 my-1 border-t border-border" />
|
||||||
|
)}
|
||||||
|
{/* Regular mentions section */}
|
||||||
|
{regularItems.map((item) => {
|
||||||
|
const realIndex = items.indexOf(item);
|
||||||
const badge = getBadge(item.type);
|
const badge = getBadge(item.type);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => onSelect(item)}
|
onClick={() => onSelect(item)}
|
||||||
className="w-full flex items-center gap-3 px-3 py-2.5 transition-colors text-left cursor-pointer"
|
className="w-full flex items-center gap-3 px-3 py-2.5 transition-colors text-left cursor-pointer"
|
||||||
style={{background: i === selectedIndex ? p.popupSelected : 'transparent'}}
|
style={{background: realIndex === selectedIndex ? p.popupSelected : 'transparent'}}
|
||||||
>
|
>
|
||||||
{item.avatar ? (
|
{item.avatar ? (
|
||||||
<img src={item.avatar} alt={item.label} className="w-7 h-7 rounded-full shrink-0"/>
|
<img src={item.avatar} alt={item.label} className="w-7 h-7 rounded-full shrink-0"/>
|
||||||
@ -261,6 +301,7 @@ export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEdi
|
|||||||
const wrapRef = useRef<HTMLDivElement>(null);
|
const wrapRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const allItems = [
|
const allItems = [
|
||||||
|
...(mentionItems.specialMentions ?? []),
|
||||||
...mentionItems.users,
|
...mentionItems.users,
|
||||||
...mentionItems.channels,
|
...mentionItems.channels,
|
||||||
...mentionItems.ai,
|
...mentionItems.ai,
|
||||||
|
|||||||
@ -18,6 +18,8 @@ import {
|
|||||||
type RoomPinResponse,
|
type RoomPinResponse,
|
||||||
type RoomResponse,
|
type RoomResponse,
|
||||||
type RoomThreadResponse,
|
type RoomThreadResponse,
|
||||||
|
categoryList as restCategoryList,
|
||||||
|
roomList as restRoomList,
|
||||||
} from '@/client';
|
} from '@/client';
|
||||||
import {
|
import {
|
||||||
createRoomWsClient,
|
createRoomWsClient,
|
||||||
@ -159,6 +161,9 @@ interface RoomContextValue {
|
|||||||
/** Room AI configs for @ai: mention suggestions */
|
/** Room AI configs for @ai: mention suggestions */
|
||||||
roomAiConfigs: RoomAiConfig[];
|
roomAiConfigs: RoomAiConfig[];
|
||||||
aiConfigsLoading?: boolean;
|
aiConfigsLoading?: boolean;
|
||||||
|
|
||||||
|
/** Typing users in the active room: roomId -> userId -> { username, avatar_url } */
|
||||||
|
typingUsers: Record<string, Record<string, { username: string; avatar_url?: string; timeoutId?: ReturnType<typeof setTimeout> }>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RoomContext = createContext<RoomContextValue | null>(null);
|
const RoomContext = createContext<RoomContextValue | null>(null);
|
||||||
@ -224,6 +229,15 @@ export function RoomProvider({
|
|||||||
const [categories, setCategories] = useState<RoomCategoryResponse[]>([]);
|
const [categories, setCategories] = useState<RoomCategoryResponse[]>([]);
|
||||||
const [categoriesLoading, setCategoriesLoading] = useState(false);
|
const [categoriesLoading, setCategoriesLoading] = useState(false);
|
||||||
|
|
||||||
|
// Merge category_info into rooms whenever either changes
|
||||||
|
const roomsWithCategory = useMemo<RoomWithCategory[]>(() => {
|
||||||
|
const catMap = new Map(categories.map((c) => [c.id, c]));
|
||||||
|
return rooms.map((r) => ({
|
||||||
|
...r,
|
||||||
|
category_info: r.category ? (catMap.get(r.category) ?? null) : null,
|
||||||
|
}));
|
||||||
|
}, [rooms, categories]);
|
||||||
|
|
||||||
const [activeRoom, setActiveRoomState] = useState<RoomResponse | null>(null);
|
const [activeRoom, setActiveRoomState] = useState<RoomResponse | null>(null);
|
||||||
|
|
||||||
const [messages, setMessages] = useState<MessageWithMeta[]>([]);
|
const [messages, setMessages] = useState<MessageWithMeta[]>([]);
|
||||||
@ -413,6 +427,9 @@ export function RoomProvider({
|
|||||||
// User presence map: user_id -> status
|
// User presence map: user_id -> status
|
||||||
const [presence, setPresence] = useState<PresenceMap>({});
|
const [presence, setPresence] = useState<PresenceMap>({});
|
||||||
|
|
||||||
|
// Typing users map: roomId -> Map<userId, { username, avatar_url, timeoutId }>
|
||||||
|
const [typingUsers, setTypingUsers] = useState<Record<string, Record<string, { username: string; avatar_url?: string; timeoutId?: ReturnType<typeof setTimeout> }>>>({});
|
||||||
|
|
||||||
const [streamingContent, setStreamingContent] = useState<Map<string, string>>(new Map());
|
const [streamingContent, setStreamingContent] = useState<Map<string, string>>(new Map());
|
||||||
|
|
||||||
// Project repos for @repository: mention suggestions
|
// Project repos for @repository: mention suggestions
|
||||||
@ -638,6 +655,41 @@ export function RoomProvider({
|
|||||||
if (payload.room_id !== activeRoomIdRef.current) return;
|
if (payload.room_id !== activeRoomIdRef.current) return;
|
||||||
setPresence((prev) => ({ ...prev, [payload.user_id]: payload.status }));
|
setPresence((prev) => ({ ...prev, [payload.user_id]: payload.status }));
|
||||||
},
|
},
|
||||||
|
onTypingStart: (payload) => {
|
||||||
|
if (payload.room_id !== activeRoomIdRef.current) return;
|
||||||
|
if (payload.user_id === user?.uid) return; // Don't show self
|
||||||
|
setTypingUsers((prev) => {
|
||||||
|
const roomMap = prev[payload.room_id] ?? {};
|
||||||
|
// Clear existing timeout for this user
|
||||||
|
const existing = roomMap[payload.user_id];
|
||||||
|
if (existing?.timeoutId) clearTimeout(existing.timeoutId);
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
setTypingUsers((p) => {
|
||||||
|
const rm = { ...p[payload.room_id] };
|
||||||
|
delete rm[payload.user_id];
|
||||||
|
return { ...p, [payload.room_id]: rm };
|
||||||
|
});
|
||||||
|
}, 4000);
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[payload.room_id]: {
|
||||||
|
...roomMap,
|
||||||
|
[payload.user_id]: { username: payload.username, avatar_url: payload.avatar_url, timeoutId },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onTypingStop: (payload) => {
|
||||||
|
if (payload.room_id !== activeRoomIdRef.current) return;
|
||||||
|
setTypingUsers((prev) => {
|
||||||
|
const roomMap = prev[payload.room_id] ?? {};
|
||||||
|
const existing = roomMap[payload.user_id];
|
||||||
|
if (existing?.timeoutId) clearTimeout(existing.timeoutId);
|
||||||
|
const newRoomMap = { ...roomMap };
|
||||||
|
delete newRoomMap[payload.user_id];
|
||||||
|
return { ...prev, [payload.room_id]: newRoomMap };
|
||||||
|
});
|
||||||
|
},
|
||||||
onStatusChange: (status) => {
|
onStatusChange: (status) => {
|
||||||
setWsStatus(status);
|
setWsStatus(status);
|
||||||
if (status === 'closed' || status === 'error') {
|
if (status === 'closed' || status === 'error') {
|
||||||
@ -682,16 +734,20 @@ export function RoomProvider({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchRooms = useCallback(async () => {
|
const fetchRooms = useCallback(async () => {
|
||||||
const client = wsClientRef.current;
|
if (!projectName) {
|
||||||
if (!projectName || !client) {
|
|
||||||
setRooms([]);
|
setRooms([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setRoomsLoading(true);
|
setRoomsLoading(true);
|
||||||
setRoomsError(null);
|
setRoomsError(null);
|
||||||
try {
|
try {
|
||||||
const resp = await client.roomList(projectName);
|
const resp = await restRoomList({ path: { project_name: projectName } });
|
||||||
setRooms(resp.map((r) => ({ ...r, category_info: null })));
|
const data = resp.data?.data;
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
setRooms(data.map((r) => ({ ...r, category_info: null })));
|
||||||
|
} else {
|
||||||
|
setRooms([]);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setRoomsError(err instanceof Error ? err : new Error('Failed to load rooms'));
|
setRoomsError(err instanceof Error ? err : new Error('Failed to load rooms'));
|
||||||
} finally {
|
} finally {
|
||||||
@ -704,12 +760,12 @@ export function RoomProvider({
|
|||||||
}, [fetchRooms]);
|
}, [fetchRooms]);
|
||||||
|
|
||||||
const fetchCategories = useCallback(async () => {
|
const fetchCategories = useCallback(async () => {
|
||||||
const client = wsClientRef.current;
|
if (!projectName) return;
|
||||||
if (!projectName || !client) return;
|
|
||||||
setCategoriesLoading(true);
|
setCategoriesLoading(true);
|
||||||
try {
|
try {
|
||||||
const resp = await client.categoryList(projectName);
|
const resp = await restCategoryList({ path: { project_name: projectName } });
|
||||||
setCategories(resp);
|
const data = resp.data?.data;
|
||||||
|
setCategories(Array.isArray(data) ? data : []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleRoomError('Load categories', error);
|
handleRoomError('Load categories', error);
|
||||||
} finally {
|
} finally {
|
||||||
@ -1236,7 +1292,7 @@ export function RoomProvider({
|
|||||||
wsClient: wsClientRef.current,
|
wsClient: wsClientRef.current,
|
||||||
connectWs,
|
connectWs,
|
||||||
disconnectWs,
|
disconnectWs,
|
||||||
rooms,
|
rooms: roomsWithCategory,
|
||||||
roomsLoading,
|
roomsLoading,
|
||||||
roomsError,
|
roomsError,
|
||||||
refreshRooms: fetchRooms,
|
refreshRooms: fetchRooms,
|
||||||
@ -1283,6 +1339,7 @@ export function RoomProvider({
|
|||||||
reposLoading,
|
reposLoading,
|
||||||
roomAiConfigs,
|
roomAiConfigs,
|
||||||
aiConfigsLoading,
|
aiConfigsLoading,
|
||||||
|
typingUsers,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
wsStatus,
|
wsStatus,
|
||||||
@ -1290,7 +1347,7 @@ export function RoomProvider({
|
|||||||
connectWs,
|
connectWs,
|
||||||
disconnectWs,
|
disconnectWs,
|
||||||
wsClientRef.current,
|
wsClientRef.current,
|
||||||
rooms,
|
roomsWithCategory,
|
||||||
roomsLoading,
|
roomsLoading,
|
||||||
roomsError,
|
roomsError,
|
||||||
fetchRooms,
|
fetchRooms,
|
||||||
@ -1336,6 +1393,7 @@ export function RoomProvider({
|
|||||||
reposLoading,
|
reposLoading,
|
||||||
roomAiConfigs,
|
roomAiConfigs,
|
||||||
aiConfigsLoading,
|
aiConfigsLoading,
|
||||||
|
typingUsers,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user