@wtfalch/email 0.5.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/mailbox/download.d.ts +43 -0
- package/dist/mailbox/download.js +68 -0
- package/dist/mailbox/index.d.ts +2 -0
- package/dist/mailbox/index.js +1 -0
- package/dist/mailbox/mail.css +4 -0
- package/dist/mailbox/react/Mail.js +36 -1
- package/dist/mailbox/react/ThreadList.js +16 -3
- package/dist/mailbox/react/ThreadView.d.ts +5 -1
- package/dist/mailbox/react/ThreadView.js +7 -4
- package/dist/mailbox/react/index.d.ts +1 -0
- package/dist/mailbox/react/index.js +1 -0
- package/dist/mailbox/react/save.d.ts +25 -0
- package/dist/mailbox/react/save.js +42 -0
- package/package.json +2 -2
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { MailClient } from './client.ts';
|
|
2
|
+
import type { Attachment } from './types.ts';
|
|
3
|
+
/**
|
|
4
|
+
* Fetching an attachment's bytes.
|
|
5
|
+
*
|
|
6
|
+
* **This module exists because a download URL is not a link.** JMAP's
|
|
7
|
+
* `downloadUrl` carries no credential of its own — RFC 8620 §6.2 expects the
|
|
8
|
+
* same authentication the API uses — so `<a href={downloadUrl} download>` and
|
|
9
|
+
* `<img src={cid}>` both fetch it without an `Authorization` header and get a
|
|
10
|
+
* 401. The bytes have to be fetched by code that holds the token, and the
|
|
11
|
+
* result handed to the browser as a blob it already has.
|
|
12
|
+
*
|
|
13
|
+
* That is the whole reason attachments were unreachable: `ThreadView` has
|
|
14
|
+
* taken an `onDownload` since it was written, and nothing could supply one
|
|
15
|
+
* without this.
|
|
16
|
+
*
|
|
17
|
+
* **`credentials: 'omit'`, for the reason `client.ts` gives at length.** A
|
|
18
|
+
* refused request makes Stalwart answer with `WWW-Authenticate: Basic` as
|
|
19
|
+
* well as `Bearer`, and a browser allowed to use credentials honours the
|
|
20
|
+
* second by opening its native password prompt — a modal that hides whatever
|
|
21
|
+
* the application was about to say. Declining that here keeps a 401 an
|
|
22
|
+
* ordinary response.
|
|
23
|
+
*/
|
|
24
|
+
/** How much of a message this will pull into memory before refusing. A mail
|
|
25
|
+
* server will happily serve a 2GB blob and a browser tab will not survive
|
|
26
|
+
* holding one; Stalwart's own default message size limit is well under this.
|
|
27
|
+
* A caller that genuinely wants more passes its own `maxBytes`. */
|
|
28
|
+
export declare const DEFAULT_MAX_DOWNLOAD_BYTES: number;
|
|
29
|
+
export type DownloadOptions = {
|
|
30
|
+
/** Refuse a blob larger than this, rather than exhausting the tab. */
|
|
31
|
+
maxBytes?: number;
|
|
32
|
+
/** Abort the fetch — a person navigating away from a slow attachment. */
|
|
33
|
+
signal?: AbortSignal;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* An attachment's bytes, as a `Blob`.
|
|
37
|
+
*
|
|
38
|
+
* The type comes from the message rather than from the response: a server
|
|
39
|
+
* that sends `application/octet-stream` for everything would otherwise turn
|
|
40
|
+
* every PDF into a file the operating system cannot open, and the part's
|
|
41
|
+
* declared type is the one the sender chose.
|
|
42
|
+
*/
|
|
43
|
+
export declare function downloadAttachment(client: MailClient, attachment: Pick<Attachment, 'blobId' | 'name' | 'type' | 'size' | 'downloadUrl'>, options?: DownloadOptions): Promise<Blob>;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { MailError, guard } from "./errors.js";
|
|
2
|
+
/**
|
|
3
|
+
* Fetching an attachment's bytes.
|
|
4
|
+
*
|
|
5
|
+
* **This module exists because a download URL is not a link.** JMAP's
|
|
6
|
+
* `downloadUrl` carries no credential of its own — RFC 8620 §6.2 expects the
|
|
7
|
+
* same authentication the API uses — so `<a href={downloadUrl} download>` and
|
|
8
|
+
* `<img src={cid}>` both fetch it without an `Authorization` header and get a
|
|
9
|
+
* 401. The bytes have to be fetched by code that holds the token, and the
|
|
10
|
+
* result handed to the browser as a blob it already has.
|
|
11
|
+
*
|
|
12
|
+
* That is the whole reason attachments were unreachable: `ThreadView` has
|
|
13
|
+
* taken an `onDownload` since it was written, and nothing could supply one
|
|
14
|
+
* without this.
|
|
15
|
+
*
|
|
16
|
+
* **`credentials: 'omit'`, for the reason `client.ts` gives at length.** A
|
|
17
|
+
* refused request makes Stalwart answer with `WWW-Authenticate: Basic` as
|
|
18
|
+
* well as `Bearer`, and a browser allowed to use credentials honours the
|
|
19
|
+
* second by opening its native password prompt — a modal that hides whatever
|
|
20
|
+
* the application was about to say. Declining that here keeps a 401 an
|
|
21
|
+
* ordinary response.
|
|
22
|
+
*/
|
|
23
|
+
/** How much of a message this will pull into memory before refusing. A mail
|
|
24
|
+
* server will happily serve a 2GB blob and a browser tab will not survive
|
|
25
|
+
* holding one; Stalwart's own default message size limit is well under this.
|
|
26
|
+
* A caller that genuinely wants more passes its own `maxBytes`. */
|
|
27
|
+
export const DEFAULT_MAX_DOWNLOAD_BYTES = 100 * 1024 * 1024;
|
|
28
|
+
/**
|
|
29
|
+
* An attachment's bytes, as a `Blob`.
|
|
30
|
+
*
|
|
31
|
+
* The type comes from the message rather than from the response: a server
|
|
32
|
+
* that sends `application/octet-stream` for everything would otherwise turn
|
|
33
|
+
* every PDF into a file the operating system cannot open, and the part's
|
|
34
|
+
* declared type is the one the sender chose.
|
|
35
|
+
*/
|
|
36
|
+
export async function downloadAttachment(client, attachment, options = {}) {
|
|
37
|
+
const max = options.maxBytes ?? DEFAULT_MAX_DOWNLOAD_BYTES;
|
|
38
|
+
/* Checked before the request as well as after. The message says how big
|
|
39
|
+
the part is, and refusing on that alone saves pulling a gigabyte over
|
|
40
|
+
the wire to then refuse it. */
|
|
41
|
+
if (attachment.size > max) {
|
|
42
|
+
throw new MailError(`${attachment.name} is ${attachment.size} bytes, over the ${max}-byte limit for a download`, { type: 'tooLarge', operation: 'download' });
|
|
43
|
+
}
|
|
44
|
+
/* The URL on the attachment was expanded when the message was read. A
|
|
45
|
+
caller that built an `Attachment` by hand may not have one, so it is
|
|
46
|
+
recomputed rather than assumed. */
|
|
47
|
+
const url = attachment.downloadUrl || (await client.downloadUrl(attachment));
|
|
48
|
+
return guard('download', async () => {
|
|
49
|
+
const response = await fetch(url, {
|
|
50
|
+
headers: { Authorization: client.authorization },
|
|
51
|
+
credentials: 'omit',
|
|
52
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
53
|
+
});
|
|
54
|
+
if (!response.ok) {
|
|
55
|
+
throw new MailError(`the server refused to send ${attachment.name}`, {
|
|
56
|
+
status: response.status,
|
|
57
|
+
operation: 'download',
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
const bytes = await response.arrayBuffer();
|
|
61
|
+
/* A server is not obliged to send `Content-Length`, and a chunked
|
|
62
|
+
response can be any size whatever the message claimed. */
|
|
63
|
+
if (bytes.byteLength > max) {
|
|
64
|
+
throw new MailError(`${attachment.name} arrived at ${bytes.byteLength} bytes, over the ${max}-byte limit`, { type: 'tooLarge', operation: 'download' });
|
|
65
|
+
}
|
|
66
|
+
return new Blob([bytes], { type: attachment.type || 'application/octet-stream' });
|
|
67
|
+
});
|
|
68
|
+
}
|
package/dist/mailbox/index.d.ts
CHANGED
|
@@ -23,6 +23,8 @@ export { draftToEmail, send, sentPatch } from './submit.ts';
|
|
|
23
23
|
export type { SendOptions } from './submit.ts';
|
|
24
24
|
export { moveThread, moveToRole, removeFromMailbox, setFlagged, setRead, } from './mutate.ts';
|
|
25
25
|
export type { MoveOptions } from './mutate.ts';
|
|
26
|
+
export { DEFAULT_MAX_DOWNLOAD_BYTES, downloadAttachment } from './download.ts';
|
|
27
|
+
export type { DownloadOptions } from './download.ts';
|
|
26
28
|
export { push } from './push.ts';
|
|
27
29
|
export type { PushHandle, PushOptions } from './push.ts';
|
|
28
30
|
export { expandTemplate } from './uri.ts';
|
package/dist/mailbox/index.js
CHANGED
|
@@ -14,5 +14,6 @@ export { identities } from "./identities.js";
|
|
|
14
14
|
export { attribution, formatAddress, forwardDraft, forwardIntroduction, forwardSubject, quoteText, replyDraft, replySubject, } from "./drafts.js";
|
|
15
15
|
export { draftToEmail, send, sentPatch } from "./submit.js";
|
|
16
16
|
export { moveThread, moveToRole, removeFromMailbox, setFlagged, setRead, } from "./mutate.js";
|
|
17
|
+
export { DEFAULT_MAX_DOWNLOAD_BYTES, downloadAttachment } from "./download.js";
|
|
17
18
|
export { push } from "./push.js";
|
|
18
19
|
export { expandTemplate } from "./uri.js";
|
package/dist/mailbox/mail.css
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Button, Command, Icon, Modal, SplitPane } from '@wtfalch/design';
|
|
3
3
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
4
|
+
import { downloadAttachment } from "../download.js";
|
|
4
5
|
import { forwardDraft, replyDraft } from "../drafts.js";
|
|
5
6
|
import { findRole } from "../mailboxes.js";
|
|
6
7
|
import { moveToRole, setFlagged, setRead } from "../mutate.js";
|
|
@@ -11,6 +12,7 @@ import { ThreadList } from "./ThreadList.js";
|
|
|
11
12
|
import { ThreadView } from "./ThreadView.js";
|
|
12
13
|
import { useIdentities, useMailboxes, usePush, useThread, useThreads } from "./hooks.js";
|
|
13
14
|
import { usePanes } from "./layout.js";
|
|
15
|
+
import { saveBlob } from "./save.js";
|
|
14
16
|
/**
|
|
15
17
|
* The whole client: mailboxes, a list, a message, and a way to write one.
|
|
16
18
|
*
|
|
@@ -73,6 +75,10 @@ export function Mail({ client, location, onNavigate, account, onError, now, clas
|
|
|
73
75
|
const [helpOpen, setHelpOpen] = useState(false);
|
|
74
76
|
const [acting, setActing] = useState(false);
|
|
75
77
|
const [note, setNote] = useState(null);
|
|
78
|
+
/* By blob id rather than a single flag: four attachments on one message can
|
|
79
|
+
be fetched at once, and one spinner for all of them would say the wrong
|
|
80
|
+
thing about three of them. */
|
|
81
|
+
const [downloading, setDownloading] = useState(new Set());
|
|
76
82
|
const searchField = useRef(null);
|
|
77
83
|
const boxes = useMailboxes(client);
|
|
78
84
|
const identities = useIdentities(client);
|
|
@@ -227,6 +233,35 @@ export function Mail({ client, location, onNavigate, account, onError, now, clas
|
|
|
227
233
|
return;
|
|
228
234
|
void act(() => setRead(client, threadId, false), 'Marked unread.');
|
|
229
235
|
}, [act, client, threadId]);
|
|
236
|
+
/**
|
|
237
|
+
* An attachment, fetched and handed to the browser.
|
|
238
|
+
*
|
|
239
|
+
* `ThreadView` has taken an `onDownload` since it was written and nothing
|
|
240
|
+
* ever supplied one, so every attachment button in this client rendered
|
|
241
|
+
* disabled. It could not be a link: a JMAP download URL carries no
|
|
242
|
+
* credential, so an anchor fetches it without the token and gets a 401.
|
|
243
|
+
*
|
|
244
|
+
* The name is said in the failure because "that did not work" over a list
|
|
245
|
+
* of four files does not say which.
|
|
246
|
+
*/
|
|
247
|
+
const download = useCallback(async (file) => {
|
|
248
|
+
setDownloading((busy) => new Set(busy).add(file.blobId));
|
|
249
|
+
try {
|
|
250
|
+
saveBlob(await downloadAttachment(client, file), file.name);
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
setNote(error instanceof Error
|
|
254
|
+
? `${file.name} did not download: ${error.message}`
|
|
255
|
+
: `${file.name} did not download.`);
|
|
256
|
+
}
|
|
257
|
+
finally {
|
|
258
|
+
setDownloading((busy) => {
|
|
259
|
+
const next = new Set(busy);
|
|
260
|
+
next.delete(file.blobId);
|
|
261
|
+
return next;
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}, [client]);
|
|
230
265
|
const flag = useCallback((next) => {
|
|
231
266
|
if (!threadId)
|
|
232
267
|
return;
|
|
@@ -403,7 +438,7 @@ export function Mail({ client, location, onNavigate, account, onError, now, clas
|
|
|
403
438
|
setQuery('');
|
|
404
439
|
}, "aria-label": "Clear the search", children: _jsx(Icon, { name: "close", size: 13 }) })) : (_jsx("span", { className: "mail-search-key", "aria-hidden": "true", children: "/" }))] }));
|
|
405
440
|
const list = (_jsx(ThreadList, { page: threads.data, selectedId: threadId, onSelect: openThread, onPage: (threads.data?.total ?? 0) > PAGE ? setPosition : undefined, limit: PAGE, loading: threads.loading, now: now, title: query ? `Results for “${query}”` : current?.name, empty: query ? _jsxs("p", { className: "threads-quiet", children: ["Nothing matches \u201C", query, "\u201D."] }) : undefined }));
|
|
406
|
-
const reader = (_jsx(ThreadView, { thread: thread.data, loading: thread.loading, onReply: startReply, onForward: startForward, onArchive: archiveBox ? archive : undefined, onTrash: trashBox ? trash : undefined, onMarkUnread: markUnread, onFlag: flag, flagged: summary?.isFlagged ?? false, busy: acting, onBack: panes === 'narrow' ? () => setThreadId(null) : undefined }));
|
|
441
|
+
const reader = (_jsx(ThreadView, { thread: thread.data, loading: thread.loading, onReply: startReply, onForward: startForward, onArchive: archiveBox ? archive : undefined, onTrash: trashBox ? trash : undefined, onMarkUnread: markUnread, onFlag: flag, onDownload: (file) => void download(file), downloading: downloading, flagged: summary?.isFlagged ?? false, busy: acting, onBack: panes === 'narrow' ? () => setThreadId(null) : undefined }));
|
|
407
442
|
return (_jsxs("div", { className: `mail is-${panes}${className ? ` ${className}` : ''}`, children: [_jsxs("header", { className: "mail-bar", children: [_jsxs("div", { className: "mail-bar-start", children: [panes === 'narrow' && (_jsx(Button, { kind: "ghost", iconOnly: true, onClick: () => setDrawerOpen(true), "aria-label": "Mailboxes", children: _jsx(Icon, { name: "menu" }) })), _jsx("span", { className: "mail-wordmark", children: "Mail" })] }), searchBox, _jsxs("div", { className: "mail-bar-end", children: [_jsxs(Button, { kind: "primary", size: "sm", onClick: startNew, disabled: !identity, children: [_jsx(Icon, { name: "pen", size: 15 }), " ", _jsx("span", { className: "mail-compose-word", children: "Compose" })] }), account] })] }), panes === 'wide' && (_jsxs(SplitPane, { className: "mail-panes", label: "Mailbox list width", defaultSize: 15, min: 11, max: 28, storageKey: "mail-sidebar", children: [_jsx(MailboxTree, { mailboxes: boxes.data ?? [], selectedId: mailboxId, onSelect: go, loading: boxes.loading }), _jsxs(SplitPane, { label: "Conversation list width", defaultSize: 36, min: 26, max: 55, storageKey: "mail-list", children: [list, reader] })] })), panes === 'medium' && (_jsxs("div", { className: "mail-panes", children: [_jsx(MailboxTree, { mailboxes: boxes.data ?? [], selectedId: mailboxId, onSelect: go, loading: boxes.loading, rail: true }), _jsxs(SplitPane, { className: "mail-panes-split", label: "Conversation list width", defaultSize: 40, min: 28, max: 60, storageKey: "mail-list", children: [list, reader] })] })), panes === 'narrow' && _jsx("div", { className: "mail-panes", children: reading ? reader : list }), drawerOpen && (_jsx(Modal, { title: "Mailboxes", width: "20rem", onClose: () => setDrawerOpen(false), children: _jsx(MailboxTree, { mailboxes: boxes.data ?? [], selectedId: mailboxId, onSelect: go, loading: boxes.loading }) })), _jsx(Command, { open: paletteOpen, onOpenChange: setPaletteOpen, groups: paletteGroups }), helpOpen && (_jsx(Modal, { title: "Keyboard shortcuts", width: "26rem", onClose: () => setHelpOpen(false), children: _jsx(Shortcuts, {}) })), draft && (_jsx(Modal, { title: draft.subject ? draft.subject : 'New message', width: "46rem", onClose: sending ? undefined : () => setDraft(null), closeDisabled: sending, children: _jsx(Composer, { draft: draft, onChange: setDraft, onSend: doSend, onCancel: () => setDraft(null), identities: identities.data ?? [], sending: sending, error: sendError }) })), _jsx(Note, { said: note, onDone: () => setNote(null) })] }));
|
|
408
443
|
}
|
|
409
444
|
/**
|
|
@@ -10,14 +10,27 @@ import { useCallback, useEffect, useRef } from 'react';
|
|
|
10
10
|
* time is the thing they do not know. */
|
|
11
11
|
export function shortTime(iso, now = new Date()) {
|
|
12
12
|
const then = new Date(iso);
|
|
13
|
+
/* Before the elapsed-time branches, not after them, and that ordering is
|
|
14
|
+
the whole of this function's only sharp edge.
|
|
15
|
+
|
|
16
|
+
Elapsed time and calendar day disagree for one hour out of every
|
|
17
|
+
twenty-four. Just after midnight a message sent forty-five minutes ago
|
|
18
|
+
was sent yesterday, so the row said "45m" under a heading that said
|
|
19
|
+
"Yesterday" -- both true, and together they read as a fault. Checking
|
|
20
|
+
elapsed time first is what let that through; the version before this one
|
|
21
|
+
only reached the clock after the hour was up, so the hour where it
|
|
22
|
+
mattered was the hour it never covered.
|
|
23
|
+
|
|
24
|
+
The heading wins, because the heading is the thing the eye has already
|
|
25
|
+
read. A row under "Yesterday" says a time of day, always. */
|
|
26
|
+
if (dayGroup(iso, now) === 'Yesterday') {
|
|
27
|
+
return then.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
|
|
28
|
+
}
|
|
13
29
|
const minutes = Math.max(0, Math.round((now.getTime() - then.getTime()) / 60_000));
|
|
14
30
|
if (minutes < 1)
|
|
15
31
|
return 'now';
|
|
16
32
|
if (minutes < 60)
|
|
17
33
|
return `${minutes}m`;
|
|
18
|
-
if (dayGroup(iso, now) === 'Yesterday') {
|
|
19
|
-
return then.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
|
|
20
|
-
}
|
|
21
34
|
const hours = Math.round(minutes / 60);
|
|
22
35
|
if (hours < 24)
|
|
23
36
|
return `${hours}h`;
|
|
@@ -49,10 +49,14 @@ export type ThreadViewProps = ThreadActions & {
|
|
|
49
49
|
/** Fetches an attachment's bytes; the URL needs the same credential the API
|
|
50
50
|
* does, so a bare link cannot do it. */
|
|
51
51
|
onDownload?: (attachment: Attachment) => void;
|
|
52
|
+
/** Blob ids currently being fetched, so each row can say so for itself.
|
|
53
|
+
* A set rather than a boolean: four files on one message download
|
|
54
|
+
* independently and one spinner for all of them lies about three. */
|
|
55
|
+
downloading?: ReadonlySet<string>;
|
|
52
56
|
/** Back to the list, on a window too narrow to show both. */
|
|
53
57
|
onBack?: () => void;
|
|
54
58
|
className?: string;
|
|
55
59
|
};
|
|
56
|
-
export declare function ThreadView({ thread, loading, onReply, onForward, onArchive, onTrash, onMarkUnread, onFlag, flagged, busy, onDownload, onBack, className, }: ThreadViewProps): import("react").JSX.Element;
|
|
60
|
+
export declare function ThreadView({ thread, loading, onReply, onForward, onArchive, onTrash, onMarkUnread, onFlag, flagged, busy, onDownload, downloading, onBack, className, }: ThreadViewProps): import("react").JSX.Element;
|
|
57
61
|
/** Bytes, the way a mail client says them. */
|
|
58
62
|
export declare function bytes(size: number): string;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Button, Empty, Icon, Identity, Menu, ScrollArea, Skeleton } from '@wtfalch/design';
|
|
3
3
|
import { useEffect, useState } from 'react';
|
|
4
|
-
export function ThreadView({ thread, loading = false, onReply, onForward, onArchive, onTrash, onMarkUnread, onFlag, flagged = false, busy = false, onDownload, onBack, className, }) {
|
|
4
|
+
export function ThreadView({ thread, loading = false, onReply, onForward, onArchive, onTrash, onMarkUnread, onFlag, flagged = false, busy = false, onDownload, downloading, onBack, className, }) {
|
|
5
5
|
if (loading && !thread) {
|
|
6
6
|
return (_jsxs("div", { className: `thread${className ? ` ${className}` : ''}`, "aria-busy": "true", children: [_jsxs("header", { className: "thread-head", children: [_jsx(Skeleton, { width: "52%", height: 5 }), _jsx(Skeleton, { width: "28%", height: 2.5 })] }), _jsx("div", { className: "thread-body", children: _jsxs("div", { className: "message open", children: [_jsxs("div", { className: "message-head", children: [_jsx(Skeleton, { variant: "circular", width: "2rem", height: 8 }), _jsx(Skeleton, { width: "30%", height: 3 })] }), _jsx("div", { className: "message-body", children: _jsx(Skeleton, { lines: 6, height: 2.5 }) })] }) })] }));
|
|
7
7
|
}
|
|
@@ -13,7 +13,7 @@ export function ThreadView({ thread, loading = false, onReply, onForward, onArch
|
|
|
13
13
|
const people = [
|
|
14
14
|
...new Map(thread.messages.flatMap((message) => message.from.map((who) => [who.email, who.name?.trim() || who.email]))).values(),
|
|
15
15
|
];
|
|
16
|
-
return (_jsxs("div", { className: `thread${className ? ` ${className}` : ''}`, children: [_jsxs("header", { className: "thread-head", children: [_jsxs("div", { className: "thread-headline", children: [onBack && (_jsx(Button, { kind: "ghost", iconOnly: true, onClick: onBack, "aria-label": "Back to the list", children: _jsx(Icon, { name: "back" }) })), _jsxs("div", { className: "thread-heading", children: [_jsx("h2", { className: "thread-title", children: thread.subject || '(no subject)' }), _jsxs("p", { className: "thread-meta", children: [people.join(', '), _jsx("span", { className: "thread-meta-sep", children: "\u00B7" }), thread.messages.length === 1 ? '1 message' : `${thread.messages.length} messages`] })] })] }), _jsx(Toolbar, { message: newest, onReply: onReply, onForward: onForward, onArchive: onArchive, onTrash: onTrash, onMarkUnread: onMarkUnread, onFlag: onFlag, flagged: flagged, busy: busy })] }), _jsxs(ScrollArea, { className: "thread-body", label: "Messages in this conversation", children: [_jsx("ol", { className: "messages", children: thread.messages.map((message, index) => (_jsx("li", { children: _jsx(MessageCard, { message: message, defaultOpen: index === last, onReply: onReply, onForward: onForward, onDownload: onDownload }) }, message.id))) }), onReply && newest && (_jsxs("div", { className: "thread-foot", children: [_jsxs(Button, { kind: "primary", onClick: () => onReply(newest, false), disabled: busy, children: [_jsx(Icon, { name: "reply" }), " Reply"] }), _jsx(Button, { kind: "ghost", onClick: () => onReply(newest, true), disabled: busy, children: "Reply all" }), onForward && (_jsx(Button, { kind: "ghost", onClick: () => onForward(newest), disabled: busy, children: "Forward" }))] }))] })] }));
|
|
16
|
+
return (_jsxs("div", { className: `thread${className ? ` ${className}` : ''}`, children: [_jsxs("header", { className: "thread-head", children: [_jsxs("div", { className: "thread-headline", children: [onBack && (_jsx(Button, { kind: "ghost", iconOnly: true, onClick: onBack, "aria-label": "Back to the list", children: _jsx(Icon, { name: "back" }) })), _jsxs("div", { className: "thread-heading", children: [_jsx("h2", { className: "thread-title", children: thread.subject || '(no subject)' }), _jsxs("p", { className: "thread-meta", children: [people.join(', '), _jsx("span", { className: "thread-meta-sep", children: "\u00B7" }), thread.messages.length === 1 ? '1 message' : `${thread.messages.length} messages`] })] })] }), _jsx(Toolbar, { message: newest, onReply: onReply, onForward: onForward, onArchive: onArchive, onTrash: onTrash, onMarkUnread: onMarkUnread, onFlag: onFlag, flagged: flagged, busy: busy })] }), _jsxs(ScrollArea, { className: "thread-body", label: "Messages in this conversation", children: [_jsx("ol", { className: "messages", children: thread.messages.map((message, index) => (_jsx("li", { children: _jsx(MessageCard, { message: message, defaultOpen: index === last, onReply: onReply, onForward: onForward, onDownload: onDownload, downloading: downloading }) }, message.id))) }), onReply && newest && (_jsxs("div", { className: "thread-foot", children: [_jsxs(Button, { kind: "primary", onClick: () => onReply(newest, false), disabled: busy, children: [_jsx(Icon, { name: "reply" }), " Reply"] }), _jsx(Button, { kind: "ghost", onClick: () => onReply(newest, true), disabled: busy, children: "Reply all" }), onForward && (_jsx(Button, { kind: "ghost", onClick: () => onForward(newest), disabled: busy, children: "Forward" }))] }))] })] }));
|
|
17
17
|
}
|
|
18
18
|
/** A hover hint on a control that has only a glyph. `aria-hidden` on the
|
|
19
19
|
* wrapper is wrong -- the button inside it has to stay in the tree -- so
|
|
@@ -74,7 +74,7 @@ function Toolbar({ message, onReply, onForward, onArchive, onTrash, onMarkUnread
|
|
|
74
74
|
];
|
|
75
75
|
return (_jsxs("div", { className: "thread-tools", children: [onReply && (_jsx(Hint, { says: "Reply \u2014 R", children: _jsx(Button, { kind: "ghost", iconOnly: true, disabled: busy, onClick: () => onReply(message, false), "aria-label": "Reply", children: _jsx(Icon, { name: "reply" }) }) })), onForward && (_jsx(Hint, { says: "Forward \u2014 F", children: _jsx(Button, { kind: "ghost", iconOnly: true, disabled: busy, onClick: () => onForward?.(message), "aria-label": "Forward", children: _jsx(Icon, { name: "forward" }) }) })), _jsx("span", { className: "thread-tools-rule", "aria-hidden": "true" }), onFlag && (_jsx(Hint, { says: flagged ? 'Unflag — S' : 'Flag — S', children: _jsx(Button, { kind: "ghost", iconOnly: true, disabled: busy, onClick: () => onFlag(!flagged), "aria-pressed": flagged, "aria-label": flagged ? 'Unflag' : 'Flag', className: flagged ? 'is-flagged' : undefined, children: _jsx(Icon, { name: flagged ? 'star-filled' : 'star' }) }) })), onArchive && (_jsx(Hint, { says: "Archive \u2014 E", children: _jsx(Button, { kind: "ghost", iconOnly: true, disabled: busy, onClick: onArchive, "aria-label": "Archive", children: _jsx(Icon, { name: "folder" }) }) })), onTrash && (_jsx(Hint, { says: "Delete \u2014 #", children: _jsx(Button, { kind: "ghost", iconOnly: true, disabled: busy, onClick: onTrash, "aria-label": "Delete", children: _jsx(Icon, { name: "trash" }) }) })), overflow.length > 0 && (_jsx(Menu, { label: "More actions", placement: "bottom end", trigger: _jsx(Button, { kind: "ghost", iconOnly: true, disabled: busy, "aria-label": "More actions", children: _jsx(Icon, { name: "more" }) }), items: overflow }))] }));
|
|
76
76
|
}
|
|
77
|
-
function MessageCard({ message, defaultOpen, onReply, onForward, onDownload, }) {
|
|
77
|
+
function MessageCard({ message, defaultOpen, onReply, onForward, onDownload, downloading, }) {
|
|
78
78
|
const [open, setOpen] = useState(defaultOpen);
|
|
79
79
|
/* The newest message changes when a reply arrives, and the card that was
|
|
80
80
|
newest should not stay open while the new one opens under it. */
|
|
@@ -119,7 +119,10 @@ function MessageCard({ message, defaultOpen, onReply, onForward, onDownload, })
|
|
|
119
119
|
reflowing it destroys both. */
|
|
120
120
|
_jsx("pre", { className: "message-text", children: message.text })) : message.html !== null ? (_jsx(HtmlNotice, { html: message.html })) : (_jsx("p", { className: "message-quiet", children: "This message has no body." })), message.isTruncated && (_jsx("p", { className: "message-quiet", children: "This message was cut short at half a megabyte. The rest has not been fetched." })), message.attachments.length > 0 && (_jsx("ul", { className: "attachments", children: message.attachments
|
|
121
121
|
.filter((file) => file.disposition !== 'inline')
|
|
122
|
-
.map((file) =>
|
|
122
|
+
.map((file) => {
|
|
123
|
+
const busy = downloading?.has(file.blobId) ?? false;
|
|
124
|
+
return (_jsx("li", { children: _jsxs("button", { type: "button", className: `attachment${busy ? ' is-busy' : ''}`, onClick: () => onDownload?.(file), disabled: !onDownload || busy, "aria-busy": busy || undefined, children: [_jsx(Icon, { name: busy ? 'spinner' : 'paperclip', className: `attachment-icon${busy ? ' spin' : ''}` }), _jsx("span", { className: "attachment-name", children: file.name }), _jsx("span", { className: "attachment-size", children: busy ? 'Fetching…' : bytes(file.size) })] }) }, file.blobId));
|
|
125
|
+
}) }))] }))] }));
|
|
123
126
|
}
|
|
124
127
|
function Recipients({ message }) {
|
|
125
128
|
const line = (label, people) => people.length === 0 ? null : (_jsxs("p", { className: "message-line", children: [_jsx("span", { className: "message-line-label", children: label }), _jsx("span", { children: people.map((p) => p.name?.trim() || p.email).join(', ') })] }));
|
|
@@ -23,4 +23,5 @@ export type { ComposerProps } from './Composer.tsx';
|
|
|
23
23
|
export { Mail } from './Mail.tsx';
|
|
24
24
|
export type { MailLocation, MailProps } from './Mail.tsx';
|
|
25
25
|
export { usePanes } from './layout.ts';
|
|
26
|
+
export { saveBlob } from './save.ts';
|
|
26
27
|
export type { Panes } from './layout.ts';
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Handing a blob to the browser as a file.
|
|
3
|
+
*
|
|
4
|
+
* Its own module, and in the react folder rather than beside `download.ts`,
|
|
5
|
+
* because this half is pure DOM: `download.ts` fetches bytes and runs
|
|
6
|
+
* anywhere, this needs a `document`. Splitting them keeps the fetch testable
|
|
7
|
+
* in Node and keeps an application that wants to do its own saving — write to
|
|
8
|
+
* a directory handle, open in a viewer, upload somewhere else — able to take
|
|
9
|
+
* the bytes and skip this.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Save a blob under a filename.
|
|
13
|
+
*
|
|
14
|
+
* **The object URL is revoked on a timer, not immediately.** Revoking in the
|
|
15
|
+
* same tick as the click cancels the download in Chrome and Safari: the
|
|
16
|
+
* anchor's activation is queued, and by the time the browser reads the URL it
|
|
17
|
+
* has already been released. A tick is the usual fix and is still a race; a
|
|
18
|
+
* minute is not, and an object URL is a pointer to memory the page already
|
|
19
|
+
* holds, so holding it a little longer costs nothing.
|
|
20
|
+
*
|
|
21
|
+
* **The anchor is not appended in Firefox's case only.** A detached anchor's
|
|
22
|
+
* click is ignored there, so it goes into the document and comes straight
|
|
23
|
+
* back out, which is invisible and works everywhere.
|
|
24
|
+
*/
|
|
25
|
+
export declare function saveBlob(blob: Blob, name: string): void;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Handing a blob to the browser as a file.
|
|
3
|
+
*
|
|
4
|
+
* Its own module, and in the react folder rather than beside `download.ts`,
|
|
5
|
+
* because this half is pure DOM: `download.ts` fetches bytes and runs
|
|
6
|
+
* anywhere, this needs a `document`. Splitting them keeps the fetch testable
|
|
7
|
+
* in Node and keeps an application that wants to do its own saving — write to
|
|
8
|
+
* a directory handle, open in a viewer, upload somewhere else — able to take
|
|
9
|
+
* the bytes and skip this.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Save a blob under a filename.
|
|
13
|
+
*
|
|
14
|
+
* **The object URL is revoked on a timer, not immediately.** Revoking in the
|
|
15
|
+
* same tick as the click cancels the download in Chrome and Safari: the
|
|
16
|
+
* anchor's activation is queued, and by the time the browser reads the URL it
|
|
17
|
+
* has already been released. A tick is the usual fix and is still a race; a
|
|
18
|
+
* minute is not, and an object URL is a pointer to memory the page already
|
|
19
|
+
* holds, so holding it a little longer costs nothing.
|
|
20
|
+
*
|
|
21
|
+
* **The anchor is not appended in Firefox's case only.** A detached anchor's
|
|
22
|
+
* click is ignored there, so it goes into the document and comes straight
|
|
23
|
+
* back out, which is invisible and works everywhere.
|
|
24
|
+
*/
|
|
25
|
+
export function saveBlob(blob, name) {
|
|
26
|
+
if (typeof document === 'undefined') {
|
|
27
|
+
throw new Error('saveBlob needs a document; call it from the browser');
|
|
28
|
+
}
|
|
29
|
+
const url = URL.createObjectURL(blob);
|
|
30
|
+
const anchor = document.createElement('a');
|
|
31
|
+
anchor.href = url;
|
|
32
|
+
/* A filename with a path separator in it is a filename a server chose, and
|
|
33
|
+
browsers vary in how much of it they honour. Only the last segment is
|
|
34
|
+
ever a name. */
|
|
35
|
+
anchor.download = name.split(/[\\/]/).pop() || 'attachment';
|
|
36
|
+
anchor.rel = 'noopener';
|
|
37
|
+
anchor.style.display = 'none';
|
|
38
|
+
document.body.appendChild(anchor);
|
|
39
|
+
anchor.click();
|
|
40
|
+
anchor.remove();
|
|
41
|
+
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
|
42
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wtfalch/email",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "The wtfalch estate: reading a mailbox over JMAP, and administering the Stalwart server it lives on. Two entries with no code in common.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
"@types/node": "^22",
|
|
62
62
|
"@types/react": "^19",
|
|
63
63
|
"@types/react-dom": "^19",
|
|
64
|
-
"@wtfalch/design": "^0.
|
|
64
|
+
"@wtfalch/design": "^0.7.0",
|
|
65
65
|
"jsdom": "^30.0.1",
|
|
66
66
|
"react": "^19",
|
|
67
67
|
"react-dom": "^19",
|