@wtfalch/email 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/mailbox/download.d.ts +43 -0
- package/dist/mailbox/download.js +68 -0
- package/dist/mailbox/fake/mailbox.js +61 -1
- package/dist/mailbox/fake/sample.d.ts +0 -9
- package/dist/mailbox/fake/sample.js +224 -4
- package/dist/mailbox/index.d.ts +4 -0
- package/dist/mailbox/index.js +2 -0
- package/dist/mailbox/mail.css +697 -47
- package/dist/mailbox/mutate.d.ts +51 -0
- package/dist/mailbox/mutate.js +132 -0
- package/dist/mailbox/react/Composer.d.ts +5 -0
- package/dist/mailbox/react/Composer.js +11 -2
- package/dist/mailbox/react/Mail.js +343 -32
- package/dist/mailbox/react/MailboxTree.d.ts +3 -1
- package/dist/mailbox/react/MailboxTree.js +43 -13
- package/dist/mailbox/react/ThreadList.d.ts +46 -14
- package/dist/mailbox/react/ThreadList.js +109 -8
- package/dist/mailbox/react/ThreadView.d.ts +33 -4
- package/dist/mailbox/react/ThreadView.js +89 -19
- package/dist/mailbox/react/index.d.ts +6 -3
- package/dist/mailbox/react/index.js +4 -2
- package/dist/mailbox/react/layout.d.ts +31 -0
- package/dist/mailbox/react/layout.js +42 -0
- package/dist/mailbox/react/save.d.ts +25 -0
- package/dist/mailbox/react/save.js +42 -0
- package/package.json +3 -3
|
@@ -1,14 +1,18 @@
|
|
|
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
|
+
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
|
-
* **
|
|
27
|
-
*
|
|
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((
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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: '
|
|
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. */
|