feat(messaging-ui): mail attachment download + scrollable reader (0.1.1)

- Attachment chips in the mail reader are now clickable: new InboxAdapter
  downloadAttachment() resolves a short-lived URL, opened in a new tab.
- Mail reader scrolls again: convert the .miu-detail/.miu-mail height:100%
  chain to flex fill so a long thread scrolls within the pane instead of
  being clipped. MockInboxAdapter implements download. Bump to 0.1.1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 00:53:08 +05:30
parent c4254749a4
commit 8878ee8c54
6 changed files with 58 additions and 7 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@insignia/iios-messaging-ui", "name": "@insignia/iios-messaging-ui",
"version": "0.1.0", "version": "0.1.1",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"module": "dist/index.js", "module": "dist/index.js",
@@ -88,6 +88,11 @@ export class MockInboxAdapter implements InboxAdapter {
return { contentRef: `mock/${this.seq++}`, mimeType: file.type || 'application/octet-stream', sizeBytes: file.size, filename: file.name }; return { contentRef: `mock/${this.seq++}`, mimeType: file.type || 'application/octet-stream', sizeBytes: file.size, filename: file.name };
} }
async downloadAttachment(attachment: MailAttachment): Promise<string> {
// Demo: no real bytes — hand back a data URL so the click resolves without a network call.
return `data:${attachment.mimeType};base64,`;
}
async directory(): Promise<MailPerson[]> { async directory(): Promise<MailPerson[]> {
return [...PEOPLE]; return [...PEOPLE];
} }
@@ -22,6 +22,10 @@ export interface InboxAdapter {
/** Upload a file to storage, returning a reference to send with a reply/compose. */ /** Upload a file to storage, returning a reference to send with a reply/compose. */
uploadAttachment?(file: File): Promise<MailAttachment>; uploadAttachment?(file: File): Promise<MailAttachment>;
/** Resolve a short-lived URL to view/download an attachment. Absent => attachment chips are
* shown but not clickable. */
downloadAttachment?(attachment: MailAttachment): Promise<string>;
// ── Compose (optional) — absent hides the "New message" affordance ── // ── Compose (optional) — absent hides the "New message" affordance ──
/** People you can compose an in-app message to. */ /** People you can compose an in-app message to. */
directory?(): Promise<MailPerson[]>; directory?(): Promise<MailPerson[]>;
+20 -1
View File
@@ -60,6 +60,8 @@ export interface MailThreadState {
reply: (content: string, attachment?: MailAttachment) => Promise<void>; reply: (content: string, attachment?: MailAttachment) => Promise<void>;
canAttach: boolean; canAttach: boolean;
upload: (file: File) => Promise<MailAttachment>; upload: (file: File) => Promise<MailAttachment>;
canDownload: boolean;
download: (attachment: MailAttachment) => Promise<string>;
refetch: () => void; refetch: () => void;
} }
@@ -113,8 +115,25 @@ export function useMailThread(threadId: string | null): MailThreadState {
}, },
[adapter], [adapter],
); );
const download = useCallback(
async (attachment: MailAttachment) => {
if (!adapter.downloadAttachment) throw new Error('attachment download is not supported by this adapter');
return adapter.downloadAttachment(attachment);
},
[adapter],
);
return { messages, loading, error, reply, canAttach: typeof adapter.uploadAttachment === 'function', upload, refetch }; return {
messages,
loading,
error,
reply,
canAttach: typeof adapter.uploadAttachment === 'function',
upload,
canDownload: typeof adapter.downloadAttachment === 'function',
download,
refetch,
};
} }
export interface ComposeState { export interface ComposeState {
+17 -2
View File
@@ -117,7 +117,7 @@ function Detail({ item, onTransition }: { item: InboxItem; onTransition: (state:
/** Read a mail thread (HTML in a sandboxed iframe) + reply. */ /** Read a mail thread (HTML in a sandboxed iframe) + reply. */
export function MailReader({ threadId, subject }: { threadId: string; subject: string }) { export function MailReader({ threadId, subject }: { threadId: string; subject: string }) {
const { messages, loading, error, reply, canAttach, upload } = useMailThread(threadId); const { messages, loading, error, reply, canAttach, upload, canDownload, download } = useMailThread(threadId);
const [draft, setDraft] = useState(''); const [draft, setDraft] = useState('');
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [pending, setPending] = useState<MailAttachment | null>(null); const [pending, setPending] = useState<MailAttachment | null>(null);
@@ -125,6 +125,15 @@ export function MailReader({ threadId, subject }: { threadId: string; subject: s
const [attachErr, setAttachErr] = useState<string | null>(null); const [attachErr, setAttachErr] = useState<string | null>(null);
const fileRef = useRef<HTMLInputElement>(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> { async function pick(e: React.ChangeEvent<HTMLInputElement>): Promise<void> {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
e.target.value = ''; e.target.value = '';
@@ -176,7 +185,13 @@ export function MailReader({ threadId, subject }: { threadId: string; subject: s
<div className="miu-mail-text">{m.text}</div> <div className="miu-mail-text">{m.text}</div>
) : null} ) : null}
{m.attachment ? ( {m.attachment ? (
<span className="miu-attach-chip">📎 {m.attachment.filename ?? 'attachment'}{m.attachment.sizeBytes ? ` · ${fmtBytes(m.attachment.sizeBytes)}` : ''}</span> 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} ) : null}
</article> </article>
))} ))}
+11 -3
View File
@@ -353,7 +353,15 @@
white-space: nowrap; white-space: nowrap;
} }
.miu-mail-msg .miu-attach-chip { .miu-mail-msg .miu-attach-chip {
margin-top: 6px; margin: 8px 12px 12px;
}
button.miu-attach-dl {
cursor: pointer;
color: var(--miu-text);
}
button.miu-attach-dl:hover {
border-color: var(--miu-accent);
color: var(--miu-accent);
} }
.miu-attach-pending { .miu-attach-pending {
display: inline-flex; display: inline-flex;
@@ -763,7 +771,7 @@
.miu-detail { .miu-detail {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; flex: 1;
min-height: 0; min-height: 0;
} }
.miu-detail-actions { .miu-detail-actions {
@@ -790,7 +798,7 @@
.miu-mail { .miu-mail {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; flex: 1;
min-height: 0; min-height: 0;
} }
.miu-mail-head { .miu-mail-head {