feat(frontend): add attachment_ids to message creation flow and types

This commit is contained in:
ZhenYi 2026-04-20 19:33:09 +08:00
parent 7736869fc4
commit e43d9fc8bf
6 changed files with 493 additions and 410 deletions

View File

@ -4434,6 +4434,7 @@ export type RoomMessageCreateRequest = {
content_type?: string | null; content_type?: string | null;
thread_id?: string | null; thread_id?: string | null;
in_reply_to?: string | null; in_reply_to?: string | null;
attachment_ids?: string[];
}; };
export type RoomMessageListResponse = { export type RoomMessageListResponse = {
@ -4456,6 +4457,7 @@ export type RoomMessageResponse = {
send_at: string; send_at: string;
revoked?: string | null; revoked?: string | null;
revoked_by?: string | null; revoked_by?: string | null;
attachment_ids?: string[];
}; };
export type RoomMessageUpdateRequest = { export type RoomMessageUpdateRequest = {

View File

@ -5,14 +5,15 @@
* Supports @mentions, file uploads, emoji picker, and rich message AST. * Supports @mentions, file uploads, emoji picker, and rich message AST.
*/ */
import { forwardRef } from 'react'; import { forwardRef, useImperativeHandle, useRef } from 'react';
import { IMEditor } from './editor/IMEditor'; import { IMEditor } from './editor/IMEditor';
import { useRoom } from '@/contexts'; import { useRoom } from '@/contexts';
import type { MessageAST } from './editor/types'; import type { MessageAST } from './editor/types';
import type { IMEditorHandle } from './editor/IMEditor';
export interface MessageInputProps { export interface MessageInputProps {
roomName: string; roomName: string;
onSend: (content: string) => void; onSend: (content: string, attachmentIds?: string[]) => void;
replyingTo?: { id: string; display_name?: string; content: string } | null; replyingTo?: { id: string; display_name?: string; content: string } | null;
onCancelReply?: () => void; onCancelReply?: () => void;
} }
@ -22,13 +23,27 @@ export interface MessageInputHandle {
clearContent: () => void; clearContent: () => void;
getContent: () => string; getContent: () => string;
insertMention: (type: string, id: string, label: string) => void; insertMention: (type: string, id: string, label: string) => void;
getAttachmentIds: () => string[];
} }
export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(function MessageInput( export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(function MessageInput(
{ roomName, onSend, replyingTo, onCancelReply }, { roomName, onSend, replyingTo, onCancelReply },
ref, ref,
) { ) {
const { members } = useRoom(); 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() ?? [],
}), []);
// Transform room data into MentionItems // Transform room data into MentionItems
const mentionItems = { const mentionItems = {
@ -43,11 +58,12 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
commands: [], // TODO: add slash commands commands: [], // TODO: add slash commands
}; };
// File upload handler — integrate with your upload API // File upload handler — POST to /rooms/{room_id}/upload
const handleUploadFile = async (file: File): Promise<{ id: string; url: string }> => { const handleUploadFile = async (file: File): Promise<{ id: string; url: string }> => {
if (!activeRoomId) throw new Error('No active room');
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
const res = await fetch('/api/upload', { method: 'POST', body: formData }); const res = await fetch(`/rooms/${activeRoomId}/upload`, { method: 'POST', body: formData });
if (!res.ok) throw new Error('Upload failed'); if (!res.ok) throw new Error('Upload failed');
return res.json(); return res.json();
}; };
@ -59,7 +75,7 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
return ( return (
<IMEditor <IMEditor
ref={ref} ref={innerEditorRef}
replyingTo={replyingTo} replyingTo={replyingTo}
onCancelReply={onCancelReply} onCancelReply={onCancelReply}
onSend={handleSend} onSend={handleSend}

View File

@ -5,478 +5,536 @@
* Colors: Clean modern palette, no Discord reference * Colors: Clean modern palette, no Discord reference
*/ */
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'; import {forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react';
import { useEditor, EditorContent, Extension } from '@tiptap/react'; import {EditorContent, Extension, useEditor} from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit'; import StarterKit from '@tiptap/starter-kit';
import Placeholder from '@tiptap/extension-placeholder'; import Placeholder from '@tiptap/extension-placeholder';
import { CustomEmojiNode } from './EmojiNode'; import {CustomEmojiNode} from './EmojiNode';
import type { MentionItem, MessageAST, MentionType } from './types'; import type {MentionItem, MentionType, MessageAST} from './types';
import { Paperclip, Smile, Send, X } from 'lucide-react'; import {Paperclip, Send, Smile, X} from 'lucide-react';
import { cn } from '@/lib/utils'; import {cn} from '@/lib/utils';
import { COMMON_EMOJIS } from '../../shared'; import {COMMON_EMOJIS} from '../../shared';
import { useTheme } from '@/contexts'; import {useTheme} from '@/contexts';
import { useImageCompress } from '@/hooks/useImageCompress'; import {useImageCompress} from '@/hooks/useImageCompress';
export interface IMEditorProps { export interface IMEditorProps {
replyingTo?: { id: string; display_name?: string; content: string } | null; replyingTo?: { id: string; display_name?: string; content: string } | null;
onCancelReply?: () => void; onCancelReply?: () => void;
onSend: (content: string, ast: MessageAST) => void; onSend: (content: string, ast: MessageAST) => void;
mentionItems: { mentionItems: {
users: MentionItem[]; users: MentionItem[];
channels: MentionItem[]; channels: MentionItem[];
ai: MentionItem[]; ai: MentionItem[];
commands: MentionItem[]; commands: MentionItem[];
}; };
onUploadFile?: (file: File) => Promise<{ id: string; url: string }>; onUploadFile?: (file: File) => Promise<{ id: string; url: string }>;
placeholder?: string; placeholder?: string;
} }
export interface IMEditorHandle { export interface IMEditorHandle {
focus: () => void; focus: () => void;
clearContent: () => void; clearContent: () => void;
getContent: () => string; getContent: () => string;
insertMention: (type: string, id: string, label: string) => void; insertMention: (type: string, id: string, label: string) => void;
getAttachmentIds: () => string[];
} }
// ─── Color System (Google AI Studio / Linear palette, no Discord) ──────────── // ─── Color System (Google AI Studio / Linear palette, no Discord) ────────────
const LIGHT = { const LIGHT = {
bg: '#ffffff', bg: '#ffffff',
bgHover: '#f7f7f8', bgHover: '#f7f7f8',
bgActive: '#ececf1', bgActive: '#ececf1',
border: '#e3e3e5', border: '#e3e3e5',
borderFocus: '#1c7ded', borderFocus: '#1c7ded',
text: '#1f1f1f', text: '#1f1f1f',
textMuted: '#8a8a8e', textMuted: '#8a8a8e',
textSubtle: '#b0b0b4', textSubtle: '#b0b0b4',
icon: '#8a8a8e', icon: '#8a8a8e',
iconHover: '#5c5c60', iconHover: '#5c5c60',
sendBg: '#1c7ded', sendBg: '#1c7ded',
sendBgHover: '#1a73d4', sendBgHover: '#1a73d4',
sendIcon: '#ffffff', sendIcon: '#ffffff',
sendDisabled:'#e3e3e5', sendDisabled: '#e3e3e5',
popupBg: '#ffffff', popupBg: '#ffffff',
popupBorder: '#e3e3e5', popupBorder: '#e3e3e5',
popupHover: '#f5f5f7', popupHover: '#f5f5f7',
popupSelected:'#e8f0fe', popupSelected: '#e8f0fe',
replyBg: '#f5f5f7', replyBg: '#f5f5f7',
badgeAi: '#dbeafe text-blue-700', badgeAi: '#dbeafe text-blue-700',
badgeChan: '#f3f4f6 text-gray-500', badgeChan: '#f3f4f6 text-gray-500',
badgeCmd: '#fef3c7 text-amber-700', badgeCmd: '#fef3c7 text-amber-700',
}; };
const DARK = { const DARK = {
bg: '#1a1a1e', bg: '#1a1a1e',
bgHover: '#222226', bgHover: '#222226',
bgActive: '#2a2a2f', bgActive: '#2a2a2f',
border: '#2e2e33', border: '#2e2e33',
borderFocus: '#4a9eff', borderFocus: '#4a9eff',
text: '#ececf1', text: '#ececf1',
textMuted: '#8a8a91', textMuted: '#8a8a91',
textSubtle: '#5c5c63', textSubtle: '#5c5c63',
icon: '#7a7a82', icon: '#7a7a82',
iconHover: '#b0b0b8', iconHover: '#b0b0b8',
sendBg: '#4a9eff', sendBg: '#4a9eff',
sendBgHover: '#6aafff', sendBgHover: '#6aafff',
sendIcon: '#ffffff', sendIcon: '#ffffff',
sendDisabled:'#2e2e33', sendDisabled: '#2e2e33',
popupBg: '#222226', popupBg: '#222226',
popupBorder: '#2e2e33', popupBorder: '#2e2e33',
popupHover: '#2a2a30', popupHover: '#2a2a30',
popupSelected:'#2a3a55', popupSelected: '#2a3a55',
replyBg: '#1f1f23', replyBg: '#1f1f23',
badgeAi: 'bg-blue-900/40 text-blue-300', badgeAi: 'bg-blue-900/40 text-blue-300',
badgeChan: 'bg-gray-800 text-gray-400', badgeChan: 'bg-gray-800 text-gray-400',
badgeCmd: 'bg-amber-900/30 text-amber-300', badgeCmd: 'bg-amber-900/30 text-amber-300',
}; };
type Palette = typeof LIGHT; type Palette = typeof LIGHT;
// ─── Emoji Picker ───────────────────────────────────────────────────────────── // ─── Emoji Picker ─────────────────────────────────────────────────────────────
function EmojiPicker({ onClose, onSelect, p }: { onClose: () => void; onSelect: (emoji: string) => void; p: Palette }) { function EmojiPicker({onClose, onSelect, p}: { onClose: () => void; onSelect: (emoji: string) => void; p: Palette }) {
return ( return (
<div <div
className="absolute bottom-full left-0 mb-2 z-50" className="absolute bottom-full left-0 mb-2 z-50"
style={{ style={{
background: p.popupBg, background: p.popupBg,
border: `1px solid ${p.popupBorder}`, border: `1px solid ${p.popupBorder}`,
borderRadius: 12, borderRadius: 12,
boxShadow: p === DARK boxShadow: p === DARK
? '0 8px 32px rgba(0,0,0,0.6)' ? '0 8px 32px rgba(0,0,0,0.6)'
: '0 8px 32px rgba(0,0,0,0.10)', : '0 8px 32px rgba(0,0,0,0.10)',
}} }}
> >
<div <div
className="flex items-center justify-between px-3 pt-3 pb-2" className="flex items-center justify-between px-3 pt-3 pb-2"
style={{ borderBottom: `1px solid ${p.popupBorder}` }} style={{borderBottom: `1px solid ${p.popupBorder}`}}
> >
<span className="text-[11px] font-semibold tracking-wide uppercase" style={{ color: p.textMuted }}> <span className="text-[11px] font-semibold tracking-wide uppercase" style={{color: p.textMuted}}>
Emoji Emoji
</span> </span>
<button onClick={onClose} className="flex items-center justify-center w-5 h-5 rounded cursor-pointer transition-colors" style={{ color: p.icon }}> <button onClick={onClose}
<X size={11} /> className="flex items-center justify-center w-5 h-5 rounded cursor-pointer transition-colors"
</button> style={{color: p.icon}}>
</div> <X size={11}/>
<div className="grid p-2 gap-0.5" style={{ gridTemplateColumns: 'repeat(6, 1fr)' }}> </button>
{COMMON_EMOJIS.map(emoji => ( </div>
<button <div className="grid p-2 gap-0.5" style={{gridTemplateColumns: 'repeat(6, 1fr)'}}>
key={emoji} {COMMON_EMOJIS.map(emoji => (
onClick={() => onSelect(emoji)} <button
className="w-9 h-9 flex items-center justify-center rounded-lg transition-all duration-100 cursor-pointer hover:scale-110 text-[18px]" key={emoji}
style={{ background: 'transparent' }} onClick={() => onSelect(emoji)}
> className="w-9 h-9 flex items-center justify-center rounded-lg transition-all duration-100 cursor-pointer hover:scale-110 text-[18px]"
{emoji} style={{background: 'transparent'}}
</button> >
))} {emoji}
</div> </button>
</div> ))}
); </div>
</div>
);
} }
// ─── Keyboard Extension ─────────────────────────────────────────────────────── // ─── Keyboard Extension ───────────────────────────────────────────────────────
const KeyboardSend = Extension.create({ const KeyboardSend = Extension.create({
name: 'keyboardSend', name: 'keyboardSend',
addKeyboardShortcuts() { addKeyboardShortcuts() {
return { return {
Enter: ({ editor }) => { Enter: ({editor}) => {
if (editor.isEmpty) return true; if (editor.isEmpty) return true;
const text = editor.getText().trim(); const text = editor.getText().trim();
if (!text) return true; if (!text) return true;
(editor.storage as any).keyboardSend?.onSend?.(text, editor.getJSON() as MessageAST); (editor.storage as any).keyboardSend?.onSend?.(text, editor.getJSON() as MessageAST);
return true; return true;
}, },
'Shift-Enter': ({ editor }) => { 'Shift-Enter': ({editor}) => {
editor.chain().focus().setHardBreak().run(); editor.chain().focus().setHardBreak().run();
return true; return true;
}, },
}; };
}, },
addStorage() { addStorage() {
return { onSend: null as ((t: string, a: MessageAST) => void) | null }; return {onSend: null as ((t: string, a: MessageAST) => void) | null};
}, },
}); });
// ─── Helpers ───────────────────────────────────────────────────────────────── // ─── Helpers ─────────────────────────────────────────────────────────────────
function filterMentionItems(all: MentionItem[], q: string): MentionItem[] { function filterMentionItems(all: MentionItem[], q: string): MentionItem[] {
return all.filter(m => m.label.toLowerCase().includes(q.toLowerCase())).slice(0, 8); return all.filter(m => m.label.toLowerCase().includes(q.toLowerCase())).slice(0, 8);
} }
function getBadge(type: MentionType): { label: string; cls: string } | null { function getBadge(type: MentionType): { label: string; cls: string } | null {
if (type === 'ai') return { label: 'AI', cls: 'bg-blue-50 text-blue-600' }; if (type === 'ai') return {label: 'AI', cls: 'bg-blue-50 text-blue-600'};
if (type === 'channel') return { label: '#', cls: 'bg-gray-100 text-gray-500' }; if (type === 'channel') return {label: '#', cls: 'bg-gray-100 text-gray-500'};
if (type === 'command') return { label: 'cmd', cls: 'bg-amber-50 text-amber-600' }; if (type === 'command') return {label: 'cmd', cls: 'bg-amber-50 text-amber-600'};
return null; return null;
} }
// ─── Mention Dropdown ──────────────────────────────────────────────────────── // ─── Mention Dropdown ────────────────────────────────────────────────────────
function MentionDropdown({ function MentionDropdown({
items, selectedIndex, onSelect, p, query, items, selectedIndex, onSelect, p, query,
}: { }: {
items: MentionItem[]; items: MentionItem[];
selectedIndex: number; selectedIndex: number;
onSelect: (item: MentionItem) => void; onSelect: (item: MentionItem) => void;
p: Palette; p: Palette;
query: string; query: string;
}) { }) {
return ( return (
<div <div
className="absolute left-0 z-50 overflow-hidden" className="absolute left-0 z-50 overflow-hidden"
style={{ style={{
background: p.popupBg, background: p.popupBg,
border: `1px solid ${p.popupBorder}`, border: `1px solid ${p.popupBorder}`,
borderRadius: 10, borderRadius: 10,
boxShadow: p === DARK ? '0 12px 40px rgba(0,0,0,0.55)' : '0 8px 30px rgba(0,0,0,0.09)', boxShadow: p === DARK ? '0 12px 40px rgba(0,0,0,0.55)' : '0 8px 30px rgba(0,0,0,0.09)',
minWidth: 240, minWidth: 240,
maxWidth: 300, maxWidth: 300,
}} }}
> >
{items.length === 0 ? ( {items.length === 0 ? (
<div className="px-4 py-5 text-sm text-center" style={{ color: p.textMuted }}> <div className="px-4 py-5 text-sm text-center" style={{color: p.textMuted}}>
No results for &ldquo;{query}&rdquo; No results for &ldquo;{query}&rdquo;
</div> </div>
) : ( ) : (
<div className="py-1 max-h-60 overflow-y-auto"> <div className="py-1 max-h-60 overflow-y-auto">
{items.map((item, i) => { {items.map((item, i) => {
const badge = getBadge(item.type); const badge = getBadge(item.type);
return ( return (
<button <button
key={item.id} key={item.id}
onClick={() => onSelect(item)} onClick={() => onSelect(item)}
className="w-full flex items-center gap-3 px-3 py-2.5 transition-colors text-left cursor-pointer" 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: i === selectedIndex ? p.popupSelected : 'transparent'}}
> >
{item.avatar ? ( {item.avatar ? (
<img src={item.avatar} alt={item.label} className="w-7 h-7 rounded-full shrink-0" /> <img src={item.avatar} alt={item.label} className="w-7 h-7 rounded-full shrink-0"/>
) : ( ) : (
<span <span
className="w-7 h-7 rounded-full shrink-0 flex items-center justify-center text-xs font-semibold" className="w-7 h-7 rounded-full shrink-0 flex items-center justify-center text-xs font-semibold"
style={{ background: p === DARK ? '#2a2a30' : '#eeeef0', color: p.text }} style={{background: p === DARK ? '#2a2a30' : '#eeeef0', color: p.text}}
> >
{item.label.charAt(0).toUpperCase()} {item.label.charAt(0).toUpperCase()}
</span> </span>
)} )}
<span className="flex-1 truncate text-sm font-medium" style={{ color: p.text }}> <span className="flex-1 truncate text-sm font-medium" style={{color: p.text}}>
{item.label} {item.label}
</span> </span>
{badge && ( {badge && (
<span className={cn('shrink-0 text-[10px] font-bold px-1.5 py-0.5 rounded-full', badge.cls)}> <span
className={cn('shrink-0 text-[10px] font-bold px-1.5 py-0.5 rounded-full', badge.cls)}>
{badge.label} {badge.label}
</span> </span>
)} )}
</button> </button>
); );
})} })}
</div>
)}
</div> </div>
)} );
</div>
);
} }
// ─── Main Component ──────────────────────────────────────────────────────────── // ─── Main Component ────────────────────────────────────────────────────────────
export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEditor( export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEditor(
{ replyingTo, onCancelReply, onSend, mentionItems, onUploadFile, placeholder = 'Message…' }, {replyingTo, onCancelReply, onSend, mentionItems, onUploadFile, placeholder = 'Message…'},
ref, ref,
) { ) {
const { resolvedTheme } = useTheme(); const {resolvedTheme} = useTheme();
const p = resolvedTheme === 'dark' ? DARK : LIGHT; const p = resolvedTheme === 'dark' ? DARK : LIGHT;
const { compress } = useImageCompress(); const {compress} = useImageCompress();
const [showEmoji, setShowEmoji] = useState(false); const [showEmoji, setShowEmoji] = useState(false);
const [mentionOpen, setMentionOpen] = useState(false); const [mentionOpen, setMentionOpen] = useState(false);
const [mentionQuery, setMentionQuery] = useState(''); const [mentionQuery, setMentionQuery] = useState('');
const [mentionItems2, setMentionItems2] = useState<MentionItem[]>([]); const [mentionItems2, setMentionItems2] = useState<MentionItem[]>([]);
const [mentionIdx, setMentionIdx] = useState(0); const [mentionIdx, setMentionIdx] = useState(0);
const [, setMentionPos] = useState({ top: 0, left: 0 }); const [, setMentionPos] = useState({top: 0, left: 0});
const [focused, setFocused] = useState(false); const [focused, setFocused] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null); const wrapRef = useRef<HTMLDivElement>(null);
const allItems = [ const allItems = [
...mentionItems.users, ...mentionItems.users,
...mentionItems.channels, ...mentionItems.channels,
...mentionItems.ai, ...mentionItems.ai,
...mentionItems.commands, ...mentionItems.commands,
]; ];
const selectMention = useCallback((item: MentionItem) => { const selectMention = useCallback((item: MentionItem) => {
if (!editor) return; if (!editor) return;
// Use backend-parseable format: @[type:id:label] // Use backend-parseable format: @[type:id:label]
const mentionStr = `@[${item.type}:${item.id}:${item.label}] `; const mentionStr = `@[${item.type}:${item.id}:${item.label}] `;
editor.chain().focus().insertContent(mentionStr).run(); editor.chain().focus().insertContent(mentionStr).run();
setMentionOpen(false);
}, []);
const editor = useEditor({
extensions: [
StarterKit.configure({ undoRedo: { depth: 100 } }),
Placeholder.configure({ placeholder }),
CustomEmojiNode,
KeyboardSend,
],
editorProps: {
handlePaste: (_v, e) => {
const img = Array.from(e.clipboardData?.items ?? []).find(i => i.type.startsWith('image'));
if (img) {
e.preventDefault();
const file = img.getAsFile();
if (file && onUploadFile) void doUpload(file);
return true;
}
return false;
},
handleDrop: (_v, e) => {
if (e.dataTransfer?.files?.[0] && onUploadFile) {
e.preventDefault();
void doUpload(e.dataTransfer.files[0]);
return true;
}
return false;
},
},
onUpdate: ({ editor: ed }) => {
const text = ed.getText();
const { from } = ed.state.selection;
let ts = from;
for (let i = from - 1; i >= 1; i--) {
const c = text[i - 1];
if (c === '@') { ts = i; break; }
if (/\s/.test(c)) break;
}
const q = text.slice(ts - 1, from);
if (q.startsWith('@') && q.length > 1) {
const results = filterMentionItems(allItems, q.slice(1));
setMentionQuery(q.slice(1));
setMentionItems2(results);
setMentionIdx(0);
setMentionOpen(true);
if (wrapRef.current) {
const sel = window.getSelection();
if (sel?.rangeCount) {
const r = sel.getRangeAt(0).getBoundingClientRect();
const cr = wrapRef.current.getBoundingClientRect();
setMentionPos({ top: r.bottom - cr.top + 6, left: Math.max(0, r.left - cr.left) });
}
}
} else {
setMentionOpen(false); setMentionOpen(false);
} }, []);
},
onFocus: () => setFocused(true),
onBlur: () => setFocused(false),
});
useEffect(() => { const editor = useEditor({
if (editor) (editor.storage as any).keyboardSend = { onSend }; extensions: [
}, [editor, onSend]); StarterKit.configure({undoRedo: {depth: 100}}),
Placeholder.configure({placeholder}),
CustomEmojiNode,
KeyboardSend,
],
editorProps: {
handlePaste: (_v, e) => {
const img = Array.from(e.clipboardData?.items ?? []).find(i => i.type.startsWith('image'));
if (img) {
e.preventDefault();
const file = img.getAsFile();
if (file && onUploadFile) void doUpload(file);
return true;
}
return false;
},
handleDrop: (_v, e) => {
if (e.dataTransfer?.files?.[0] && onUploadFile) {
e.preventDefault();
void doUpload(e.dataTransfer.files[0]);
return true;
}
return false;
},
},
onUpdate: ({editor: ed}) => {
const text = ed.getText();
const {from} = ed.state.selection;
const doUpload = async (file: File) => { let ts = from;
if (!editor || !onUploadFile) return; for (let i = from - 1; i >= 1; i--) {
try { const c = text[i - 1];
// Compress image before upload (only if it's an image and > 500KB) if (c === '@') {
let uploadFile = file; ts = i;
if (file.type.startsWith('image/') && file.size > 500 * 1024) { break;
const result = await compress(file, { maxSizeMB: 1, maxWidthOrHeight: 1920, useWebWorker: true }); }
uploadFile = result.file; if (/\s/.test(c)) break;
} }
const res = await onUploadFile(uploadFile); const q = text.slice(ts - 1, from);
editor.chain().focus().insertContent({ type: 'file', attrs: { id: res.id, name: uploadFile.name, url: res.url, size: uploadFile.size, type: uploadFile.type, status: 'done' } }).insertContent(' ').run();
} catch { /* ignore */ }
};
const send = () => { if (q.startsWith('@') && q.length > 1) {
if (!editor || editor.isEmpty) return; const results = filterMentionItems(allItems, q.slice(1));
const text = editor.getText().trim(); setMentionQuery(q.slice(1));
if (!text) return; setMentionItems2(results);
onSend(text, editor.getJSON() as MessageAST); setMentionIdx(0);
editor.commands.clearContent(); setMentionOpen(true);
};
useImperativeHandle(ref, () => ({ if (wrapRef.current) {
focus: () => editor?.commands.focus(), const sel = window.getSelection();
clearContent: () => editor?.commands.clearContent(), if (sel?.rangeCount) {
getContent: () => editor?.getText() ?? '', const r = sel.getRangeAt(0).getBoundingClientRect();
insertMention: (type: string, id: string, label: string) => { const cr = wrapRef.current.getBoundingClientRect();
if (!editor) return; setMentionPos({top: r.bottom - cr.top + 6, left: Math.max(0, r.left - cr.left)});
const mentionStr = `@[${type}:${id}:${label}] `; }
editor.chain().focus().insertContent(mentionStr).run(); }
}, } else {
})); setMentionOpen(false);
}
},
onFocus: () => setFocused(true),
onBlur: () => setFocused(false),
});
const hasContent = !!editor && !editor.isEmpty; useEffect(() => {
if (editor) (editor.storage as any).keyboardSend = {onSend};
}, [editor, onSend]);
// Dynamic styles const doUpload = async (file: File) => {
const borderColor = focused ? p.borderFocus : p.border; if (!editor || !onUploadFile) return;
const boxShadow = focused try {
? (p === DARK ? `0 0 0 3px rgba(74,158,255,0.18)` : `0 0 0 3px rgba(28,125,237,0.12)`) // Compress image before upload (only if it's an image and > 500KB)
: 'none'; let uploadFile = file;
if (file.type.startsWith('image/') && file.size > 500 * 1024) {
const result = await compress(file, {maxSizeMB: 1, maxWidthOrHeight: 1920, useWebWorker: true});
uploadFile = result.file;
}
const res = await onUploadFile(uploadFile);
editor.chain().focus().insertContent({
type: 'file',
attrs: {
id: res.id,
name: uploadFile.name,
url: res.url,
size: uploadFile.size,
type: uploadFile.type,
status: 'done'
}
}).insertContent(' ').run();
} catch { /* ignore */
}
};
return ( const send = () => {
<div className="relative flex flex-col" ref={wrapRef}> if (!editor || editor.isEmpty) return;
{/* Reply strip */} const text = editor.getText().trim();
{replyingTo && ( if (!text) return;
<div onSend(text, editor.getJSON() as MessageAST);
className="flex items-center gap-3 px-4 py-2.5" editor.commands.clearContent();
style={{ background: p.replyBg, borderRadius: '12px 12px 0 0', borderBottom: `1px solid ${p.border}` }} };
>
<div className="flex-1 min-w-0 flex items-center gap-2">
<span className="text-[11px] font-semibold shrink-0" style={{ color: p.borderFocus }}>Replying to</span>
<span className="truncate text-sm font-medium" style={{ color: p.text }}>{replyingTo.display_name}</span>
<span className="truncate shrink-1 text-sm" style={{ color: p.textMuted }}> {replyingTo.content}</span>
</div>
{onCancelReply && (
<button onClick={onCancelReply} className="flex items-center justify-center w-6 h-6 rounded-full cursor-pointer shrink-0 transition-colors" style={{ color: p.icon }}>
<X size={13} />
</button>
)}
</div>
)}
{/* Input area */} useImperativeHandle(ref, () => ({
<div focus: () => editor?.commands.focus(),
onClick={() => editor?.commands.focus()} clearContent: () => editor?.commands.clearContent(),
style={{ getContent: () => editor?.getText() ?? '',
background: p.bg, insertMention: (type: string, id: string, label: string) => {
border: `1.5px solid ${borderColor}`, if (!editor) return;
borderRadius: replyingTo ? '0 0 14px 14px' : '14px', const mentionStr = `@[${type}:${id}:${label}] `;
boxShadow, editor.chain().focus().insertContent(mentionStr).run();
transition: 'border-color 160ms ease, box-shadow 160ms ease', },
}} getAttachmentIds: () => {
> if (!editor) return [];
{/* Mention dropdown */} const json = editor.getJSON();
{mentionOpen && ( const ids: string[] = [];
<MentionDropdown const walk = (node: Record<string, unknown>) => {
items={mentionItems2} if (node['type'] === 'file' && node['attrs']) {
selectedIndex={mentionIdx} const attrs = node['attrs'] as Record<string, unknown>;
onSelect={selectMention} if (attrs['id']) ids.push(String(attrs['id']));
p={p} }
query={mentionQuery} const children = node['content'] as Record<string, unknown>[] | undefined;
/> if (children) children.forEach(walk);
)} };
walk(json as Record<string, unknown>);
return ids;
},
}));
{/* Editor */} const hasContent = !!editor && !editor.isEmpty;
<div
className="ai-editor"
style={{ padding: '12px 14px 10px' }}
>
<EditorContent editor={editor} />
</div>
{/* Discord-style toolbar: icons left, send right */} // Dynamic styles
<div className="flex items-center justify-between px-2 pb-2"> const borderColor = focused ? p.borderFocus : p.border;
{/* Left — emoji + attach */} const boxShadow = focused
<div className="flex items-center gap-0.5"> ? (p === DARK ? `0 0 0 3px rgba(74,158,255,0.18)` : `0 0 0 3px rgba(28,125,237,0.12)`)
<div className="relative"> : 'none';
<button
onClick={(e) => { e.stopPropagation(); setShowEmoji(v => !v); }} return (
className="flex items-center justify-center w-8 h-8 rounded-lg transition-colors cursor-pointer" <div className="relative flex flex-col mt-2 ml-3 mr-3 mb-1" ref={wrapRef}>
style={{ color: showEmoji ? p.iconHover : p.icon, background: showEmoji ? p.bgActive : 'transparent' }} {replyingTo && (
title="Emoji" <div
> className="flex items-center gap-3 px-4 py-2.5"
<Smile size={18} /> style={{
</button> background: p.replyBg,
{showEmoji && <EmojiPicker onClose={() => setShowEmoji(false)} onSelect={(emoji) => { editor?.chain().focus().insertContent(emoji).insertContent(' ').run(); setShowEmoji(false); }} p={p} />} borderRadius: '12px 12px 0 0',
borderBottom: `1px solid ${p.border}`
}}
>
<div className="flex-1 min-w-0 flex items-center gap-2">
<span className="text-[11px] font-semibold shrink-0"
style={{color: p.borderFocus}}>Replying to</span>
<span className="truncate text-sm font-medium"
style={{color: p.text}}>{replyingTo.display_name}</span>
<span className="truncate shrink-1 text-sm"
style={{color: p.textMuted}}> {replyingTo.content}</span>
</div>
{onCancelReply && (
<button onClick={onCancelReply}
className="flex items-center justify-center w-6 h-6 rounded-full cursor-pointer shrink-0 transition-colors"
style={{color: p.icon}}>
<X size={13}/>
</button>
)}
</div>
)}
{/* Input area */}
<div
onClick={() => editor?.commands.focus()}
style={{
background: p.bg,
border: `1.5px solid ${borderColor}`,
borderRadius: replyingTo ? '0 0 14px 14px' : '14px',
boxShadow,
transition: 'border-color 160ms ease, box-shadow 160ms ease',
}}
>
{/* Mention dropdown */}
{mentionOpen && (
<MentionDropdown
items={mentionItems2}
selectedIndex={mentionIdx}
onSelect={selectMention}
p={p}
query={mentionQuery}
/>
)}
{/* Editor */}
<div
className="ai-editor"
style={{padding: '12px 14px 10px'}}
>
<EditorContent editor={editor}/>
</div>
{/* Discord-style toolbar: icons left, send right */}
<div className="flex items-center justify-between px-2 pb-2">
{/* Left — emoji + attach */}
<div className="flex items-center gap-0.5">
<div className="relative">
<button
onClick={(e) => {
e.stopPropagation();
setShowEmoji(v => !v);
}}
className="flex items-center justify-center w-8 h-8 rounded-lg transition-colors cursor-pointer"
style={{
color: showEmoji ? p.iconHover : p.icon,
background: showEmoji ? p.bgActive : 'transparent'
}}
title="Emoji"
>
<Smile size={18}/>
</button>
{showEmoji && <EmojiPicker onClose={() => setShowEmoji(false)} onSelect={(emoji) => {
editor?.chain().focus().insertContent(emoji).insertContent(' ').run();
setShowEmoji(false);
}} p={p}/>}
</div>
<label
className="flex items-center justify-center w-8 h-8 rounded-lg transition-colors cursor-pointer"
style={{color: p.icon}}
title="Attach file"
>
<Paperclip size={18}/>
<input type="file" className="hidden" onChange={e => {
e.stopPropagation();
const f = e.target.files?.[0];
if (f && onUploadFile) void doUpload(f);
e.target.value = '';
}}/>
</label>
</div>
{/* Right — hint + send */}
<div className="flex items-center gap-2.5">
<span className="text-[11px]" style={{color: p.textSubtle}}> send</span>
<button
onClick={(e) => {
e.stopPropagation();
send();
}}
disabled={!hasContent}
className="flex items-center justify-center w-8 h-8 rounded-full transition-all duration-150 cursor-pointer"
style={{
background: hasContent ? p.sendBg : p.sendDisabled,
color: hasContent ? p.sendIcon : p.icon,
opacity: hasContent ? 1 : 0.55,
transform: hasContent ? 'scale(1)' : 'scale(0.92)',
}}
title="Send"
>
<Send size={13}/>
</button>
</div>
</div>
</div> </div>
<label <style>{`
className="flex items-center justify-center w-8 h-8 rounded-lg transition-colors cursor-pointer"
style={{ color: p.icon }}
title="Attach file"
>
<Paperclip size={18} />
<input type="file" className="hidden" onChange={e => { e.stopPropagation(); const f = e.target.files?.[0]; if (f && onUploadFile) void doUpload(f); e.target.value = ''; }} />
</label>
</div>
{/* Right — hint + send */}
<div className="flex items-center gap-2.5">
<span className="text-[11px]" style={{ color: p.textSubtle }}> send</span>
<button
onClick={(e) => { e.stopPropagation(); send(); }}
disabled={!hasContent}
className="flex items-center justify-center w-8 h-8 rounded-full transition-all duration-150 cursor-pointer"
style={{
background: hasContent ? p.sendBg : p.sendDisabled,
color: hasContent ? p.sendIcon : p.icon,
opacity: hasContent ? 1 : 0.55,
transform: hasContent ? 'scale(1)' : 'scale(0.92)',
}}
title="Send"
>
<Send size={13} />
</button>
</div>
</div>
</div>
<style>{`
.ai-editor .ProseMirror { .ai-editor .ProseMirror {
outline: none; outline: none;
white-space: pre-wrap; white-space: pre-wrap;
@ -495,8 +553,8 @@ export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEdi
} }
.ai-editor .ProseMirror::-webkit-scrollbar { display: none; } .ai-editor .ProseMirror::-webkit-scrollbar { display: none; }
`}</style> `}</style>
</div> </div>
); );
}); });
IMEditor.displayName = 'IMEditor'; IMEditor.displayName = 'IMEditor';

