diff --git a/src/components/room/DiscordChatPanel.tsx b/src/components/room/DiscordChatPanel.tsx
index d174650..d4c2c95 100644
--- a/src/components/room/DiscordChatPanel.tsx
+++ b/src/components/room/DiscordChatPanel.tsx
@@ -59,6 +59,7 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete, onToggleCha
refreshThreads,
roomAiConfigs,
presence,
+ typingUsers,
} = useRoom();
const messagesEndRef = useRef
(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 (
+
+
+ {[0, 1, 2].map((i) => (
+
+ ))}
+
+ {label}
+
+ );
+ })()}
+
- {/* 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 (
+
+ );
+ })}
+
+ {/* 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 (