@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.
@@ -1,14 +1,18 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Button, Command, Icon, Input, Modal, SplitPane } from '@wtfalch/design';
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";
7
+ import { moveToRole, setFlagged, setRead } from "../mutate.js";
6
8
  import { send } from "../submit.js";
7
9
  import { Composer } from "./Composer.js";
8
10
  import { MailboxTree, flatMailboxes } from "./MailboxTree.js";
9
11
  import { ThreadList } from "./ThreadList.js";
10
12
  import { ThreadView } from "./ThreadView.js";
11
13
  import { useIdentities, useMailboxes, usePush, useThread, useThreads } from "./hooks.js";
14
+ import { usePanes } from "./layout.js";
15
+ import { saveBlob } from "./save.js";
12
16
  /**
13
17
  * The whole client: mailboxes, a list, a message, and a way to write one.
14
18
  *
@@ -18,13 +22,26 @@ import { useIdentities, useMailboxes, usePush, useThread, useThreads } from "./h
18
22
  * having on its own -- an application with its own layout can take the four
19
23
  * views and skip this entirely.
20
24
  *
25
+ * **Three panes, two, or one.** `usePanes` decides, and the difference is not
26
+ * cosmetic: at one column the list and the message occupy the same space, so
27
+ * which is showing becomes state. See `layout.ts` for where the two
28
+ * measurements came from.
29
+ *
21
30
  * **Push moves the list, not the message.** A state change refetches the
22
31
  * mailbox counts and the current page; it deliberately does not refetch the
23
32
  * thread being read, because replacing the message under somebody mid-sentence
24
33
  * is worse than showing them a copy that is thirty seconds old.
25
34
  *
26
- * **The palette is the keyboard.** Cmd-K opens it, and it carries the
27
- * mailboxes as "go to" entries, so moving around never requires the mouse.
35
+ * **Opening a conversation marks it read**, which is what opening means. Only
36
+ * when there is something unread in it: an `Email/set` that changes nothing
37
+ * still moves the account's state string and wakes every other client's push
38
+ * connection.
39
+ *
40
+ * **The keyboard is the point.** Cmd-K opens the palette; the single letters
41
+ * are the ones every mail client has had since pine, and `?` lists them. They
42
+ * are bound at the root rather than per pane so they work from inside the
43
+ * message as well as from the list -- and they are ignored while a field has
44
+ * focus, or `c` could never be typed into a subject.
28
45
  */
29
46
  const PAGE = 50;
