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:
ZhenYi 2026-04-24 00:04:46 +08:00
parent 59640c6f44
commit 6aca08b8ab
7 changed files with 208 additions and 22 deletions

View File

@ -13,6 +13,7 @@ import {
PointerSensor,
useSensor,
useSensors,
useDroppable,
type DragEndEvent,
type UniqueIdentifier,
} from '@dnd-kit/core';
@ -141,6 +142,9 @@ const ChannelGroup = memo(function ChannelGroup({
}) {
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 (
<div
className="discord-channel-category"
@ -148,7 +152,8 @@ const ChannelGroup = memo(function ChannelGroup({
onDrop={canReceiveDrops ? () => undefined /* handled by DnD */ : undefined}
>
<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}
title={isCollapsed ? 'Expand' : 'Collapse'}
>
@ -309,25 +314,30 @@ export const DiscordChannelSidebar = memo(function DiscordChannelSidebar({
[onMoveRoomToCategory],
);
// Group rooms by category
// Group rooms by category — empty categories still show as collapsible groups
const uncategorized = useMemo(
() => rooms.filter((r) => !r.category_info?.name),
() => rooms.filter((r) => !r.category),
[rooms],
);
const categorized = useMemo(
() => rooms.filter((r) => r.category_info?.name),
() => rooms.filter((r) => r.category),
[rooms],
);
const categoryMap = useMemo(() => {
// Start with ALL categories (including empty ones), then merge in rooms
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) {
const name = room.category_info!.name;
if (!map.has(name)) map.set(name, []);
map.get(name)!.push(room);
const catName = room.category_info?.name;
if (catName && map.has(catName)) {
map.get(catName)!.push(room);
}
}
return map;
}, [categorized]);
}, [categorized, categories]);
return (
<div className="discord-channel-sidebar flex flex-col h-full">

View File

@ -59,6 +59,7 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete, onToggleCha
refreshThreads,
roomAiConfigs,
presence,
typingUsers,
} = useRoom();
const messagesEndRef = useRef<HTMLDivElement>(null);
@ -352,6 +353,35 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete, onToggleCha
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
ref={messageInputRef}
roomName={room.room_name ?? 'room'}

View File

@ -13,6 +13,8 @@ import type { MessageWithMeta } from '@/contexts';
import { useCallback, useState } from 'react';
import { toast } from 'sonner';
const QUICK_EMOJIS = ['👍', '❤️', '😂', '🎉', '😮'];
interface MessageActionsProps {
message: MessageWithMeta;
isOwner: boolean;
@ -52,7 +54,26 @@ export function MessageActions({
return (
<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}>
<PopoverTrigger
render={

View File

@ -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 },
];
// 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
const mentionItems = useMemo(() => ({
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 }[],
ai: [] as { id: string; label: string; type: 'ai'; avatar?: string }[],
commands: SLASH_COMMANDS,
specialMentions: SPECIAL_MENTIONS,
}), [members]);
// File upload handler — POST to /rooms/{room_id}/upload

View File

@ -108,10 +108,13 @@ export const MessageList = memo(function MessageList({
const result: MessageRow[] = [];
let lastDateKey: 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) {
const dateKey = getDateKey(message.send_at);
const senderKey = getSenderKey(message);
const msgTime = new Date(message.send_at).getTime();
if (dateKey !== lastDateKey) {
result.push({
@ -121,9 +124,14 @@ export const MessageList = memo(function MessageList({
});
lastDateKey = dateKey;
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({
type: 'message',
message,
@ -132,6 +140,7 @@ export const MessageList = memo(function MessageList({
key: message.id,
});
lastSenderKey = senderKey;
lastMessageTime = msgTime;
}
return result;
}, [messages, replyMap]);

View File

@ -26,6 +26,7 @@ export interface IMEditorProps {
channels: MentionItem[];
ai: MentionItem[];
commands: MentionItem[];
specialMentions?: MentionItem[];
};
onUploadFile?: (file: File) => Promise<{ id: string; url: string }>;
placeholder?: string;
@ -185,6 +186,10 @@ function MentionDropdown({
p: Palette;
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 (
<div
className="absolute left-0 z-50 overflow-hidden"
@ -203,14 +208,49 @@ function MentionDropdown({
</div>
) : (
<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);
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: i === selectedIndex ? p.popupSelected : 'transparent'}}
style={{background: realIndex === selectedIndex ? p.popupSelected : 'transparent'}}
>
{item.avatar ? (
<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 allItems = [
...(mentionItems.specialMentions ?? []),
...mentionItems.users,
...mentionItems.channels,
...mentionItems.ai,

View File

@ -18,6 +18,8 @@ import {
type RoomPinResponse,
type RoomResponse,
type RoomThreadResponse,
categoryList as restCategoryList,
roomList as restRoomList,
} from '@/client';
import {
createRoomWsClient,
@ -159,6 +161,9 @@ interface RoomContextValue {
/** Room AI configs for @ai: mention suggestions */
roomAiConfigs: RoomAiConfig[];
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);
@ -224,6 +229,15 @@ export function RoomProvider({
const [categories, setCategories] = useState<RoomCategoryResponse[]>([]);
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 [messages, setMessages] = useState<MessageWithMeta[]>([]);
@ -413,6 +427,9 @@ export function RoomProvider({
// User presence map: user_id -> status
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());
// Project repos for @repository: mention suggestions
@ -638,6 +655,41 @@ export function RoomProvider({
if (payload.room_id !== activeRoomIdRef.current) return;
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) => {
setWsStatus(status);
if (status === 'closed' || status === 'error') {
@ -682,16 +734,20 @@ export function RoomProvider({
}, []);
const fetchRooms = useCallback(async () => {
const client = wsClientRef.current;
if (!projectName || !client) {
if (!projectName) {
setRooms([]);
return;
}
setRoomsLoading(true);
setRoomsError(null);
try {
const resp = await client.roomList(projectName);
setRooms(resp.map((r) => ({ ...r, category_info: null })));
const resp = await restRoomList({ path: { project_name: projectName } });
const data = resp.data?.data;
if (Array.isArray(data)) {
setRooms(data.map((r) => ({ ...r, category_info: null })));
} else {
setRooms([]);
}
} catch (err) {
setRoomsError(err instanceof Error ? err : new Error('Failed to load rooms'));
} finally {
@ -704,12 +760,12 @@ export function RoomProvider({
}, [fetchRooms]);
const fetchCategories = useCallback(async () => {
const client = wsClientRef.current;
if (!projectName || !client) return;
if (!projectName) return;
setCategoriesLoading(true);
try {
const resp = await client.categoryList(projectName);
setCategories(resp);
const resp = await restCategoryList({ path: { project_name: projectName } });
const data = resp.data?.data;
setCategories(Array.isArray(data) ? data : []);
} catch (error) {
handleRoomError('Load categories', error);
} finally {
@ -1236,7 +1292,7 @@ export function RoomProvider({
wsClient: wsClientRef.current,
connectWs,
disconnectWs,
rooms,
rooms: roomsWithCategory,
roomsLoading,
roomsError,
refreshRooms: fetchRooms,
@ -1283,6 +1339,7 @@ export function RoomProvider({
reposLoading,
roomAiConfigs,
aiConfigsLoading,
typingUsers,
}),
[
wsStatus,
@ -1290,7 +1347,7 @@ export function RoomProvider({
connectWs,
disconnectWs,
wsClientRef.current,
rooms,
roomsWithCategory,
roomsLoading,
roomsError,
fetchRooms,
@ -1336,6 +1393,7 @@ export function RoomProvider({
reposLoading,
roomAiConfigs,
aiConfigsLoading,
typingUsers,
],
);