gitdataai/src/components/room/message/MessageInput.tsx
ZhenYi 6aca08b8ab 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
2026-04-24 00:04:46 +08:00

114 lines
4.1 KiB
TypeScript

'use client';
/**
* Chat input using the production-ready TipTap IMEditor.
* Supports @mentions, file uploads, emoji picker, and rich message AST.
*/
import { forwardRef, useImperativeHandle, useMemo, useRef } from 'react';
import { IMEditor } from './editor/IMEditor';
import { useRoom } from '@/contexts';
import type { MessageAST } from './editor/types';
import type { IMEditorHandle } from './editor/IMEditor';
export interface MessageInputProps {
roomName: string;
onSend: (content: string, attachmentIds?: string[]) => void;
replyingTo?: { id: string; display_name?: string; content: string } | null;
onCancelReply?: () => void;
}
export interface MessageInputHandle {
focus: () => void;
clearContent: () => void;
getContent: () => string;
insertMention: (type: string, id: string, label: string) => void;
getAttachmentIds: () => string[];
}
export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(function MessageInput(
{ roomName, onSend, replyingTo, onCancelReply },
ref,
) {
const { members, activeRoomId } = useRoom();
// Ref passed to the inner IMEditor
const innerEditorRef = useRef<IMEditorHandle | null>(null);
// Expose a subset of IMEditorHandle (plus getAttachmentIds) as MessageInputHandle
useImperativeHandle(ref, () => ({
focus: () => innerEditorRef.current?.focus(),
clearContent: () => innerEditorRef.current?.clearContent(),
getContent: () => innerEditorRef.current?.getContent() ?? '',
insertMention: (type: string, id: string, label: string) =>
innerEditorRef.current?.insertMention(type, id, label),
getAttachmentIds: () => innerEditorRef.current?.getAttachmentIds() ?? [],
}), []);
// Slash commands available in the editor
const SLASH_COMMANDS = [
{ id: 'ai', label: '/ai', description: 'Ask AI a question', type: 'command' as const },
{ id: 'remind', label: '/remind', description: 'Set a reminder (e.g. /remind 10m Check CI)', type: 'command' as const },
{ id: 'poll', label: '/poll', description: 'Create a poll (e.g. /poll "Question?" A B C)', 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
const mentionItems = useMemo(() => ({
users: members.map((m) => ({
id: m.user,
label: m.user_info?.username ?? m.user,
type: 'user' as const,
avatar: m.user_info?.avatar_url ?? undefined,
})),
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
const handleUploadFile = async (file: File): Promise<{ id: string; url: string }> => {
if (!activeRoomId) throw new Error('No active room');
const formData = new FormData();
formData.append('file', file);
const baseUrl = import.meta.env.VITE_API_BASE_URL ?? window.location.origin;
const res = await fetch(`${baseUrl}/rooms/${activeRoomId}/upload`, { method: 'POST', body: formData });
if (!res.ok) throw new Error('Upload failed');
return res.json();
};
// onSend: extract plain text from MessageAST for sending
const handleSend = (text: string, _ast: MessageAST) => {
onSend(text);
};
return (
<IMEditor
ref={innerEditorRef}
replyingTo={replyingTo}
onCancelReply={onCancelReply}
onSend={handleSend}
mentionItems={mentionItems}
onUploadFile={handleUploadFile}
placeholder={`Message #${roomName}`}
/>
);
});