30
47
  export function Mail({ client, location, onNavigate, account, onError, now, className, }) {
@@ -46,6 +63,7 @@ export function Mail({ client, location, onNavigate, account, onError, now, clas
46
63
  }, [controlled, onNavigate]);
47
64
  const setMailboxId = useCallback((next) => navigate({ mailboxId: next, threadId: null }), [navigate]);
48
65
  const setThreadId = useCallback((next) => navigate({ mailboxId, threadId: next }), [navigate, mailboxId]);
66
+ const panes = usePanes();
49
67
  const [position, setPosition] = useState(0);
50
68
  const [query, setQuery] = useState('');
51
69
  const [text, setText] = useState('');
@@ -53,6 +71,15 @@ export function Mail({ client, location, onNavigate, account, onError, now, clas
53
71
  const [sending, setSending] = useState(false);
54
72
  const [sendError, setSendError] = useState();
55
73
  const [paletteOpen, setPaletteOpen] = useState(false);
74
+ const [drawerOpen, setDrawerOpen] = useState(false);
75
+ const [helpOpen, setHelpOpen] = useState(false);
76
+ const [acting, setActing] = useState(false);
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());
82
+ const searchField = useRef(null);
56
83
  const boxes = useMailboxes(client);
57
84
  const identities = useIdentities(client);
58
85
  /* The inbox, once, when the mailboxes first arrive. Not in an effect that
@@ -83,39 +110,60 @@ export function Mail({ client, location, onNavigate, account, onError, now, clas
83
110
  reported.current = failure;
84
111
  onError?.(failure);
85
112
  }, [failure, onError]);
113
+ const current = boxes.data?.find((box) => box.id === mailboxId);
114
+ const summary = threads.data?.items.find((item) => item.id === threadId);
115
+ /* Stalwart creates five mailboxes and Archive is not among them -- Inbox,
116
+ Drafts, Sent Items, Junk Mail, Deleted Items. So the button is offered
117
+ only where there is somewhere for it to move mail to, rather than being
118
+ a control that fails the same way every time it is pressed on the
119
+ commonest server this client faces. Somebody who has made an Archive
120
+ mailbox gets it; somebody who has not never sees it. */
121
+ const archiveBox = boxes.data ? findRole(boxes.data, 'archive') : undefined;
122
+ const trashBox = boxes.data ? findRole(boxes.data, 'trash') : undefined;
86
123
  const go = useCallback((mailbox) => {
87
124
  navigate({ mailboxId: mailbox.id, threadId: null });
88
125
  setPosition(0);
89
126
  setQuery('');
90
127
  setText('');
128
+ setDrawerOpen(false);
91
129
  }, [navigate]);
92
- const openThread = useCallback((summary) => setThreadId(summary.id), [setThreadId]);
93
- /* Cmd-K, and Escape out of a search. Bound on the window rather than on a
94
- container, because the palette has to open from wherever focus is --
95
- including from inside the message being read. */
130
+ const openThread = useCallback((picked) => setThreadId(picked.id), [setThreadId]);
131
+ /**
132
+ * Opening a conversation is what marks it read.
133
+ *
134
+ * Fired from the thread actually arriving rather than from the click, so a
135
+ * thread reached by a pasted URL is marked too. The guard is the unread
136
+ * count, not a ref: a thread re-opened after being marked unread again has
137
+ * to mark again, which a "have I done this one" set would prevent.
138
+ */
96
139
  useEffect(() => {
97
- const onKey = (event) => {
98
- if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
99
- event.preventDefault();
100
- setPaletteOpen(true);
101
- }
140
+ if (!threadId || !summary || summary.unreadCount === 0)
141
+ return;
142
+ let live = true;
143
+ setRead(client, threadId, true)
144
+ .then(() => live && refresh())
145
+ .catch(() => {
146
+ /* Left unread rather than announced. The count is still right --
147
+ nothing was changed -- and a toast for a failure the person did not
148
+ ask for is noise on top of a mail server that is already unwell. */
149
+ });
150
+ return () => {
151
+ live = false;
102
152
  };
103
- window.addEventListener('keydown', onKey);
104
- return () => window.removeEventListener('keydown', onKey);
105
- }, []);
153
+ }, [client, threadId, summary, refresh]);
106
154
  const identity = identities.data?.[0];
