Files
iios/packages/iios-messaging-ui/src/components/composer.tsx
T
maaz519 eb0ace8ad7 fix(messaging-ui): composer height was being clobbered by a class-name collision
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>
2026-07-25 17:57:23 +05:30

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>
);
}