main
/**
* 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<HTMLTextAreaElement | null>(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<HTMLTextAreaElement>) => {
// 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<HTMLTextAreaElement>) => {
const files = [...event.clipboardData.files]
if (files.length > 0) {
event.preventDefault()
composer.addFiles(files)
}
}
const onDrop = (event: React.DragEvent<HTMLDivElement>) => {
const files = [...event.dataTransfer.files]
if (files.length === 0) return
event.preventDefault()
composer.addFiles(files)
}
if (disabledNote) {
return (
<div className="flex h-11 items-center justify-center rounded-lg bg-shell-field px-3 text-[13px] text-shell-ink-muted">
{disabledNote}
</div>
)
}
const length = textLength(text)
const over = length > MAX_MESSAGE_LEN
const blocked = composer.blocked
const canSend = composer.canSend && slowModeRemaining === 0
return (
<div onDragOver={(event) => event.preventDefault()} onDrop={onDrop} className="chat-composer">
{editing ? (
<ContextBar
icon={<span className="text-[11px]">✎</span>}
label={t('chat.composer.editLabel')}
preview={editing.content}
hint={t('chat.composer.escCancel')}
onCancel={onCancelEdit}
/>
) : replyTo ? (
<ContextBar
icon={<AtSign className="h-3.5 w-3.5" />}
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 ? (
<div className="flex flex-wrap gap-2 px-2.5 pt-2.5">
{composer.uploads.map((item) => (
<div
key={item.localId}
className="relative flex h-14 w-32 items-center gap-1.5 overflow-hidden rounded-md border border-shell-hairline bg-shell-surface p-1.5"
>
{item.previewUrl ? (
<img src={item.previewUrl} alt="" className="h-10 w-10 shrink-0 rounded object-cover" />
) : (
<FileText className="h-5 w-5 shrink-0 text-shell-ink-muted" />
)}
<div className="min-w-0 flex-1">
<p className="truncate text-[11px] font-medium">{item.file.name}</p>
<p className="truncate text-[10px] text-shell-ink-muted">
{item.state === 'ready'
? formatSize(item.file.size)
: item.state === 'failed' || item.state === 'too_big'
? (item.error ?? t('chat.composer.uploadFailedShort'))
: t('chat.composer.uploading')}
</p>
</div>
{item.state === 'failed' ? (
<button
type="button"
aria-label={t('chat.composer.retryUpload')}
onClick={() => composer.retryUpload(item.localId)}
className="grid h-5 w-5 place-content-center rounded bg-[#e5484d]/12 text-[#e5484d]"
>
<RefreshCw className="h-3 w-3" />
</button>
) : item.state === 'ready' ? (
<span className="grid h-5 w-5 place-content-center rounded bg-[#3ba55d]/15 text-[#3ba55d]">✓</span>
) : item.state === 'too_big' ? null : (
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-shell-ink-muted" />
)}
<button
type="button"
aria-label={t('chat.composer.removeFile', { name: item.file.name })}
onClick={() => composer.removeUpload(item.localId)}
className="absolute right-0.5 top-0.5 grid h-4 w-4 place-content-center rounded bg-shell-surface/90 text-shell-ink-muted hover:text-shell-ink"
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
) : null}
<div className="chat-composer-input">
<div className="relative min-w-0 flex-1">
<textarea
ref={boxRef}
value={text}
rows={1}
placeholder={placeholder ?? t('chat.composer.entryHint', { room: roomLabel })}
aria-label={t('chat.composer.inputAria')}
aria-describedby={helpId}
spellCheck
onChange={(event) => {
setText(event.target.value)
syncMention(event.target.value, event.target.selectionStart ?? 0)
onTyping?.()
}}
onClick={(event) => syncMention(text, event.currentTarget.selectionStart ?? 0)}
onBlur={() => setMention(null)}
onKeyDown={onKeyDown}
onPaste={onPaste}
className="max-h-56 w-full resize-none bg-transparent py-1.5 text-[15px] leading-[1.5] outline-none placeholder:text-shell-ink-muted"
/>
<Popover
open={Boolean(mention) && candidates.length > 0}
onClose={() => setMention(null)}
className="w-64 p-1"
>
<p className="px-2 py-1 text-[10.5px] font-bold uppercase tracking-wide text-shell-ink-muted">
{t('chat.composer.mentionMembers')}
</p>
{candidates.map((member, index) => (
<button
key={member.user.user_id}
type="button"
onMouseEnter={() => setActive(index)}
onMouseDown={(event) => {
// mousedown, not click: the textarea's blur would otherwise
// close the list before the pick is registered.
event.preventDefault()
insertMention(member)
}}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[12.5px] ${
index === activeIndex ? 'bg-shell-active' : 'hover:bg-shell-field'
}`}
>
<span className="truncate font-medium">
{member.nick || member.user.display_name || member.user.username}
</span>
<span className="min-w-0 flex-1 truncate text-shell-ink-muted">@{member.user.username}</span>
{member.role !== 'member' ? (
<span className="shrink-0 rounded bg-shell-field px-1 text-[10px]">{member.role}</span>
) : null}
</button>
))}
</Popover>
</div>
</div>
<div className="chat-composer-toolbar">
<label className="chat-composer-tool" title={t('chat.composer.addAttachmentHint')}>
<input
type="file"
multiple
aria-label={t('chat.composer.addAttachment')}
className="sr-only"
onChange={(event) => {
const files = [...(event.target.files ?? [])]
composer.addFiles(files)
event.target.value = ''
}}
/>
<Paperclip className="h-[18px] w-[18px]" />
</label>
<div className="relative shrink-0">
<button
type="button"
aria-label={t('chat.composer.insertEmoji')}
title={t('chat.composer.insertEmoji')}
aria-expanded={emojiOpen}
onClick={() => setEmojiOpen((value) => !value)}
className="chat-composer-tool"
>
<Smile className="h-[18px] w-[18px]" />
</button>
<Popover open={emojiOpen} onClose={() => setEmojiOpen(false)} className="w-80">
<EmojiPicker custom={customEmojis} spaceName={spaceName} onPick={insertEmoji} />
</Popover>
</div>
<p id={helpId} className="chat-composer-help">
<span>
<kbd>Enter</kbd> {t('chat.composer.enterSends')}
</span>
<span>
<kbd>Shift + Enter</kbd> {t('chat.composer.shiftEnterNewline')}
</span>
</p>
<button
type="button"
aria-label={t('chat.composer.sendAria')}
title={
slowModeRemaining > 0
? t('chat.composer.slowModeWait', { seconds: slowModeRemaining })
: blocked
? blocked
: over
? t('chat.composer.tooLongBy', { count: length - MAX_MESSAGE_LEN })
: t('chat.composer.sendWithEnter')
}
disabled={!canSend}
onClick={submit}
className="chat-send-button"
>
{slowModeRemaining > 0 ? (
<span className="text-[11px] font-bold tabular-nums">{slowModeRemaining}</span>
) : (
<Send className="h-4 w-4" />
)}
<span>{editing ? t('chat.composer.save') : t('chat.composer.sendAria')}</span>
</button>
</div>
{(over || blocked || composer.draftNotice || (slowModeSeconds > 0 && length === 0)) && (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 pb-2 text-[11.5px]">
{over ? (
<span className="flex items-center gap-1 text-[#e5484d]">
<TriangleAlert className="h-3 w-3" />
{t('chat.composer.tooLongDetail', { over: length - MAX_MESSAGE_LEN, max: MAX_MESSAGE_LEN })}
</span>
) : null}
{blocked ? <span className="text-shell-ink-muted">{blocked}</span> : null}
{composer.draftNotice ? <span className="text-shell-ink-muted">{composer.draftNotice}</span> : null}
{slowModeSeconds > 0 && length === 0 ? (
<span className="text-shell-ink-muted">
{t('chat.composer.slowModeDetail', { seconds: slowModeSeconds })}
</span>
) : null}
</div>
)}
</div>
)
}
function ContextBar({
icon,
label,
preview,
hint,
onCancel,
}: {
icon: React.ReactNode
label: string
preview: string
hint: string
onCancel?: () => void
}) {
const { t } = useT()
return (
<div className="flex items-center gap-2 rounded-t-lg border-b border-shell-hairline bg-shell-surface px-3 py-1.5">
{icon}
<span className="shrink-0 text-[12px] font-semibold">{label}</span>
<span className="min-w-0 flex-1 truncate text-[12px] text-shell-ink-muted">
{preview || t('chat.composer.attachmentPlaceholder')}
</span>
<span className="hidden shrink-0 text-[11px] text-shell-ink-muted lg:inline">{hint}</span>
{onCancel ? (
<button
type="button"
aria-label={t('common.action.cancel')}
onClick={onCancel}
className="shrink-0 text-shell-ink-muted hover:text-shell-ink"
>
<X className="h-3.5 w-3.5" />
</button>
) : null}
</div>
)
}