@wtfalch/email 0.4.1 → 0.6.0
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/fake/mailbox.js +61 -1
- package/dist/mailbox/fake/sample.d.ts +0 -9
- package/dist/mailbox/fake/sample.js +224 -4
- package/dist/mailbox/index.d.ts +4 -0
- package/dist/mailbox/index.js +2 -0
- package/dist/mailbox/mail.css +697 -47
- package/dist/mailbox/mutate.d.ts +51 -0
- package/dist/mailbox/mutate.js +132 -0
- package/dist/mailbox/react/Composer.d.ts +5 -0
- package/dist/mailbox/react/Composer.js +11 -2
- package/dist/mailbox/react/Mail.js +343 -32
- package/dist/mailbox/react/MailboxTree.d.ts +3 -1
- package/dist/mailbox/react/MailboxTree.js +43 -13
- package/dist/mailbox/react/ThreadList.d.ts +46 -14
- package/dist/mailbox/react/ThreadList.js +109 -8
- package/dist/mailbox/react/ThreadView.d.ts +33 -4
- package/dist/mailbox/react/ThreadView.js +89 -19
- package/dist/mailbox/react/index.d.ts +6 -3
- package/dist/mailbox/react/index.js +4 -2
- package/dist/mailbox/react/layout.d.ts +31 -0
- package/dist/mailbox/react/layout.js +42 -0
- package/dist/mailbox/react/save.d.ts +25 -0
- package/dist/mailbox/react/save.js +42 -0
- package/package.json +3 -3
|
@@ -2,19 +2,35 @@ import type { ThreadPage, ThreadSummary } from '../types.ts';
|
|
|
2
2
|
/**
|
|
3
3
|
* The list of conversations, and what a row of it has to say.
|
|
4
4
|
*
|
|
5
|
-
* **
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* the
|
|
5
|
+
* **Two lines, not four.** The row that was here stacked sender, subject,
|
|
6
|
+
* preview and a row of word-shaped pills, which came to about 140px: four
|
|
7
|
+
* conversations to a laptop screen, and a mailbox with sixty in it takes
|
|
8
|
+
* fifteen screens to scan. Sender and time on one line, subject and preview
|
|
9
|
+
* sharing the second, marks as glyphs rather than words -- that is around
|
|
10
|
+
* 60px, and the whole point of a list is how much of it you can see at once.
|
|
11
|
+
*
|
|
12
|
+
* **Unread is weight and a rail, not colour and not a bullet in the text.**
|
|
13
|
+
* A blue dot beside a subject is invisible to a reader who cannot distinguish
|
|
14
|
+
* it and disappears on a projector. The row goes to the strong weight, and a
|
|
15
|
+
* 3px bar sits in the gutter -- outside the text, so a read row and an unread
|
|
16
|
+
* row start their subjects at the same x. The bullet that used to be inline
|
|
17
|
+
* moved every subject two characters to the right and only on unread rows,
|
|
18
|
+
* which read as a rendering fault.
|
|
19
|
+
*
|
|
20
|
+
* **The rows are grouped by day, and the heading sticks.** Scrolling a long
|
|
21
|
+
* mailbox with no landmarks is how you lose your place; "Yesterday" pinned to
|
|
22
|
+
* the top of the viewport is the cheapest orientation there is. Groups are
|
|
23
|
+
* computed from the same `now` the relative times use, so a story and a
|
|
24
|
+
* screenshot hold still.
|
|
10
25
|
*
|
|
11
26
|
* **The time is relative and titled.** "12m" is what you scan; the full
|
|
12
27
|
* timestamp is in the `title` and the `dateTime`, so hovering and a screen
|
|
13
|
-
* reader both get the real thing.
|
|
14
|
-
* nobody reads.
|
|
28
|
+
* reader both get the real thing.
|
|
15
29
|
*
|
|
16
|
-
* **
|
|
17
|
-
*
|
|
30
|
+
* **Arrow keys move, and focus follows.** The list is a single tab stop --
|
|
31
|
+
* one row is `tabIndex=0` and the rest are `-1` -- so tabbing through the
|
|
32
|
+
* client does not mean tabbing through sixty conversations. Within it, the
|
|
33
|
+
* arrows and `j`/`k` move the selection.
|
|
18
34
|
*/
|
|
19
35
|
export type ThreadListProps = {
|
|
20
36
|
page: ThreadPage | undefined;
|
|
@@ -26,12 +42,28 @@ export type ThreadListProps = {
|
|
|
26
42
|
loading?: boolean;
|
|
27
43
|
/** Shown when there is nothing, instead of an empty box. */
|
|
28
44
|
empty?: React.ReactNode;
|
|
29
|
-
/**
|
|
30
|
-
|
|
45
|
+
/** The mailbox being listed, for the heading above the rows. */
|
|
46
|
+
title?: string;
|
|
47
|
+
/** Now, for the relative times and the day grouping. Passed in so a story
|
|
48
|
+
* and a screenshot are stable rather than drifting with the clock. */
|
|
31
49
|
now?: Date;
|
|
32
50
|
className?: string;
|
|
33
51
|
};
|
|
34
|
-
/** "12m", "3h", "Tue", "14 Mar" -- coarser the further back it is,
|
|
35
|
-
* precision stops being useful and starts being noise.
|
|
52
|
+
/** "12m", "3h", "09:42", "Tue", "14 Mar" -- coarser the further back it is,
|
|
53
|
+
* because precision stops being useful and starts being noise.
|
|
54
|
+
*
|
|
55
|
+
* Yesterday is the clock rather than the weekday, and that is not a detail:
|
|
56
|
+
* the row sits under a heading that already says "Yesterday", so printing
|
|
57
|
+
* "Wed" beside it spends the column on a word the reader has just read. The
|
|
58
|
+
* time is the thing they do not know. */
|
|
36
59
|
export declare function shortTime(iso: string, now?: Date): string;
|
|
37
|
-
|
|
60
|
+
/**
|
|
61
|
+
* The heading a row sits under.
|
|
62
|
+
*
|
|
63
|
+
* Calendar days apart, not hours apart: a message at 00:30 and one at 23:30
|
|
64
|
+
* are 23 hours apart and both "today", and a rule written in hours puts them
|
|
65
|
+
* in different groups, which is the sort of thing nobody reports as a bug and
|
|
66
|
+
* everybody finds confusing.
|
|
67
|
+
*/
|
|
68
|
+
export declare function dayGroup(iso: string, now?: Date): string;
|
|
69
|
+
export declare function ThreadList({ page, selectedId, onSelect, onPage, limit, loading, empty, title, now, className, }: ThreadListProps): import("react").JSX.Element;
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Empty,
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
import { Empty, Icon, Pagination, ScrollArea, Skeleton } from '@wtfalch/design';
|
|
3
|
+
import { useCallback, useEffect, useRef } from 'react';
|
|
4
|
+
/** "12m", "3h", "09:42", "Tue", "14 Mar" -- coarser the further back it is,
|
|
5
|
+
* because precision stops being useful and starts being noise.
|
|
6
|
+
*
|
|
7
|
+
* Yesterday is the clock rather than the weekday, and that is not a detail:
|
|
8
|
+
* the row sits under a heading that already says "Yesterday", so printing
|
|
9
|
+
* "Wed" beside it spends the column on a word the reader has just read. The
|
|
10
|
+
* time is the thing they do not know. */
|
|
5
11
|
export function shortTime(iso, now = new Date()) {
|
|
6
12
|
const then = new Date(iso);
|
|
7
13
|
const minutes = Math.max(0, Math.round((now.getTime() - then.getTime()) / 60_000));
|
|
@@ -9,6 +15,9 @@ export function shortTime(iso, now = new Date()) {
|
|
|
9
15
|
return 'now';
|
|
10
16
|
if (minutes < 60)
|
|
11
17
|
return `${minutes}m`;
|
|
18
|
+
if (dayGroup(iso, now) === 'Yesterday') {
|
|
19
|
+
return then.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
|
|
20
|
+
}
|
|
12
21
|
const hours = Math.round(minutes / 60);
|
|
13
22
|
if (hours < 24)
|
|
14
23
|
return `${hours}h`;
|
|
@@ -20,15 +29,102 @@ export function shortTime(iso, now = new Date()) {
|
|
|
20
29
|
}
|
|
21
30
|
return then.toLocaleDateString('en-GB', { year: 'numeric', month: 'short' });
|
|
22
31
|
}
|
|
23
|
-
|
|
32
|
+
/**
|
|
33
|
+
* The heading a row sits under.
|
|
34
|
+
*
|
|
35
|
+
* Calendar days apart, not hours apart: a message at 00:30 and one at 23:30
|
|
36
|
+
* are 23 hours apart and both "today", and a rule written in hours puts them
|
|
37
|
+
* in different groups, which is the sort of thing nobody reports as a bug and
|
|
38
|
+
* everybody finds confusing.
|
|
39
|
+
*/
|
|
40
|
+
export function dayGroup(iso, now = new Date()) {
|
|
41
|
+
const then = new Date(iso);
|
|
42
|
+
const midnight = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
|
43
|
+
const days = Math.round((midnight(now) - midnight(then)) / 86_400_000);
|
|
44
|
+
if (days <= 0)
|
|
45
|
+
return 'Today';
|
|
46
|
+
if (days === 1)
|
|
47
|
+
return 'Yesterday';
|
|
48
|
+
if (days < 7)
|
|
49
|
+
return 'Earlier this week';
|
|
50
|
+
if (days < 30)
|
|
51
|
+
return 'Earlier this month';
|
|
52
|
+
if (then.getFullYear() === now.getFullYear()) {
|
|
53
|
+
return then.toLocaleDateString('en-GB', { month: 'long' });
|
|
54
|
+
}
|
|
55
|
+
return then.toLocaleDateString('en-GB', { year: 'numeric', month: 'long' });
|
|
56
|
+
}
|
|
57
|
+
/** The rows, in order, with the heading each one begins. */
|
|
58
|
+
function group(items, now) {
|
|
59
|
+
const out = [];
|
|
60
|
+
for (const item of items) {
|
|
61
|
+
const heading = dayGroup(item.receivedAt, now);
|
|
62
|
+
const last = out[out.length - 1];
|
|
63
|
+
if (last?.heading === heading)
|
|
64
|
+
last.items.push(item);
|
|
65
|
+
else
|
|
66
|
+
out.push({ heading, items: [item] });
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
export function ThreadList({ page, selectedId, onSelect, onPage, limit = 50, loading = false, empty, title, now, className, }) {
|
|
24
71
|
const items = page?.items ?? [];
|
|
72
|
+
const at = now ?? new Date();
|
|
73
|
+
const list = useRef(null);
|
|
74
|
+
/* Keep the selected row in view when the selection moved from somewhere
|
|
75
|
+
else -- the command palette, a keyboard shortcut, a URL that was pasted.
|
|
76
|
+
`nearest` rather than `center`, which would scroll on every click. */
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (!selectedId)
|
|
79
|
+
return;
|
|
80
|
+
const row = list.current?.querySelector(`[data-thread="${CSS.escape(selectedId)}"]`);
|
|
81
|
+
row?.scrollIntoView({ block: 'nearest' });
|
|
82
|
+
}, [selectedId]);
|
|
83
|
+
/**
|
|
84
|
+
* Arrow keys, and `j`/`k` for the people who never left mutt.
|
|
85
|
+
*
|
|
86
|
+
* Handled on the container rather than per row: the rows are a moving set
|
|
87
|
+
* and the container is not, so there is one listener whatever the page
|
|
88
|
+
* size. `preventDefault` matters -- without it the arrows scroll the pane
|
|
89
|
+
* as well as move the selection, and the two disagree.
|
|
90
|
+
*/
|
|
91
|
+
const onKeyDown = useCallback((event) => {
|
|
92
|
+
const forward = event.key === 'ArrowDown' || event.key === 'j';
|
|
93
|
+
const back = event.key === 'ArrowUp' || event.key === 'k';
|
|
94
|
+
if (!forward && !back)
|
|
95
|
+
return;
|
|
96
|
+
if (event.metaKey || event.ctrlKey || event.altKey)
|
|
97
|
+
return;
|
|
98
|
+
event.preventDefault();
|
|
99
|
+
const index = items.findIndex((item) => item.id === selectedId);
|
|
100
|
+
const next = index === -1 ? 0 : Math.min(items.length - 1, Math.max(0, index + (forward ? 1 : -1)));
|
|
101
|
+
const target = items[next];
|
|
102
|
+
if (target)
|
|
103
|
+
onSelect(target);
|
|
104
|
+
}, [items, selectedId, onSelect]);
|
|
25
105
|
if (loading && items.length === 0) {
|
|
26
|
-
return
|
|
106
|
+
return (_jsxs("div", { className: `threads${className ? ` ${className}` : ''}`, children: [title && _jsx(Head, { title: title }), _jsx(LoadingRows, {})] }));
|
|
27
107
|
}
|
|
28
108
|
if (items.length === 0) {
|
|
29
|
-
return (
|
|
109
|
+
return (_jsxs("div", { className: `threads${className ? ` ${className}` : ''}`, children: [title && _jsx(Head, { title: title }), _jsx("div", { className: "threads-empty", children: empty ?? _jsx(Empty, { icon: "mail", children: "Nothing here." }) })] }));
|
|
30
110
|
}
|
|
31
|
-
return (_jsxs("div", { className: `threads${className ? ` ${className}` : ''}`, children: [_jsx(ScrollArea, { className: "threads-scroll", label: "Conversations", children: _jsx("
|
|
111
|
+
return (_jsxs("div", { className: `threads${className ? ` ${className}` : ''}`, children: [title && _jsx(Head, { title: title, count: page?.total }), _jsx(ScrollArea, { className: "threads-scroll", label: "Conversations", children: _jsx("div", { ref: list, className: "threads-list", onKeyDown: onKeyDown, "aria-busy": loading || undefined, children: group(items, at).map((section) => (_jsxs("section", { className: "threads-group", children: [_jsx("h3", { className: "threads-day", children: section.heading }), _jsx("ul", { className: "threads-rows", "aria-label": section.heading, children: section.items.map((item) => (_jsx("li", { children: _jsx(ThreadRow, { thread: item, selected: item.id === selectedId, onSelect: onSelect, now: at }) }, item.id))) })] }, section.heading))) }) }), onPage && page && (_jsx(Pagination, { className: "threads-pager", position: page.position, limit: limit, total: page.total, count: items.length, onChange: onPage, label: "Conversation pages", unit: "conversations" }))] }));
|
|
112
|
+
}
|
|
113
|
+
/** The mailbox's name over its list, so the middle column says where it is.
|
|
114
|
+
* Without it the only label for these rows is a highlight in another pane. */
|
|
115
|
+
function Head({ title, count }) {
|
|
116
|
+
return (_jsxs("header", { className: "threads-head", children: [_jsx("h2", { className: "threads-title", children: title }), count !== undefined && (_jsxs("span", { className: "threads-total", children: [count.toLocaleString('en-GB'), _jsx("span", { className: "sr-only", children: " conversations" })] }))] }));
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Placeholder rows at the real row's geometry.
|
|
120
|
+
*
|
|
121
|
+
* Six of them, and each one the height of the row it stands in for, so the
|
|
122
|
+
* list does not jump when the mail arrives. A spinner in the middle of an
|
|
123
|
+
* empty column tells you nothing about what is coming; this tells you it is a
|
|
124
|
+
* list and roughly how much of one.
|
|
125
|
+
*/
|
|
126
|
+
function LoadingRows() {
|
|
127
|
+
return (_jsx("div", { className: "threads-scroll", "aria-busy": "true", "aria-live": "polite", "aria-label": "Loading conversations", children: _jsx("div", { className: "threads-list", children: [0, 1, 2, 3, 4, 5].map((n) => (_jsxs("div", { className: "thread-row is-skeleton", children: [_jsx("span", { className: "thread-rail" }), _jsxs("span", { className: "thread-main", children: [_jsxs("span", { className: "thread-top", children: [_jsx(Skeleton, { width: `${38 + ((n * 13) % 26)}%`, height: 2.5 }), _jsx(Skeleton, { width: "2.5rem", height: 2.5, className: "thread-when" })] }), _jsx("span", { className: "thread-bottom", children: _jsx(Skeleton, { width: `${60 + ((n * 17) % 30)}%`, height: 2.5 }) })] })] }, n))) }) }));
|
|
32
128
|
}
|
|
33
129
|
function ThreadRow({ thread, selected, onSelect, now, }) {
|
|
34
130
|
const unread = thread.unreadCount > 0;
|
|
@@ -37,5 +133,10 @@ function ThreadRow({ thread, selected, onSelect, now, }) {
|
|
|
37
133
|
mailbox it is drawing, so it shows the last writer and lets the row's
|
|
38
134
|
participants carry the rest. */
|
|
39
135
|
const who = thread.from ?? thread.participants[thread.participants.length - 1] ?? null;
|
|
40
|
-
|
|
136
|
+
const name = who?.name?.trim() || who?.email || 'Unknown sender';
|
|
137
|
+
const when = new Date(thread.receivedAt);
|
|
138
|
+
return (_jsxs("button", { type: "button", "data-thread": thread.id, className: `thread-row${selected ? ' on' : ''}${unread ? ' unread' : ''}`, onClick: () => onSelect(thread), "aria-current": selected ? 'true' : undefined,
|
|
139
|
+
/* One tab stop for the list: the selected row is the one the tab order
|
|
140
|
+
knows about, and the arrows move between them from there. */
|
|
141
|
+
tabIndex: selected ? 0 : -1, children: [_jsx("span", { className: "thread-rail", "aria-hidden": "true" }), _jsxs("span", { className: "thread-main", children: [_jsxs("span", { className: "thread-top", children: [_jsx("span", { className: "thread-who", children: name }), _jsxs("span", { className: "thread-marks", children: [thread.isDraft && _jsx("span", { className: "thread-draft", children: "Draft" }), thread.emailCount > 1 && (_jsxs("span", { className: "thread-count", children: [thread.emailCount, _jsx("span", { className: "sr-only", children: " messages" })] })), thread.hasAttachment && (_jsx(Icon, { name: "paperclip", size: 13, title: "Has an attachment", className: "thread-mark" })), thread.isFlagged && (_jsx(Icon, { name: "star-filled", size: 13, title: "Flagged", className: "thread-mark is-flag" }))] }), _jsx("time", { className: "thread-when", dateTime: thread.receivedAt, title: when.toLocaleString('en-GB'), children: shortTime(thread.receivedAt, now) })] }), _jsxs("span", { className: "thread-bottom", children: [_jsx("span", { className: "thread-subject", children: thread.subject || '(no subject)' }), thread.preview && _jsx("span", { className: "thread-preview", children: thread.preview })] })] }), unread && _jsxs("span", { className: "sr-only", children: [thread.unreadCount, " unread"] })] }));
|
|
41
142
|
}
|
|
@@ -2,6 +2,17 @@ import type { Attachment, Message, ThreadDetail } from '../types.ts';
|
|
|
2
2
|
/**
|
|
3
3
|
* A conversation, opened.
|
|
4
4
|
*
|
|
5
|
+
* **The actions are a toolbar, not a menu behind a gear.** Reply, archive and
|
|
6
|
+
* delete are the three things anybody does to a message, and they were three
|
|
7
|
+
* clicks deep behind an icon that means "settings" everywhere else in the
|
|
8
|
+
* system. They are buttons now, in reach, in the order of how often they are
|
|
9
|
+
* pressed; the rest is behind `more`.
|
|
10
|
+
*
|
|
11
|
+
* **The head stays put.** Subject, participants and toolbar are pinned, so
|
|
12
|
+
* archiving the twelfth message of a thread does not mean scrolling back up
|
|
13
|
+
* to find the button. It is the one piece of chrome that has to survive the
|
|
14
|
+
* scroll.
|
|
15
|
+
*
|
|
5
16
|
* **The newest message is expanded and the rest are collapsed**, because that
|
|
6
17
|
* is the one you came to read. A thread view that opens everything makes you
|
|
7
18
|
* scroll past mail you have already seen to reach the mail you have not.
|
|
@@ -18,16 +29,34 @@ import type { Attachment, Message, ThreadDetail } from '../types.ts';
|
|
|
18
29
|
* megabyte of each part; a message longer than that would otherwise appear to
|
|
19
30
|
* end mid-sentence with no explanation.
|
|
20
31
|
*/
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
32
|
+
/** What the toolbar can do. Every one is optional: a caller that has not
|
|
33
|
+
* wired an action does not get a button that does nothing. */
|
|
34
|
+
export type ThreadActions = {
|
|
24
35
|
onReply?: (message: Message, all: boolean) => void;
|
|
25
36
|
onForward?: (message: Message) => void;
|
|
37
|
+
onArchive?: () => void;
|
|
38
|
+
onTrash?: () => void;
|
|
39
|
+
onMarkUnread?: () => void;
|
|
40
|
+
onFlag?: (flagged: boolean) => void;
|
|
41
|
+
/** True when the thread carries `$flagged`, for the star's pressed state. */
|
|
42
|
+
flagged?: boolean;
|
|
43
|
+
/** Set while an action is in flight, so it cannot be pressed twice. */
|
|
44
|
+
busy?: boolean;
|
|
45
|
+
};
|
|
46
|
+
export type ThreadViewProps = ThreadActions & {
|
|
47
|
+
thread: ThreadDetail | undefined;
|
|
48
|
+
loading?: boolean;
|
|
26
49
|
/** Fetches an attachment's bytes; the URL needs the same credential the API
|
|
27
50
|
* does, so a bare link cannot do it. */
|
|
28
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>;
|
|
56
|
+
/** Back to the list, on a window too narrow to show both. */
|
|
57
|
+
onBack?: () => void;
|
|
29
58
|
className?: string;
|
|
30
59
|
};
|
|
31
|
-
export declare function ThreadView({ thread, loading, onReply, onForward, onDownload, 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;
|
|
32
61
|
/** Bytes, the way a mail client says them. */
|
|
33
62
|
export declare function bytes(size: number): string;
|
|
@@ -1,37 +1,104 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Button, Empty, Icon, Identity, Menu, ScrollArea } from '@wtfalch/design';
|
|
3
|
-
import { useState } from 'react';
|
|
4
|
-
export function ThreadView({ thread, loading = false, onReply, onForward, onDownload, className, }) {
|
|
5
|
-
if (loading && !thread)
|
|
6
|
-
return _jsx("
|
|
2
|
+
import { Button, Empty, Icon, Identity, Menu, ScrollArea, Skeleton } from '@wtfalch/design';
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
export function ThreadView({ thread, loading = false, onReply, onForward, onArchive, onTrash, onMarkUnread, onFlag, flagged = false, busy = false, onDownload, downloading, onBack, className, }) {
|
|
5
|
+
if (loading && !thread) {
|
|
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
8
|
if (!thread || thread.messages.length === 0) {
|
|
8
|
-
return (_jsx("div", { className: `thread${className ? ` ${className}` : ''}`, children:
|
|
9
|
+
return (_jsx("div", { className: `thread is-empty${className ? ` ${className}` : ''}`, children: _jsxs(Empty, { icon: "mail", children: ["Pick a conversation to read it.", _jsxs("span", { className: "thread-hint", children: [_jsx("kbd", { children: "\u2191" }), " ", _jsx("kbd", { children: "\u2193" }), " to move, ", _jsx("kbd", { children: "\u2318K" }), " for anywhere else"] })] }) }));
|
|
9
10
|
}
|
|
10
11
|
const last = thread.messages.length - 1;
|
|
11
|
-
|
|
12
|
+
const newest = thread.messages[last];
|
|
13
|
+
const people = [
|
|
14
|
+
...new Map(thread.messages.flatMap((message) => message.from.map((who) => [who.email, who.name?.trim() || who.email]))).values(),
|
|
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, 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
|
+
}
|
|
18
|
+
/** A hover hint on a control that has only a glyph. `aria-hidden` on the
|
|
19
|
+
* wrapper is wrong -- the button inside it has to stay in the tree -- so
|
|
20
|
+
* this is a bare span whose only job is to own the `title`. */
|
|
21
|
+
function Hint({ says, children }) {
|
|
22
|
+
return (_jsx("span", { className: "thread-tool", title: says, children: children }));
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The actions, in the order they are used.
|
|
26
|
+
*
|
|
27
|
+
* Icon-only, because a row of six labelled buttons is wider than the pane at
|
|
28
|
+
* the width this has to survive -- and every one carries an `aria-label` and
|
|
29
|
+
* a `title`, which is the trade an icon-only control has to make. The
|
|
30
|
+
* keyboard letter goes in the `title`: that is where somebody looks when they
|
|
31
|
+
* are wondering whether there is a faster way.
|
|
32
|
+
*
|
|
33
|
+
* Not the design system's `Tooltip`, which is an explainer -- its trigger is
|
|
34
|
+
* a question mark and its child is the tip. Wrapping a button in it makes the
|
|
35
|
+
* button the tip text and puts a "?" on the toolbar, which is exactly what
|
|
36
|
+
* the first version of this did.
|
|
37
|
+
*
|
|
38
|
+
* The `title` sits on a wrapping span rather than the button, because the
|
|
39
|
+
* system's `Button` types its props off React Aria's and `title` is not
|
|
40
|
+
* among them. Hovering the button hovers the span, so the hint appears
|
|
41
|
+
* either way.
|
|
42
|
+
*/
|
|
43
|
+
function Toolbar({ message, onReply, onForward, onArchive, onTrash, onMarkUnread, onFlag, flagged, busy, }) {
|
|
44
|
+
if (!message)
|
|
45
|
+
return null;
|
|
46
|
+
/* Reply all lives here rather than on the bar. It has no glyph that says
|
|
47
|
+
"all" -- the speech bubble it briefly had reads as "chat" everywhere else
|
|
48
|
+
in the system -- and a toolbar of two arrows that plainly mean reply and
|
|
49
|
+
forward is worth more than a third that has to be guessed at. It is also
|
|
50
|
+
under the message as a word, which is where it is actually used. */
|
|
51
|
+
const overflow = [
|
|
52
|
+
...(onReply
|
|
53
|
+
? [
|
|
54
|
+
{
|
|
55
|
+
id: 'reply-all',
|
|
56
|
+
label: 'Reply all',
|
|
57
|
+
icon: 'reply',
|
|
58
|
+
shortcut: 'A',
|
|
59
|
+
onAction: () => onReply(message, true),
|
|
60
|
+
},
|
|
61
|
+
]
|
|
62
|
+
: []),
|
|
63
|
+
...(onMarkUnread
|
|
64
|
+
? [
|
|
65
|
+
{
|
|
66
|
+
id: 'unread',
|
|
67
|
+
label: 'Mark unread',
|
|
68
|
+
icon: 'mail',
|
|
69
|
+
shortcut: 'U',
|
|
70
|
+
onAction: onMarkUnread,
|
|
71
|
+
},
|
|
72
|
+
]
|
|
73
|
+
: []),
|
|
74
|
+
];
|
|
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 }))] }));
|
|
12
76
|
}
|
|
13
|
-
function MessageCard({ message, defaultOpen, onReply, onForward, onDownload, }) {
|
|
77
|
+
function MessageCard({ message, defaultOpen, onReply, onForward, onDownload, downloading, }) {
|
|
14
78
|
const [open, setOpen] = useState(defaultOpen);
|
|
79
|
+
/* The newest message changes when a reply arrives, and the card that was
|
|
80
|
+
newest should not stay open while the new one opens under it. */
|
|
81
|
+
useEffect(() => setOpen(defaultOpen), [defaultOpen]);
|
|
15
82
|
const sender = message.from[0];
|
|
16
83
|
const when = new Date(message.sentAt ?? message.receivedAt);
|
|
17
|
-
return (_jsxs("article", { className: `message${open ? ' open' : ''}`, children: [_jsxs("header", { className: "message-head", children: [_jsxs("button", { type: "button", className: "message-toggle", onClick: () => setOpen((was) => !was), "aria-expanded": open, children: [sender ? (_jsx(Identity, { name: sender.name, address: sender.email, kind: open ? 'full' : 'line' })) : (_jsx("span", { className: "message-nobody", children: "Unknown sender" })), _jsx("time", { className: "message-when", dateTime: when.toISOString(), children: when.toLocaleString('en-GB', {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
84
|
+
return (_jsxs("article", { className: `message${open ? ' open' : ''}`, children: [_jsxs("header", { className: "message-head", children: [_jsxs("button", { type: "button", className: "message-toggle", onClick: () => setOpen((was) => !was), "aria-expanded": open, children: [sender ? (_jsx(Identity, { name: sender.name, address: sender.email, kind: open ? 'full' : 'line' })) : (_jsx("span", { className: "message-nobody", children: "Unknown sender" })), !open && _jsx("span", { className: "message-peek", children: message.preview }), _jsxs("span", { className: "message-aside", children: [message.attachments.some((file) => file.disposition !== 'inline') && (_jsx(Icon, { name: "paperclip", size: 13, title: "Has an attachment" })), _jsx("time", { className: "message-when", dateTime: when.toISOString(), children: when.toLocaleString('en-GB', {
|
|
85
|
+
day: 'numeric',
|
|
86
|
+
month: 'short',
|
|
87
|
+
hour: '2-digit',
|
|
88
|
+
minute: '2-digit',
|
|
89
|
+
}) })] })] }), open && (onReply || onForward) && (_jsx(Menu, { label: "Message actions", placement: "bottom end", trigger: _jsx(Button, { kind: "ghost", iconOnly: true, "aria-label": "Message actions", children: _jsx(Icon, { name: "more" }) }), items: [
|
|
23
90
|
...(onReply
|
|
24
91
|
? [
|
|
25
92
|
{
|
|
26
93
|
id: 'reply',
|
|
27
94
|
label: 'Reply',
|
|
28
|
-
|
|
95
|
+
icon: 'reply',
|
|
29
96
|
onAction: () => onReply(message, false),
|
|
30
97
|
},
|
|
31
98
|
{
|
|
32
99
|
id: 'reply-all',
|
|
33
100
|
label: 'Reply all',
|
|
34
|
-
|
|
101
|
+
icon: 'chat',
|
|
35
102
|
onAction: () => onReply(message, true),
|
|
36
103
|
},
|
|
37
104
|
]
|
|
@@ -41,7 +108,7 @@ function MessageCard({ message, defaultOpen, onReply, onForward, onDownload, })
|
|
|
41
108
|
{
|
|
42
109
|
id: 'forward',
|
|
43
110
|
label: 'Forward',
|
|
44
|
-
|
|
111
|
+
icon: 'forward',
|
|
45
112
|
onAction: () => onForward(message),
|
|
46
113
|
},
|
|
47
114
|
]
|
|
@@ -52,7 +119,10 @@ function MessageCard({ message, defaultOpen, onReply, onForward, onDownload, })
|
|
|
52
119
|
reflowing it destroys both. */
|
|
53
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
|
|
54
121
|
.filter((file) => file.disposition !== 'inline')
|
|
55
|
-
.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
|
+
}) }))] }))] }));
|
|
56
126
|
}
|
|
57
127
|
function Recipients({ message }) {
|
|
58
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(', ') })] }));
|
|
@@ -68,7 +138,7 @@ function Recipients({ message }) {
|
|
|
68
138
|
*/
|
|
69
139
|
function HtmlNotice({ html }) {
|
|
70
140
|
const [shown, setShown] = useState(false);
|
|
71
|
-
return (_jsxs("div", { className: "message-html", children: [_jsx("p", { className: "message-quiet", children: "This message has an HTML body and no plain-text one. It is not rendered here: a mail client that puts a stranger's markup into its own page is one script tag from being read by them." }), _jsx(Button, { kind: "ghost", onClick: () => setShown((was) => !was), children: shown ? 'Hide the source' : 'Show the source' }), shown && _jsx("pre", { className: "message-source", children: html })] }));
|
|
141
|
+
return (_jsxs("div", { className: "message-html", children: [_jsx("p", { className: "message-quiet", children: "This message has an HTML body and no plain-text one. It is not rendered here: a mail client that puts a stranger's markup into its own page is one script tag from being read by them." }), _jsx(Button, { kind: "ghost", size: "sm", onClick: () => setShown((was) => !was), children: shown ? 'Hide the source' : 'Show the source' }), shown && _jsx("pre", { className: "message-source", children: html })] }));
|
|
72
142
|
}
|
|
73
143
|
/** Bytes, the way a mail client says them. */
|
|
74
144
|
export function bytes(size) {
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* and reused by an application with its own layout. `Mail` is the one piece
|
|
7
7
|
* that holds state and calls the hooks.
|
|
8
8
|
*
|
|
9
|
-
* On `@wtfalch/design` 0.
|
|
9
|
+
* On `@wtfalch/design` 0.5.0 and `@wtfalch/mail`. Import `@wtfalch/mail/mail.css`
|
|
10
10
|
* after the design stylesheet.
|
|
11
11
|
*/
|
|
12
12
|
export { MailProvider, useMailClient, useOptionalMailClient } from './context.tsx';
|
|
@@ -14,11 +14,14 @@ export { useIdentities, useMailboxes, usePush, useThread, useThreads, } from './
|
|
|
14
14
|
export type { Resource, ThreadsQuery } from './hooks.ts';
|
|
15
15
|
export { MailboxTree, flatMailboxes, orderForReading } from './MailboxTree.tsx';
|
|
16
16
|
export type { MailboxTreeProps } from './MailboxTree.tsx';
|
|
17
|
-
export { ThreadList, shortTime } from './ThreadList.tsx';
|
|
17
|
+
export { ThreadList, dayGroup, shortTime } from './ThreadList.tsx';
|
|
18
18
|
export type { ThreadListProps } from './ThreadList.tsx';
|
|
19
19
|
export { ThreadView, bytes } from './ThreadView.tsx';
|
|
20
|
-
export type { ThreadViewProps } from './ThreadView.tsx';
|
|
20
|
+
export type { ThreadActions, ThreadViewProps } from './ThreadView.tsx';
|
|
21
21
|
export { Composer, parseAddresses } from './Composer.tsx';
|
|
22
22
|
export type { ComposerProps } from './Composer.tsx';
|
|
23
23
|
export { Mail } from './Mail.tsx';
|
|
24
24
|
export type { MailLocation, MailProps } from './Mail.tsx';
|
|
25
|
+
export { usePanes } from './layout.ts';
|
|
26
|
+
export { saveBlob } from './save.ts';
|
|
27
|
+
export type { Panes } from './layout.ts';
|
|
@@ -6,13 +6,15 @@
|
|
|
6
6
|
* and reused by an application with its own layout. `Mail` is the one piece
|
|
7
7
|
* that holds state and calls the hooks.
|
|
8
8
|
*
|
|
9
|
-
* On `@wtfalch/design` 0.
|
|
9
|
+
* On `@wtfalch/design` 0.5.0 and `@wtfalch/mail`. Import `@wtfalch/mail/mail.css`
|
|
10
10
|
* after the design stylesheet.
|
|
11
11
|
*/
|
|
12
12
|
export { MailProvider, useMailClient, useOptionalMailClient } from "./context.js";
|
|
13
13
|
export { useIdentities, useMailboxes, usePush, useThread, useThreads, } from "./hooks.js";
|
|
14
14
|
export { MailboxTree, flatMailboxes, orderForReading } from "./MailboxTree.js";
|
|
15
|
-
export { ThreadList, shortTime } from "./ThreadList.js";
|
|
15
|
+
export { ThreadList, dayGroup, shortTime } from "./ThreadList.js";
|
|
16
16
|
export { ThreadView, bytes } from "./ThreadView.js";
|
|
17
17
|
export { Composer, parseAddresses } from "./Composer.js";
|
|
18
18
|
export { Mail } from "./Mail.js";
|
|
19
|
+
export { usePanes } from "./layout.js";
|
|
20
|
+
export { saveBlob } from "./save.js";
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How many panes fit.
|
|
3
|
+
*
|
|
4
|
+
* A mail client is three columns on a desk and one column on a phone, and the
|
|
5
|
+
* difference is not a matter of styling: at one column the list and the
|
|
6
|
+
* message are the *same* space, so which of them is on screen becomes state
|
|
7
|
+
* the shell has to hold. CSS can narrow a column and cannot answer that, which
|
|
8
|
+
* is why this is a hook and not a media query in the stylesheet.
|
|
9
|
+
*
|
|
10
|
+
* Three, not a continuum:
|
|
11
|
+
*
|
|
12
|
+
* - `wide` — mailboxes, list and message side by side. Selecting a
|
|
13
|
+
* conversation reveals it; there is nothing to navigate to.
|
|
14
|
+
* - `medium` — the mailboxes collapse to a rail of icons, and the list and
|
|
15
|
+
* the message share what is left. Below about 1150px a 3-column layout puts
|
|
16
|
+
* the reading measure under 45 characters, which is where prose stops being
|
|
17
|
+
* readable, and the mailbox names are the least valuable of the three.
|
|
18
|
+
* - `narrow` — one at a time, with the mailboxes in a drawer. Measured at
|
|
19
|
+
* 860px, where the list's two-line row can no longer hold a sender, a time
|
|
20
|
+
* and a subject without the subject truncating to nothing.
|
|
21
|
+
*/
|
|
22
|
+
export type Panes = 'wide' | 'medium' | 'narrow';
|
|
23
|
+
/**
|
|
24
|
+
* The current layout, updated as the window changes.
|
|
25
|
+
*
|
|
26
|
+
* `useState` with an initialiser rather than an effect that sets state on
|
|
27
|
+
* mount: the effect version renders `wide` once and then corrects itself,
|
|
28
|
+
* which on a phone is a visible flash of a three-column layout in a
|
|
29
|
+
* 390px-wide window.
|
|
30
|
+
*/
|
|
31
|
+
export declare function usePanes(): Panes;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
const WIDE = '(min-width: 1150px)';
|
|
3
|
+
const MEDIUM = '(min-width: 860px)';
|
|
4
|
+
function read() {
|
|
5
|
+
/* Server rendering, and jsdom without the shim, both land here. `wide` is
|
|
6
|
+
the right default for both: it is the layout with no hidden state, so a
|
|
7
|
+
render that never gets a second chance still shows everything. */
|
|
8
|
+
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function')
|
|
9
|
+
return 'wide';
|
|
10
|
+
if (window.matchMedia(WIDE).matches)
|
|
11
|
+
return 'wide';
|
|
12
|
+
if (window.matchMedia(MEDIUM).matches)
|
|
13
|
+
return 'medium';
|
|
14
|
+
return 'narrow';
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The current layout, updated as the window changes.
|
|
18
|
+
*
|
|
19
|
+
* `useState` with an initialiser rather than an effect that sets state on
|
|
20
|
+
* mount: the effect version renders `wide` once and then corrects itself,
|
|
21
|
+
* which on a phone is a visible flash of a three-column layout in a
|
|
22
|
+
* 390px-wide window.
|
|
23
|
+
*/
|
|
24
|
+
export function usePanes() {
|
|
25
|
+
const [panes, setPanes] = useState(read);
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function')
|
|
28
|
+
return;
|
|
29
|
+
const queries = [window.matchMedia(WIDE), window.matchMedia(MEDIUM)];
|
|
30
|
+
const update = () => setPanes(read());
|
|
31
|
+
for (const query of queries)
|
|
32
|
+
query.addEventListener('change', update);
|
|
33
|
+
// Once on mount as well: the window can have been resized between the
|
|
34
|
+
// initialiser running and the listeners being attached.
|
|
35
|
+
update();
|
|
36
|
+
return () => {
|
|
37
|
+
for (const query of queries)
|
|
38
|
+
query.removeEventListener('change', update);
|
|
39
|
+
};
|
|
40
|
+
}, []);
|
|
41
|
+
return panes;
|
|
42
|
+
}
|
|
@@ -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;
|