196 lines
7.5 KiB
TypeScript
196 lines
7.5 KiB
TypeScript
'use client';
|
|
|
|
/**
|
|
* Chat input using the production-ready TipTap IMEditor.
|
|
* Supports @mentions, file uploads, emoji picker, and rich message AST.
|
|
*/
|
|
|
|
import {forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef} from 'react';
|
|
import type {IMEditorHandle} from './editor/IMEditor';
|
|
import {IMEditor} from './editor/IMEditor';
|
|
import {useRoom} from '@/contexts';
|
|
import type {EditorNode, MessageAST} from './editor/types';
|
|
|
|
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[];
|
|
}
|
|
|
|
// 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,
|
|
},
|
|
];
|
|
|
|
/** Serialize tiptap AST to backend-parseable string format. */
|
|
function serializeMessageAst(ast: MessageAST): string {
|
|
return ast.content.map(serializeNode).join('\n');
|
|
}
|
|
|
|
function serializeNode(node: EditorNode): string {
|
|
if (node.type === 'text') return node.text;
|
|
if (node.type === 'mention') return `@[${node.attrs.type}:${node.attrs.id}:${node.attrs.label}]`;
|
|
if (node.type === 'hardBreak') return '\n';
|
|
if (node.type === 'file') return ''; // files are sent separately via attachmentIds
|
|
if (node.type === 'emoji') return `[emoji:${node.attrs.name}]`;
|
|
// Recurse into container nodes (paragraph, bulletList, etc.)
|
|
const children = (node as any).content as EditorNode[] | undefined;
|
|
if (children) return children.map(serializeNode).join('');
|
|
return '';
|
|
}
|
|
|
|
export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(function MessageInput(
|
|
{roomName, onSend, replyingTo, onCancelReply},
|
|
ref,
|
|
) {
|
|
const {members, activeRoomId, roomAiConfigs, projectRepos, wsClient} = 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() ?? [],
|
|
}), []);
|
|
|
|
// Typing indicator: debounce start/stop
|
|
const typingStopTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
const sendTypingStart = useCallback(() => {
|
|
if (!wsClient || !activeRoomId) {
|
|
console.debug('[MessageInput] sendTypingStart skipped: wsClient=', !!wsClient, 'activeRoomId=', activeRoomId);
|
|
return;
|
|
}
|
|
console.debug('[MessageInput] sendTypingStart room:', activeRoomId);
|
|
if (typingStopTimerRef.current) {
|
|
clearTimeout(typingStopTimerRef.current);
|
|
typingStopTimerRef.current = null;
|
|
}
|
|
wsClient.sendTyping(activeRoomId, 'start');
|
|
}, [wsClient, activeRoomId]);
|
|
|
|
const sendTypingStop = useCallback(() => {
|
|
if (!wsClient || !activeRoomId) return;
|
|
if (typingStopTimerRef.current) {
|
|
clearTimeout(typingStopTimerRef.current);
|
|
typingStopTimerRef.current = null;
|
|
}
|
|
wsClient.sendTyping(activeRoomId, 'stop');
|
|
}, [wsClient, activeRoomId]);
|
|
|
|
const handleEditorUpdate = useCallback((text: string) => {
|
|
if (!text.trim()) {
|
|
// Ignore empty updates (e.g. TipTap fires onUpdate("") on init).
|
|
// Only stop typing on explicit clear or send.
|
|
return;
|
|
}
|
|
console.debug('[MessageInput] handleEditorUpdate text_len:', text.length, 'ws:', !!wsClient, 'room:', activeRoomId);
|
|
sendTypingStart();
|
|
// Auto-stop after 1.5s of inactivity
|
|
if (typingStopTimerRef.current) clearTimeout(typingStopTimerRef.current);
|
|
typingStopTimerRef.current = setTimeout(sendTypingStop, 1500);
|
|
}, [sendTypingStart, sendTypingStop]);
|
|
|
|
// Stop typing on send or clear
|
|
useEffect(() => {
|
|
return () => {
|
|
if (typingStopTimerRef.current) clearTimeout(typingStopTimerRef.current);
|
|
};
|
|
}, []);
|
|
|
|
// 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: roomAiConfigs.map((cfg) => ({
|
|
id: cfg.model,
|
|
// Fallback: try modelName, then short model ID (no provider prefix), then 'AI'
|
|
label: cfg.modelName
|
|
|| cfg.model?.split('/').pop()
|
|
|| 'AI',
|
|
type: 'ai' as const,
|
|
})),
|
|
repos: projectRepos.map((r) => ({
|
|
id: r.repo_name,
|
|
label: r.repo_name,
|
|
type: 'repo' as const,
|
|
description: r.description ?? undefined,
|
|
})),
|
|
commands: SLASH_COMMANDS,
|
|
specialMentions: SPECIAL_MENTIONS,
|
|
}), [members, roomAiConfigs, projectRepos]);
|
|
|
|
// 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: serialize AST to backend-parseable format
|
|
const handleSend = (_text: string, ast: MessageAST) => {
|
|
sendTypingStop();
|
|
const serialized = serializeMessageAst(ast);
|
|
onSend(serialized);
|
|
};
|
|
|
|
return (
|
|
<IMEditor
|
|
ref={innerEditorRef}
|
|
replyingTo={replyingTo}
|
|
onCancelReply={onCancelReply}
|
|
onSend={handleSend}
|
|
mentionItems={mentionItems}
|
|
onUploadFile={handleUploadFile}
|
|
placeholder={`Message #${roomName}`}
|
|
onUpdate={handleEditorUpdate}
|
|
/>
|
|
);
|
|
}); |