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:
@@ -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[]>;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user