107
- const startReply = (message, all) => {
155
+ const startReply = useCallback((message, all) => {
108
156
  if (!identity)
109
157
  return;
110
158
  setSendError(undefined);
111
159
  setDraft(replyDraft(message, { identity, all }));
112
- };
113
- const startForward = (message) => {
160
+ }, [identity]);
161
+ const startForward = useCallback((message) => {
114
162
  if (!identity)
115
163
  return;
116
164
  setSendError(undefined);
117
165
  setDraft(forwardDraft(message, { identity }));
118
- };
166
+ }, [identity]);
119
167
  const startNew = useCallback(() => {
120
168
  if (!identity)
121
169
  return;
@@ -133,6 +181,7 @@ export function Mail({ client, location, onNavigate, account, onError, now, clas
133
181
  ...(boxes.data ? { mailboxes: boxes.data } : {}),
134
182
  });
135
183
  setDraft(null);
184
+ setNote('Message sent.');
136
185
  refresh();
137
186
  }
138
187
  catch (error) {
@@ -142,6 +191,195 @@ export function Mail({ client, location, onNavigate, account, onError, now, clas
142
191
  setSending(false);
143
192
  }
144
193
  };
194
+ /**
195
+ * An action on the open conversation.
196
+ *
197
+ * Every one of them ends the same way -- the list is stale, and on the
198
+ * layouts where the message has its own column the selection now points at
199
+ * a thread that is no longer in the mailbox being shown. So the selection
200
+ * is cleared and the list refetched, once, here, rather than three times at
201
+ * three call sites that would each forget a different half of it.
202
+ */
203
+ const act = useCallback(async (what, said, clears = true) => {
204
+ if (!threadId)
205
+ return;
206
+ setActing(true);
207
+ try {
208
+ await what();
209
+ if (clears)
210
+ setThreadId(null);
211
+ setNote(said);
212
+ refresh();
213
+ }
214
+ catch (error) {
215
+ setNote(error instanceof Error ? error.message : 'That did not work.');
216
+ }
217
+ finally {
218
+ setActing(false);
219
+ }
220
+ }, [threadId, setThreadId, refresh]);
221
+ const archive = useCallback(() => {
222
+ if (!threadId)
223
+ return;
224
+ void act(() => moveToRole(client, threadId, 'archive', { mailboxes: boxes.data ?? [] }), 'Archived.');
225
+ }, [act, client, threadId, boxes.data]);
226
+ const trash = useCallback(() => {
227
+ if (!threadId)
228
+ return;
229
+ void act(() => moveToRole(client, threadId, 'trash', { mailboxes: boxes.data ?? [] }), 'Moved to Deleted Items.');
230
+ }, [act, client, threadId, boxes.data]);
231
+ const markUnread = useCallback(() => {
232
+ if (!threadId)
233
+ return;
234
+ void act(() => setRead(client, threadId, false), 'Marked unread.');
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]);
265
+ const flag = useCallback((next) => {
266
+ if (!threadId)
267
+ return;
268
+ void act(() => setFlagged(client, threadId, next), next ? 'Flagged.' : 'Unflagged.', false);
269
+ }, [act, client, threadId]);
270
+ /**
271
+ * The shortcuts.
272
+ *
273
+ * Bound on the window rather than on a container, because the palette has
274
+ * to open from wherever focus is -- including from inside the message being
275
+ * read.
276
+ *
277
+ * **A field swallows everything but Escape and Cmd-K.** `c` for compose and
278
+ * `c` typed into a subject line are the same keystroke, and a client that
279
+ * cannot tell them apart is a client you cannot write mail in. The test is
280
+ * the event's target, not a focus flag, because focus can be inside a
281
+ * portalled dialog that this component never rendered.
282
+ */
283
+ useEffect(() => {
284
+ const typing = (target) => {
285
+ const el = target;
286
+ if (!el?.tagName)
287
+ return false;
288
+ return (el.tagName === 'INPUT' ||
289
+ el.tagName === 'TEXTAREA' ||
290
+ el.tagName === 'SELECT' ||
291
+ el.isContentEditable);
292
+ };
293
+ const onKey = (event) => {
294
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
295
+ event.preventDefault();
296
+ setPaletteOpen(true);
297
+ return;
298
+ }
299
+ if (event.metaKey || event.ctrlKey || event.altKey)
300
+ return;
301
+ if (typing(event.target))
302
+ return;
303
+ switch (event.key) {
304
+ case '/':
305
+ event.preventDefault();
306
+ searchField.current?.focus();
307
+ break;
308
+ case '?':
309
+ event.preventDefault();
310
+ setHelpOpen(true);
311
+ break;
312
+ case 'c':
313
+ event.preventDefault();
314
+ startNew();
315
+ break;
316
+ case 'e':
317
+ if (!threadId || !archiveBox)
318
+ break;
319
+ event.preventDefault();
320
+ archive();
321
+ break;
322
+ case '#':
323
+ if (!threadId || !trashBox)
324
+ break;
325
+ event.preventDefault();
326
+ trash();
327
+ break;
328
+ case 'u':
329
+ if (!threadId)
330
+ break;
331
+ event.preventDefault();
332
+ markUnread();
333
+ break;
334
+ case 's':
335
+ if (!threadId)
336
+ break;
337
+ event.preventDefault();
338
+ flag(!summary?.isFlagged);
339
+ break;
340
+ case 'r':
341
+ case 'a': {
342
+ const newest = thread.data?.messages[thread.data.messages.length - 1];
343
+ if (!newest)
344
+ break;
345
+ event.preventDefault();
346
+ startReply(newest, event.key === 'a');
347
+ break;
348
+ }
349
+ case 'f': {
350
+ const newest = thread.data?.messages[thread.data.messages.length - 1];
351
+ if (!newest)
352
+ break;
353
+ event.preventDefault();
354
+ startForward(newest);
355
+ break;
356
+ }
357
+ case 'Escape':
358
+ if (threadId && panes === 'narrow')
359
+ setThreadId(null);
360
+ break;
361
+ default:
362
+ break;
363
+ }
364
+ };
365
+ window.addEventListener('keydown', onKey);
366
+ return () => window.removeEventListener('keydown', onKey);
367
+ }, [
368
+ threadId,
369
+ thread.data,
370
+ summary,
371
+ panes,
372
+ archive,
373
+ trash,
374
+ markUnread,
375
+ flag,
376
+ startNew,
377
+ startReply,
378
+ startForward,
379
+ setThreadId,
380
+ archiveBox,
381
+ trashBox,
382
+ ]);
145
383
  const paletteGroups = useMemo(() => [
146
384
  {
147
385
  title: 'Go to',
@@ -156,20 +394,93 @@ export function Mail({ client, location, onNavigate, account, onError, now, clas
156
394
  title: 'Message',
157
395
  commands: [
158
396
  { id: 'new', label: 'New message', shortcut: 'C', onRun: startNew },
397
+ ...(threadId && archiveBox
398
+ ? [{ id: 'archive', label: 'Archive', shortcut: 'E', onRun: archive }]
399
+ : []),
400
+ ...(threadId && trashBox
401
+ ? [{ id: 'trash', label: 'Delete', shortcut: '#', onRun: trash }]
402
+ : []),
403
+ ...(threadId
404
+ ? [{ id: 'unread', label: 'Mark unread', shortcut: 'U', onRun: markUnread }]
405
+ : []),
159
406
  { id: 'refresh', label: 'Refresh', shortcut: 'R', onRun: refresh },
407
+ {
408
+ id: 'help',
409
+ label: 'Keyboard shortcuts',
410
+ shortcut: '?',
411
+ onRun: () => setHelpOpen(true),
412
+ },
160
413
  ],
161
414
  },
162
- ], [boxes.data, go, refresh, startNew]);
163
- const current = boxes.data?.find((box) => box.id === mailboxId);
164
- return (_jsxs("div", { className: `mail${className ? ` ${className}` : ''}`, children: [_jsxs("header", { className: "mail-bar", children: [_jsxs(Button, { kind: "primary", onClick: startNew, disabled: !identity, children: [_jsx(Icon, { name: "chat" }), " New"] }), _jsx("form", { className: "mail-search", onSubmit: (event) => {
165
- event.preventDefault();
166
- setQuery(text);
167
- setPosition(0);
168
- setThreadId(null);
169
- }, children: _jsx(Input, { block: true, value: text, placeholder: `Search${current ? ` ${current.name}` : ''}…`, "aria-label": "Search mail", onChange: (event) => setText(event.target.value), onKeyDown: (event) => {
170
- if (event.key !== 'Escape')
171
- return;
172
- setText('');
173
- setQuery('');
174
- } }) }), _jsx(Button, { kind: "ghost", onClick: () => setPaletteOpen(true), "aria-label": "Open the palette", children: _jsx(Icon, { name: "bolt" }) }), account] }), _jsxs(SplitPane, { className: "mail-panes", label: "Mailbox list width", defaultSize: 22, min: 14, max: 40, storageKey: "mail-sidebar", children: [_jsx(MailboxTree, { mailboxes: boxes.data ?? [], selectedId: mailboxId, onSelect: go, loading: boxes.loading }), _jsxs(SplitPane, { label: "Conversation list width", defaultSize: 38, min: 22, max: 60, storageKey: "mail-list", children: [_jsx(ThreadList, { page: threads.data, selectedId: threadId, onSelect: openThread, onPage: setPosition, limit: PAGE, loading: threads.loading, now: now, empty: query ? _jsxs("p", { className: "threads-quiet", children: ["Nothing matches \u201C", query, "\u201D."] }) : undefined }), _jsx(ThreadView, { thread: thread.data, loading: thread.loading, onReply: startReply, onForward: startForward })] })] }), _jsx(Command, { open: paletteOpen, onOpenChange: setPaletteOpen, groups: paletteGroups }), draft && (_jsx(Modal, { title: "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 }) }))] }));
415
+ ], [boxes.data, go, refresh, startNew, threadId, archive, trash, markUnread, archiveBox, trashBox]);
416
+ /* On one column the message replaces the list; on two or three they are
417
+ both up and this is always false. */
418
+ const reading = panes === 'narrow' && threadId !== null;
419
+ const searchBox = (_jsxs("form", { className: "mail-search", onSubmit: (event) => {
420
+ event.preventDefault();
421
+ setQuery(text);
422
+ setPosition(0);
423
+ setThreadId(null);
424
+ },
425
+ /* biome-ignore lint/a11y/useSemanticElements: `role="search"` on the
426
+ form is the landmark, and the rule is proposing the field. The input
427
+ inside this already is `type="search"`; a landmark and a control are
428
+ different things and only one of them can be a region a screen
429
+ reader jumps to. */
430
+ role: "search", children: [_jsx(Icon, { name: "search", size: 15, className: "mail-search-icon" }), _jsx("input", { ref: searchField, className: "mail-search-field", type: "search", value: text, placeholder: `Search${current ? ` ${current.name}` : ' mail'}`, "aria-label": "Search mail", onChange: (event) => setText(event.target.value), onKeyDown: (event) => {
431
+ if (event.key !== 'Escape')
432
+ return;
433
+ setText('');
434
+ setQuery('');
435
+ event.currentTarget.blur();
436
+ } }), query ? (_jsx("button", { type: "button", className: "mail-search-clear", onClick: () => {
437
+ setText('');
438
+ setQuery('');
439
+ }, "aria-label": "Clear the search", children: _jsx(Icon, { name: "close", size: 13 }) })) : (_jsx("span", { className: "mail-search-key", "aria-hidden": "true", children: "/" }))] }));
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 }));
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 }));
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) })] }));
443
+ }
444
+ /**
445
+ * What just happened, said once and then gone.
446
+ *
447
+ * A live region rather than the design system's `Toast`, which is a provider
448
+ * and a hook: `Mail` would have to wrap itself in `ToastHost` to use it, and
449
+ * an application that already has one would then have two regions announcing
450
+ * into the same page. This is a strip that this component owns.
451
+ *
452
+ * `role="status"` is polite, so it waits for a screen reader to finish the
453
+ * sentence it is on rather than interrupting -- right for "Archived." and
454
+ * wrong for anything that needs an answer, which is why nothing here asks
455
+ * for one.
456
+ */
457
+ function Note({ said, onDone }) {
458
+ useEffect(() => {
459
+ if (!said)
460
+ return;
461
+ const timer = window.setTimeout(onDone, 4000);
462
+ return () => window.clearTimeout(timer);
463
+ }, [said, onDone]);
464
+ /* Always rendered, so the region exists in the accessibility tree before
465
+ the text arrives. A live region created at the same moment as its
466
+ content is a live region most screen readers do not announce. */
467
+ return (_jsx("output", { className: `mail-note${said ? ' is-shown' : ''}`, "aria-live": "polite", children: said }));
468
+ }
469
+ /** What the letters do, because a client with shortcuts nobody can discover
470
+ * has no shortcuts. Reachable on `?` and from the palette. */
471
+ function Shortcuts() {
472
+ const rows = [
473
+ ['⌘K', 'Everything — go to a mailbox, or run a command'],
474
+ ['/', 'Search this mailbox'],
475
+ ['↑ ↓ or J K', 'Move through the list'],
476
+ ['C', 'Write a new message'],
477
+ ['R / A', 'Reply / reply all'],
478
+ ['F', 'Forward'],
479
+ ['E', 'Archive'],
480
+ ['#', 'Delete'],
481
+ ['U', 'Mark unread'],
482
+ ['S', 'Flag'],
483
+ ['?', 'This list'],
484
+ ];
485
+ return (_jsx("dl", { className: "keys", children: rows.map(([key, what]) => (_jsxs("div", { className: "keys-row", children: [_jsx("dt", { children: _jsx("kbd", { children: key }) }), _jsx("dd", { children: what })] }, key))) }));
175
486
  }
