@wtfalch/email 0.4.0 → 0.5.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,16 @@
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
4
  import { forwardDraft, replyDraft } from "../drafts.js";
5
5
  import { findRole } from "../mailboxes.js";
6
+ import { moveToRole, setFlagged, setRead } from "../mutate.js";
6
7
  import { send } from "../submit.js";
7
8
  import { Composer } from "./Composer.js";
8
9
  import { MailboxTree, flatMailboxes } from "./MailboxTree.js";
9
10
  import { ThreadList } from "./ThreadList.js";
10
11
  import { ThreadView } from "./ThreadView.js";
11
12
  import { useIdentities, useMailboxes, usePush, useThread, useThreads } from "./hooks.js";
13
+ import { usePanes } from "./layout.js";
12
14
  /**
13
15
  * The whole client: mailboxes, a list, a message, and a way to write one.
14
16
  *
@@ -18,13 +20,26 @@ import { useIdentities, useMailboxes, usePush, useThread, useThreads } from "./h
18
20
  * having on its own -- an application with its own layout can take the four
19
21
  * views and skip this entirely.
20
22
  *
23
+ * **Three panes, two, or one.** `usePanes` decides, and the difference is not
24
+ * cosmetic: at one column the list and the message occupy the same space, so
25
+ * which is showing becomes state. See `layout.ts` for where the two
26
+ * measurements came from.
27
+ *
21
28
  * **Push moves the list, not the message.** A state change refetches the
22
29
  * mailbox counts and the current page; it deliberately does not refetch the
23
30
  * thread being read, because replacing the message under somebody mid-sentence
24
31
  * is worse than showing them a copy that is thirty seconds old.
25
32
  *
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.
33
+ * **Opening a conversation marks it read**, which is what opening means. Only
34
+ * when there is something unread in it: an `Email/set` that changes nothing
35
+ * still moves the account's state string and wakes every other client's push
36
+ * connection.
37
+ *
38
+ * **The keyboard is the point.** Cmd-K opens the palette; the single letters
39
+ * are the ones every mail client has had since pine, and `?` lists them. They
40
+ * are bound at the root rather than per pane so they work from inside the
41
+ * message as well as from the list -- and they are ignored while a field has
42
+ * focus, or `c` could never be typed into a subject.
28
43
  */
29
44
  const PAGE = 50;
