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