import { useEffect, useMemo, useRef, useState, type ChangeEvent, type FormEvent, type KeyboardEvent } from 'react'; import { SPECIAL_MENTIONS, insertMention, resolveMentions, trailingMentionQuery, } from '../mentions'; import type { Attachment, Person, SendOpts } from '../types'; interface Suggestion { key: string; label: string; insert: string; } /** Keyboard shortcut → the marker it wraps the selection in. */ const SHORTCUTS: Record = { b: '*', i: '_', e: '`' }; /** Toolbar affordances for the same markers (strikethrough is button-only — no common shortcut). */ const FORMAT_BUTTONS: Array<{ marker: string; label: string; title: string }> = [ { marker: '*', label: 'B', title: 'Bold (⌘B)' }, { marker: '_', label: 'I', title: 'Italic (⌘I)' }, { marker: '~', label: 'S', title: 'Strikethrough' }, { marker: '`', label: '‹›', title: 'Code (⌘E)' }, ]; /** * The message input: draft, @mention autocomplete, and attachment staging. Shared by the main * Thread and the ThreadPane (which passes a parentInteractionId so a reply lands in the thread). */ export function Composer({ members, canUpload, upload, onSend, onTyping, parentInteractionId, placeholder = 'Type a message… @ to mention, *bold*', }: { members: Person[]; canUpload: boolean; upload: (file: File) => Promise; onSend: (text: string, opts?: SendOpts) => Promise; onTyping?: () => void; parentInteractionId?: string; placeholder?: string; }) { const [draft, setDraft] = useState(''); const [sending, setSending] = useState(false); const [staged, setStaged] = useState(null); const [uploading, setUploading] = useState(false); const [uploadingName, setUploadingName] = useState(null); const fileRef = useRef(null); const inputRef = useRef(null); // Grow with the content up to the CSS max-height, then scroll — a chat box, not a fixed field. // The box is border-box, but scrollHeight excludes the border, so add it back or every measure // lands a couple of pixels short and the textarea shows a scrollbar it doesn't need. useEffect(() => { const el = inputRef.current; if (!el) return; el.style.height = 'auto'; const border = el.offsetHeight - el.clientHeight; el.style.height = `${el.scrollHeight + border}px`; }, [draft]); /** * Wrap the current selection in a marker (or, with nothing selected, drop in an empty pair and * park the caret inside). Text stays plain — the marker characters ARE the format, so this never * needs a rich-text model and the native spellchecker keeps working on the raw string. */ function wrapSelection(marker: string): void { const el = inputRef.current; if (!el) return; const start = el.selectionStart ?? draft.length; const end = el.selectionEnd ?? start; const selected = draft.slice(start, end); const next = `${draft.slice(0, start)}${marker}${selected}${marker}${draft.slice(end)}`; setDraft(next); // Restore a sensible selection after React re-renders the value. const caret = selected ? start + marker.length + selected.length + marker.length : start + marker.length; requestAnimationFrame(() => { el.focus(); el.setSelectionRange(selected ? start + marker.length : caret, selected ? caret - marker.length : caret); }); } const query = trailingMentionQuery(draft); const suggestions = useMemo(() => { if (query === null) return []; const q = query.toLowerCase(); const specials = SPECIAL_MENTIONS.filter((s) => s.startsWith(q)).map((s) => ({ key: `@${s}`, label: `@${s}`, insert: s })); const people = members.filter((m) => m.name.toLowerCase().includes(q)).map((m) => ({ key: m.id, label: m.name, insert: m.name })); return [...specials, ...people].slice(0, 6); }, [query, members]); const showSuggest = query !== null && suggestions.length > 0; function pick(insert: string): void { setDraft((d) => insertMention(d, insert)); } async function onPickFile(e: ChangeEvent): Promise { const file = e.target.files?.[0]; e.target.value = ''; if (!file) return; setUploading(true); setUploadingName(file.name); try { setStaged(await upload(file)); } catch { /* host surfaces upload errors */ } finally { setUploading(false); setUploadingName(null); } } async function submit(e?: FormEvent): Promise { e?.preventDefault(); const text = draft.trim(); if ((!text && !staged) || sending) return; const mentions = resolveMentions(text, members); const att = staged; setDraft(''); setStaged(null); setSending(true); try { await onSend(text, { ...(mentions.length ? { mentions } : {}), ...(att ? { attachment: att } : {}), ...(parentInteractionId ? { parentInteractionId } : {}), }); } catch { setStaged(att); } finally { setSending(false); } } function onKeyDown(e: KeyboardEvent): void { // Formatting shortcuts, matching the toolbar. Cmd on macOS, Ctrl elsewhere. if (e.metaKey || e.ctrlKey) { const marker = SHORTCUTS[e.key.toLowerCase()]; if (marker) { e.preventDefault(); wrapSelection(marker); return; } } if (e.key !== 'Enter') return; if (showSuggest) { e.preventDefault(); pick(suggestions[0]!.insert); return; } // Enter sends; Shift+Enter (and IME composition) inserts a newline. if (!e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); void submit(); } } return (
{showSuggest ? (
    {suggestions.map((s) => (
  • ))}
) : null} {uploading && uploadingName ? (
) : staged ? (
📎 {staged.name}
) : null}
{FORMAT_BUTTONS.map((b) => ( ))}
{canUpload ? ( <> ) : null}