eb0ace8ad7
The chat composer reused .miu-textarea, which the INBOX MAIL composer already owned further down the stylesheet with `resize: vertical; min-height: 90px`. Equal specificity, later rule wins — so the mail styling applied to the chat box: 90px tall with a resize grabber, and every height fix in 0.1.10–0.1.12 was silently overridden. That is why the box never changed. The composer now uses its own .miu-composer-box; the inbox rule is untouched. Adds a regression test asserting the composer does not carry .miu-textarea. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
229 lines
8.1 KiB
TypeScript
229 lines
8.1 KiB
TypeScript
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<string, string> = { 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<Attachment>;
|
|
onSend: (text: string, opts?: SendOpts) => Promise<void>;
|
|
onTyping?: () => void;
|
|
parentInteractionId?: string;
|
|
placeholder?: string;
|
|
}) {
|
|
const [draft, setDraft] = useState('');
|
|
const [sending, setSending] = useState(false);
|
|
const [staged, setStaged] = useState<Attachment | null>(null);
|
|
const [uploading, setUploading] = useState(false);
|
|
const [uploadingName, setUploadingName] = useState<string | null>(null);
|
|
const fileRef = useRef<HTMLInputElement>(null);
|
|
const inputRef = useRef<HTMLTextAreaElement>(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<Suggestion[]>(() => {
|
|
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<HTMLInputElement>): Promise<void> {
|
|
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<void> {
|
|
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<HTMLTextAreaElement>): 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 (
|
|
<form className="miu-composer" onSubmit={submit}>
|
|
{showSuggest ? (
|
|
<ul className="miu-suggest" role="listbox" aria-label="Mention suggestions">
|
|
{suggestions.map((s) => (
|
|
<li key={s.key}>
|
|
<button type="button" role="option" aria-selected="false" className="miu-suggest-item" onClick={() => pick(s.insert)}>
|
|
{s.label}
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : null}
|
|
{uploading && uploadingName ? (
|
|
<div className="miu-staged is-uploading">
|
|
<span className="miu-spinner" aria-hidden="true" /> {uploadingName} · uploading…
|
|
</div>
|
|
) : staged ? (
|
|
<div className="miu-staged">
|
|
📎 {staged.name}
|
|
<button type="button" className="miu-staged-x" onClick={() => setStaged(null)} aria-label="Remove attachment">
|
|
✕
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
<div className="miu-format-bar" role="group" aria-label="Formatting">
|
|
{FORMAT_BUTTONS.map((b) => (
|
|
<button key={b.marker} type="button" className="miu-format-btn" title={b.title} aria-label={b.title} onClick={() => wrapSelection(b.marker)}>
|
|
{b.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="miu-composer-row">
|
|
{canUpload ? (
|
|
<>
|
|
<input ref={fileRef} type="file" className="miu-file-input" onChange={onPickFile} aria-label="Attach a file" />
|
|
<button type="button" className="miu-attach-btn" title="Attach a file" disabled={uploading} onClick={() => fileRef.current?.click()}>
|
|
{uploading ? '…' : '📎'}
|
|
</button>
|
|
</>
|
|
) : null}
|
|
<textarea
|
|
ref={inputRef}
|
|
className="miu-input miu-composer-box"
|
|
value={draft}
|
|
placeholder={placeholder}
|
|
aria-label="Message"
|
|
rows={1}
|
|
// Native platform text services: the browser/OS supplies the red squiggle, the right-click
|
|
// suggestions and "Add to dictionary", and mobile keyboards add autocorrect. Nothing to ship.
|
|
spellCheck
|
|
autoCorrect="on"
|
|
autoCapitalize="sentences"
|
|
onChange={(e) => {
|
|
setDraft(e.target.value);
|
|
onTyping?.();
|
|
}}
|
|
onKeyDown={onKeyDown}
|
|
/>
|
|
<button type="submit" className="miu-send" disabled={(!draft.trim() && !staged) || sending}>
|
|
Send
|
|
</button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|