Files
iios/packages/iios-messaging-ui/src/components/composer.tsx
T
maaz519 22eaf0f654 feat(messaging-ui): Gmail-style uploading chip while an attachment uploads (0.1.2)
Picking a file now shows an immediate chip with the filename + a spinner while
the bytes upload (mail compose, mail reply, and the messenger composer), instead
of only appearing once upload finishes. Respects prefers-reduced-motion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 01:04:52 +05:30

155 lines
4.9 KiB
TypeScript

import { 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;
}
/**
* 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',
}: {
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 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<HTMLInputElement>): void {
if (showSuggest && e.key === 'Enter') {
e.preventDefault();
pick(suggestions[0]!.insert);
}
}
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-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}
<input
className="miu-input"
value={draft}
placeholder={placeholder}
aria-label="Message"
onChange={(e) => {
setDraft(e.target.value);
onTyping?.();
}}
onKeyDown={onKeyDown}
/>
<button type="submit" className="miu-send" disabled={(!draft.trim() && !staged) || sending}>
Send
</button>
</div>
</form>
);
}