22eaf0f654
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>
155 lines
4.9 KiB
TypeScript
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>
|
|
);
|
|
}
|