@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.
- 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 +2 -0
- package/dist/mailbox/index.js +1 -0
- package/dist/mailbox/mail.css +693 -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 +308 -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 +29 -4
- package/dist/mailbox/react/ThreadView.js +85 -18
- package/dist/mailbox/react/index.d.ts +5 -3
- package/dist/mailbox/react/index.js +3 -2
- package/dist/mailbox/react/layout.d.ts +31 -0
- package/dist/mailbox/react/layout.js +42 -0
- package/dist/postmaster/objects.d.ts +22 -3
- package/dist/postmaster/objects.js +2 -2
- package/package.json +3 -3
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Button, Command, Icon,
|
|
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
|
-
* **
|
|
27
|
-
*
|
|
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((
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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: '
|
|
24
|
-
drafts: '
|
|
25
|
-
sent: '
|
|
42
|
+
inbox: 'mail',
|
|
43
|
+
drafts: 'pen',
|
|
44
|
+
sent: 'send',
|
|
26
45
|
archive: 'folder',
|
|
27
46
|
junk: 'warning',
|
|
28
|
-
trash: '
|
|
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
|
-
|
|
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:
|
|
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
|
|
42
|
-
return (_jsx(ScrollArea, { className:
|
|
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
|
-
|
|
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
|
-
* **
|
|
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;
|