@remit/web-client 0.0.46 → 0.0.48
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/package.json +1 -1
- package/src/components/layout/MailTopBar.tsx +5 -9
- package/src/components/mail/DailyBrief.tsx +2 -36
- package/src/components/mail/FlaggedList.tsx +2 -2
- package/src/components/mail/MessageListItem.tsx +70 -293
- package/src/components/mail/MessageRow.render.test.ts +61 -0
- package/src/components/mail/MessageRow.tsx +279 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.48",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
|
|
6
6
|
"exports": {
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* MailTopBar — the app's one search surface and its global actions.
|
|
3
3
|
*
|
|
4
|
-
* Mounted
|
|
5
|
-
*
|
|
6
|
-
* list header drops its own field wherever this bar is
|
|
7
|
-
* search input exists on the page and the "/" shortcut
|
|
4
|
+
* Mounted by the `/mail` shell over the list, the reading pane and the
|
|
5
|
+
* intelligence rail, starting on the list's left edge. It is the app's search,
|
|
6
|
+
* not the list's: the list header drops its own field wherever this bar is
|
|
7
|
+
* mounted, so exactly one search input exists on the page and the "/" shortcut
|
|
8
|
+
* has one target.
|
|
8
9
|
*
|
|
9
10
|
* The actions here are the ones that belong to the app rather than to whatever
|
|
10
11
|
* is currently listed or open — compose, bug report, account. Reply, delete,
|
|
@@ -54,11 +55,6 @@ export function MailTopBar({ accounts }: MailTopBarProps) {
|
|
|
54
55
|
|
|
55
56
|
return (
|
|
56
57
|
<AppTopBar
|
|
57
|
-
leading={
|
|
58
|
-
<span className="px-1 text-sm font-semibold tracking-tight text-fg">
|
|
59
|
-
remit
|
|
60
|
-
</span>
|
|
61
|
-
}
|
|
62
58
|
search={
|
|
63
59
|
<SearchBar
|
|
64
60
|
value={searchInput}
|
|
@@ -21,17 +21,12 @@ import {
|
|
|
21
21
|
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
22
22
|
import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
|
|
23
23
|
import {
|
|
24
|
-
Avatar,
|
|
25
24
|
type BriefCategoryFilter,
|
|
26
25
|
BriefSections,
|
|
27
26
|
briefFilterConfig,
|
|
28
|
-
ComfortableRowTextContent,
|
|
29
|
-
cn,
|
|
30
|
-
comfortableRowClass,
|
|
31
27
|
type FilterSheetProps,
|
|
32
28
|
type FilterSheetSource,
|
|
33
29
|
KeyboardHintBar,
|
|
34
|
-
LIST_ROW_ATTRIBUTE,
|
|
35
30
|
type SearchResult,
|
|
36
31
|
type ThreadRowData,
|
|
37
32
|
type ThreadSection,
|
|
@@ -56,6 +51,7 @@ import { useMailContext } from "@/lib/mail-context";
|
|
|
56
51
|
import { relatedSearchResults, rowToSearchResult } from "@/lib/search-result";
|
|
57
52
|
import { parseSearchTokens } from "@/lib/search-tokens";
|
|
58
53
|
import { MailListHeader } from "./MailListHeader";
|
|
54
|
+
import { MessageRow } from "./MessageRow";
|
|
59
55
|
|
|
60
56
|
/* The brief's attribute chips as predicates (mirrors the kit `briefFilterChips`
|
|
61
57
|
ids) so the phone search takeover narrows results the same way the list does. */
|
|
@@ -124,36 +120,6 @@ const ErrorBanner = ({ accountEmail }: ErrorBannerProps) => {
|
|
|
124
120
|
);
|
|
125
121
|
};
|
|
126
122
|
|
|
127
|
-
// ---------------------------------------------------------------------------
|
|
128
|
-
// Brief row — a navigation-aware row satisfying remit-ui's BriefRowComponent
|
|
129
|
-
// ---------------------------------------------------------------------------
|
|
130
|
-
|
|
131
|
-
const BriefRow = ({
|
|
132
|
-
thread,
|
|
133
|
-
active,
|
|
134
|
-
onClick,
|
|
135
|
-
}: {
|
|
136
|
-
thread: ThreadRowData;
|
|
137
|
-
active?: boolean;
|
|
138
|
-
onClick?: () => void;
|
|
139
|
-
}) => {
|
|
140
|
-
const unread = !thread.isRead;
|
|
141
|
-
return (
|
|
142
|
-
<button
|
|
143
|
-
type="button"
|
|
144
|
-
{...LIST_ROW_ATTRIBUTE}
|
|
145
|
-
onClick={onClick}
|
|
146
|
-
className={cn("group w-full", comfortableRowClass({ active }))}
|
|
147
|
-
>
|
|
148
|
-
{unread && (
|
|
149
|
-
<span className="absolute left-1.5 top-1/2 size-1.5 -translate-y-1/2 rounded-full bg-accent" />
|
|
150
|
-
)}
|
|
151
|
-
<Avatar name={thread.fromName} email={thread.fromEmail} size="sm" />
|
|
152
|
-
<ComfortableRowTextContent thread={thread} />
|
|
153
|
-
</button>
|
|
154
|
-
);
|
|
155
|
-
};
|
|
156
|
-
|
|
157
123
|
// ---------------------------------------------------------------------------
|
|
158
124
|
// Main component
|
|
159
125
|
// ---------------------------------------------------------------------------
|
|
@@ -441,7 +407,7 @@ export function DailyBrief({
|
|
|
441
407
|
) : (
|
|
442
408
|
<BriefSections
|
|
443
409
|
sections={sections}
|
|
444
|
-
Row={
|
|
410
|
+
Row={MessageRow}
|
|
445
411
|
briefCategory={selectedCategory}
|
|
446
412
|
onSelectBriefCategory={setSelectedCategory}
|
|
447
413
|
sources={accountSources}
|
|
@@ -14,7 +14,6 @@
|
|
|
14
14
|
* `listBody` so the real rows render at every width.
|
|
15
15
|
*/
|
|
16
16
|
import {
|
|
17
|
-
ComfortableRow,
|
|
18
17
|
flaggedFilterConfig,
|
|
19
18
|
LIST_ROW_SELECTOR,
|
|
20
19
|
MessageListPane,
|
|
@@ -37,6 +36,7 @@ import { rowToSearchResult } from "@/lib/search-result";
|
|
|
37
36
|
import { parseSearchTokens } from "@/lib/search-tokens";
|
|
38
37
|
import { dedupeByThread } from "@/lib/starred-rows";
|
|
39
38
|
import { MailViewChrome } from "./MailViewChrome";
|
|
39
|
+
import { MessageRow } from "./MessageRow";
|
|
40
40
|
|
|
41
41
|
const FILTER_PREDICATES: Record<string, (t: ThreadRowData) => boolean> = {
|
|
42
42
|
unread: (t) => !t.isRead,
|
|
@@ -138,7 +138,7 @@ export function FlaggedList({
|
|
|
138
138
|
<div ref={listRef} className="flex-1 overflow-y-auto">
|
|
139
139
|
<div className="divide-y divide-line">
|
|
140
140
|
{rows.map((thread) => (
|
|
141
|
-
<
|
|
141
|
+
<MessageRow
|
|
142
142
|
key={thread.id}
|
|
143
143
|
thread={thread}
|
|
144
144
|
active={thread.id === selectedMessageId}
|
|
@@ -1,31 +1,19 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* MessageListItem — the mailbox list's adapter onto the shared `MessageRow`.
|
|
3
|
+
*
|
|
4
|
+
* It maps an API thread to the row's `ThreadRowData` shape and supplies the two
|
|
5
|
+
* things only the mailbox list has: route-linking rows and the auto-moved
|
|
6
|
+
* badge. Everything visual and interactive lives in `MessageRow`, which the
|
|
7
|
+
* brief and Flagged render too.
|
|
8
|
+
*/
|
|
2
9
|
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
ComfortableRowTextContent,
|
|
6
|
-
CompactRowBody,
|
|
7
|
-
comfortableRowClass,
|
|
8
|
-
compactRowClass,
|
|
9
|
-
type Density,
|
|
10
|
-
mergeProps,
|
|
11
|
-
type SenderTrustLevel,
|
|
12
|
-
type ThreadRowData,
|
|
13
|
-
useLongPress,
|
|
14
|
-
} from "@remit/ui";
|
|
15
|
-
import { useQueryClient } from "@tanstack/react-query";
|
|
16
|
-
import { Link } from "@tanstack/react-router";
|
|
17
|
-
import { Check } from "lucide-react";
|
|
18
|
-
import { type MouseEvent, memo, useCallback } from "react";
|
|
10
|
+
import type { Density, SenderTrustLevel, ThreadRowData } from "@remit/ui";
|
|
11
|
+
import { memo } from "react";
|
|
19
12
|
import type { SelectionModifiers } from "@/hooks/useSelection";
|
|
20
13
|
import { toDisplayCategory } from "@/lib/display-category";
|
|
21
14
|
import { formatEmailDate } from "@/lib/format";
|
|
22
|
-
import { cn } from "@/lib/utils";
|
|
23
15
|
import { AutoMovedIndicator } from "./AutoMovedIndicator";
|
|
24
|
-
|
|
25
|
-
interface MailboxLinkSearch {
|
|
26
|
-
selectedMessageId?: string;
|
|
27
|
-
q?: string;
|
|
28
|
-
}
|
|
16
|
+
import { MessageRow } from "./MessageRow";
|
|
29
17
|
|
|
30
18
|
interface MessageListItemProps {
|
|
31
19
|
thread: RemitImapThreadMessageResponse;
|
|
@@ -33,305 +21,94 @@ interface MessageListItemProps {
|
|
|
33
21
|
/** Owning account, used to resolve the Inbox/Junk mailboxes for the auto-moved badge's undo action. */
|
|
34
22
|
accountId?: string;
|
|
35
23
|
isSelected: boolean;
|
|
36
|
-
/**
|
|
37
|
-
* Roving keyboard focus cursor (#429). Distinct from `isSelected` (the open
|
|
38
|
-
* thread): a focused-but-not-open row shows the left accent rail; the open
|
|
39
|
-
* row shows the full highlight. Both can be true (the open row stays focused).
|
|
40
|
-
*/
|
|
41
24
|
isFocused?: boolean;
|
|
42
|
-
/**
|
|
43
|
-
* Whether this row holds the list's single tab stop (roving tabindex). Every
|
|
44
|
-
* other row is `tabIndex={-1}`, so Tab enters the list at the cursor and
|
|
45
|
-
* Shift+Tab leaves it rather than stepping through hundreds of rows.
|
|
46
|
-
*/
|
|
47
25
|
isTabStop?: boolean;
|
|
48
|
-
/** Called when the row takes DOM focus, so the roving cursor follows it. */
|
|
49
26
|
onFocusRow?: (messageId: string) => void;
|
|
50
27
|
isChecked: boolean;
|
|
51
28
|
onToggleCheck: (id: string) => void;
|
|
52
|
-
/**
|
|
53
|
-
* Desktop mouse selection. Called from the row's onClick with the click
|
|
54
|
-
* modifiers. Returns true when selection consumed the click (the row should
|
|
55
|
-
* not navigate); false for a plain click (navigation proceeds).
|
|
56
|
-
*/
|
|
57
29
|
onRowSelect: (messageId: string, modifiers: SelectionModifiers) => boolean;
|
|
58
30
|
messageCount?: number;
|
|
59
|
-
/** When true, the checkbox is always visible (e.g. mobile multi-select mode). */
|
|
60
31
|
isMultiSelectMode?: boolean;
|
|
61
|
-
/** Called on long press (mobile only). Receives the row's messageId. */
|
|
62
32
|
onLongPress?: (messageId: string) => void;
|
|
63
|
-
/** Whether the current viewport is desktop size. */
|
|
64
33
|
isDesktop?: boolean;
|
|
65
|
-
/** Row density — comfortable (default) or compact (mutt mode). */
|
|
66
34
|
density?: Density;
|
|
67
35
|
}
|
|
68
36
|
|
|
69
37
|
/**
|
|
70
|
-
* Map a RemitImapThreadMessageResponse to the ThreadRowData shape
|
|
71
|
-
*
|
|
38
|
+
* Map a RemitImapThreadMessageResponse to the ThreadRowData shape the shared
|
|
39
|
+
* row renders.
|
|
72
40
|
*/
|
|
73
|
-
const
|
|
41
|
+
export const threadToRowData = (
|
|
74
42
|
thread: RemitImapThreadMessageResponse,
|
|
75
|
-
messageCount
|
|
76
|
-
): ThreadRowData => {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
suspicious,
|
|
97
|
-
};
|
|
98
|
-
};
|
|
43
|
+
messageCount?: number,
|
|
44
|
+
): ThreadRowData => ({
|
|
45
|
+
id: thread.messageId,
|
|
46
|
+
accountId: thread.accountConfigId,
|
|
47
|
+
mailboxId: thread.mailboxId,
|
|
48
|
+
fromName: thread.fromName ?? thread.fromEmail ?? "Unknown",
|
|
49
|
+
fromEmail: thread.fromEmail ?? "",
|
|
50
|
+
subject: thread.subject ?? "(No subject)",
|
|
51
|
+
snippet: thread.snippet ?? "",
|
|
52
|
+
timeLabel: formatEmailDate(thread.sentDate),
|
|
53
|
+
isRead: thread.isRead,
|
|
54
|
+
hasAttachment: thread.hasAttachment,
|
|
55
|
+
starred: thread.hasStars === true,
|
|
56
|
+
trust: thread.senderTrust as SenderTrustLevel,
|
|
57
|
+
category: toDisplayCategory(thread.category),
|
|
58
|
+
messageCount,
|
|
59
|
+
// The backend's DKIM-alignment verdict is authoritative: it already accounts
|
|
60
|
+
// for the multi-signature / alignment semantics a single string compare
|
|
61
|
+
// misses.
|
|
62
|
+
suspicious: thread.authenticity?.dkimMismatch === true,
|
|
63
|
+
});
|
|
99
64
|
|
|
100
65
|
const MessageListItemComponent = ({
|
|
101
66
|
thread,
|
|
102
67
|
mailboxId,
|
|
103
68
|
accountId,
|
|
104
69
|
isSelected,
|
|
105
|
-
isFocused
|
|
106
|
-
isTabStop
|
|
70
|
+
isFocused,
|
|
71
|
+
isTabStop,
|
|
107
72
|
onFocusRow,
|
|
108
73
|
isChecked,
|
|
109
74
|
onToggleCheck,
|
|
110
75
|
onRowSelect,
|
|
111
76
|
messageCount,
|
|
112
|
-
isMultiSelectMode
|
|
77
|
+
isMultiSelectMode,
|
|
113
78
|
onLongPress,
|
|
114
|
-
isDesktop
|
|
115
|
-
density
|
|
116
|
-
}: MessageListItemProps) =>
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
(e: MouseEvent) => {
|
|
143
|
-
if (!isDesktop) {
|
|
144
|
-
if (!isMultiSelectMode) return;
|
|
145
|
-
e.preventDefault();
|
|
146
|
-
e.stopPropagation();
|
|
147
|
-
onToggleCheck(messageId);
|
|
148
|
-
return;
|
|
149
|
-
}
|
|
150
|
-
const modifiers = {
|
|
151
|
-
shiftKey: e.shiftKey,
|
|
152
|
-
metaKey: e.metaKey,
|
|
153
|
-
ctrlKey: e.ctrlKey,
|
|
154
|
-
};
|
|
155
|
-
const handled = onRowSelect(messageId, modifiers);
|
|
156
|
-
if (!handled) return;
|
|
157
|
-
e.preventDefault();
|
|
158
|
-
e.stopPropagation();
|
|
159
|
-
if (modifiers.shiftKey) {
|
|
160
|
-
window.getSelection()?.removeAllRanges();
|
|
161
|
-
}
|
|
162
|
-
},
|
|
163
|
-
[isDesktop, isMultiSelectMode, onToggleCheck, onRowSelect, messageId],
|
|
164
|
-
);
|
|
165
|
-
|
|
166
|
-
// Shift-click starts a native text selection on mousedown; suppressing it
|
|
167
|
-
// there keeps the drag from painting a text range over the rows.
|
|
168
|
-
const handleRowMouseDown = useCallback(
|
|
169
|
-
(e: MouseEvent) => {
|
|
170
|
-
if (!isDesktop) return;
|
|
171
|
-
if (e.shiftKey) e.preventDefault();
|
|
172
|
-
},
|
|
173
|
-
[isDesktop],
|
|
174
|
-
);
|
|
175
|
-
|
|
176
|
-
const handleLongPress = useCallback(() => {
|
|
177
|
-
onLongPress?.(messageId);
|
|
178
|
-
}, [onLongPress, messageId]);
|
|
179
|
-
|
|
180
|
-
const { longPressProps } = useLongPress({
|
|
181
|
-
onLongPress: handleLongPress,
|
|
182
|
-
delayMs: 500,
|
|
183
|
-
accessibilityDescription: isChecked ? "Deselect message" : "Select message",
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
// Intent-based prefetch: by the time the user clicks, the body is in
|
|
187
|
-
// React Query's cache and the detail pane renders without a spinner.
|
|
188
|
-
const prefetchMessage = useCallback(() => {
|
|
189
|
-
queryClient.prefetchQuery(
|
|
190
|
-
messageOperationsDescribeMessageOptions({
|
|
191
|
-
path: { messageId: thread.messageId },
|
|
192
|
-
}),
|
|
193
|
-
);
|
|
194
|
-
}, [queryClient, thread.messageId]);
|
|
195
|
-
|
|
196
|
-
const handleRowFocus = useCallback(() => {
|
|
197
|
-
prefetchMessage();
|
|
198
|
-
onFocusRow?.(messageId);
|
|
199
|
-
}, [prefetchMessage, onFocusRow, messageId]);
|
|
200
|
-
|
|
201
|
-
// Listbox semantics + roving tabindex, shared by both densities. Merged
|
|
202
|
-
// (not spread) with the mobile long-press props: react-aria's pressProps
|
|
203
|
-
// carries its own onClick for its internal press bookkeeping, and a plain
|
|
204
|
-
// object spread would silently drop whichever onClick landed second
|
|
205
|
-
// instead of running both.
|
|
206
|
-
const rowInteractionProps = mergeProps(
|
|
207
|
-
{
|
|
208
|
-
"data-message-row": true,
|
|
209
|
-
"data-message-id": messageId,
|
|
210
|
-
role: "option" as const,
|
|
211
|
-
"aria-selected": isChecked,
|
|
212
|
-
tabIndex: isTabStop ? 0 : -1,
|
|
213
|
-
onClick: handleRowClick,
|
|
214
|
-
onMouseDown: handleRowMouseDown,
|
|
215
|
-
onMouseEnter: prefetchMessage,
|
|
216
|
-
onFocus: handleRowFocus,
|
|
217
|
-
},
|
|
218
|
-
isDesktop ? {} : longPressProps,
|
|
219
|
-
);
|
|
220
|
-
|
|
221
|
-
const unread = !thread.isRead;
|
|
222
|
-
|
|
223
|
-
if (density === "compact") {
|
|
224
|
-
return (
|
|
225
|
-
<Link
|
|
226
|
-
to="/mail/$mailboxId"
|
|
227
|
-
params={{ mailboxId }}
|
|
228
|
-
search={(prev: MailboxLinkSearch) => ({
|
|
229
|
-
...prev,
|
|
230
|
-
selectedMessageId: thread.messageId,
|
|
231
|
-
})}
|
|
232
|
-
{...rowInteractionProps}
|
|
233
|
-
className={cn(
|
|
234
|
-
compactRowClass({ active: isSelected, focused: isFocused }),
|
|
235
|
-
"outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-inset",
|
|
236
|
-
isChecked && "bg-accent-soft",
|
|
237
|
-
// Long-press enters selection mode; without these, Android Chrome
|
|
238
|
-
// opens the link context menu / starts text selection and iOS
|
|
239
|
-
// Safari fires the callout, racing the app's handler. react-aria
|
|
240
|
-
// suppresses contextmenu/text-selection but not iOS's callout —
|
|
241
|
-
// it fires no cancelable event, so CSS is the only lever.
|
|
242
|
-
!isDesktop && "min-h-11 select-none [-webkit-touch-callout:none]",
|
|
243
|
-
)}
|
|
244
|
-
>
|
|
245
|
-
<CompactRowBody thread={rowData} />
|
|
246
|
-
</Link>
|
|
247
|
-
);
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
// Comfortable density: avatar/checkbox leading slot + text content.
|
|
251
|
-
// The slot is a fixed 36px so the row never reflows when state changes.
|
|
252
|
-
// Unread dot is positioned absolute (left-1.5, vertically centered).
|
|
253
|
-
return (
|
|
254
|
-
<Link
|
|
255
|
-
to="/mail/$mailboxId"
|
|
256
|
-
params={{ mailboxId }}
|
|
257
|
-
search={(prev: MailboxLinkSearch) => ({
|
|
258
|
-
...prev,
|
|
259
|
-
selectedMessageId: thread.messageId,
|
|
260
|
-
})}
|
|
261
|
-
{...rowInteractionProps}
|
|
262
|
-
className={cn(
|
|
263
|
-
"group",
|
|
264
|
-
comfortableRowClass({ active: isSelected, focused: isFocused }),
|
|
265
|
-
"outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-inset",
|
|
266
|
-
isChecked && "bg-accent-soft",
|
|
267
|
-
// See the compact-density Link above for why both are required.
|
|
268
|
-
!isDesktop && "min-h-11 select-none [-webkit-touch-callout:none]",
|
|
269
|
-
)}
|
|
270
|
-
>
|
|
271
|
-
{/* Absolute unread dot — 6px gutter from the left pane hairline */}
|
|
272
|
-
{unread && (
|
|
273
|
-
<span className="absolute left-1.5 top-1/2 size-1.5 -translate-y-1/2 rounded-full bg-accent" />
|
|
274
|
-
)}
|
|
275
|
-
|
|
276
|
-
{/* Leading slot: avatar by default, checkbox on hover (desktop) or
|
|
277
|
-
while checked / in multi-select mode. Fixed 28px slot (size-7,
|
|
278
|
-
matching Avatar size="sm" in remit-ui ComfortableRow). */}
|
|
279
|
-
<div className="relative size-7 shrink-0">
|
|
280
|
-
<Avatar
|
|
281
|
-
name={thread.fromName ?? thread.fromEmail ?? "?"}
|
|
282
|
-
email={thread.fromEmail ?? undefined}
|
|
79
|
+
isDesktop,
|
|
80
|
+
density,
|
|
81
|
+
}: MessageListItemProps) => (
|
|
82
|
+
<MessageRow
|
|
83
|
+
thread={threadToRowData(thread, messageCount)}
|
|
84
|
+
linkMailboxId={mailboxId}
|
|
85
|
+
inListbox
|
|
86
|
+
active={isSelected}
|
|
87
|
+
focused={isFocused}
|
|
88
|
+
isTabStop={isTabStop}
|
|
89
|
+
density={density}
|
|
90
|
+
isDesktop={isDesktop}
|
|
91
|
+
onFocusRow={onFocusRow}
|
|
92
|
+
selection={{
|
|
93
|
+
isChecked,
|
|
94
|
+
onToggleCheck,
|
|
95
|
+
onRowSelect,
|
|
96
|
+
isMultiSelectMode,
|
|
97
|
+
onLongPress,
|
|
98
|
+
}}
|
|
99
|
+
badge={
|
|
100
|
+
thread.autoMoved ? (
|
|
101
|
+
<AutoMovedIndicator
|
|
102
|
+
accountId={accountId}
|
|
103
|
+
messageId={thread.messageId}
|
|
104
|
+
threadId={thread.threadId}
|
|
105
|
+
mailboxId={thread.mailboxId}
|
|
106
|
+
autoMoved={thread.autoMoved}
|
|
283
107
|
size="sm"
|
|
284
|
-
className={cn(
|
|
285
|
-
"absolute inset-0",
|
|
286
|
-
"sm:group-hover:opacity-0 transition-opacity",
|
|
287
|
-
(isChecked || isMultiSelectMode) && "opacity-0",
|
|
288
|
-
)}
|
|
289
108
|
/>
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
// put focus on something invisible on every row.
|
|
295
|
-
tabIndex={-1}
|
|
296
|
-
onClick={handleCheckboxClick}
|
|
297
|
-
className={cn(
|
|
298
|
-
"absolute inset-0 size-7 rounded-full border items-center justify-center transition-opacity",
|
|
299
|
-
isMultiSelectMode ? "flex" : "hidden sm:flex",
|
|
300
|
-
isChecked
|
|
301
|
-
? "bg-accent border-accent text-accent-fg opacity-100"
|
|
302
|
-
: isMultiSelectMode
|
|
303
|
-
? "border-fg-subtle/40 opacity-100 bg-canvas"
|
|
304
|
-
: "border-fg-subtle/40 opacity-0 group-hover:opacity-100 bg-canvas",
|
|
305
|
-
)}
|
|
306
|
-
aria-label={isChecked ? "Deselect message" : "Select message"}
|
|
307
|
-
>
|
|
308
|
-
{isChecked && <Check className="size-3" />}
|
|
309
|
-
</button>
|
|
310
|
-
</div>
|
|
311
|
-
|
|
312
|
-
{/* Text/glyph content block */}
|
|
313
|
-
<ComfortableRowTextContent
|
|
314
|
-
thread={rowData}
|
|
315
|
-
badge={
|
|
316
|
-
thread.autoMoved && (
|
|
317
|
-
<AutoMovedIndicator
|
|
318
|
-
accountId={accountId}
|
|
319
|
-
messageId={thread.messageId}
|
|
320
|
-
threadId={thread.threadId}
|
|
321
|
-
mailboxId={thread.mailboxId}
|
|
322
|
-
autoMoved={thread.autoMoved}
|
|
323
|
-
size="sm"
|
|
324
|
-
/>
|
|
325
|
-
)
|
|
326
|
-
}
|
|
327
|
-
/>
|
|
328
|
-
</Link>
|
|
329
|
-
);
|
|
330
|
-
};
|
|
109
|
+
) : undefined
|
|
110
|
+
}
|
|
111
|
+
/>
|
|
112
|
+
);
|
|
331
113
|
|
|
332
|
-
// Wrapped in React.memo so virtualized rows don't re-render on every parent
|
|
333
|
-
// state change. The `search` callback prop on Link is inline, but it gets a
|
|
334
|
-
// stable reference because the parent itself is stable across re-renders
|
|
335
|
-
// (mailboxId is a string from route params). React.memo with default shallow
|
|
336
|
-
// equality is appropriate here since props are primitives + stable callback.
|
|
337
114
|
export const MessageListItem = memo(MessageListItemComponent);
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Row semantics for the shared `MessageRow` (#149).
|
|
3
|
+
*
|
|
4
|
+
* `role="option"` belongs to the mailbox list, whose rows sit inside a
|
|
5
|
+
* `role="listbox"` container. The brief and Flagged render rows in ordinary
|
|
6
|
+
* containers: an orphan `option` there is invalid ARIA and, more concretely,
|
|
7
|
+
* strips the row of its button role — which is how the black-box suite and any
|
|
8
|
+
* screen reader address it.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { describe, it } from "node:test";
|
|
13
|
+
import type { ThreadRowData } from "@remit/ui";
|
|
14
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
15
|
+
import React, { createElement } from "react";
|
|
16
|
+
import { renderToString } from "react-dom/server";
|
|
17
|
+
import { MessageRow } from "./MessageRow";
|
|
18
|
+
|
|
19
|
+
(globalThis as { React?: typeof React }).React = React;
|
|
20
|
+
|
|
21
|
+
const thread: ThreadRowData = {
|
|
22
|
+
id: "m1",
|
|
23
|
+
accountId: "a1",
|
|
24
|
+
fromName: "Alex Rivera",
|
|
25
|
+
fromEmail: "alex@example.com",
|
|
26
|
+
subject: "Quarterly numbers are in",
|
|
27
|
+
snippet: "The deck is on the shared drive.",
|
|
28
|
+
timeLabel: "9:42",
|
|
29
|
+
isRead: true,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const render = (props: Parameters<typeof MessageRow>[0]): string =>
|
|
33
|
+
renderToString(
|
|
34
|
+
createElement(
|
|
35
|
+
QueryClientProvider,
|
|
36
|
+
{ client: new QueryClient() },
|
|
37
|
+
createElement(MessageRow, props),
|
|
38
|
+
) as never,
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
describe("MessageRow semantics", () => {
|
|
42
|
+
it("renders a plain button outside a listbox (brief, Flagged)", () => {
|
|
43
|
+
const html = render({ thread });
|
|
44
|
+
assert.match(html, /<button/);
|
|
45
|
+
assert.doesNotMatch(html, /role="option"/);
|
|
46
|
+
assert.doesNotMatch(html, /aria-selected/);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("renders listbox option semantics when the container is a listbox", () => {
|
|
50
|
+
const html = render({ thread, inListbox: true });
|
|
51
|
+
assert.match(html, /role="option"/);
|
|
52
|
+
assert.match(html, /aria-selected/);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("renders the row's sender, subject and snippet", () => {
|
|
56
|
+
const html = render({ thread });
|
|
57
|
+
assert.match(html, /Alex Rivera/);
|
|
58
|
+
assert.match(html, /Quarterly numbers are in/);
|
|
59
|
+
assert.match(html, /shared drive/);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MessageRow — the one thread row every list in the app renders.
|
|
3
|
+
*
|
|
4
|
+
* The mailbox list, the daily brief and Flagged used to carry three
|
|
5
|
+
* near-identical rows (#149). They differ in two axes only, both props here:
|
|
6
|
+
*
|
|
7
|
+
* - `linkMailboxId` — set for the mailbox list, whose rows are real links so a
|
|
8
|
+
* plain click routes and a middle click opens a tab. Omitted for the brief
|
|
9
|
+
* and Flagged, whose rows report the tap through `onClick`.
|
|
10
|
+
* - `selection` — set where multi-select is available. Absent renders the
|
|
11
|
+
* avatar alone with no checkbox, which is what "non-selectable mode" means.
|
|
12
|
+
*/
|
|
13
|
+
import { messageOperationsDescribeMessageOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
14
|
+
import {
|
|
15
|
+
ComfortableRowBody,
|
|
16
|
+
CompactRowBody,
|
|
17
|
+
comfortableRowClass,
|
|
18
|
+
compactRowClass,
|
|
19
|
+
type Density,
|
|
20
|
+
mergeProps,
|
|
21
|
+
type ThreadRowData,
|
|
22
|
+
useLongPress,
|
|
23
|
+
} from "@remit/ui";
|
|
24
|
+
import { useQueryClient } from "@tanstack/react-query";
|
|
25
|
+
import { Link } from "@tanstack/react-router";
|
|
26
|
+
import { type MouseEvent, memo, type ReactNode, useCallback } from "react";
|
|
27
|
+
import type { SelectionModifiers } from "@/hooks/useSelection";
|
|
28
|
+
import { cn } from "@/lib/utils";
|
|
29
|
+
|
|
30
|
+
interface MailboxLinkSearch {
|
|
31
|
+
selectedMessageId?: string;
|
|
32
|
+
q?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface MessageRowSelection {
|
|
36
|
+
isChecked: boolean;
|
|
37
|
+
onToggleCheck: (messageId: string) => void;
|
|
38
|
+
/**
|
|
39
|
+
* Desktop mouse selection. Returns true when selection consumed the click
|
|
40
|
+
* (the row must not open); false for a plain click.
|
|
41
|
+
*/
|
|
42
|
+
onRowSelect: (messageId: string, modifiers: SelectionModifiers) => boolean;
|
|
43
|
+
/** Checkbox stays visible — mobile multi-select mode. */
|
|
44
|
+
isMultiSelectMode?: boolean;
|
|
45
|
+
/** Long press enters selection mode (mobile only). */
|
|
46
|
+
onLongPress?: (messageId: string) => void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface MessageRowProps {
|
|
50
|
+
thread: ThreadRowData;
|
|
51
|
+
/** The row is the open thread. */
|
|
52
|
+
active?: boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Roving keyboard focus cursor (#429). Distinct from `active`: a
|
|
55
|
+
* focused-but-not-open row shows the left accent rail; the open row shows the
|
|
56
|
+
* full highlight. Both can be true.
|
|
57
|
+
*/
|
|
58
|
+
focused?: boolean;
|
|
59
|
+
/**
|
|
60
|
+
* Whether this row holds the list's single tab stop (roving tabindex). Every
|
|
61
|
+
* other row is `tabIndex={-1}`, so Tab enters the list at the cursor and
|
|
62
|
+
* Shift+Tab leaves it rather than stepping through hundreds of rows.
|
|
63
|
+
*/
|
|
64
|
+
isTabStop?: boolean;
|
|
65
|
+
density?: Density;
|
|
66
|
+
isDesktop?: boolean;
|
|
67
|
+
/** Extra chip after the category badge (e.g. the auto-moved indicator). */
|
|
68
|
+
badge?: ReactNode;
|
|
69
|
+
selection?: MessageRowSelection;
|
|
70
|
+
/**
|
|
71
|
+
* The row sits inside a container with `role="listbox"` (the mailbox list),
|
|
72
|
+
* so it carries `role="option"` and `aria-selected`. Off everywhere else:
|
|
73
|
+
* the brief and Flagged render their rows in ordinary containers, where an
|
|
74
|
+
* orphan `option` is invalid ARIA and costs the row its button semantics.
|
|
75
|
+
*/
|
|
76
|
+
inListbox?: boolean;
|
|
77
|
+
/** Mailbox whose route the row links to; omit for callback-driven rows. */
|
|
78
|
+
linkMailboxId?: string;
|
|
79
|
+
/** Called when the row is opened by a plain click on a non-linking row. */
|
|
80
|
+
onClick?: () => void;
|
|
81
|
+
/** Called when the row takes DOM focus, so the roving cursor follows it. */
|
|
82
|
+
onFocusRow?: (messageId: string) => void;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const MessageRowComponent = ({
|
|
86
|
+
thread,
|
|
87
|
+
active = false,
|
|
88
|
+
focused = false,
|
|
89
|
+
isTabStop = false,
|
|
90
|
+
density = "comfortable",
|
|
91
|
+
isDesktop = true,
|
|
92
|
+
badge,
|
|
93
|
+
selection,
|
|
94
|
+
inListbox = false,
|
|
95
|
+
linkMailboxId,
|
|
96
|
+
onClick,
|
|
97
|
+
onFocusRow,
|
|
98
|
+
}: MessageRowProps) => {
|
|
99
|
+
const queryClient = useQueryClient();
|
|
100
|
+
const messageId = thread.id;
|
|
101
|
+
const isChecked = selection?.isChecked ?? false;
|
|
102
|
+
const isMultiSelectMode = selection?.isMultiSelectMode ?? false;
|
|
103
|
+
const onToggleCheck = selection?.onToggleCheck;
|
|
104
|
+
const onRowSelect = selection?.onRowSelect;
|
|
105
|
+
const onLongPress = selection?.onLongPress;
|
|
106
|
+
|
|
107
|
+
const handleCheckboxClick = useCallback(
|
|
108
|
+
(e: MouseEvent) => {
|
|
109
|
+
e.preventDefault();
|
|
110
|
+
e.stopPropagation();
|
|
111
|
+
onToggleCheck?.(messageId);
|
|
112
|
+
},
|
|
113
|
+
[onToggleCheck, messageId],
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
// Desktop mouse selection semantics. Plain click falls through to the Link's
|
|
117
|
+
// navigation (or `onClick` on a non-linking row); shift / cmd / ctrl click is
|
|
118
|
+
// routed to selection and the navigation is suppressed.
|
|
119
|
+
//
|
|
120
|
+
// A modified click must preventDefault: the router skips navigation for any
|
|
121
|
+
// modified click and leaves the anchor's own default in place, which in a
|
|
122
|
+
// browser means shift-click opens a new window and cmd-click a new tab.
|
|
123
|
+
// Shift-click also drags a native text selection across the rows it spans,
|
|
124
|
+
// so drop it — the row highlight is the selection the user asked for.
|
|
125
|
+
//
|
|
126
|
+
// On mobile, once selection mode is active (#92) a plain tap toggles the row
|
|
127
|
+
// instead of opening it — the same tap-to-toggle contract every reference
|
|
128
|
+
// mail client uses once you're mid-selection.
|
|
129
|
+
const handleRowClick = useCallback(
|
|
130
|
+
(e: MouseEvent) => {
|
|
131
|
+
if (!isDesktop) {
|
|
132
|
+
if (isMultiSelectMode) {
|
|
133
|
+
e.preventDefault();
|
|
134
|
+
e.stopPropagation();
|
|
135
|
+
onToggleCheck?.(messageId);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
onClick?.();
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const modifiers = {
|
|
142
|
+
shiftKey: e.shiftKey,
|
|
143
|
+
metaKey: e.metaKey,
|
|
144
|
+
ctrlKey: e.ctrlKey,
|
|
145
|
+
};
|
|
146
|
+
if (onRowSelect?.(messageId, modifiers)) {
|
|
147
|
+
e.preventDefault();
|
|
148
|
+
e.stopPropagation();
|
|
149
|
+
if (modifiers.shiftKey) window.getSelection()?.removeAllRanges();
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
onClick?.();
|
|
153
|
+
},
|
|
154
|
+
[
|
|
155
|
+
isDesktop,
|
|
156
|
+
isMultiSelectMode,
|
|
157
|
+
onToggleCheck,
|
|
158
|
+
onRowSelect,
|
|
159
|
+
onClick,
|
|
160
|
+
messageId,
|
|
161
|
+
],
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
// Shift-click starts a native text selection on mousedown; suppressing it
|
|
165
|
+
// there keeps the drag from painting a text range over the rows.
|
|
166
|
+
const handleRowMouseDown = useCallback(
|
|
167
|
+
(e: MouseEvent) => {
|
|
168
|
+
if (!isDesktop) return;
|
|
169
|
+
if (e.shiftKey) e.preventDefault();
|
|
170
|
+
},
|
|
171
|
+
[isDesktop],
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const handleLongPress = useCallback(() => {
|
|
175
|
+
onLongPress?.(messageId);
|
|
176
|
+
}, [onLongPress, messageId]);
|
|
177
|
+
|
|
178
|
+
const { longPressProps } = useLongPress({
|
|
179
|
+
onLongPress: handleLongPress,
|
|
180
|
+
delayMs: 500,
|
|
181
|
+
accessibilityDescription: isChecked ? "Deselect message" : "Select message",
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// Intent-based prefetch: by the time the user clicks, the body is in
|
|
185
|
+
// React Query's cache and the detail pane renders without a spinner.
|
|
186
|
+
const prefetchMessage = useCallback(() => {
|
|
187
|
+
queryClient.prefetchQuery(
|
|
188
|
+
messageOperationsDescribeMessageOptions({ path: { messageId } }),
|
|
189
|
+
);
|
|
190
|
+
}, [queryClient, messageId]);
|
|
191
|
+
|
|
192
|
+
const handleRowFocus = useCallback(() => {
|
|
193
|
+
prefetchMessage();
|
|
194
|
+
onFocusRow?.(messageId);
|
|
195
|
+
}, [prefetchMessage, onFocusRow, messageId]);
|
|
196
|
+
|
|
197
|
+
// Listbox semantics + roving tabindex, shared by both densities. Merged (not
|
|
198
|
+
// spread) with the mobile long-press props: react-aria's pressProps carries
|
|
199
|
+
// its own onClick for its internal press bookkeeping, and a plain object
|
|
200
|
+
// spread would silently drop whichever onClick landed second instead of
|
|
201
|
+
// running both.
|
|
202
|
+
const interactionProps = mergeProps(
|
|
203
|
+
{
|
|
204
|
+
"data-list-row": "",
|
|
205
|
+
"data-message-row": true,
|
|
206
|
+
"data-message-id": messageId,
|
|
207
|
+
...(inListbox
|
|
208
|
+
? { role: "option" as const, "aria-selected": isChecked }
|
|
209
|
+
: {}),
|
|
210
|
+
tabIndex: isTabStop ? 0 : -1,
|
|
211
|
+
onClick: handleRowClick,
|
|
212
|
+
onMouseDown: handleRowMouseDown,
|
|
213
|
+
onMouseEnter: prefetchMessage,
|
|
214
|
+
onFocus: handleRowFocus,
|
|
215
|
+
},
|
|
216
|
+
isDesktop || !selection ? {} : longPressProps,
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
const className = cn(
|
|
220
|
+
density === "compact"
|
|
221
|
+
? compactRowClass({ active, focused })
|
|
222
|
+
: cn("group", comfortableRowClass({ active, focused })),
|
|
223
|
+
"outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-inset",
|
|
224
|
+
isChecked && "bg-accent-soft",
|
|
225
|
+
// Long-press enters selection mode; without these, Android Chrome opens
|
|
226
|
+
// the link context menu / starts text selection and iOS Safari fires the
|
|
227
|
+
// callout, racing the app's handler. react-aria suppresses
|
|
228
|
+
// contextmenu/text-selection but not iOS's callout — it fires no
|
|
229
|
+
// cancelable event, so CSS is the only lever.
|
|
230
|
+
!isDesktop && "min-h-11 select-none [-webkit-touch-callout:none]",
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
const body =
|
|
234
|
+
density === "compact" ? (
|
|
235
|
+
<CompactRowBody thread={thread} />
|
|
236
|
+
) : (
|
|
237
|
+
<ComfortableRowBody
|
|
238
|
+
thread={thread}
|
|
239
|
+
badge={badge}
|
|
240
|
+
selection={
|
|
241
|
+
selection
|
|
242
|
+
? {
|
|
243
|
+
checked: isChecked,
|
|
244
|
+
alwaysVisible: isMultiSelectMode,
|
|
245
|
+
onToggle: handleCheckboxClick,
|
|
246
|
+
}
|
|
247
|
+
: undefined
|
|
248
|
+
}
|
|
249
|
+
/>
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
if (linkMailboxId === undefined) {
|
|
253
|
+
return (
|
|
254
|
+
<button type="button" {...interactionProps} className={className}>
|
|
255
|
+
{body}
|
|
256
|
+
</button>
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return (
|
|
261
|
+
<Link
|
|
262
|
+
to="/mail/$mailboxId"
|
|
263
|
+
params={{ mailboxId: linkMailboxId }}
|
|
264
|
+
search={(prev: MailboxLinkSearch) => ({
|
|
265
|
+
...prev,
|
|
266
|
+
selectedMessageId: messageId,
|
|
267
|
+
})}
|
|
268
|
+
{...interactionProps}
|
|
269
|
+
className={className}
|
|
270
|
+
>
|
|
271
|
+
{body}
|
|
272
|
+
</Link>
|
|
273
|
+
);
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
// Wrapped in React.memo so virtualized rows don't re-render on every parent
|
|
277
|
+
// state change. Props are primitives plus callbacks the parents keep stable, so
|
|
278
|
+
// default shallow equality is appropriate.
|
|
279
|
+
export const MessageRow = memo(MessageRowComponent);
|