diff --git a/src/client/types.gen.ts b/src/client/types.gen.ts index 4654c51..5e9a71d 100644 --- a/src/client/types.gen.ts +++ b/src/client/types.gen.ts @@ -4434,6 +4434,7 @@ export type RoomMessageCreateRequest = { content_type?: string | null; thread_id?: string | null; in_reply_to?: string | null; + attachment_ids?: string[]; }; export type RoomMessageListResponse = { @@ -4456,6 +4457,7 @@ export type RoomMessageResponse = { send_at: string; revoked?: string | null; revoked_by?: string | null; + attachment_ids?: string[]; }; export type RoomMessageUpdateRequest = { diff --git a/src/components/room/message/MessageInput.tsx b/src/components/room/message/MessageInput.tsx index 491c63f..82f6c8b 100644 --- a/src/components/room/message/MessageInput.tsx +++ b/src/components/room/message/MessageInput.tsx @@ -5,14 +5,15 @@ * 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 { useRoom } from '@/contexts'; import type { MessageAST } from './editor/types'; +import type { IMEditorHandle } from './editor/IMEditor'; export interface MessageInputProps { roomName: string; - onSend: (content: string) => void; + onSend: (content: string, attachmentIds?: string[]) => void; replyingTo?: { id: string; display_name?: string; content: string } | null; onCancelReply?: () => void; } @@ -22,13 +23,27 @@ export interface MessageInputHandle { clearContent: () => void; getContent: () => string; insertMention: (type: string, id: string, label: string) => void; + getAttachmentIds: () => string[]; } export const MessageInput = forwardRef(function MessageInput( { roomName, onSend, replyingTo, onCancelReply }, ref, ) { - const { members } = useRoom(); + const { members, activeRoomId } = useRoom(); + + // Ref passed to the inner IMEditor + const innerEditorRef = useRef(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 const mentionItems = { @@ -43,11 +58,12 @@ export const MessageInput = forwardRef(fu 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 }> => { + if (!activeRoomId) throw new Error('No active room'); const formData = new FormData(); 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'); return res.json(); }; @@ -59,7 +75,7 @@ export const MessageInput = forwardRef(fu return ( void; - onSend: (content: string, ast: MessageAST) => void; - mentionItems: { - users: MentionItem[]; - channels: MentionItem[]; - ai: MentionItem[]; - commands: MentionItem[]; - }; - onUploadFile?: (file: File) => Promise<{ id: string; url: string }>; - placeholder?: string; + replyingTo?: { id: string; display_name?: string; content: string } | null; + onCancelReply?: () => void; + onSend: (content: string, ast: MessageAST) => void; + mentionItems: { + users: MentionItem[]; + channels: MentionItem[]; + ai: MentionItem[]; + commands: MentionItem[]; + }; + onUploadFile?: (file: File) => Promise<{ id: string; url: string }>; + placeholder?: string; } export interface IMEditorHandle { - focus: () => void; - clearContent: () => void; - getContent: () => string; - insertMention: (type: string, id: string, label: string) => void; + focus: () => void; + clearContent: () => void; + getContent: () => string; + insertMention: (type: string, id: string, label: string) => void; + getAttachmentIds: () => string[]; } // ─── Color System (Google AI Studio / Linear palette, no Discord) ──────────── const LIGHT = { - bg: '#ffffff', - bgHover: '#f7f7f8', - bgActive: '#ececf1', - border: '#e3e3e5', - borderFocus: '#1c7ded', - text: '#1f1f1f', - textMuted: '#8a8a8e', - textSubtle: '#b0b0b4', - icon: '#8a8a8e', - iconHover: '#5c5c60', - sendBg: '#1c7ded', - sendBgHover: '#1a73d4', - sendIcon: '#ffffff', - sendDisabled:'#e3e3e5', - popupBg: '#ffffff', - popupBorder: '#e3e3e5', - popupHover: '#f5f5f7', - popupSelected:'#e8f0fe', - replyBg: '#f5f5f7', - badgeAi: '#dbeafe text-blue-700', - badgeChan: '#f3f4f6 text-gray-500', - badgeCmd: '#fef3c7 text-amber-700', + bg: '#ffffff', + bgHover: '#f7f7f8', + bgActive: '#ececf1', + border: '#e3e3e5', + borderFocus: '#1c7ded', + text: '#1f1f1f', + textMuted: '#8a8a8e', + textSubtle: '#b0b0b4', + icon: '#8a8a8e', + iconHover: '#5c5c60', + sendBg: '#1c7ded', + sendBgHover: '#1a73d4', + sendIcon: '#ffffff', + sendDisabled: '#e3e3e5', + popupBg: '#ffffff', + popupBorder: '#e3e3e5', + popupHover: '#f5f5f7', + popupSelected: '#e8f0fe', + replyBg: '#f5f5f7', + badgeAi: '#dbeafe text-blue-700', + badgeChan: '#f3f4f6 text-gray-500', + badgeCmd: '#fef3c7 text-amber-700', }; const DARK = { - bg: '#1a1a1e', - bgHover: '#222226', - bgActive: '#2a2a2f', - border: '#2e2e33', - borderFocus: '#4a9eff', - text: '#ececf1', - textMuted: '#8a8a91', - textSubtle: '#5c5c63', - icon: '#7a7a82', - iconHover: '#b0b0b8', - sendBg: '#4a9eff', - sendBgHover: '#6aafff', - sendIcon: '#ffffff', - sendDisabled:'#2e2e33', - popupBg: '#222226', - popupBorder: '#2e2e33', - popupHover: '#2a2a30', - popupSelected:'#2a3a55', - replyBg: '#1f1f23', - badgeAi: 'bg-blue-900/40 text-blue-300', - badgeChan: 'bg-gray-800 text-gray-400', - badgeCmd: 'bg-amber-900/30 text-amber-300', + bg: '#1a1a1e', + bgHover: '#222226', + bgActive: '#2a2a2f', + border: '#2e2e33', + borderFocus: '#4a9eff', + text: '#ececf1', + textMuted: '#8a8a91', + textSubtle: '#5c5c63', + icon: '#7a7a82', + iconHover: '#b0b0b8', + sendBg: '#4a9eff', + sendBgHover: '#6aafff', + sendIcon: '#ffffff', + sendDisabled: '#2e2e33', + popupBg: '#222226', + popupBorder: '#2e2e33', + popupHover: '#2a2a30', + popupSelected: '#2a3a55', + replyBg: '#1f1f23', + badgeAi: 'bg-blue-900/40 text-blue-300', + badgeChan: 'bg-gray-800 text-gray-400', + badgeCmd: 'bg-amber-900/30 text-amber-300', }; type Palette = typeof LIGHT; // ─── Emoji Picker ───────────────────────────────────────────────────────────── -function EmojiPicker({ onClose, onSelect, p }: { onClose: () => void; onSelect: (emoji: string) => void; p: Palette }) { - return ( -
-
- +function EmojiPicker({onClose, onSelect, p}: { onClose: () => void; onSelect: (emoji: string) => void; p: Palette }) { + return ( +
+
+ Emoji - -
-
- {COMMON_EMOJIS.map(emoji => ( - - ))} -
-
- ); + +
+
+ {COMMON_EMOJIS.map(emoji => ( + + ))} +
+
+ ); } // ─── Keyboard Extension ─────────────────────────────────────────────────────── const KeyboardSend = Extension.create({ - name: 'keyboardSend', - addKeyboardShortcuts() { - return { - Enter: ({ editor }) => { - if (editor.isEmpty) return true; - const text = editor.getText().trim(); - if (!text) return true; - (editor.storage as any).keyboardSend?.onSend?.(text, editor.getJSON() as MessageAST); - return true; - }, - 'Shift-Enter': ({ editor }) => { - editor.chain().focus().setHardBreak().run(); - return true; - }, - }; - }, - addStorage() { - return { onSend: null as ((t: string, a: MessageAST) => void) | null }; - }, + name: 'keyboardSend', + addKeyboardShortcuts() { + return { + Enter: ({editor}) => { + if (editor.isEmpty) return true; + const text = editor.getText().trim(); + if (!text) return true; + (editor.storage as any).keyboardSend?.onSend?.(text, editor.getJSON() as MessageAST); + return true; + }, + 'Shift-Enter': ({editor}) => { + editor.chain().focus().setHardBreak().run(); + return true; + }, + }; + }, + addStorage() { + return {onSend: null as ((t: string, a: MessageAST) => void) | null}; + }, }); // ─── Helpers ───────────────────────────────────────────────────────────────── 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 { - 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 === 'command') return { label: 'cmd', cls: 'bg-amber-50 text-amber-600' }; - return null; + 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 === 'command') return {label: 'cmd', cls: 'bg-amber-50 text-amber-600'}; + return null; } // ─── Mention Dropdown ──────────────────────────────────────────────────────── function MentionDropdown({ - items, selectedIndex, onSelect, p, query, -}: { - items: MentionItem[]; - selectedIndex: number; - onSelect: (item: MentionItem) => void; - p: Palette; - query: string; + items, selectedIndex, onSelect, p, query, + }: { + items: MentionItem[]; + selectedIndex: number; + onSelect: (item: MentionItem) => void; + p: Palette; + query: string; }) { - return ( -
- {items.length === 0 ? ( -
- No results for “{query}” -
- ) : ( -
- {items.map((item, i) => { - const badge = getBadge(item.type); - return ( - - ); - })} + )} + + ); + })} +
+ )}
- )} - - ); + ); } // ─── Main Component ──────────────────────────────────────────────────────────── export const IMEditor = forwardRef(function IMEditor( - { replyingTo, onCancelReply, onSend, mentionItems, onUploadFile, placeholder = 'Message…' }, - ref, + {replyingTo, onCancelReply, onSend, mentionItems, onUploadFile, placeholder = 'Message…'}, + ref, ) { - const { resolvedTheme } = useTheme(); - const p = resolvedTheme === 'dark' ? DARK : LIGHT; - const { compress } = useImageCompress(); + const {resolvedTheme} = useTheme(); + const p = resolvedTheme === 'dark' ? DARK : LIGHT; + const {compress} = useImageCompress(); - const [showEmoji, setShowEmoji] = useState(false); - const [mentionOpen, setMentionOpen] = useState(false); - const [mentionQuery, setMentionQuery] = useState(''); - const [mentionItems2, setMentionItems2] = useState([]); - const [mentionIdx, setMentionIdx] = useState(0); - const [, setMentionPos] = useState({ top: 0, left: 0 }); - const [focused, setFocused] = useState(false); + const [showEmoji, setShowEmoji] = useState(false); + const [mentionOpen, setMentionOpen] = useState(false); + const [mentionQuery, setMentionQuery] = useState(''); + const [mentionItems2, setMentionItems2] = useState([]); + const [mentionIdx, setMentionIdx] = useState(0); + const [, setMentionPos] = useState({top: 0, left: 0}); + const [focused, setFocused] = useState(false); - const wrapRef = useRef(null); + const wrapRef = useRef(null); - const allItems = [ - ...mentionItems.users, - ...mentionItems.channels, - ...mentionItems.ai, - ...mentionItems.commands, - ]; + const allItems = [ + ...mentionItems.users, + ...mentionItems.channels, + ...mentionItems.ai, + ...mentionItems.commands, + ]; - const selectMention = useCallback((item: MentionItem) => { - if (!editor) return; - // Use backend-parseable format: @[type:id:label] - const mentionStr = `@[${item.type}:${item.id}:${item.label}] `; - 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 { + const selectMention = useCallback((item: MentionItem) => { + if (!editor) return; + // Use backend-parseable format: @[type:id:label] + const mentionStr = `@[${item.type}:${item.id}:${item.label}] `; + editor.chain().focus().insertContent(mentionStr).run(); setMentionOpen(false); - } - }, - onFocus: () => setFocused(true), - onBlur: () => setFocused(false), - }); + }, []); - useEffect(() => { - if (editor) (editor.storage as any).keyboardSend = { onSend }; - }, [editor, onSend]); + 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; - const doUpload = async (file: File) => { - if (!editor || !onUploadFile) return; - try { - // Compress image before upload (only if it's an image and > 500KB) - 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 */ } - }; + 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); - const send = () => { - if (!editor || editor.isEmpty) return; - const text = editor.getText().trim(); - if (!text) return; - onSend(text, editor.getJSON() as MessageAST); - editor.commands.clearContent(); - }; + if (q.startsWith('@') && q.length > 1) { + const results = filterMentionItems(allItems, q.slice(1)); + setMentionQuery(q.slice(1)); + setMentionItems2(results); + setMentionIdx(0); + setMentionOpen(true); - useImperativeHandle(ref, () => ({ - focus: () => editor?.commands.focus(), - clearContent: () => editor?.commands.clearContent(), - getContent: () => editor?.getText() ?? '', - insertMention: (type: string, id: string, label: string) => { - if (!editor) return; - const mentionStr = `@[${type}:${id}:${label}] `; - editor.chain().focus().insertContent(mentionStr).run(); - }, - })); + 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); + } + }, + 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 borderColor = focused ? p.borderFocus : p.border; - const boxShadow = focused - ? (p === DARK ? `0 0 0 3px rgba(74,158,255,0.18)` : `0 0 0 3px rgba(28,125,237,0.12)`) - : 'none'; + const doUpload = async (file: File) => { + if (!editor || !onUploadFile) return; + try { + // Compress image before upload (only if it's an image and > 500KB) + 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 ( -
- {/* Reply strip */} - {replyingTo && ( -
-
- Replying to - {replyingTo.display_name} - — {replyingTo.content} -
- {onCancelReply && ( - - )} -
- )} + const send = () => { + if (!editor || editor.isEmpty) return; + const text = editor.getText().trim(); + if (!text) return; + onSend(text, editor.getJSON() as MessageAST); + editor.commands.clearContent(); + }; - {/* Input area */} -
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 && ( - - )} + useImperativeHandle(ref, () => ({ + focus: () => editor?.commands.focus(), + clearContent: () => editor?.commands.clearContent(), + getContent: () => editor?.getText() ?? '', + insertMention: (type: string, id: string, label: string) => { + if (!editor) return; + const mentionStr = `@[${type}:${id}:${label}] `; + editor.chain().focus().insertContent(mentionStr).run(); + }, + getAttachmentIds: () => { + if (!editor) return []; + const json = editor.getJSON(); + const ids: string[] = []; + const walk = (node: Record) => { + if (node['type'] === 'file' && node['attrs']) { + const attrs = node['attrs'] as Record; + if (attrs['id']) ids.push(String(attrs['id'])); + } + const children = node['content'] as Record[] | undefined; + if (children) children.forEach(walk); + }; + walk(json as Record); + return ids; + }, + })); - {/* Editor */} -
- -
+ const hasContent = !!editor && !editor.isEmpty; - {/* Discord-style toolbar: icons left, send right */} -
- {/* Left — emoji + attach */} -
-
- - {showEmoji && setShowEmoji(false)} onSelect={(emoji) => { editor?.chain().focus().insertContent(emoji).insertContent(' ').run(); setShowEmoji(false); }} p={p} />} + // Dynamic styles + const borderColor = focused ? p.borderFocus : p.border; + const boxShadow = focused + ? (p === DARK ? `0 0 0 3px rgba(74,158,255,0.18)` : `0 0 0 3px rgba(28,125,237,0.12)`) + : 'none'; + + return ( +
+ {replyingTo && ( +
+
+ Replying to + {replyingTo.display_name} + — {replyingTo.content} +
+ {onCancelReply && ( + + )} +
+ )} + + {/* Input area */} +
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 && ( + + )} + + {/* Editor */} +
+ +
+ + {/* Discord-style toolbar: icons left, send right */} +
+ {/* Left — emoji + attach */} +
+
+ + {showEmoji && setShowEmoji(false)} onSelect={(emoji) => { + editor?.chain().focus().insertContent(emoji).insertContent(' ').run(); + setShowEmoji(false); + }} p={p}/>} +
+ + +
+ + {/* Right — hint + send */} +
+ ↵ send + +
+
- -
- - {/* Right — hint + send */} -
- ↵ send - -
-
-
- - -
- ); +
+ ); }); IMEditor.displayName = 'IMEditor'; diff --git a/src/contexts/room-context.tsx b/src/contexts/room-context.tsx index ffe40fc..dd0eae8 100644 --- a/src/contexts/room-context.tsx +++ b/src/contexts/room-context.tsx @@ -63,6 +63,8 @@ export type MessageWithMeta = RoomMessageResponse & { /** True for messages sent by the current user that haven't been confirmed by the server */ isOptimistic?: boolean; reactions?: ReactionGroup[]; + /** Attachment IDs for files uploaded with this message */ + attachment_ids?: string[]; }; export type RoomWithCategory = RoomResponse & { @@ -119,7 +121,7 @@ interface RoomContextValue { isTransitioningRoom: boolean; nextCursor: number | null; loadMore: (cursor?: number | null) => void; - sendMessage: (content: string, contentType?: string, inReplyTo?: string) => Promise; + sendMessage: (content: string, contentType?: string, inReplyTo?: string, attachmentIds?: string[]) => Promise; editMessage: (messageId: string, content: string) => Promise; revokeMessage: (messageId: string) => Promise; updateReadSeq: (seq: number) => Promise; @@ -837,7 +839,7 @@ export function RoomProvider({ const sendingRef = useRef(false); const sendMessage = useCallback( - async (content: string, contentType = 'text', inReplyTo?: string) => { + async (content: string, contentType = 'text', inReplyTo?: string, attachmentIds?: string[]) => { const client = wsClientRef.current; if (!activeRoomId || !client) return; if (sendingRef.current) return; @@ -861,6 +863,7 @@ export function RoomProvider({ thread_id: inReplyTo, in_reply_to: inReplyTo, reactions: [], + attachment_ids: attachmentIds, }; setMessages((prev) => [...prev, optimisticMsg]); @@ -869,6 +872,7 @@ export function RoomProvider({ const confirmedMsg = await client.messageCreate(activeRoomId, content, { contentType, inReplyTo, + attachmentIds, }); // Replace optimistic message with server-confirmed one setMessages((prev) => { diff --git a/src/lib/room-ws-client.ts b/src/lib/room-ws-client.ts index b74ca9d..5bbeae4 100644 --- a/src/lib/room-ws-client.ts +++ b/src/lib/room-ws-client.ts @@ -622,6 +622,7 @@ export class RoomWsClient { contentType?: string; threadId?: string; inReplyTo?: string; + attachmentIds?: string[]; }, ): Promise { return this.request('message.create', { @@ -630,6 +631,7 @@ export class RoomWsClient { content_type: options?.contentType, thread_id: options?.threadId, in_reply_to: options?.inReplyTo, + attachment_ids: options?.attachmentIds, }); } diff --git a/src/lib/ws-protocol.ts b/src/lib/ws-protocol.ts index df5554d..d02293d 100644 --- a/src/lib/ws-protocol.ts +++ b/src/lib/ws-protocol.ts @@ -91,6 +91,7 @@ export interface WsRequestParams { min_score?: number; query?: string; message_ids?: string[]; + attachment_ids?: string[]; } export interface WsResponse {