feat(messaging-ui): attachments in inbox mail — reply + compose upload
InboxAdapter gains uploadAttachment + attachment params on mailReply/ composeInternal/composeExternal. MailReader reply and ComposeModal grow an attach affordance (paperclip + pending chips); mail history renders sent attachments. MockInboxAdapter implements upload. 67 tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,13 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { useEffect, useRef, useState, type FormEvent } from 'react';
|
||||
import { useCompose, useInbox, useMailThread } from './hooks';
|
||||
import type { InboxItem, InboxState, MailPerson } from './types';
|
||||
import type { InboxItem, InboxState, MailAttachment, MailPerson } from './types';
|
||||
|
||||
const fmtBytes = (n: number): string => {
|
||||
if (!n) return '';
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
|
||||
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
const FILTERS: { value: InboxState; label: string }[] = [
|
||||
{ value: 'OPEN', label: 'Open' },
|
||||
@@ -109,20 +116,42 @@ function Detail({ item, onTransition }: { item: InboxItem; onTransition: (state:
|
||||
|
||||
/** Read a mail thread (HTML in a sandboxed iframe) + reply. */
|
||||
export function MailReader({ threadId, subject }: { threadId: string; subject: string }) {
|
||||
const { messages, loading, error, reply } = useMailThread(threadId);
|
||||
const { messages, loading, error, reply, canAttach, upload } = useMailThread(threadId);
|
||||
const [draft, setDraft] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [pending, setPending] = useState<MailAttachment | null>(null);
|
||||
const [attaching, setAttaching] = useState(false);
|
||||
const [attachErr, setAttachErr] = useState<string | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
async function pick(e: React.ChangeEvent<HTMLInputElement>): Promise<void> {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
setAttaching(true);
|
||||
setAttachErr(null);
|
||||
try {
|
||||
setPending(await upload(file));
|
||||
} catch (err) {
|
||||
setAttachErr(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setAttaching(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(e: FormEvent): Promise<void> {
|
||||
e.preventDefault();
|
||||
const text = draft.trim();
|
||||
if (!text || sending) return;
|
||||
if ((!text && !pending) || sending) return;
|
||||
const att = pending;
|
||||
setDraft('');
|
||||
setPending(null);
|
||||
setSending(true);
|
||||
try {
|
||||
await reply(text);
|
||||
await reply(text, att ?? undefined);
|
||||
} catch {
|
||||
setDraft(text);
|
||||
setPending(att);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
@@ -142,16 +171,34 @@ export function MailReader({ threadId, subject }: { threadId: string; subject: s
|
||||
</div>
|
||||
{m.html ? (
|
||||
<iframe sandbox="" srcDoc={m.html} title="mail body" className="miu-mail-frame" />
|
||||
) : (
|
||||
) : m.text ? (
|
||||
<div className="miu-mail-text">{m.text}</div>
|
||||
)}
|
||||
) : null}
|
||||
{m.attachment ? (
|
||||
<span className="miu-attach-chip">📎 {m.attachment.filename ?? 'attachment'}{m.attachment.sizeBytes ? ` · ${fmtBytes(m.attachment.sizeBytes)}` : ''}</span>
|
||||
) : null}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<form className="miu-composer" onSubmit={submit}>
|
||||
{attachErr ? <div className="miu-empty miu-error">{attachErr}</div> : null}
|
||||
{pending ? (
|
||||
<div className="miu-attach-pending">
|
||||
<span className="miu-attach-chip">📎 {pending.filename ?? 'attachment'}{pending.sizeBytes ? ` · ${fmtBytes(pending.sizeBytes)}` : ''}</span>
|
||||
<button type="button" className="miu-attach-x" onClick={() => setPending(null)} aria-label="Remove attachment">✕</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="miu-composer-row">
|
||||
{canAttach ? (
|
||||
<>
|
||||
<input ref={fileRef} type="file" hidden onChange={pick} aria-label="Attach file" />
|
||||
<button type="button" className="miu-attach-btn" onClick={() => fileRef.current?.click()} disabled={attaching || !!pending} title="Attach a file" aria-label="Attach a file">
|
||||
{attaching ? '…' : '📎'}
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
<input className="miu-input" value={draft} onChange={(e) => setDraft(e.target.value)} placeholder="Reply…" aria-label="Reply" />
|
||||
<button type="submit" className="miu-send" disabled={!draft.trim() || sending}>Reply</button>
|
||||
<button type="submit" className="miu-send" disabled={(!draft.trim() && !pending) || sending}>Reply</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -167,17 +214,37 @@ function ComposeModal({ onClose, onSent }: { onClose: () => void; onSent: () =>
|
||||
const [q, setQ] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [attachments, setAttachments] = useState<MailAttachment[]>([]);
|
||||
const [attaching, setAttaching] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const filtered = compose.directory.filter((p) => p.name.toLowerCase().includes(q.trim().toLowerCase()));
|
||||
const canSend = !!recipient && !!subject.trim() && !!body.trim() && !busy;
|
||||
const canSend = !!recipient && !!subject.trim() && (!!body.trim() || attachments.length > 0) && !busy && !attaching;
|
||||
|
||||
async function pick(e: React.ChangeEvent<HTMLInputElement>): Promise<void> {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!file || attachments.length >= 10) return;
|
||||
setAttaching(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const ref = await compose.upload(file);
|
||||
setAttachments((a) => [...a, ref]);
|
||||
} catch (e2) {
|
||||
setErr(e2 instanceof Error ? e2.message : String(e2));
|
||||
} finally {
|
||||
setAttaching(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function send(): Promise<void> {
|
||||
if (!canSend) return;
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
const atts = attachments.length > 0 ? attachments : undefined;
|
||||
try {
|
||||
if (mode === 'internal') await compose.sendInternal(recipient, subject.trim(), body.trim());
|
||||
else await compose.sendExternal(recipient.trim(), subject.trim(), body.trim());
|
||||
if (mode === 'internal') await compose.sendInternal(recipient, subject.trim(), body.trim(), atts);
|
||||
else await compose.sendExternal(recipient.trim(), subject.trim(), body.trim(), atts);
|
||||
onSent();
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e));
|
||||
@@ -229,6 +296,23 @@ function ComposeModal({ onClose, onSent }: { onClose: () => void; onSent: () =>
|
||||
<span className="miu-field-lbl">Message</span>
|
||||
<textarea className="miu-input miu-textarea" value={body} onChange={(e) => setBody(e.target.value)} placeholder="Write your message…" rows={5} aria-label="Message body" />
|
||||
</div>
|
||||
{compose.canAttach ? (
|
||||
<div className="miu-field">
|
||||
<span className="miu-field-lbl">Attachments</span>
|
||||
<div className="miu-attach-list">
|
||||
{attachments.map((a, i) => (
|
||||
<span key={`${a.contentRef}-${i}`} className="miu-attach-pending">
|
||||
<span className="miu-attach-chip">📎 {a.filename ?? 'attachment'}{a.sizeBytes ? ` · ${fmtBytes(a.sizeBytes)}` : ''}</span>
|
||||
<button type="button" className="miu-attach-x" onClick={() => setAttachments((prev) => prev.filter((_, j) => j !== i))} aria-label="Remove attachment">✕</button>
|
||||
</span>
|
||||
))}
|
||||
<input ref={fileRef} type="file" hidden onChange={pick} aria-label="Attach file" />
|
||||
<button type="button" className="miu-tab" onClick={() => fileRef.current?.click()} disabled={attaching || attachments.length >= 10}>
|
||||
{attaching ? 'Uploading…' : '📎 Attach'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{err ? <div className="miu-empty miu-error">{err}</div> : null}
|
||||
</div>
|
||||
<div className="miu-modal-foot">
|
||||
|
||||
Reference in New Issue
Block a user