View File

@ -63,6 +63,8 @@ export type MessageWithMeta = RoomMessageResponse & {
/** True for messages sent by the current user that haven't been confirmed by the server */ /** True for messages sent by the current user that haven't been confirmed by the server */
isOptimistic?: boolean; isOptimistic?: boolean;
reactions?: ReactionGroup[]; reactions?: ReactionGroup[];
/** Attachment IDs for files uploaded with this message */
attachment_ids?: string[];
}; };
export type RoomWithCategory = RoomResponse & { export type RoomWithCategory = RoomResponse & {
@ -119,7 +121,7 @@ interface RoomContextValue {
isTransitioningRoom: boolean; isTransitioningRoom: boolean;
nextCursor: number | null; nextCursor: number | null;
loadMore: (cursor?: number | null) => void; loadMore: (cursor?: number | null) => void;
sendMessage: (content: string, contentType?: string, inReplyTo?: string) => Promise<void>; sendMessage: (content: string, contentType?: string, inReplyTo?: string, attachmentIds?: string[]) => Promise<void>;
editMessage: (messageId: string, content: string) => Promise<void>; editMessage: (messageId: string, content: string) => Promise<void>;
revokeMessage: (messageId: string) => Promise<void>; revokeMessage: (messageId: string) => Promise<void>;
updateReadSeq: (seq: number) => Promise<void>; updateReadSeq: (seq: number) => Promise<void>;
@ -837,7 +839,7 @@ export function RoomProvider({
const sendingRef = useRef(false); const sendingRef = useRef(false);
const sendMessage = useCallback( const sendMessage = useCallback(
async (content: string, contentType = 'text', inReplyTo?: string) => { async (content: string, contentType = 'text', inReplyTo?: string, attachmentIds?: string[]) => {
const client = wsClientRef.current; const client = wsClientRef.current;
if (!activeRoomId || !client) return; if (!activeRoomId || !client) return;
if (sendingRef.current) return; if (sendingRef.current) return;
@ -861,6 +863,7 @@ export function RoomProvider({
thread_id: inReplyTo, thread_id: inReplyTo,
in_reply_to: inReplyTo, in_reply_to: inReplyTo,
reactions: [], reactions: [],
attachment_ids: attachmentIds,
}; };
setMessages((prev) => [...prev, optimisticMsg]); setMessages((prev) => [...prev, optimisticMsg]);
@ -869,6 +872,7 @@ export function RoomProvider({
const confirmedMsg = await client.messageCreate(activeRoomId, content, { const confirmedMsg = await client.messageCreate(activeRoomId, content, {
contentType, contentType,
inReplyTo, inReplyTo,
attachmentIds,
}); });
// Replace optimistic message with server-confirmed one // Replace optimistic message with server-confirmed one
setMessages((prev) => { setMessages((prev) => {

View File

@ -622,6 +622,7 @@ export class RoomWsClient {
contentType?: string; contentType?: string;
threadId?: string; threadId?: string;
inReplyTo?: string; inReplyTo?: string;
attachmentIds?: string[];
}, },
): Promise<RoomMessageResponse> { ): Promise<RoomMessageResponse> {
return this.request<RoomMessageResponse>('message.create', { return this.request<RoomMessageResponse>('message.create', {
@ -630,6 +631,7 @@ export class RoomWsClient {
content_type: options?.contentType, content_type: options?.contentType,
thread_id: options?.threadId, thread_id: options?.threadId,
in_reply_to: options?.inReplyTo, in_reply_to: options?.inReplyTo,
attachment_ids: options?.attachmentIds,
}); });
} }

View File

@ -91,6 +91,7 @@ export interface WsRequestParams {
min_score?: number; min_score?: number;
query?: string; query?: string;
message_ids?: string[]; message_ids?: string[];
attachment_ids?: string[];
} }
export interface WsResponse { export interface WsResponse {