@remit/web-client 0.0.153 → 0.0.155
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/compose/ComposeForm.tsx +7 -0
- package/src/components/compose/ComposeProvider.tsx +7 -29
- package/src/components/compose/compose-clears-open-thread.render.test.ts +32 -22
- package/src/components/compose/compose-spellcheck.test.ts +122 -0
- package/src/components/compose/compose-spellcheck.ts +56 -0
- package/src/components/layout/ComposeFab.tsx +2 -7
- package/src/components/mail/FlaggedList.tsx +45 -15
- package/src/components/mail/FlaggedPane.tsx +106 -54
- package/src/components/mail/MailSidebarAdapter.tsx +1 -1
- package/src/components/mail/MessageActionMenu.tsx +2 -10
- package/src/components/mail/MessageList.tsx +1 -1
- package/src/components/ui/ErrorBanner.tsx +16 -1
- package/src/components/ui/ErrorBannerStack.tsx +1 -0
- package/src/components/ui/error-banners.ts +12 -0
- package/src/hooks/search-mirror-detail.render.test.ts +85 -61
- package/src/hooks/useSearchMirror.ts +3 -11
- package/src/lib/mail-search.ts +3 -4
- package/src/routeTree.gen.ts +72 -0
- package/src/routes/mail/flagged/$threadId/$messageId.tsx +19 -0
- package/src/routes/mail/flagged/$threadId/index.tsx +14 -0
- package/src/routes/mail/flagged/$threadId.tsx +16 -0
- package/src/routes/mail/flagged.tsx +7 -4
- package/src/routing/open-thread.ts +14 -2
|
@@ -6,15 +6,19 @@
|
|
|
6
6
|
* reading / phone slots. The list itself is a FLAT inbox of starred mail (see
|
|
7
7
|
* `FlaggedList`), not the sectioned brief.
|
|
8
8
|
*
|
|
9
|
-
* The
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* The row prefers the starred listing, the same query that produced the rows,
|
|
10
|
+
* and falls back to resolving the thread on its own when the address names
|
|
11
|
+
* one the listing doesn't hold. The unified listing is INBOX-scoped, so
|
|
12
|
+
* resolving against it left every starred thread filed elsewhere — Sent, an
|
|
13
|
+
* archive folder, anything past the inbox window — visible in the list but
|
|
14
|
+
* impossible to open (issue #70).
|
|
13
15
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
+
* Usage in the list layout route:
|
|
17
|
+
*
|
|
18
|
+
* <FlaggedPane thread={useOpenThreadPath()}>
|
|
19
|
+
* <MailShell
|
|
16
20
|
* list={<FlaggedPane.List />}
|
|
17
|
-
* reading={<
|
|
21
|
+
* reading={<Outlet />}
|
|
18
22
|
* />
|
|
19
23
|
* </FlaggedPane>
|
|
20
24
|
*
|
|
@@ -40,22 +44,34 @@ import { useDeleteMessages } from "@/hooks/useDeleteMessages";
|
|
|
40
44
|
import { useToggleReadFor } from "@/hooks/useMarkAsRead";
|
|
41
45
|
import { useStarredThreads } from "@/hooks/useStarredThreads";
|
|
42
46
|
import { type ThreadActions, useThreadActions } from "@/hooks/useThreadActions";
|
|
47
|
+
import { useThreadRow } from "@/hooks/useThreadRow";
|
|
43
48
|
import {
|
|
44
49
|
type TriageContext,
|
|
45
50
|
useTriageContext,
|
|
46
51
|
useTriageLayer,
|
|
47
52
|
} from "@/hooks/useTriageLayer";
|
|
53
|
+
import type { ConversationTarget } from "@/lib/conversation-target";
|
|
48
54
|
import { useMailContext } from "@/lib/mail-context";
|
|
55
|
+
import type { OpenThreadPath, OpenThreadTarget } from "@/routing";
|
|
49
56
|
|
|
50
57
|
/* ------------------------------------------------------------------ */
|
|
51
58
|
/* Context */
|
|
52
59
|
/* ------------------------------------------------------------------ */
|
|
53
60
|
|
|
54
61
|
interface FlaggedPaneContextValue {
|
|
62
|
+
/** The row the reader pointed at, which is the one the list highlights. */
|
|
55
63
|
selectedMessageId: string | undefined;
|
|
56
64
|
selectedThread: RemitImapThreadMessageResponse | undefined;
|
|
57
|
-
|
|
65
|
+
/** The conversation the pane shows, or none when the address names no thread. */
|
|
66
|
+
conversation: ConversationTarget | undefined;
|
|
67
|
+
onOpenThread: (
|
|
68
|
+
target: OpenThreadTarget,
|
|
69
|
+
options?: OpenMessageOptions,
|
|
70
|
+
) => void;
|
|
58
71
|
onCloseThread: () => void;
|
|
72
|
+
/** The rows either side of the open one — the phone's swipe gestures. */
|
|
73
|
+
nextThread: OpenThreadTarget | undefined;
|
|
74
|
+
previousThread: OpenThreadTarget | undefined;
|
|
59
75
|
/**
|
|
60
76
|
* Toolbar verbs for the open thread, keyed by the thread's own mailbox and
|
|
61
77
|
* account — Flagged spans accounts, so there is no route mailbox to key by.
|
|
@@ -64,8 +80,6 @@ interface FlaggedPaneContextValue {
|
|
|
64
80
|
/** Keyboard, multi-select and next/previous, shared with the mailbox view. */
|
|
65
81
|
triage: TriageContext;
|
|
66
82
|
onDeleteMessages: (messageIds: string[]) => void;
|
|
67
|
-
nextMessageId: string | undefined;
|
|
68
|
-
previousMessageId: string | undefined;
|
|
69
83
|
/**
|
|
70
84
|
* Deselects the open message when it's the one a mutation just removed
|
|
71
85
|
* from view — wired into every mutation that can take the open message out
|
|
@@ -88,39 +102,65 @@ function useFlaggedPane(): FlaggedPaneContextValue {
|
|
|
88
102
|
/* ------------------------------------------------------------------ */
|
|
89
103
|
|
|
90
104
|
interface FlaggedPaneProps {
|
|
91
|
-
|
|
105
|
+
/** The open conversation, as the address states it. */
|
|
106
|
+
thread: OpenThreadPath | undefined;
|
|
92
107
|
children: ReactNode;
|
|
93
108
|
}
|
|
94
109
|
|
|
95
|
-
function FlaggedPaneProvider({
|
|
96
|
-
selectedMessageId,
|
|
97
|
-
children,
|
|
98
|
-
}: FlaggedPaneProps) {
|
|
110
|
+
function FlaggedPaneProvider({ thread, children }: FlaggedPaneProps) {
|
|
99
111
|
const navigate = useNavigate();
|
|
112
|
+
const { searchInput } = useMailContext();
|
|
113
|
+
const threadId = thread?.threadId;
|
|
114
|
+
const pointedAtMessageId = thread?.messageId;
|
|
100
115
|
|
|
101
116
|
const { threads } = useStarredThreads();
|
|
102
117
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
118
|
+
// The row the starred listing itself holds, preferred because a mutation
|
|
119
|
+
// patches it in place. A thread reached from a cold address is in no listing
|
|
120
|
+
// here and answers for itself — the folder it is filed in is the thread's own
|
|
121
|
+
// data, so Starred spanning folders costs the URL nothing.
|
|
122
|
+
const listedThread = useMemo(() => {
|
|
123
|
+
if (!threadId) return undefined;
|
|
124
|
+
return (
|
|
125
|
+
threads.find((t) => t.messageId === pointedAtMessageId) ??
|
|
126
|
+
threads.find((t) => t.threadId === threadId)
|
|
127
|
+
);
|
|
128
|
+
}, [threads, threadId, pointedAtMessageId]);
|
|
129
|
+
const ownRow = useThreadRow(threadId, pointedAtMessageId);
|
|
130
|
+
const selectedThread = listedThread ?? ownRow;
|
|
131
|
+
|
|
132
|
+
const selectedMessageId = pointedAtMessageId ?? selectedThread?.messageId;
|
|
107
133
|
|
|
108
|
-
const
|
|
109
|
-
(
|
|
134
|
+
const conversation = useMemo<ConversationTarget | undefined>(() => {
|
|
135
|
+
if (!threadId) return undefined;
|
|
136
|
+
return {
|
|
137
|
+
threadId,
|
|
138
|
+
mailboxId: selectedThread?.mailboxId ?? "",
|
|
139
|
+
subject: selectedThread?.subject,
|
|
140
|
+
messageId: selectedMessageId,
|
|
141
|
+
authenticity: selectedThread?.authenticity,
|
|
142
|
+
};
|
|
143
|
+
}, [threadId, selectedThread, selectedMessageId]);
|
|
144
|
+
|
|
145
|
+
const handleOpenThread = useCallback(
|
|
146
|
+
(target: OpenThreadTarget, options?: OpenMessageOptions) => {
|
|
110
147
|
navigate({
|
|
111
|
-
to: "/mail/flagged",
|
|
112
|
-
|
|
148
|
+
to: "/mail/flagged/$threadId/$messageId",
|
|
149
|
+
params: target,
|
|
113
150
|
replace: options?.replace,
|
|
151
|
+
// Commit the active query with the open so the debounced q-mirror —
|
|
152
|
+
// which walks back up to the list when the query goes active — is
|
|
153
|
+
// already satisfied and leaves the conversation alone. The *live*
|
|
154
|
+
// `searchInput`: a row can be tapped before the debounce settles, when
|
|
155
|
+
// the committed query is still empty.
|
|
156
|
+
search: (prev) => ({ ...prev, q: searchInput || undefined }),
|
|
114
157
|
});
|
|
115
158
|
},
|
|
116
|
-
[navigate],
|
|
159
|
+
[navigate, searchInput],
|
|
117
160
|
);
|
|
118
161
|
|
|
119
162
|
const handleCloseThread = useCallback(() => {
|
|
120
|
-
navigate({
|
|
121
|
-
to: "/mail/flagged",
|
|
122
|
-
search: (prev) => ({ ...prev, selectedMessageId: undefined }),
|
|
123
|
-
});
|
|
163
|
+
navigate({ to: "/mail/flagged", search: (prev) => prev });
|
|
124
164
|
}, [navigate]);
|
|
125
165
|
|
|
126
166
|
const handleDeselectIfRemoved = useCallback(
|
|
@@ -187,16 +227,29 @@ function FlaggedPaneProvider({
|
|
|
187
227
|
},
|
|
188
228
|
});
|
|
189
229
|
|
|
230
|
+
// The swipe gestures open a whole conversation, so the adjacent row has to
|
|
231
|
+
// name its thread. The starred listing is where that is looked up, so a row
|
|
232
|
+
// it does not hold offers no gesture rather than a tap that goes nowhere.
|
|
233
|
+
const adjacentThread = useCallback(
|
|
234
|
+
(messageId: string | undefined): OpenThreadTarget | undefined => {
|
|
235
|
+
if (!messageId) return undefined;
|
|
236
|
+
const row = threads.find((t) => t.messageId === messageId);
|
|
237
|
+
return row ? { threadId: row.threadId, messageId } : undefined;
|
|
238
|
+
},
|
|
239
|
+
[threads],
|
|
240
|
+
);
|
|
241
|
+
|
|
190
242
|
const ctx: FlaggedPaneContextValue = {
|
|
191
243
|
selectedMessageId,
|
|
192
244
|
selectedThread,
|
|
193
|
-
|
|
245
|
+
conversation,
|
|
246
|
+
onOpenThread: handleOpenThread,
|
|
194
247
|
onCloseThread: handleCloseThread,
|
|
248
|
+
nextThread: adjacentThread(nextMessageId),
|
|
249
|
+
previousThread: adjacentThread(previousMessageId),
|
|
195
250
|
actions,
|
|
196
251
|
triage,
|
|
197
252
|
onDeleteMessages: deleteMessages,
|
|
198
|
-
nextMessageId,
|
|
199
|
-
previousMessageId,
|
|
200
253
|
handleDeselectIfRemoved,
|
|
201
254
|
};
|
|
202
255
|
|
|
@@ -211,12 +264,12 @@ function FlaggedPaneProvider({
|
|
|
211
264
|
|
|
212
265
|
/** Flat starred list. Mount in the `list` slot of `AppShellSlotted`. */
|
|
213
266
|
function FlaggedListSlot() {
|
|
214
|
-
const { selectedMessageId,
|
|
267
|
+
const { selectedMessageId, onOpenThread, triage, onDeleteMessages } =
|
|
215
268
|
useFlaggedPane();
|
|
216
269
|
return (
|
|
217
270
|
<FlaggedList
|
|
218
271
|
selectedMessageId={selectedMessageId}
|
|
219
|
-
|
|
272
|
+
onOpenThread={onOpenThread}
|
|
220
273
|
commandsRef={triage.listCommandsRef}
|
|
221
274
|
onTriageContextChange={triage.onTriageContextChange}
|
|
222
275
|
onDeleteMessages={onDeleteMessages}
|
|
@@ -229,13 +282,13 @@ function FlaggedListSlot() {
|
|
|
229
282
|
* Mount in the `reading` slot of `AppShellSlotted`. Only rendered ≥ 1024px.
|
|
230
283
|
*/
|
|
231
284
|
function FlaggedReading() {
|
|
232
|
-
const {
|
|
285
|
+
const { conversation, actions } = useFlaggedPane();
|
|
233
286
|
const { intelligenceOpen, onToggleIntelligence } = useMailContext();
|
|
234
287
|
// The rail's own width gate, not the shell tier: between 1024 and 1280 the
|
|
235
288
|
// reading pane is mounted but the rail is not, so "enabled" would promise an
|
|
236
289
|
// open that cannot happen.
|
|
237
290
|
const railFits = useAppShellLayout()?.showIntelligencePane ?? false;
|
|
238
|
-
const hasThread = Boolean(
|
|
291
|
+
const hasThread = Boolean(conversation);
|
|
239
292
|
const canToggleIntelligence = railFits && hasThread;
|
|
240
293
|
|
|
241
294
|
return (
|
|
@@ -266,12 +319,13 @@ function FlaggedReading() {
|
|
|
266
319
|
}
|
|
267
320
|
/>
|
|
268
321
|
<div className="min-h-0 flex-1 overflow-hidden">
|
|
269
|
-
{
|
|
322
|
+
{conversation ? (
|
|
270
323
|
<ConversationView
|
|
271
|
-
threadId={
|
|
272
|
-
mailboxId={
|
|
273
|
-
subject={
|
|
274
|
-
|
|
324
|
+
threadId={conversation.threadId}
|
|
325
|
+
mailboxId={conversation.mailboxId}
|
|
326
|
+
subject={conversation.subject}
|
|
327
|
+
selectedMessageId={conversation.messageId}
|
|
328
|
+
authenticity={conversation.authenticity}
|
|
275
329
|
composeRequest={actions.composeRequest}
|
|
276
330
|
onComposeClose={actions.clearComposeRequest}
|
|
277
331
|
/>
|
|
@@ -306,31 +360,29 @@ function FlaggedIntelligence() {
|
|
|
306
360
|
function FlaggedPhone() {
|
|
307
361
|
const {
|
|
308
362
|
selectedThread,
|
|
363
|
+
conversation,
|
|
309
364
|
onCloseThread,
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
365
|
+
onOpenThread,
|
|
366
|
+
nextThread,
|
|
367
|
+
previousThread,
|
|
313
368
|
handleDeselectIfRemoved,
|
|
314
369
|
} = useFlaggedPane();
|
|
315
370
|
const { intelligenceOpen, onToggleIntelligence } = useMailContext();
|
|
316
371
|
|
|
317
|
-
if (
|
|
372
|
+
if (conversation) {
|
|
318
373
|
return (
|
|
319
374
|
<>
|
|
320
375
|
<ConversationView
|
|
321
|
-
threadId={
|
|
322
|
-
mailboxId={
|
|
323
|
-
subject={
|
|
324
|
-
|
|
376
|
+
threadId={conversation.threadId}
|
|
377
|
+
mailboxId={conversation.mailboxId}
|
|
378
|
+
subject={conversation.subject}
|
|
379
|
+
selectedMessageId={conversation.messageId}
|
|
380
|
+
authenticity={conversation.authenticity}
|
|
325
381
|
onBack={onCloseThread}
|
|
326
382
|
onOpenIntelligence={onToggleIntelligence}
|
|
327
|
-
onSwipeNext={
|
|
328
|
-
nextMessageId ? () => onSelectMessage(nextMessageId) : undefined
|
|
329
|
-
}
|
|
383
|
+
onSwipeNext={nextThread ? () => onOpenThread(nextThread) : undefined}
|
|
330
384
|
onSwipePrevious={
|
|
331
|
-
|
|
332
|
-
? () => onSelectMessage(previousMessageId)
|
|
333
|
-
: undefined
|
|
385
|
+
previousThread ? () => onOpenThread(previousThread) : undefined
|
|
334
386
|
}
|
|
335
387
|
mobileIntelligenceOpen={intelligenceOpen}
|
|
336
388
|
/>
|
|
@@ -258,7 +258,7 @@ export function MailSidebarAdapter({
|
|
|
258
258
|
return (
|
|
259
259
|
<NavLink
|
|
260
260
|
to="/mail/flagged"
|
|
261
|
-
search={{ q: undefined
|
|
261
|
+
search={{ q: undefined }}
|
|
262
262
|
onClick={() => onClick?.()}
|
|
263
263
|
className={className}
|
|
264
264
|
aria-label={ariaLabel}
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
5
5
|
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
6
6
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
7
|
-
import { useNavigate
|
|
7
|
+
import { useNavigate } from "@tanstack/react-router";
|
|
8
8
|
import {
|
|
9
9
|
BadgeCheck,
|
|
10
10
|
Check,
|
|
@@ -104,15 +104,7 @@ export const MessageActionMenu = ({
|
|
|
104
104
|
const queryClient = useQueryClient();
|
|
105
105
|
const navigate = useNavigate();
|
|
106
106
|
const { pushError } = useErrorBanners();
|
|
107
|
-
|
|
108
|
-
// path; the flagged list, still to move, says so in `?selectedMessageId=`.
|
|
109
|
-
const openThread = useOpenThreadPath();
|
|
110
|
-
const { selectedMessageId: selectedFromQuery } = useSearch({
|
|
111
|
-
strict: false,
|
|
112
|
-
}) as {
|
|
113
|
-
selectedMessageId?: string;
|
|
114
|
-
};
|
|
115
|
-
const selectedMessageId = openThread?.messageId ?? selectedFromQuery;
|
|
107
|
+
const selectedMessageId = useOpenThreadPath()?.messageId;
|
|
116
108
|
|
|
117
109
|
const { mutate: updateFlags, isPending: isUpdatingFlags } = useMutation({
|
|
118
110
|
...messageBulkOperationsUpdateFlagsMutation(),
|
|
@@ -243,7 +243,7 @@ export const MessageList = ({
|
|
|
243
243
|
const { pushError } = useErrorBanners();
|
|
244
244
|
|
|
245
245
|
// Roving focus cursor (#429): the keyboard "where am I" pointer, distinct
|
|
246
|
-
// from the open thread (
|
|
246
|
+
// from the open thread (the message segment in the path). j/k move this
|
|
247
247
|
// cursor; Enter opens the focused row → sets selected, and on desktop the
|
|
248
248
|
// reading pane follows the cursor of its own accord (see the follow-focus
|
|
249
249
|
// wiring below). It seeds from the open thread so opening a message also
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { AlertCircle, AlertTriangle, Info, X } from "lucide-react";
|
|
2
2
|
import { cn } from "../../lib/utils";
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
ErrorBannerAction,
|
|
5
|
+
ErrorBannerSeverity,
|
|
6
|
+
} from "./error-banners.js";
|
|
4
7
|
|
|
5
8
|
interface ErrorBannerProps {
|
|
6
9
|
id: string;
|
|
7
10
|
severity: ErrorBannerSeverity;
|
|
8
11
|
title: string;
|
|
9
12
|
detail?: string;
|
|
13
|
+
action?: ErrorBannerAction;
|
|
10
14
|
onDismiss: (id: string) => void;
|
|
11
15
|
}
|
|
12
16
|
|
|
@@ -51,6 +55,7 @@ export const ErrorBanner = ({
|
|
|
51
55
|
severity,
|
|
52
56
|
title,
|
|
53
57
|
detail,
|
|
58
|
+
action,
|
|
54
59
|
onDismiss,
|
|
55
60
|
}: ErrorBannerProps) => {
|
|
56
61
|
const styles = SEVERITY_STYLES[severity];
|
|
@@ -77,6 +82,16 @@ export const ErrorBanner = ({
|
|
|
77
82
|
{detail && (
|
|
78
83
|
<p className="mt-0.5 text-xs text-fg-muted break-words">{detail}</p>
|
|
79
84
|
)}
|
|
85
|
+
{action && (
|
|
86
|
+
<a
|
|
87
|
+
href={action.href}
|
|
88
|
+
target="_blank"
|
|
89
|
+
rel="noopener noreferrer"
|
|
90
|
+
className="mt-1 inline-block text-xs font-medium text-accent-2 underline underline-offset-2"
|
|
91
|
+
>
|
|
92
|
+
{action.label}
|
|
93
|
+
</a>
|
|
94
|
+
)}
|
|
80
95
|
</div>
|
|
81
96
|
<button
|
|
82
97
|
type="button"
|
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
export type ErrorBannerSeverity = "error" | "warning" | "info";
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* A way out of the banner. A failure the user cannot act on is still theirs to
|
|
5
|
+
* report, and a link they have to assemble themselves is one nobody follows.
|
|
6
|
+
*/
|
|
7
|
+
export interface ErrorBannerAction {
|
|
8
|
+
label: string;
|
|
9
|
+
href: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
3
12
|
export interface ErrorBannerEntry {
|
|
4
13
|
id: string;
|
|
5
14
|
severity: ErrorBannerSeverity;
|
|
6
15
|
title: string;
|
|
7
16
|
detail?: string;
|
|
17
|
+
action?: ErrorBannerAction;
|
|
8
18
|
createdAt: number;
|
|
9
19
|
}
|
|
10
20
|
|
|
@@ -12,6 +22,7 @@ export interface PushErrorInput {
|
|
|
12
22
|
severity?: ErrorBannerSeverity;
|
|
13
23
|
title: string;
|
|
14
24
|
detail?: string;
|
|
25
|
+
action?: ErrorBannerAction;
|
|
15
26
|
/**
|
|
16
27
|
* The error being reported, when there is one. Pass it: a banner is a soft
|
|
17
28
|
* surface, and `pushError` uses this to refuse errors that are not soft —
|
|
@@ -116,5 +127,6 @@ export const buildEntry = (
|
|
|
116
127
|
severity: input.severity ?? "error",
|
|
117
128
|
title: input.title,
|
|
118
129
|
detail: input.detail,
|
|
130
|
+
action: input.action,
|
|
119
131
|
createdAt: now,
|
|
120
132
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Where the search mirror writes, now that
|
|
2
|
+
* Where the search mirror writes, now that a list's open thread is a path
|
|
3
3
|
* segment (#718).
|
|
4
4
|
*
|
|
5
5
|
* Mirroring `q` used to be one navigation with one destination, because the
|
|
@@ -8,9 +8,12 @@
|
|
|
8
8
|
* walking up to the list — while every other write has to keep the address it
|
|
9
9
|
* found, or clearing the search would shut the conversation being read.
|
|
10
10
|
*
|
|
11
|
-
* Driven through a real router over
|
|
11
|
+
* Driven through a real router over each list's real route shape, because the
|
|
12
12
|
* whole question is what the router resolves a destination to from a thread
|
|
13
|
-
* route. Reasoning about `to: "."` is not evidence.
|
|
13
|
+
* route under it. Reasoning about `to: "."` is not evidence. The brief and the
|
|
14
|
+
* flagged list share this exact shape — a flat/sectioned list with a
|
|
15
|
+
* `$threadId/$messageId` pair below it — so the two are driven through the
|
|
16
|
+
* same cases rather than one covering for the other.
|
|
14
17
|
*/
|
|
15
18
|
|
|
16
19
|
import assert from "node:assert/strict";
|
|
@@ -45,14 +48,18 @@ afterEach(() => {
|
|
|
45
48
|
const THREAD_ID = "th-1";
|
|
46
49
|
const MESSAGE_ID = "msg-1";
|
|
47
50
|
|
|
48
|
-
const mailContext = (
|
|
51
|
+
const mailContext = (
|
|
52
|
+
input: string,
|
|
53
|
+
committed: string,
|
|
54
|
+
searchViewKey: string,
|
|
55
|
+
): MailContextValue => ({
|
|
49
56
|
accounts: [],
|
|
50
57
|
mailboxNameIndex: new Map(),
|
|
51
58
|
accountNameIndex: new Map(),
|
|
52
59
|
resultFolderIndex: EMPTY_RESULT_FOLDER_INDEX,
|
|
53
60
|
searchQuery: committed,
|
|
54
61
|
searchInput: input,
|
|
55
|
-
searchViewKey
|
|
62
|
+
searchViewKey,
|
|
56
63
|
onSearchChange: () => {},
|
|
57
64
|
onSearchClear: () => {},
|
|
58
65
|
onSearchClearQuery: () => {},
|
|
@@ -61,11 +68,15 @@ const mailContext = (input: string, committed: string): MailContextValue => ({
|
|
|
61
68
|
onSetIntelligenceOpen: () => {},
|
|
62
69
|
});
|
|
63
70
|
|
|
71
|
+
type ListPath = "/mail/brief" | "/mail/flagged";
|
|
72
|
+
|
|
64
73
|
/**
|
|
65
|
-
*
|
|
66
|
-
* segments under it, so a write from the list has a matched child to
|
|
74
|
+
* Both lists' shape: the list is a layout route and the thread and message
|
|
75
|
+
* are segments under it, so a write from the list has a matched child to
|
|
76
|
+
* lose.
|
|
67
77
|
*/
|
|
68
|
-
const routerAt = (href: string): AnyRouter => {
|
|
78
|
+
const routerAt = (listPath: ListPath, href: string): AnyRouter => {
|
|
79
|
+
const listSegment = listPath.slice("/mail".length);
|
|
69
80
|
const rootRoute = createRootRoute({ component: Outlet });
|
|
70
81
|
const passthrough = (search: Record<string, unknown>) => search;
|
|
71
82
|
const mailRoute = createRoute({
|
|
@@ -74,17 +85,17 @@ const routerAt = (href: string): AnyRouter => {
|
|
|
74
85
|
validateSearch: passthrough,
|
|
75
86
|
component: Outlet,
|
|
76
87
|
});
|
|
77
|
-
const
|
|
88
|
+
const listRoute = createRoute({
|
|
78
89
|
getParentRoute: () => mailRoute,
|
|
79
|
-
path:
|
|
90
|
+
path: listSegment,
|
|
80
91
|
validateSearch: passthrough,
|
|
81
92
|
component: () => {
|
|
82
|
-
useSearchMirror({ to:
|
|
93
|
+
useSearchMirror({ to: listPath });
|
|
83
94
|
return createElement(Outlet);
|
|
84
95
|
},
|
|
85
96
|
});
|
|
86
97
|
const threadRoute = createRoute({
|
|
87
|
-
getParentRoute: () =>
|
|
98
|
+
getParentRoute: () => listRoute,
|
|
88
99
|
path: "/$threadId",
|
|
89
100
|
component: Outlet,
|
|
90
101
|
});
|
|
@@ -95,7 +106,7 @@ const routerAt = (href: string): AnyRouter => {
|
|
|
95
106
|
});
|
|
96
107
|
const routeTree = rootRoute.addChildren([
|
|
97
108
|
mailRoute.addChildren([
|
|
98
|
-
|
|
109
|
+
listRoute.addChildren([threadRoute.addChildren([messageRoute])]),
|
|
99
110
|
]),
|
|
100
111
|
]);
|
|
101
112
|
return createRouter({
|
|
@@ -108,6 +119,7 @@ const mount = async (
|
|
|
108
119
|
router: AnyRouter,
|
|
109
120
|
input: string,
|
|
110
121
|
committed: string,
|
|
122
|
+
searchViewKey: string,
|
|
111
123
|
): Promise<DomHarness> => {
|
|
112
124
|
const created = createDomHarness();
|
|
113
125
|
harness = created;
|
|
@@ -115,7 +127,7 @@ const mount = async (
|
|
|
115
127
|
created.renderApp(
|
|
116
128
|
createElement(
|
|
117
129
|
MailContext.Provider,
|
|
118
|
-
{ value: mailContext(input, committed) },
|
|
130
|
+
{ value: mailContext(input, committed, searchViewKey) },
|
|
119
131
|
createElement(RouterProvider, { router }),
|
|
120
132
|
),
|
|
121
133
|
);
|
|
@@ -124,58 +136,62 @@ const mount = async (
|
|
|
124
136
|
return created;
|
|
125
137
|
};
|
|
126
138
|
|
|
127
|
-
const
|
|
139
|
+
const LIST_PATHS: ListPath[] = ["/mail/brief", "/mail/flagged"];
|
|
128
140
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const router = routerAt(threadHref);
|
|
132
|
-
await mount(router, "invoice", "invoice");
|
|
141
|
+
for (const listPath of LIST_PATHS) {
|
|
142
|
+
const threadHref = `${listPath}/${THREAD_ID}/${MESSAGE_ID}`;
|
|
133
143
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
144
|
+
describe(`mirroring a query that goes active (${listPath})`, () => {
|
|
145
|
+
it("walks up to the list, so no thread stays matched behind the results", async () => {
|
|
146
|
+
const router = routerAt(listPath, threadHref);
|
|
147
|
+
await mount(router, "invoice", "invoice", listPath);
|
|
137
148
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
149
|
+
assert.equal(router.history.location.pathname, listPath);
|
|
150
|
+
assert.match(router.history.location.search, /q=invoice/);
|
|
151
|
+
});
|
|
141
152
|
|
|
142
|
-
|
|
143
|
-
|
|
153
|
+
it("closes the pane from a bare thread address too", async () => {
|
|
154
|
+
const router = routerAt(listPath, `${listPath}/${THREAD_ID}`);
|
|
155
|
+
await mount(router, "invoice", "invoice", listPath);
|
|
156
|
+
|
|
157
|
+
assert.equal(router.history.location.pathname, listPath);
|
|
158
|
+
});
|
|
144
159
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
160
|
+
it("replaces rather than pushes, so Back is not a search step", async () => {
|
|
161
|
+
const router = routerAt(listPath, threadHref);
|
|
162
|
+
const before = router.history.length;
|
|
163
|
+
await mount(router, "invoice", "invoice", listPath);
|
|
149
164
|
|
|
150
|
-
|
|
165
|
+
assert.equal(router.history.length, before);
|
|
166
|
+
});
|
|
151
167
|
});
|
|
152
|
-
});
|
|
153
168
|
|
|
154
|
-
describe(
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
169
|
+
describe(`mirroring a cleared query (${listPath})`, () => {
|
|
170
|
+
it("keeps the conversation open — dropping the search is not closing it", async () => {
|
|
171
|
+
const router = routerAt(listPath, `${threadHref}?q=invoice`);
|
|
172
|
+
await mount(router, "", "", listPath);
|
|
158
173
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
174
|
+
assert.equal(router.history.location.pathname, threadHref);
|
|
175
|
+
assert.equal(router.history.location.search.includes("q="), false);
|
|
176
|
+
});
|
|
162
177
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
178
|
+
it("leaves the address alone when there is nothing to write", async () => {
|
|
179
|
+
const router = routerAt(listPath, `${threadHref}?q=invoice`);
|
|
180
|
+
await mount(router, "invoice", "invoice", listPath);
|
|
166
181
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
182
|
+
assert.equal(router.history.location.pathname, threadHref);
|
|
183
|
+
assert.match(router.history.location.search, /q=invoice/);
|
|
184
|
+
});
|
|
170
185
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
186
|
+
it("waits for the debounce rather than writing the previous query", async () => {
|
|
187
|
+
const router = routerAt(listPath, threadHref);
|
|
188
|
+
await mount(router, "invo", "", listPath);
|
|
174
189
|
|
|
175
|
-
|
|
176
|
-
|
|
190
|
+
assert.equal(router.history.location.pathname, threadHref);
|
|
191
|
+
assert.equal(router.history.location.search.includes("q="), false);
|
|
192
|
+
});
|
|
177
193
|
});
|
|
178
|
-
}
|
|
194
|
+
}
|
|
179
195
|
|
|
180
196
|
/**
|
|
181
197
|
* The other half of why a typed query survives an opened thread: the field
|
|
@@ -185,9 +201,12 @@ describe("mirroring a cleared query", () => {
|
|
|
185
201
|
* Read off the matches the router resolved for a real address, so the ids under
|
|
186
202
|
* test are the ones path segments produce rather than ids written out here.
|
|
187
203
|
*/
|
|
188
|
-
describe("the view key of an open
|
|
189
|
-
const viewKeyAt = async (
|
|
190
|
-
|
|
204
|
+
describe("the view key of an open thread", () => {
|
|
205
|
+
const viewKeyAt = async (
|
|
206
|
+
listPath: ListPath,
|
|
207
|
+
href: string,
|
|
208
|
+
): Promise<string> => {
|
|
209
|
+
const router = routerAt(listPath, href);
|
|
191
210
|
await router.load();
|
|
192
211
|
return mailViewKey(
|
|
193
212
|
router.state.matches.map((match: { routeId: string }) => ({
|
|
@@ -196,10 +215,15 @@ describe("the view key of an open brief thread", () => {
|
|
|
196
215
|
);
|
|
197
216
|
};
|
|
198
217
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
218
|
+
for (const listPath of LIST_PATHS) {
|
|
219
|
+
it(`is the list's own, thread and message segments alike (${listPath})`, async () => {
|
|
220
|
+
const list = await viewKeyAt(listPath, listPath);
|
|
221
|
+
assert.notEqual(list, "", "the list resolves to a view key at all");
|
|
222
|
+
assert.equal(await viewKeyAt(listPath, `${listPath}/${THREAD_ID}`), list);
|
|
223
|
+
assert.equal(
|
|
224
|
+
await viewKeyAt(listPath, `${listPath}/${THREAD_ID}/${MESSAGE_ID}`),
|
|
225
|
+
list,
|
|
226
|
+
);
|
|
227
|
+
});
|
|
228
|
+
}
|
|
205
229
|
});
|