feat(frontend): add attachment_ids to message creation flow and types
This commit is contained in:
parent
7736869fc4
commit
e43d9fc8bf
@ -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 = {
|
||||
|
||||
@ -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<MessageInputHandle, MessageInputProps>(function MessageInput(
|
||||
{ roomName, onSend, replyingTo, onCancelReply },
|
||||
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
|
||||
const mentionItems = {
|
||||
@ -43,11 +58,12 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(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<MessageInputHandle, MessageInputProps>(fu
|
||||
|
||||
return (
|
||||
<IMEditor
|
||||
ref={ref}
|
||||
ref={innerEditorRef}
|
||||
replyingTo={replyingTo}
|
||||
onCancelReply={onCancelReply}
|
||||
onSend={handleSend}
|
||||
|
||||
@ -5,17 +5,17 @@
|
||||
* Colors: Clean modern palette, no Discord reference
|
||||
*/
|
||||
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
|
||||
import { useEditor, EditorContent, Extension } from '@tiptap/react';
|
||||
import {forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react';
|
||||
import {EditorContent, Extension, useEditor} from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
import { CustomEmojiNode } from './EmojiNode';
|
||||
import type { MentionItem, MessageAST, MentionType } from './types';
|
||||
import { Paperclip, Smile, Send, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { COMMON_EMOJIS } from '../../shared';
|
||||
import { useTheme } from '@/contexts';
|
||||
import { useImageCompress } from '@/hooks/useImageCompress';
|
||||
import {CustomEmojiNode} from './EmojiNode';
|
||||
import type {MentionItem, MentionType, MessageAST} from './types';
|
||||
import {Paperclip, Send, Smile, X} from 'lucide-react';
|
||||
import {cn} from '@/lib/utils';
|
||||
import {COMMON_EMOJIS} from '../../shared';
|
||||
import {useTheme} from '@/contexts';
|
||||
import {useImageCompress} from '@/hooks/useImageCompress';
|
||||
|
||||
export interface IMEditorProps {
|
||||
replyingTo?: { id: string; display_name?: string; content: string } | null;
|
||||
@ -36,6 +36,7 @@ export interface IMEditorHandle {
|
||||
clearContent: () => void;
|
||||
getContent: () => string;
|
||||
insertMention: (type: string, id: string, label: string) => void;
|
||||
getAttachmentIds: () => string[];
|
||||
}
|
||||
|
||||
// ─── Color System (Google AI Studio / Linear palette, no Discord) ────────────
|
||||
@ -54,11 +55,11 @@ const LIGHT = {
|
||||
sendBg: '#1c7ded',
|
||||
sendBgHover: '#1a73d4',
|
||||
sendIcon: '#ffffff',
|
||||
sendDisabled:'#e3e3e5',
|
||||
sendDisabled: '#e3e3e5',
|
||||
popupBg: '#ffffff',
|
||||
popupBorder: '#e3e3e5',
|
||||
popupHover: '#f5f5f7',
|
||||
popupSelected:'#e8f0fe',
|
||||
popupSelected: '#e8f0fe',
|
||||
replyBg: '#f5f5f7',
|
||||
badgeAi: '#dbeafe text-blue-700',
|
||||
badgeChan: '#f3f4f6 text-gray-500',
|
||||
@ -79,11 +80,11 @@ const DARK = {
|
||||
sendBg: '#4a9eff',
|
||||
sendBgHover: '#6aafff',
|
||||
sendIcon: '#ffffff',
|
||||
sendDisabled:'#2e2e33',
|
||||
sendDisabled: '#2e2e33',
|
||||
popupBg: '#222226',
|
||||
popupBorder: '#2e2e33',
|
||||
popupHover: '#2a2a30',
|
||||
popupSelected:'#2a3a55',
|
||||
popupSelected: '#2a3a55',
|
||||
replyBg: '#1f1f23',
|
||||
badgeAi: 'bg-blue-900/40 text-blue-300',
|
||||
badgeChan: 'bg-gray-800 text-gray-400',
|
||||
@ -94,7 +95,7 @@ type Palette = typeof LIGHT;
|
||||
|
||||
// ─── 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 (
|
||||
<div
|
||||
className="absolute bottom-full left-0 mb-2 z-50"
|
||||
@ -109,22 +110,24 @@ function EmojiPicker({ onClose, onSelect, p }: { onClose: () => void; onSelect:
|
||||
>
|
||||
<div
|
||||
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
|
||||
</span>
|
||||
<button onClick={onClose} className="flex items-center justify-center w-5 h-5 rounded cursor-pointer transition-colors" style={{ color: p.icon }}>
|
||||
<X size={11} />
|
||||
<button onClick={onClose}
|
||||
className="flex items-center justify-center w-5 h-5 rounded cursor-pointer transition-colors"
|
||||
style={{color: p.icon}}>
|
||||
<X size={11}/>
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid p-2 gap-0.5" style={{ gridTemplateColumns: 'repeat(6, 1fr)' }}>
|
||||
<div className="grid p-2 gap-0.5" style={{gridTemplateColumns: 'repeat(6, 1fr)'}}>
|
||||
{COMMON_EMOJIS.map(emoji => (
|
||||
<button
|
||||
key={emoji}
|
||||
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]"
|
||||
style={{ background: 'transparent' }}
|
||||
style={{background: 'transparent'}}
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
@ -140,21 +143,21 @@ const KeyboardSend = Extension.create({
|
||||
name: 'keyboardSend',
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Enter: ({ editor }) => {
|
||||
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 }) => {
|
||||
'Shift-Enter': ({editor}) => {
|
||||
editor.chain().focus().setHardBreak().run();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
addStorage() {
|
||||
return { onSend: null as ((t: string, a: MessageAST) => void) | null };
|
||||
return {onSend: null as ((t: string, a: MessageAST) => void) | null};
|
||||
},
|
||||
});
|
||||
|
||||
@ -165,9 +168,9 @@ function filterMentionItems(all: MentionItem[], q: string): MentionItem[] {
|
||||
}
|
||||
|
||||
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' };
|
||||
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;
|
||||
}
|
||||
|
||||
@ -175,7 +178,7 @@ function getBadge(type: MentionType): { label: string; cls: string } | null {
|
||||
|
||||
function MentionDropdown({
|
||||
items, selectedIndex, onSelect, p, query,
|
||||
}: {
|
||||
}: {
|
||||
items: MentionItem[];
|
||||
selectedIndex: number;
|
||||
onSelect: (item: MentionItem) => void;
|
||||
@ -195,7 +198,7 @@ function MentionDropdown({
|
||||
}}
|
||||
>
|
||||
{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 “{query}”
|
||||
</div>
|
||||
) : (
|
||||
@ -207,23 +210,24 @@ function MentionDropdown({
|
||||
key={item.id}
|
||||
onClick={() => onSelect(item)}
|
||||
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 ? (
|
||||
<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
|
||||
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()}
|
||||
</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}
|
||||
</span>
|
||||
{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}
|
||||
</span>
|
||||
)}
|
||||
@ -239,19 +243,19 @@ function MentionDropdown({
|
||||
// ─── Main Component ────────────────────────────────────────────────────────────
|
||||
|
||||
export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEditor(
|
||||
{ replyingTo, onCancelReply, onSend, mentionItems, onUploadFile, placeholder = 'Message…' },
|
||||
{replyingTo, onCancelReply, onSend, mentionItems, onUploadFile, placeholder = 'Message…'},
|
||||
ref,
|
||||
) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const {resolvedTheme} = useTheme();
|
||||
const p = resolvedTheme === 'dark' ? DARK : LIGHT;
|
||||
const { compress } = useImageCompress();
|
||||
const {compress} = useImageCompress();
|
||||
|
||||
const [showEmoji, setShowEmoji] = useState(false);
|
||||
const [mentionOpen, setMentionOpen] = useState(false);
|
||||
const [mentionQuery, setMentionQuery] = useState('');
|
||||
const [mentionItems2, setMentionItems2] = useState<MentionItem[]>([]);
|
||||
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 wrapRef = useRef<HTMLDivElement>(null);
|
||||
@ -273,8 +277,8 @@ export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEdi
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({ undoRedo: { depth: 100 } }),
|
||||
Placeholder.configure({ placeholder }),
|
||||
StarterKit.configure({undoRedo: {depth: 100}}),
|
||||
Placeholder.configure({placeholder}),
|
||||
CustomEmojiNode,
|
||||
KeyboardSend,
|
||||
],
|
||||
@ -298,14 +302,17 @@ export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEdi
|
||||
return false;
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor: ed }) => {
|
||||
onUpdate: ({editor: ed}) => {
|
||||
const text = ed.getText();
|
||||
const { from } = ed.state.selection;
|
||||
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 (c === '@') {
|
||||
ts = i;
|
||||
break;
|
||||
}
|
||||
if (/\s/.test(c)) break;
|
||||
}
|
||||
const q = text.slice(ts - 1, from);
|
||||
@ -322,7 +329,7 @@ export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEdi
|
||||
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) });
|
||||
setMentionPos({top: r.bottom - cr.top + 6, left: Math.max(0, r.left - cr.left)});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@ -334,7 +341,7 @@ export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEdi
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (editor) (editor.storage as any).keyboardSend = { onSend };
|
||||
if (editor) (editor.storage as any).keyboardSend = {onSend};
|
||||
}, [editor, onSend]);
|
||||
|
||||
const doUpload = async (file: File) => {
|
||||
@ -343,12 +350,23 @@ export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEdi
|
||||
// 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 });
|
||||
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 */ }
|
||||
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 = () => {
|
||||
@ -368,6 +386,21 @@ export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEdi
|
||||
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<string, unknown>) => {
|
||||
if (node['type'] === 'file' && node['attrs']) {
|
||||
const attrs = node['attrs'] as Record<string, unknown>;
|
||||
if (attrs['id']) ids.push(String(attrs['id']));
|
||||
}
|
||||
const children = node['content'] as Record<string, unknown>[] | undefined;
|
||||
if (children) children.forEach(walk);
|
||||
};
|
||||
walk(json as Record<string, unknown>);
|
||||
return ids;
|
||||
},
|
||||
}));
|
||||
|
||||
const hasContent = !!editor && !editor.isEmpty;
|
||||
@ -379,21 +412,29 @@ export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEdi
|
||||
: 'none';
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col" ref={wrapRef}>
|
||||
{/* Reply strip */}
|
||||
<div className="relative flex flex-col mt-2 ml-3 mr-3 mb-1" ref={wrapRef}>
|
||||
{replyingTo && (
|
||||
<div
|
||||
className="flex items-center gap-3 px-4 py-2.5"
|
||||
style={{ background: p.replyBg, borderRadius: '12px 12px 0 0', borderBottom: `1px solid ${p.border}` }}
|
||||
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>
|
||||
<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 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>
|
||||
@ -424,9 +465,9 @@ export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEdi
|
||||
{/* Editor */}
|
||||
<div
|
||||
className="ai-editor"
|
||||
style={{ padding: '12px 14px 10px' }}
|
||||
style={{padding: '12px 14px 10px'}}
|
||||
>
|
||||
<EditorContent editor={editor} />
|
||||
<EditorContent editor={editor}/>
|
||||
</div>
|
||||
|
||||
{/* Discord-style toolbar: icons left, send right */}
|
||||
@ -435,31 +476,48 @@ export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEdi
|
||||
<div className="flex items-center gap-0.5">
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setShowEmoji(v => !v); }}
|
||||
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' }}
|
||||
style={{
|
||||
color: showEmoji ? p.iconHover : p.icon,
|
||||
background: showEmoji ? p.bgActive : 'transparent'
|
||||
}}
|
||||
title="Emoji"
|
||||
>
|
||||
<Smile size={18} />
|
||||
<Smile size={18}/>
|
||||
</button>
|
||||
{showEmoji && <EmojiPicker onClose={() => setShowEmoji(false)} onSelect={(emoji) => { editor?.chain().focus().insertContent(emoji).insertContent(' ').run(); setShowEmoji(false); }} p={p} />}
|
||||
{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 }}
|
||||
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 = ''; }} />
|
||||
<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>
|
||||
<span className="text-[11px]" style={{color: p.textSubtle}}>↵ send</span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); send(); }}
|
||||
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={{
|
||||
@ -470,7 +528,7 @@ export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEdi
|
||||
}}
|
||||
title="Send"
|
||||
>
|
||||
<Send size={13} />
|
||||
<Send size={13}/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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<void>;
|
||||
sendMessage: (content: string, contentType?: string, inReplyTo?: string, attachmentIds?: string[]) => Promise<void>;
|
||||
editMessage: (messageId: string, content: string) => Promise<void>;
|
||||
revokeMessage: (messageId: string) => Promise<void>;
|
||||
updateReadSeq: (seq: number) => Promise<void>;
|
||||
@ -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) => {
|
||||
|
||||
@ -622,6 +622,7 @@ export class RoomWsClient {
|
||||
contentType?: string;
|
||||
threadId?: string;
|
||||
inReplyTo?: string;
|
||||
attachmentIds?: string[];
|
||||
},
|
||||
): Promise<RoomMessageResponse> {
|
||||
return this.request<RoomMessageResponse>('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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -91,6 +91,7 @@ export interface WsRequestParams {
|
||||
min_score?: number;
|
||||
query?: string;
|
||||
message_ids?: string[];
|
||||
attachment_ids?: string[];
|
||||
}
|
||||
|
||||
export interface WsResponse {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user