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;
|
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 = {
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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 “{query}”
|
No results for “{query}”
|
||||||
</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';
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user