@wtfalch/email 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,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,30 @@ 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
+ /** Back to the list, on a window too narrow to show both. */
53
+ onBack?: () => void;
29
54
  className?: string;
30
55
  };
31
- export declare function ThreadView({ thread, loading, onReply, onForward, onDownload, className, }: ThreadViewProps): import("react").JSX.Element;
56
+ export declare function ThreadView({ thread, loading, onReply, onForward, onArchive, onTrash, onMarkUnread, onFlag, flagged, busy, onDownload, onBack, className, }: ThreadViewProps): import("react").JSX.Element;
32
57
  /** Bytes, the way a mail client says them. */
33
58
  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, 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 }) }, 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
77
  function MessageCard({ message, defaultOpen, onReply, onForward, onDownload, }) {
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,7 @@ 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) => (_jsx("li", { children: _jsxs("button", { type: "button", className: "attachment", onClick: () => onDownload?.(file), disabled: !onDownload, children: [_jsx(Icon, { name: "paperclip", className: "attachment-icon" }), _jsx("span", { className: "attachment-name", children: file.name }), _jsx("span", { className: "attachment-size", children: bytes(file.size) })] }) }, file.blobId))) }))] }))] }));
56
123
  }
57
124
  function Recipients({ message }) {
58
125
  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 +135,7 @@ function Recipients({ message }) {
68
135
  */
69
136
  function HtmlNotice({ html }) {
70
137
  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 })] }));
138
+ 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
139
  }
73
140
  /** Bytes, the way a mail client says them. */
74
141
  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,13 @@ 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 type { Panes } from './layout.ts';
@@ -6,13 +6,14 @@
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";
@@ -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
+ }
@@ -13,13 +13,32 @@ export interface MailObjectsInput {
13
13
  /**
14
14
  * The project's identity provider, once the mail applications are
15
15
  * registered there (docs/plans/email.md E3, phase-4.md slice 1): tokens
16
- * from `issuerUrl` for `audience` (the ZITADEL project id) sign people in,
17
- * on the domain and as the server's default. Absent, the domain keeps the
18
- * internal directory and people have passwords.
16
+ * from `issuerUrl` for `audience` (the provider's project or resource id)
17
+ * sign people in, on the domain and as the server's default. Absent, the
18
+ * domain keeps the internal directory and people have passwords.
19
19
  */
20
20
  oidc?: {
21
21
  issuerUrl: string;
22
22
  audience: string;
23
+ /**
24
+ * Scopes an access token must carry, on top of the audience check.
25
+ *
26
+ * **Empty by default, and that is the safe default rather than a lax
27
+ * one.** Whether an access token carries a `scope` claim at all is the
28
+ * provider's choice: OIDC Core keeps the `profile` and `email` claims at
29
+ * the userinfo endpoint rather than in the token, and a provider that
30
+ * follows it mints access tokens with no `scope` claim to check. Stalwart
31
+ * reads this against the token's own claims when it validates a JWT
32
+ * offline, so a requirement here refuses *every* token such a provider
33
+ * issues — 401 on a credential that is real, current and for this
34
+ * audience, which is a dead end with nothing on screen to explain it.
35
+ *
36
+ * The audience is what binds a token to this server. Set this only for a
37
+ * provider known to put scopes in the token.
38
+ */
39
+ requireScopes?: Record<string, boolean>;
40
+ /** The claim holding the login name. The OIDC standard one by default. */
41
+ claimUsername?: string;
23
42
  };
24
43
  }
25
44
  /** A value that names an object created earlier in the same plan. */
@@ -19,8 +19,8 @@ export function mailObjects(i) {
19
19
  description: oidcDescription,
20
20
  issuerUrl: i.oidc.issuerUrl,
21
21
  requireAudience: i.oidc.audience,
22
- requireScopes: { openid: true, email: true },
23
- claimUsername: 'preferred_username',
22
+ requireScopes: i.oidc.requireScopes ?? {},
23
+ claimUsername: i.oidc.claimUsername ?? 'preferred_username',
24
24
  usernameDomain: i.apex,
25
25
  claimName: 'name',
26
26
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/email",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "The wtfalch estate: reading a mailbox over JMAP, and administering the Stalwart server it lives on. Two entries with no code in common.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -42,7 +42,7 @@
42
42
  "record-fixtures": "node scripts/record-fixtures.mjs"
43
43
  },
44
44
  "peerDependencies": {
45
- "@wtfalch/design": ">=0.4.0",
45
+ "@wtfalch/design": ">=0.5.0",
46
46
  "react": "^19.0.0",
47
47
  "react-dom": "^19.0.0"
48
48
  },
@@ -61,7 +61,7 @@
61
61
  "@types/node": "^22",
62
62
  "@types/react": "^19",
63
63
  "@types/react-dom": "^19",
64
- "@wtfalch/design": "^0.4.0",
64
+ "@wtfalch/design": "^0.5.0",
65
65
  "jsdom": "^30.0.1",
66
66
  "react": "^19",
67
67
  "react-dom": "^19",