30
45
  export function Mail({ client, location, onNavigate, account, onError, now, className, }) {
@@ -46,6 +61,7 @@ export function Mail({ client, location, onNavigate, account, onError, now, clas
46
61
  }, [controlled, onNavigate]);
47
62
  const setMailboxId = useCallback((next) => navigate({ mailboxId: next, threadId: null }), [navigate]);
48
63
  const setThreadId = useCallback((next) => navigate({ mailboxId, threadId: next }), [navigate, mailboxId]);
64
+ const panes = usePanes();
49
65
  const [position, setPosition] = useState(0);
50
66
  const [query, setQuery] = useState('');
51
67
  const [text, setText] = useState('');
@@ -53,6 +69,11 @@ export function Mail({ client, location, onNavigate, account, onError, now, clas
53
69
  const [sending, setSending] = useState(false);
54
70
  const [sendError, setSendError] = useState();
55
71
  const [paletteOpen, setPaletteOpen] = useState(false);
72
+ const [drawerOpen, setDrawerOpen] = useState(false);
73
+ const [helpOpen, setHelpOpen] = useState(false);
74
+ const [acting, setActing] = useState(false);
75
+ const [note, setNote] = useState(null);
76
+ const searchField = useRef(null);
56
77
  const boxes = useMailboxes(client);
57
78
  const identities = useIdentities(client);
58
79
  /* The inbox, once, when the mailboxes first arrive. Not in an effect that
@@ -83,39 +104,60 @@ export function Mail({ client, location, onNavigate, account, onError, now, clas
83
104
  reported.current = failure;
84
105
  onError?.(failure);
85
106
  }, [failure, onError]);
107
+ const current = boxes.data?.find((box) => box.id === mailboxId);
108
+ const summary = threads.data?.items.find((item) => item.id === threadId);
109
+ /* Stalwart creates five mailboxes and Archive is not among them -- Inbox,
110
+ Drafts, Sent Items, Junk Mail, Deleted Items. So the button is offered
111
+ only where there is somewhere for it to move mail to, rather than being
112
+ a control that fails the same way every time it is pressed on the
113
+ commonest server this client faces. Somebody who has made an Archive
114
+ mailbox gets it; somebody who has not never sees it. */
115
+ const archiveBox = boxes.data ? findRole(boxes.data, 'archive') : undefined;
116
+ const trashBox = boxes.data ? findRole(boxes.data, 'trash') : undefined;
86
117
  const go = useCallback((mailbox) => {
87
118
  navigate({ mailboxId: mailbox.id, threadId: null });
88
119
  setPosition(0);
89
120
  setQuery('');
90
121
  setText('');
122
+ setDrawerOpen(false);
91
123
  }, [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. */
124
+ const openThread = useCallback((picked) => setThreadId(picked.id), [setThreadId]);
125
+ /**
126
+ * Opening a conversation is what marks it read.
127
+ *
128
+ * Fired from the thread actually arriving rather than from the click, so a
129
+ * thread reached by a pasted URL is marked too. The guard is the unread
130
+ * count, not a ref: a thread re-opened after being marked unread again has
131
+ * to mark again, which a "have I done this one" set would prevent.
132
+ */
96
133
  useEffect(() => {
97
- const onKey = (event) => {
98
- if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
99
- event.preventDefault();
100
- setPaletteOpen(true);
101
- }
134
+ if (!threadId || !summary || summary.unreadCount === 0)
135
+ return;
136
+ let live = true;
137
+ setRead(client, threadId, true)
138
+ .then(() => live && refresh())
139
+ .catch(() => {
140
+ /* Left unread rather than announced. The count is still right --
141
+ nothing was changed -- and a toast for a failure the person did not
142
+ ask for is noise on top of a mail server that is already unwell. */
143
+ });
144
+ return () => {
145
+ live = false;
102
146
  };
103
- window.addEventListener('keydown', onKey);
104
- return () => window.removeEventListener('keydown', onKey);
105
- }, []);
147
+ }, [client, threadId, summary, refresh]);
106
148
  const identity = identities.data?.[0];
107
- const startReply = (message, all) => {
149
+ const startReply = useCallback((message, all) => {
108
150
  if (!identity)
109
151
  return;
110
152
  setSendError(undefined);
111
153
  setDraft(replyDraft(message, { identity, all }));
112
- };
113
- const startForward = (message) => {
154
+ }, [identity]);
155
+ const startForward = useCallback((message) => {
114
156
  if (!identity)
115
157
  return;
116
158
  setSendError(undefined);
117
159
  setDraft(forwardDraft(message, { identity }));
118
- };
160
+ }, [identity]);
119
161
  const startNew = useCallback(() => {
120
162
  if (!identity)
121
163
  return;
@@ -133,6 +175,7 @@ export function Mail({ client, location, onNavigate, account, onError, now, clas
133
175
  ...(boxes.data ? { mailboxes: boxes.data } : {}),
134
176
  });
135
177
  setDraft(null);
178
+ setNote('Message sent.');
136
179
  refresh();
137
180
  }
138
181
  catch (error) {
@@ -142,6 +185,166 @@ export function Mail({ client, location, onNavigate, account, onError, now, clas
142
185
  setSending(false);
143
186
  }
144
187
  };
188
+ /**
189
+ * An action on the open conversation.
190
+ *
191
+ * Every one of them ends the same way -- the list is stale, and on the
192
+ * layouts where the message has its own column the selection now points at
193
+ * a thread that is no longer in the mailbox being shown. So the selection
194
+ * is cleared and the list refetched, once, here, rather than three times at
195
+ * three call sites that would each forget a different half of it.
196
+ */
197
+ const act = useCallback(async (what, said, clears = true) => {
198
+ if (!threadId)
199
+ return;
200
+ setActing(true);
201
+ try {
202
+ await what();
203
+ if (clears)
204
+ setThreadId(null);
205
+ setNote(said);
206
+ refresh();
207
+ }
208
+ catch (error) {
209
+ setNote(error instanceof Error ? error.message : 'That did not work.');
210
+ }
211
+ finally {
212
+ setActing(false);
213
+ }
214
+ }, [threadId, setThreadId, refresh]);
215
+ const archive = useCallback(() => {
216
+ if (!threadId)
217
+ return;
218
+ void act(() => moveToRole(client, threadId, 'archive', { mailboxes: boxes.data ?? [] }), 'Archived.');
219
+ }, [act, client, threadId, boxes.data]);
220
+ const trash = useCallback(() => {
221
+ if (!threadId)
222
+ return;
223
+ void act(() => moveToRole(client, threadId, 'trash', { mailboxes: boxes.data ?? [] }), 'Moved to Deleted Items.');
224
+ }, [act, client, threadId, boxes.data]);
225
+ const markUnread = useCallback(() => {
226
+ if (!threadId)
227
+ return;
228
+ void act(() => setRead(client, threadId, false), 'Marked unread.');
229
+ }, [act, client, threadId]);
230
+ const flag = useCallback((next) => {
231
+ if (!threadId)
232
+ return;
233
+ void act(() => setFlagged(client, threadId, next), next ? 'Flagged.' : 'Unflagged.', false);
234
+ }, [act, client, threadId]);
235
+ /**
236
+ * The shortcuts.
237
+ *
238
+ * Bound on the window rather than on a container, because the palette has
239
+ * to open from wherever focus is -- including from inside the message being
240
+ * read.
241
+ *
242
+ * **A field swallows everything but Escape and Cmd-K.** `c` for compose and
243
+ * `c` typed into a subject line are the same keystroke, and a client that
244
+ * cannot tell them apart is a client you cannot write mail in. The test is
245
+ * the event's target, not a focus flag, because focus can be inside a
246
+ * portalled dialog that this component never rendered.
247
+ */
248
+ useEffect(() => {
249
+ const typing = (target) => {
250
+ const el = target;
251
+ if (!el?.tagName)
252
+ return false;
253
+ return (el.tagName === 'INPUT' ||
254
+ el.tagName === 'TEXTAREA' ||
255
+ el.tagName === 'SELECT' ||
256
+ el.isContentEditable);
257
+ };
258
+ const onKey = (event) => {
259
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
260
+ event.preventDefault();
261
+ setPaletteOpen(true);
262
+ return;
263
+ }
264
+ if (event.metaKey || event.ctrlKey || event.altKey)
265
+ return;
266
+ if (typing(event.target))
267
+ return;
268
+ switch (event.key) {
269
+ case '/':
270
+ event.preventDefault();
271
+ searchField.current?.focus();
272
+ break;
273
+ case '?':
274
+ event.preventDefault();
275
+ setHelpOpen(true);
276
+ break;
277
+ case 'c':
278
+ event.preventDefault();
279
+ startNew();
280
+ break;
281
+ case 'e':
282
+ if (!threadId || !archiveBox)
283
+ break;
284
+ event.preventDefault();
285
+ archive();
286
+ break;
287
+ case '#':
288
+ if (!threadId || !trashBox)
289
+ break;
290
+ event.preventDefault();
291
+ trash();
292
+ break;
293
+ case 'u':
294
+ if (!threadId)
295
+ break;
296
+ event.preventDefault();
297
+ markUnread();
298
+ break;
299
+ case 's':
300
+ if (!threadId)
301
+ break;
302
+ event.preventDefault();
303
+ flag(!summary?.isFlagged);
304
+ break;
305
+ case 'r':
306
+ case 'a': {
307
+ const newest = thread.data?.messages[thread.data.messages.length - 1];
308
+ if (!newest)
309
+ break;
310
+ event.preventDefault();
311
+ startReply(newest, event.key === 'a');
312
+ break;
313
+ }
314
+ case 'f': {
315
+ const newest = thread.data?.messages[thread.data.messages.length - 1];
316
+ if (!newest)
317
+ break;
318
+ event.preventDefault();
319
+ startForward(newest);
320
+ break;
321
+ }
322
+ case 'Escape':
323
+ if (threadId && panes === 'narrow')
324
+ setThreadId(null);
325
+ break;
326
+ default:
327
+ break;
328
+ }
329
+ };
330
+ window.addEventListener('keydown', onKey);
331
+ return () => window.removeEventListener('keydown', onKey);
332
+ }, [
333
+ threadId,
334
+ thread.data,
335
+ summary,
336
+ panes,
337
+ archive,
338
+ trash,
339
+ markUnread,
340
+ flag,
341
+ startNew,
342
+ startReply,
343
+ startForward,
344
+ setThreadId,
345
+ archiveBox,
346
+ trashBox,
347
+ ]);
145
348
  const paletteGroups = useMemo(() => [
146
349
  {
147
350
  title: 'Go to',
@@ -156,20 +359,93 @@ export function Mail({ client, location, onNavigate, account, onError, now, clas
156
359
  title: 'Message',
157
360
  commands: [
158
361
  { id: 'new', label: 'New message', shortcut: 'C', onRun: startNew },
362
+ ...(threadId && archiveBox
363
+ ? [{ id: 'archive', label: 'Archive', shortcut: 'E', onRun: archive }]
364
+ : []),
365
+ ...(threadId && trashBox
366
+ ? [{ id: 'trash', label: 'Delete', shortcut: '#', onRun: trash }]
367
+ : []),
368
+ ...(threadId
369
+ ? [{ id: 'unread', label: 'Mark unread', shortcut: 'U', onRun: markUnread }]
370
+ : []),
159
371
  { id: 'refresh', label: 'Refresh', shortcut: 'R', onRun: refresh },
372
+ {
373
+ id: 'help',
374
+ label: 'Keyboard shortcuts',
375
+ shortcut: '?',
376
+ onRun: () => setHelpOpen(true),
377
+ },
160
378
  ],
161
379
  },
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 }) }))] }));
380
+ ], [boxes.data, go, refresh, startNew, threadId, archive, trash, markUnread, archiveBox, trashBox]);
381
+ /* On one column the message replaces the list; on two or three they are
382
+ both up and this is always false. */
383
+ const reading = panes === 'narrow' && threadId !== null;
384
+ const searchBox = (_jsxs("form", { className: "mail-search", onSubmit: (event) => {
385
+ event.preventDefault();
386
+ setQuery(text);
387
+ setPosition(0);
388
+ setThreadId(null);
389
+ },
390
+ /* biome-ignore lint/a11y/useSemanticElements: `role="search"` on the
391
+ form is the landmark, and the rule is proposing the field. The input
392
+ inside this already is `type="search"`; a landmark and a control are
393
+ different things and only one of them can be a region a screen
394
+ reader jumps to. */
395
+ 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) => {
396
+ if (event.key !== 'Escape')
397
+ return;
398
+ setText('');
399
+ setQuery('');
400
+ event.currentTarget.blur();
401
+ } }), query ? (_jsx("button", { type: "button", className: "mail-search-clear", onClick: () => {
402
+ setText('');
403
+ setQuery('');
404
+ }, "aria-label": "Clear the search", children: _jsx(Icon, { name: "close", size: 13 }) })) : (_jsx("span", { className: "mail-search-key", "aria-hidden": "true", children: "/" }))] }));
405
+ 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 }));
407
+ 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
+ }
409
+ /**
410
+ * What just happened, said once and then gone.
411
+ *
412
+ * A live region rather than the design system's `Toast`, which is a provider
413
+ * and a hook: `Mail` would have to wrap itself in `ToastHost` to use it, and
414
+ * an application that already has one would then have two regions announcing
415
+ * into the same page. This is a strip that this component owns.
416
+ *
417
+ * `role="status"` is polite, so it waits for a screen reader to finish the
418
+ * sentence it is on rather than interrupting -- right for "Archived." and
419
+ * wrong for anything that needs an answer, which is why nothing here asks
420
+ * for one.
421
+ */
422
+ function Note({ said, onDone }) {
423
+ useEffect(() => {
424
+ if (!said)
425
+ return;
426
+ const timer = window.setTimeout(onDone, 4000);
427
+ return () => window.clearTimeout(timer);
428
+ }, [said, onDone]);
429
+ /* Always rendered, so the region exists in the accessibility tree before
430
+ the text arrives. A live region created at the same moment as its
431
+ content is a live region most screen readers do not announce. */
432
+ return (_jsx("output", { className: `mail-note${said ? ' is-shown' : ''}`, "aria-live": "polite", children: said }));
433
+ }
434
+ /** What the letters do, because a client with shortcuts nobody can discover
435
+ * has no shortcuts. Reachable on `?` and from the palette. */
436
+ function Shortcuts() {
437
+ const rows = [
438
+ ['⌘K', 'Everything — go to a mailbox, or run a command'],
439
+ ['/', 'Search this mailbox'],
440
+ ['↑ ↓ or J K', 'Move through the list'],
441
+ ['C', 'Write a new message'],
442
+ ['R / A', 'Reply / reply all'],
443
+ ['F', 'Forward'],
444
+ ['E', 'Archive'],
445
+ ['#', 'Delete'],
446
+ ['U', 'Mark unread'],
447
+ ['S', 'Flag'],
448
+ ['?', 'This list'],
449
+ ];
450
+ 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
451
  }
@@ -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. */
@@ -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
- * **Unread is weight, not colour.** A blue dot beside a subject is invisible
6
- * to a reader who cannot distinguish it and disappears on a projector; the
7
- * whole row goes to the strong weight instead, which survives both. The dot
8
- * is there as well, because two signals are better than one, and it carries
9
- * the accessible text that says which rows they are.
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. A list of absolute timestamps is a list
14
- * nobody reads.
28
+ * reader both get the real thing.
15
29
  *
16
- * **A conversation says how many messages it holds**, because "3" beside a
17
- * subject is the difference between one mail and a thread you are behind on.
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
- /** Now, for the relative times. Passed in so a story and a screenshot are
30
- * stable rather than drifting with the clock. */
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, because
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
- export declare function ThreadList({ page, selectedId, onSelect, onPage, limit, loading, empty, now, className, }: ThreadListProps): import("react").JSX.Element;
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;