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>
359 lines
16 KiB
TypeScript
359 lines
16 KiB
TypeScript
import { useEffect, useRef, useState, type FormEvent } from 'react';
|
|
import { ModalPortal } from '../components/modal-portal';
|
|
import { useCompose, useInbox, useMailThread } from './hooks';
|
|
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' },
|
|
{ value: 'SNOOZED', label: 'Snoozed' },
|
|
{ value: 'DONE', label: 'Done' },
|
|
{ value: 'ARCHIVED', label: 'Archived' },
|
|
];
|
|
|
|
const KIND_LABEL: Record<string, string> = {
|
|
MAIL: 'Mail',
|
|
MENTION: 'Mention',
|
|
NEEDS_REPLY: 'Needs reply',
|
|
SYSTEM_ALERT: 'Alert',
|
|
SUPPORT_UPDATE: 'Support',
|
|
};
|
|
|
|
const timeOf = (iso?: string): string => {
|
|
if (!iso) return '';
|
|
const d = new Date(iso);
|
|
return Number.isNaN(+d) ? '' : d.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
|
};
|
|
|
|
/** The unified inbox: work items + mail in one list; click a threaded row to read + reply. */
|
|
export function Inbox() {
|
|
const [filter, setFilter] = useState<InboxState>('OPEN');
|
|
const { items, loading, error, transition, refetch } = useInbox(filter);
|
|
const compose = useCompose();
|
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
const [composing, setComposing] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (selectedId && items.some((i) => i.id === selectedId)) return;
|
|
setSelectedId(items[0]?.id ?? null);
|
|
}, [items, selectedId]);
|
|
|
|
const selected = items.find((i) => i.id === selectedId) ?? null;
|
|
|
|
return (
|
|
<div className="miu-inbox">
|
|
<div className="miu-inbox-bar">
|
|
<div className="miu-inbox-filters">
|
|
{FILTERS.map((f) => (
|
|
<button key={f.value} type="button" className={`miu-tab${filter === f.value ? ' is-active' : ''}`} onClick={() => setFilter(f.value)}>
|
|
{f.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
{compose.supported ? (
|
|
<button type="button" className="miu-send" onClick={() => setComposing(true)}>
|
|
New message
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="miu-inbox-body">
|
|
<aside className="miu-inbox-list">
|
|
{loading && items.length === 0 ? <div className="miu-empty">Loading…</div> : null}
|
|
{error ? <div className="miu-empty miu-error">{error}</div> : null}
|
|
{!loading && items.length === 0 ? <div className="miu-empty">Nothing here — you're all caught up 🎉</div> : null}
|
|
{items.map((it) => (
|
|
<button key={it.id} type="button" className={`miu-inbox-row${it.id === selectedId ? ' is-active' : ''}`} onClick={() => setSelectedId(it.id)}>
|
|
<span className="miu-inbox-glyph" aria-hidden="true">{it.kind === 'MAIL' ? '✉' : it.kind === 'MENTION' ? '@' : '•'}</span>
|
|
<span className="miu-inbox-main">
|
|
<span className="miu-inbox-row-top">
|
|
<span className="miu-pill">{KIND_LABEL[it.kind] ?? it.kind}</span>
|
|
<span className="miu-inbox-title">{it.title}</span>
|
|
</span>
|
|
{it.summary ? <span className="miu-inbox-summary">{it.summary}</span> : null}
|
|
</span>
|
|
{it.state !== 'OPEN' ? <span className="miu-pill">{it.state.toLowerCase()}</span> : null}
|
|
</button>
|
|
))}
|
|
</aside>
|
|
|
|
<section className="miu-inbox-detail">
|
|
{selected ? <Detail item={selected} onTransition={(s) => void transition(selected.id, s)} /> : <div className="miu-empty miu-thread-empty">Select an item to read.</div>}
|
|
</section>
|
|
</div>
|
|
|
|
{composing ? <ComposeModal onClose={() => setComposing(false)} onSent={() => { setComposing(false); refetch(); }} /> : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Detail({ item, onTransition }: { item: InboxItem; onTransition: (state: InboxState) => void }) {
|
|
return (
|
|
<div className="miu-detail">
|
|
{item.state === 'OPEN' && item.kind !== 'MAIL' ? (
|
|
<div className="miu-detail-actions">
|
|
<button type="button" className="miu-tab" onClick={() => onTransition('SNOOZED')}>Snooze</button>
|
|
<button type="button" className="miu-tab" onClick={() => onTransition('DONE')}>Done</button>
|
|
<button type="button" className="miu-tab" onClick={() => onTransition('ARCHIVED')}>Archive</button>
|
|
</div>
|
|
) : null}
|
|
{item.threadId ? (
|
|
<MailReader threadId={item.threadId} subject={item.title} />
|
|
) : (
|
|
<div className="miu-detail-body">
|
|
<div className="miu-detail-title">{item.title}</div>
|
|
{item.summary ? <div className="miu-detail-summary">{item.summary}</div> : null}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Read a mail thread (HTML in a sandboxed iframe) + reply. */
|
|
export function MailReader({ threadId, subject }: { threadId: string; subject: string }) {
|
|
const { messages, loading, error, reply, canAttach, upload, canDownload, download } = useMailThread(threadId);
|
|
const [draft, setDraft] = useState('');
|
|
const [sending, setSending] = useState(false);
|
|
const [pending, setPending] = useState<MailAttachment | null>(null);
|
|
const [attaching, setAttaching] = useState(false);
|
|
const [uploadingName, setUploadingName] = useState<string | null>(null);
|
|
const [attachErr, setAttachErr] = useState<string | null>(null);
|
|
const fileRef = useRef<HTMLInputElement>(null);
|
|
|
|
async function openAttachment(att: MailAttachment): Promise<void> {
|
|
try {
|
|
const url = await download(att);
|
|
window.open(url, '_blank', 'noopener,noreferrer');
|
|
} catch (err) {
|
|
setAttachErr(err instanceof Error ? err.message : String(err));
|
|
}
|
|
}
|
|
|
|
async function pick(e: React.ChangeEvent<HTMLInputElement>): Promise<void> {
|
|
const file = e.target.files?.[0];
|
|
e.target.value = '';
|
|
if (!file) return;
|
|
setAttaching(true);
|
|
setUploadingName(file.name);
|
|
setAttachErr(null);
|
|
try {
|
|
setPending(await upload(file));
|
|
} catch (err) {
|
|
setAttachErr(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setAttaching(false);
|
|
setUploadingName(null);
|
|
}
|
|
}
|
|
|
|
async function submit(e: FormEvent): Promise<void> {
|
|
e.preventDefault();
|
|
const text = draft.trim();
|
|
if ((!text && !pending) || sending) return;
|
|
const att = pending;
|
|
setDraft('');
|
|
setPending(null);
|
|
setSending(true);
|
|
try {
|
|
await reply(text, att ?? undefined);
|
|
} catch {
|
|
setDraft(text);
|
|
setPending(att);
|
|
} finally {
|
|
setSending(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="miu-mail">
|
|
<header className="miu-mail-head">{subject || '(no subject)'}</header>
|
|
<div className="miu-mail-body">
|
|
{loading && messages.length === 0 ? <div className="miu-empty">Loading…</div> : null}
|
|
{error ? <div className="miu-empty miu-error">{error}</div> : null}
|
|
{messages.map((m) => (
|
|
<article key={m.id} className="miu-mail-msg">
|
|
<div className="miu-mail-meta">
|
|
<span>{m.kind === 'EMAIL' ? 'Email' : 'Reply'}{m.actorId ? ` · ${m.actorId}` : ''}</span>
|
|
<span>{timeOf(m.at)}</span>
|
|
</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 ? (
|
|
canDownload ? (
|
|
<button type="button" className="miu-attach-chip miu-attach-dl" onClick={() => void openAttachment(m.attachment!)} title="Download">
|
|
📎 {m.attachment.filename ?? 'attachment'}{m.attachment.sizeBytes ? ` · ${fmtBytes(m.attachment.sizeBytes)}` : ''} ↓
|
|
</button>
|
|
) : (
|
|
<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}
|
|
{attaching && uploadingName ? (
|
|
<div className="miu-attach-pending">
|
|
<span className="miu-attach-chip is-uploading"><span className="miu-spinner" aria-hidden="true" /> {uploadingName} · uploading…</span>
|
|
</div>
|
|
) : 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() && !pending) || sending}>Reply</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ComposeModal({ onClose, onSent }: { onClose: () => void; onSent: () => void }) {
|
|
const compose = useCompose();
|
|
const [mode, setMode] = useState<'internal' | 'external'>('internal');
|
|
const [recipient, setRecipient] = useState('');
|
|
const [subject, setSubject] = useState('');
|
|
const [body, setBody] = useState('');
|
|
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 [uploadingName, setUploadingName] = useState<string | null>(null);
|
|
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() || 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);
|
|
setUploadingName(file.name);
|
|
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);
|
|
setUploadingName(null);
|
|
}
|
|
}
|
|
|
|
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(), atts);
|
|
else await compose.sendExternal(recipient.trim(), subject.trim(), body.trim(), atts);
|
|
onSent();
|
|
} catch (e) {
|
|
setErr(e instanceof Error ? e.message : String(e));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<ModalPortal>
|
|
<div className="miu-modal-overlay" onMouseDown={onClose}>
|
|
<div className="miu-modal" role="dialog" aria-modal="true" onMouseDown={(e) => e.stopPropagation()}>
|
|
<div className="miu-modal-head">
|
|
<span>New message</span>
|
|
<button type="button" className="miu-pane-close" onClick={onClose} aria-label="Close">✕</button>
|
|
</div>
|
|
<div className="miu-modal-body">
|
|
<div className="miu-compose-modes">
|
|
<button type="button" className={`miu-tab${mode === 'internal' ? ' is-active' : ''}`} onClick={() => { setMode('internal'); setRecipient(''); }}>In-app</button>
|
|
<button type="button" className={`miu-tab${mode === 'external' ? ' is-active' : ''}`} onClick={() => { setMode('external'); setRecipient(''); }}>Email</button>
|
|
</div>
|
|
|
|
{mode === 'internal' ? (
|
|
<div className="miu-field">
|
|
<span className="miu-field-lbl">To (person)</span>
|
|
<input className="miu-input" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search people…" aria-label="Search people" />
|
|
<div className="miu-people">
|
|
{filtered.length === 0 ? <div className="miu-empty">No people found.</div> : null}
|
|
{filtered.map((p: MailPerson) => (
|
|
<label key={p.id} className={`miu-person${recipient === p.id ? ' is-active' : ''}`}>
|
|
<input type="radio" name="miu-recipient" checked={recipient === p.id} onChange={() => setRecipient(p.id)} />
|
|
<span>{p.name}</span>
|
|
<span className="miu-pill">{p.kind}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="miu-field">
|
|
<span className="miu-field-lbl">To (email)</span>
|
|
<input className="miu-input" value={recipient} onChange={(e) => setRecipient(e.target.value)} placeholder="name@company.com" aria-label="To email" />
|
|
</div>
|
|
)}
|
|
|
|
<div className="miu-field">
|
|
<span className="miu-field-lbl">Subject</span>
|
|
<input className="miu-input" value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="Subject" aria-label="Subject" />
|
|
</div>
|
|
<div className="miu-field">
|
|
<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>
|
|
))}
|
|
{attaching && uploadingName ? (
|
|
<span className="miu-attach-pending">
|
|
<span className="miu-attach-chip is-uploading"><span className="miu-spinner" aria-hidden="true" /> {uploadingName} · uploading…</span>
|
|
</span>
|
|
) : null}
|
|
<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}>
|
|
📎 Attach
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
{err ? <div className="miu-empty miu-error">{err}</div> : null}
|
|
</div>
|
|
<div className="miu-modal-foot">
|
|
<button type="button" className="miu-tab" onClick={onClose}>Cancel</button>
|
|
<button type="button" className="miu-send" onClick={() => void send()} disabled={!canSend}>{busy ? 'Sending…' : 'Send'}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</ModalPortal>
|
|
);
|
|
}
|