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,
|
||||
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">
|
||||
|
||||
@ -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'}
|
||||
|
||||
@ -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={
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user