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>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@insignia/iios-messaging-ui",
|
"name": "@insignia/iios-messaging-ui",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"module": "dist/index.js",
|
"module": "dist/index.js",
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export function Composer({
|
|||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [staged, setStaged] = useState<Attachment | null>(null);
|
const [staged, setStaged] = useState<Attachment | null>(null);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [uploadingName, setUploadingName] = useState<string | null>(null);
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const query = trailingMentionQuery(draft);
|
const query = trailingMentionQuery(draft);
|
||||||
@@ -59,12 +60,14 @@ export function Composer({
|
|||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
|
setUploadingName(file.name);
|
||||||
try {
|
try {
|
||||||
setStaged(await upload(file));
|
setStaged(await upload(file));
|
||||||
} catch {
|
} catch {
|
||||||
/* host surfaces upload errors */
|
/* host surfaces upload errors */
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
|
setUploadingName(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +113,11 @@ export function Composer({
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
) : null}
|
) : null}
|
||||||
{staged ? (
|
{uploading && uploadingName ? (
|
||||||
|
<div className="miu-staged is-uploading">
|
||||||
|
<span className="miu-spinner" aria-hidden="true" /> {uploadingName} · uploading…
|
||||||
|
</div>
|
||||||
|
) : staged ? (
|
||||||
<div className="miu-staged">
|
<div className="miu-staged">
|
||||||
📎 {staged.name}
|
📎 {staged.name}
|
||||||
<button type="button" className="miu-staged-x" onClick={() => setStaged(null)} aria-label="Remove attachment">
|
<button type="button" className="miu-staged-x" onClick={() => setStaged(null)} aria-label="Remove attachment">
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ export function MailReader({ threadId, subject }: { threadId: string; subject: s
|
|||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [pending, setPending] = useState<MailAttachment | null>(null);
|
const [pending, setPending] = useState<MailAttachment | null>(null);
|
||||||
const [attaching, setAttaching] = useState(false);
|
const [attaching, setAttaching] = useState(false);
|
||||||
|
const [uploadingName, setUploadingName] = useState<string | null>(null);
|
||||||
const [attachErr, setAttachErr] = useState<string | null>(null);
|
const [attachErr, setAttachErr] = useState<string | null>(null);
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -139,6 +140,7 @@ export function MailReader({ threadId, subject }: { threadId: string; subject: s
|
|||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
setAttaching(true);
|
setAttaching(true);
|
||||||
|
setUploadingName(file.name);
|
||||||
setAttachErr(null);
|
setAttachErr(null);
|
||||||
try {
|
try {
|
||||||
setPending(await upload(file));
|
setPending(await upload(file));
|
||||||
@@ -146,6 +148,7 @@ export function MailReader({ threadId, subject }: { threadId: string; subject: s
|
|||||||
setAttachErr(err instanceof Error ? err.message : String(err));
|
setAttachErr(err instanceof Error ? err.message : String(err));
|
||||||
} finally {
|
} finally {
|
||||||
setAttaching(false);
|
setAttaching(false);
|
||||||
|
setUploadingName(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,7 +201,11 @@ export function MailReader({ threadId, subject }: { threadId: string; subject: s
|
|||||||
</div>
|
</div>
|
||||||
<form className="miu-composer" onSubmit={submit}>
|
<form className="miu-composer" onSubmit={submit}>
|
||||||
{attachErr ? <div className="miu-empty miu-error">{attachErr}</div> : null}
|
{attachErr ? <div className="miu-empty miu-error">{attachErr}</div> : null}
|
||||||
{pending ? (
|
{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">
|
<div className="miu-attach-pending">
|
||||||
<span className="miu-attach-chip">📎 {pending.filename ?? 'attachment'}{pending.sizeBytes ? ` · ${fmtBytes(pending.sizeBytes)}` : ''}</span>
|
<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>
|
<button type="button" className="miu-attach-x" onClick={() => setPending(null)} aria-label="Remove attachment">✕</button>
|
||||||
@@ -232,6 +239,7 @@ function ComposeModal({ onClose, onSent }: { onClose: () => void; onSent: () =>
|
|||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
const [attachments, setAttachments] = useState<MailAttachment[]>([]);
|
const [attachments, setAttachments] = useState<MailAttachment[]>([]);
|
||||||
const [attaching, setAttaching] = useState(false);
|
const [attaching, setAttaching] = useState(false);
|
||||||
|
const [uploadingName, setUploadingName] = useState<string | null>(null);
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const filtered = compose.directory.filter((p) => p.name.toLowerCase().includes(q.trim().toLowerCase()));
|
const filtered = compose.directory.filter((p) => p.name.toLowerCase().includes(q.trim().toLowerCase()));
|
||||||
@@ -242,6 +250,7 @@ function ComposeModal({ onClose, onSent }: { onClose: () => void; onSent: () =>
|
|||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
if (!file || attachments.length >= 10) return;
|
if (!file || attachments.length >= 10) return;
|
||||||
setAttaching(true);
|
setAttaching(true);
|
||||||
|
setUploadingName(file.name);
|
||||||
setErr(null);
|
setErr(null);
|
||||||
try {
|
try {
|
||||||
const ref = await compose.upload(file);
|
const ref = await compose.upload(file);
|
||||||
@@ -250,6 +259,7 @@ function ComposeModal({ onClose, onSent }: { onClose: () => void; onSent: () =>
|
|||||||
setErr(e2 instanceof Error ? e2.message : String(e2));
|
setErr(e2 instanceof Error ? e2.message : String(e2));
|
||||||
} finally {
|
} finally {
|
||||||
setAttaching(false);
|
setAttaching(false);
|
||||||
|
setUploadingName(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,9 +333,14 @@ function ComposeModal({ onClose, onSent }: { onClose: () => void; onSent: () =>
|
|||||||
<button type="button" className="miu-attach-x" onClick={() => setAttachments((prev) => prev.filter((_, j) => j !== i))} aria-label="Remove attachment">✕</button>
|
<button type="button" className="miu-attach-x" onClick={() => setAttachments((prev) => prev.filter((_, j) => j !== i))} aria-label="Remove attachment">✕</button>
|
||||||
</span>
|
</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" />
|
<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}>
|
<button type="button" className="miu-tab" onClick={() => fileRef.current?.click()} disabled={attaching || attachments.length >= 10}>
|
||||||
{attaching ? 'Uploading…' : '📎 Attach'}
|
📎 Attach
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -368,6 +368,25 @@ button.miu-attach-dl:hover {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
.miu-attach-chip.is-uploading,
|
||||||
|
.miu-staged.is-uploading {
|
||||||
|
color: var(--miu-muted);
|
||||||
|
}
|
||||||
|
.miu-spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border: 2px solid var(--miu-border);
|
||||||
|
border-top-color: var(--miu-accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: miu-spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes miu-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.miu-spinner { animation-duration: 2s; }
|
||||||
|
}
|
||||||
.miu-attach-x {
|
.miu-attach-x {
|
||||||
border: none;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
|
|||||||
Reference in New Issue
Block a user