From 6aca08b8ab7bbec31417b02a0e99d8e51494d55c Mon Sep 17 00:00:00 2001 From: ZhenYi <434836402@qq.com> Date: Fri, 24 Apr 2026 00:04:46 +0800 Subject: [PATCH] feat(room-ui): typing indicator, quick reactions, message grouping, @here/@channel, drag-drop categories, REST category loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/components/room/DiscordChannelSidebar.tsx | 26 +++++-- src/components/room/DiscordChatPanel.tsx | 30 +++++++ .../room/message/MessageActions.tsx | 23 +++++- src/components/room/message/MessageInput.tsx | 17 ++++ src/components/room/message/MessageList.tsx | 11 ++- .../room/message/editor/IMEditor.tsx | 45 ++++++++++- src/contexts/room-context.tsx | 78 ++++++++++++++++--- 7 files changed, 208 insertions(+), 22 deletions(-) diff --git a/src/components/room/DiscordChannelSidebar.tsx b/src/components/room/DiscordChannelSidebar.tsx index f1ef110..8337e37 100644 --- a/src/components/room/DiscordChannelSidebar.tsx +++ b/src/components/room/DiscordChannelSidebar.tsx @@ -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 (
undefined /* handled by DnD */ : undefined} > + ); + })} + + {/* Add more reaction — opens full picker */} (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(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 diff --git a/src/components/room/message/MessageList.tsx b/src/components/room/message/MessageList.tsx index 157e17e..25b6add 100644 --- a/src/components/room/message/MessageList.tsx +++ b/src/components/room/message/MessageList.tsx @@ -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]); diff --git a/src/components/room/message/editor/IMEditor.tsx b/src/components/room/message/editor/IMEditor.tsx index 916dc2d..f742e0b 100644 --- a/src/components/room/message/editor/IMEditor.tsx +++ b/src/components/room/message/editor/IMEditor.tsx @@ -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 (
) : (
- {items.map((item, i) => { + {/* Special mentions section */} + {specialItems.length > 0 && regularItems.length > 0 && ( +
+ Notify +
+ )} + {specialItems.map((item) => { + const realIndex = items.indexOf(item); + const icon = item.type === 'special_here' ? '📍' : '📢'; + return ( + + ); + })} + {specialItems.length > 0 && regularItems.length > 0 && ( +
+ )} + {/* Regular mentions section */} + {regularItems.map((item) => { + const realIndex = items.indexOf(item); const badge = getBadge(item.type); return (