@@ -6,9 +6,11 @@ export type MailboxTreeProps = {
6
6
  onSelect: (mailbox: MailboxNode) => void;
7
7
  /** Shown in place of the list while the first fetch is out. */
8
8
  loading?: boolean;
9
+ /** Icons only, for a window too narrow to spend a column on names. */
10
+ rail?: boolean;
9
11
  className?: string;
10
12
  };
11
- export declare function MailboxTree({ mailboxes, selectedId, onSelect, loading, className, }: MailboxTreeProps): import("react").JSX.Element;
13
+ export declare function MailboxTree({ mailboxes, selectedId, onSelect, loading, rail, className, }: MailboxTreeProps): import("react").JSX.Element;
12
14
  /** Every mailbox flattened, for a caller that needs the list rather than the
13
15
  * tree -- a command palette's "go to" entries, say. */
14
16
  export declare function flatMailboxes(nodes: readonly MailboxNode[]): MailboxNode[];
@@ -1,5 +1,5 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Icon, ScrollArea } from '@wtfalch/design';
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Icon, ScrollArea, Skeleton } from '@wtfalch/design';
3
3
  import { walk } from "../mailboxes.js";
4
4
  /**
5
5
  * The mailboxes, down the side.
@@ -12,20 +12,39 @@ import { walk } from "../mailboxes.js";
12
12
  * server's order underneath. That reordering belongs here rather than in
13
13
  * `mailboxes()`, which reports what the server said.
14
14
  *
15
+ * **The roles and the folders are two lists with a rule between them.** They
16
+ * were one list, and a mailbox called "Archive 2019" sat in the run of
17
+ * Inbox/Drafts/Sent looking like part of the furniture. The six roles are
18
+ * the client's; everything under "Folders" is the person's.
19
+ *
20
+ * **A folder gets no icon.** The role icons mean something -- an envelope is
21
+ * the inbox, a paper plane is what you sent -- and giving every user folder
22
+ * the same generic folder glyph adds a column of identical marks that carry
23
+ * no information and cost the names their indentation. Depth is drawn with
24
+ * space, which is what depth is.
25
+ *
15
26
  * **The unread count is threads, not messages**, to agree with the list beside
16
27
  * it: a conversation with four unread replies is one bold row there and should
17
- * be one in the count.
28
+ * be one in the count. It sits in a fixed-width slot so a count arriving does
29
+ * not shorten the name beside it.
30
+ *
31
+ * **`rail` is the same list with the names taken away**, for the width where
32
+ * three columns will not fit and the names are the least valuable of them.
33
+ * The name becomes the accessible label rather than disappearing.
18
34
  */
