/** * The composer band: message entry, @-mention completion, emoji insertion, * attachment staging, and the reply / edit contexts. * * Details that are easy to get wrong and are handled here: * - **Enter sends, Shift+Enter breaks a line — unless an IME is composing.** * `e.isComposing` is checked first: confirming a Chinese candidate with Enter * must never fire a half-finished message. * - The mention list matches the backend's own token grammar (`@name`, trailing * punctuation stripped), and an unresolvable name simply posts as plain text * rather than being rejected. * - Attachments stage in the strip above the box; the send button explains *why* * it is unavailable (uploading, failed file, too long, slow mode) instead of * silently ignoring the click. * - Paste and drag-and-drop both feed the same queue as the file button. */ import { type ChatMessage, type CustomEmoji, formatSize, MAX_MESSAGE_LEN, type RoomMember, textLength, } from '@app/domains/chat/model' import { useT } from '@app/i18n' import { IconAt as AtSign, IconFileText as FileText, IconLoader2 as Loader2, IconPaperclip as Paperclip, IconRefresh as RefreshCw, IconSend as Send, IconMoodSmile as Smile, IconAlertTriangle as TriangleAlert, IconX as X, } from '@tabler/icons-react' import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' import type { ComposerState } from '../model/use-composer' import { EmojiPicker } from './emoji-picker' import { Popover } from './parts' const MAX_BOX_HEIGHT = 224 export interface ComposerProps { composer: ComposerState roomLabel: string /** Override the entry hint (voice channels post bullet comments). */ placeholder?: string /** Thread scope changes the placeholder and the reply affordance. */ threadRoot?: ChatMessage /** Quote-reply target, kept out of the text itself. */ replyTo?: ChatMessage | null onCancelReply?: () => void editing?: ChatMessage | null onCancelEdit?: () => void /** Room's `rate_limit_per_user`, mirrored so the box can explain itself. */ slowModeSeconds?: number slowModeRemaining?: number members: RoomMember[] customEmojis?: CustomEmoji[] spaceName?: string /** Voice channel, deleted room, read-only …: replaces the box with a note. */ disabledNote?: string | null onTyping?: () => void onSubmit?: () => void } export function ChatComposer({ composer, roomLabel, placeholder, replyTo, onCancelReply, editing, onCancelEdit, slowModeSeconds = 0, slowModeRemaining = 0, members, customEmojis = [], spaceName, disabledNote, onTyping, onSubmit, }: ComposerProps) { const { t } = useT() const boxRef = useRef(null) const helpId = useId() const [emojiOpen, setEmojiOpen] = useState(false) const [mention, setMention] = useState<{ query: string; start: number } | null>(null) const [active, setActive] = useState(0) const { text, setText } = composer // Grow with the content up to a limit, then scroll inside the box. useEffect(() => { const node = boxRef.current if (!node) return node.style.height = 'auto' node.style.height = `${Math.min(node.scrollHeight, MAX_BOX_HEIGHT)}px` }, [text]) useEffect(() => { if (!disabledNote) boxRef.current?.focus() }, [disabledNote, roomLabel]) const candidates = useMemo(() => { if (!mention) return [] const query = mention.query.toLowerCase() return members .filter((member) => { const name = member.user.username.toLowerCase() const display = (member.nick || member.user.display_name || '').toLowerCase() return !query || name.startsWith(query) || display.includes(query) }) .slice(0, 8) }, [members, mention]) // Clamped during render instead of reset in an effect: the highlight must // follow a shrinking candidate list without a second render pass. const activeIndex = candidates.length > 0 ? Math.min(active, candidates.length - 1) : 0 const syncMention = useCallback((value: string, caret: number) => { const upto = value.slice(0, caret) const start = upto.lastIndexOf('@') if (start < 0) return setMention(null) // A space before the @ (or the very start) is what makes it a mention and // not an e-mail address typed mid-sentence. const boundary = start === 0 || /\s/.test(upto[start - 1] ?? '') const token = upto.slice(start + 1) if (!boundary || /\s/.test(token) || token.length > 40) return setMention(null) setMention({ query: token, start }) }, []) const insertMention = useCallback( (member: RoomMember) => { const node = boxRef.current if (!mention || !node) return const caret = node.selectionStart ?? text.length const before = text.slice(0, mention.start) const after = text.slice(caret) const token = `@${member.user.username}` const next = `${before}${token} ${after}` setText(next.slice(0, MAX_MESSAGE_LEN + 200)) setMention(null) requestAnimationFrame(() => { node.focus() const position = before.length + token.length + 1 node.setSelectionRange(position, position) }) }, [mention, setText, text], ) const insertEmoji = useCallback( (emoji: string) => { const node = boxRef.current const caret = node?.selectionStart ?? text.length const next = `${text.slice(0, caret)}${emoji}${text.slice(caret)}` setText(next) setEmojiOpen(false) requestAnimationFrame(() => { if (!node) return node.focus() const position = caret + emoji.length node.setSelectionRange(position, position) }) }, [setText, text], ) const submit = useCallback(() => { if (!composer.canSend || slowModeRemaining > 0) return setMention(null) onSubmit?.() }, [composer.canSend, onSubmit, slowModeRemaining]) const onKeyDown = (event: React.KeyboardEvent) => { // IME first: Enter belongs to the candidate window while it is open. if (event.nativeEvent.isComposing || event.keyCode === 229) return if (mention && candidates.length > 0) { if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { event.preventDefault() setActive((index) => { const current = Math.min(index, candidates.length - 1) return (current + (event.key === 'ArrowDown' ? 1 : candidates.length - 1)) % candidates.length }) return } if (event.key === 'Enter' || event.key === 'Tab') { event.preventDefault() const picked = candidates[activeIndex] if (picked) insertMention(picked) return } if (event.key === 'Escape') { event.preventDefault() setMention(null) return } } if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault() submit() return } if (event.key === 'Escape') { if (editing) onCancelEdit?.() else if (replyTo) onCancelReply?.() } } const onPaste = (event: React.ClipboardEvent) => { const files = [...event.clipboardData.files] if (files.length > 0) { event.preventDefault() composer.addFiles(files) } } const onDrop = (event: React.DragEvent) => { const files = [...event.dataTransfer.files] if (files.length === 0) return event.preventDefault() composer.addFiles(files) } if (disabledNote) { return (
{disabledNote}
) } const length = textLength(text) const over = length > MAX_MESSAGE_LEN const blocked = composer.blocked const canSend = composer.canSend && slowModeRemaining === 0 return (
event.preventDefault()} onDrop={onDrop} className="chat-composer"> {editing ? ( ✎} label={t('chat.composer.editLabel')} preview={editing.content} hint={t('chat.composer.escCancel')} onCancel={onCancelEdit} /> ) : replyTo ? ( } label={t('chat.composer.replyLabel', { author: replyTo.author?.display_name || replyTo.author?.username || t('chat.composer.someMessage'), })} preview={replyTo.content} hint={t('chat.composer.escCancel')} onCancel={onCancelReply} /> ) : null} {composer.uploads.length > 0 ? (
{composer.uploads.map((item) => (
{item.previewUrl ? ( ) : ( )}

{item.file.name}

{item.state === 'ready' ? formatSize(item.file.size) : item.state === 'failed' || item.state === 'too_big' ? (item.error ?? t('chat.composer.uploadFailedShort')) : t('chat.composer.uploading')}

{item.state === 'failed' ? ( ) : item.state === 'ready' ? ( ✓ ) : item.state === 'too_big' ? null : ( )}
))}
) : null}