19
35
  /** The order a mail client draws the roles it knows. Anything else sorts
20
36
  * after, in the order the server gave. */
21
37
  const ROLE_ORDER = ['inbox', 'drafts', 'sent', 'archive', 'junk', 'trash'];
38
+ /* The set gained these in `@wtfalch/design` 0.5.0. Before that Inbox was a
39
+ speech bubble, Sent was a download and Deleted Items was a close cross --
40
+ the right shapes for three different words. */
22
41
  const ROLE_ICON = {
23
- inbox: 'chat',
24
- drafts: 'file',
25
- sent: 'download',
42
+ inbox: 'mail',
43
+ drafts: 'pen',
44
+ sent: 'send',
26
45
  archive: 'folder',
27
46
  junk: 'warning',
28
- trash: 'close',
47
+ trash: 'trash',
29
48
  };
30
49
  export function orderForReading(nodes) {
31
50
  const rank = (node) => {
@@ -34,16 +53,27 @@ export function orderForReading(nodes) {
34
53
  };
35
54
  return [...nodes].sort((a, b) => rank(a) - rank(b));
36
55
  }
37
- export function MailboxTree({ mailboxes, selectedId, onSelect, loading = false, className, }) {
56
+ /** The client's mailboxes and the person's, split where the rule goes. */
57
+ function partition(nodes) {
58
+ const ordered = orderForReading(nodes);
59
+ return {
60
+ roles: ordered.filter((node) => node.role && ROLE_ICON[node.role]),
61
+ folders: ordered.filter((node) => !node.role || !ROLE_ICON[node.role]),
62
+ };
63
+ }
64
+ export function MailboxTree({ mailboxes, selectedId, onSelect, loading = false, rail = false, className, }) {
65
+ const classes = `mailboxes${rail ? ' is-rail' : ''}${className ? ` ${className}` : ''}`;
38
66
  if (loading && mailboxes.length === 0) {
39
- return (_jsx("nav", { className: `mailboxes${className ? ` ${className}` : ''}`, "aria-label": "Mailboxes", children: _jsx("p", { className: "mailboxes-quiet", children: "Loading\u2026" }) }));
67
+ return (_jsx("nav", { className: classes, "aria-label": "Mailboxes", "aria-busy": "true", children: _jsx("ul", { className: "mailboxes-list", children: [0, 1, 2, 3, 4].map((n) => (_jsxs("li", { className: "mailbox is-skeleton", children: [_jsx(Skeleton, { variant: "rounded", width: "1.1rem", height: 4 }), !rail && _jsx(Skeleton, { width: `${45 + n * 8}%`, height: 2.5 })] }, n))) }) }));
40
68
  }
41
- const ordered = orderForReading(mailboxes);
42
- return (_jsx(ScrollArea, { className: `mailboxes${className ? ` ${className}` : ''}`, label: "Mailboxes", children: _jsx("nav", { "aria-label": "Mailboxes", children: _jsx("ul", { className: "mailboxes-list", children: ordered.map((node) => (_jsx(MailboxRow, { node: node, depth: 0, selectedId: selectedId, onSelect: onSelect }, node.id))) }) }) }));
69
+ const { roles, folders } = partition(mailboxes);
70
+ return (_jsx(ScrollArea, { className: classes, label: "Mailboxes", children: _jsxs("nav", { "aria-label": "Mailboxes", children: [_jsx("ul", { className: "mailboxes-list", children: roles.map((node) => (_jsx(MailboxRow, { node: node, depth: 0, selectedId: selectedId, onSelect: onSelect, rail: rail }, node.id))) }), folders.length > 0 && (_jsxs(_Fragment, { children: [_jsx("h3", { className: "mailboxes-heading", children: rail ? _jsx("span", { className: "rule" }) : 'Folders' }), _jsx("ul", { className: "mailboxes-list", children: folders.map((node) => (_jsx(MailboxRow, { node: node, depth: 0, selectedId: selectedId, onSelect: onSelect, rail: rail }, node.id))) })] }))] }) }));
43
71
  }
44
- function MailboxRow({ node, depth, selectedId, onSelect, }) {
72
+ function MailboxRow({ node, depth, selectedId, onSelect, rail, }) {
45
73
  const unread = node.unreadThreads;
46
- return (_jsxs("li", { children: [_jsxs("button", { type: "button", className: `mailbox${node.id === selectedId ? ' on' : ''}${unread > 0 ? ' unread' : ''}`, style: { '--depth': depth }, onClick: () => onSelect(node), "aria-current": node.id === selectedId ? 'true' : undefined, children: [node.role && ROLE_ICON[node.role] && (_jsx(Icon, { name: ROLE_ICON[node.role], className: "mailbox-icon" })), _jsx("span", { className: "mailbox-name", children: node.name }), unread > 0 && (_jsxs("span", { className: "mailbox-count", children: [unread, _jsx("span", { className: "sr-only", children: " unread" })] }))] }), node.children.length > 0 && (_jsx("ul", { className: "mailboxes-list", children: node.children.map((child) => (_jsx(MailboxRow, { node: child, depth: depth + 1, selectedId: selectedId, onSelect: onSelect }, child.id))) }))] }));
74
+ const icon = node.role ? ROLE_ICON[node.role] : undefined;
75
+ const selected = node.id === selectedId;
76
+ return (_jsxs("li", { children: [_jsxs("button", { type: "button", className: `mailbox${selected ? ' on' : ''}${unread > 0 ? ' unread' : ''}`, style: { '--depth': depth }, onClick: () => onSelect(node), "aria-current": selected ? 'true' : undefined, "aria-label": rail ? `${node.name}${unread > 0 ? `, ${unread} unread` : ''}` : undefined, title: rail ? node.name : undefined, children: [_jsx("span", { className: "mailbox-icon", "aria-hidden": "true", children: icon ? _jsx(Icon, { name: icon, size: 17 }) : _jsx("span", { className: "mailbox-pip" }) }), !rail && _jsx("span", { className: "mailbox-name", children: node.name }), !rail && (_jsx("span", { className: "mailbox-count", children: unread > 0 && (_jsxs(_Fragment, { children: [unread, _jsx("span", { className: "sr-only", children: " unread" })] })) })), rail && unread > 0 && _jsx("span", { className: "mailbox-pip is-unread", "aria-hidden": "true" })] }), node.children.length > 0 && (_jsx("ul", { className: "mailboxes-list", children: node.children.map((child) => (_jsx(MailboxRow, { node: child, depth: depth + 1, selectedId: selectedId, onSelect: onSelect, rail: rail }, child.id))) }))] }));
47
77
  }
48
78
  /** Every mailbox flattened, for a caller that needs the list rather than the
49
79
  * tree -- a command palette's "go to" entries, say. */