@remit/web-client 0.0.144 → 0.0.146
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 +169 -120
- package/src/components/compose/ComposeSmtpMissingBanner.tsx +4 -0
- package/src/components/compose/mobile-header-stays-expanded.render.test.ts +182 -0
- package/src/components/layout/MailShell.tsx +98 -0
- package/src/components/mail/BriefPane.tsx +5 -5
- package/src/components/mail/FlaggedPane.tsx +1 -1
- package/src/components/mail/MailSidebarAdapter.tsx +5 -8
- package/src/components/mail/MailboxPane.tsx +5 -5
- package/src/components/ui/FatalErrorOverlay.tsx +4 -1
- package/src/hooks/useSearchMirror.ts +93 -0
- package/src/hooks/useSearchScope.ts +1 -1
- package/src/lib/compose-routes.test.ts +3 -1
- package/src/lib/compose-routes.ts +1 -1
- package/src/lib/mail-route.test.ts +157 -57
- package/src/lib/mail-route.ts +71 -38
- package/src/lib/mail-search.ts +51 -0
- package/src/lib/route-search-query.test.ts +50 -28
- package/src/lib/search-scope.ts +11 -19
- package/src/lib/search-view.test.ts +142 -38
- package/src/lib/search-view.ts +35 -11
- package/src/routeTree.gen.ts +155 -18
- package/src/router.tsx +17 -0
- package/src/routes/index.tsx +1 -1
- package/src/routes/mail/$mailboxId/index.tsx +10 -0
- package/src/routes/mail/$mailboxId.tsx +27 -19
- package/src/routes/mail/brief/index.tsx +14 -0
- package/src/routes/mail/brief.tsx +50 -0
- package/src/routes/mail/flagged/index.tsx +10 -0
- package/src/routes/mail/flagged.tsx +25 -13
- package/src/routes/mail/index.tsx +9 -36
- package/src/routes/mail/outbox/index.tsx +10 -0
- package/src/routes/mail/outbox.tsx +23 -13
- package/src/routes/mail.tsx +35 -246
- package/src/test-support/list-search-binding.ts +34 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { AppShellSlotted } from "@remit/ui";
|
|
2
|
+
import { createContext, type ReactNode, useContext } from "react";
|
|
3
|
+
import { AppShellSkeleton } from "@/components/layout/AppShellSkeleton";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The chrome every list shares, published by the `/mail` layout and consumed by
|
|
7
|
+
* the list route that mounts the shell.
|
|
8
|
+
*
|
|
9
|
+
* It lives in a module reached only through the `@/` alias for the same reason
|
|
10
|
+
* `lib/mail-context.ts` does: the generated route tree imports route files
|
|
11
|
+
* relatively, so a context declared inside one of them can resolve to a second
|
|
12
|
+
* module instance and hand the consumer an empty default.
|
|
13
|
+
*/
|
|
14
|
+
export interface MailShellChrome {
|
|
15
|
+
/**
|
|
16
|
+
* Below the reading boundary (phone AND tablet) the shell shows one pane, so
|
|
17
|
+
* the list route mounts its phone view instead of the slotted panes.
|
|
18
|
+
*/
|
|
19
|
+
isSinglePane: boolean;
|
|
20
|
+
isLoading: boolean;
|
|
21
|
+
intelligenceOpen: boolean;
|
|
22
|
+
nav: ReactNode;
|
|
23
|
+
topBar: ReactNode;
|
|
24
|
+
overlay: ReactNode;
|
|
25
|
+
navOpen: boolean;
|
|
26
|
+
onOpenNav: () => void;
|
|
27
|
+
onCloseNav: () => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const MailShellCtx = createContext<MailShellChrome | null>(null);
|
|
31
|
+
|
|
32
|
+
export const MailShellProvider = ({
|
|
33
|
+
chrome,
|
|
34
|
+
children,
|
|
35
|
+
}: {
|
|
36
|
+
chrome: MailShellChrome;
|
|
37
|
+
children: ReactNode;
|
|
38
|
+
}) => <MailShellCtx.Provider value={chrome}>{children}</MailShellCtx.Provider>;
|
|
39
|
+
|
|
40
|
+
export interface MailShellProps {
|
|
41
|
+
/** The single pane, list and open thread both, below the reading boundary. */
|
|
42
|
+
phone: ReactNode;
|
|
43
|
+
list: ReactNode;
|
|
44
|
+
reading: ReactNode;
|
|
45
|
+
intelligence?: ReactNode;
|
|
46
|
+
/** Whether the reading pane has a thread — the intelligence rail needs one. */
|
|
47
|
+
hasThread?: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The shell a list route mounts around its own panes.
|
|
52
|
+
*
|
|
53
|
+
* Below the reading boundary the shell is one pane and takes `phone`, which
|
|
54
|
+
* swaps between the list and whatever is open in place. Above it the panes sit
|
|
55
|
+
* side by side and the reading and intelligence slots are filled.
|
|
56
|
+
*/
|
|
57
|
+
export function MailShell({
|
|
58
|
+
phone,
|
|
59
|
+
list,
|
|
60
|
+
reading,
|
|
61
|
+
intelligence,
|
|
62
|
+
hasThread = false,
|
|
63
|
+
}: MailShellProps) {
|
|
64
|
+
const chrome = useContext(MailShellCtx);
|
|
65
|
+
if (!chrome) return <AppShellSkeleton />;
|
|
66
|
+
|
|
67
|
+
const shared = {
|
|
68
|
+
nav: chrome.nav,
|
|
69
|
+
overlay: chrome.overlay,
|
|
70
|
+
skeleton: <AppShellSkeleton />,
|
|
71
|
+
isLoading: chrome.isLoading,
|
|
72
|
+
navOpen: chrome.navOpen,
|
|
73
|
+
onOpenNav: chrome.onOpenNav,
|
|
74
|
+
onCloseNav: chrome.onCloseNav,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
if (chrome.isSinglePane) {
|
|
78
|
+
return (
|
|
79
|
+
<AppShellSlotted
|
|
80
|
+
{...shared}
|
|
81
|
+
list={phone}
|
|
82
|
+
intelligenceOpen={chrome.intelligenceOpen}
|
|
83
|
+
/>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<AppShellSlotted
|
|
89
|
+
{...shared}
|
|
90
|
+
topBar={chrome.topBar}
|
|
91
|
+
list={list}
|
|
92
|
+
reading={reading}
|
|
93
|
+
intelligence={intelligence}
|
|
94
|
+
intelligenceOpen={chrome.intelligenceOpen}
|
|
95
|
+
hasThread={hasThread}
|
|
96
|
+
/>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
@@ -132,7 +132,7 @@ function BriefPaneProvider({ selectedMessageId, children }: BriefPaneProps) {
|
|
|
132
132
|
const handleSelectMessage = useCallback(
|
|
133
133
|
(id: string, options?: OpenMessageOptions) => {
|
|
134
134
|
navigate({
|
|
135
|
-
to: "/mail",
|
|
135
|
+
to: "/mail/brief",
|
|
136
136
|
search: (prev) => ({
|
|
137
137
|
...prev,
|
|
138
138
|
selectedMessageId: id,
|
|
@@ -148,12 +148,12 @@ function BriefPaneProvider({ selectedMessageId, children }: BriefPaneProps) {
|
|
|
148
148
|
const handleSelectSearchResult = useCallback(
|
|
149
149
|
(result: SearchResult, options?: OpenMessageOptions) => {
|
|
150
150
|
navigate({
|
|
151
|
-
to: "/mail",
|
|
151
|
+
to: "/mail/brief",
|
|
152
152
|
replace: options?.replace,
|
|
153
153
|
search: (prev) => ({
|
|
154
154
|
...prev,
|
|
155
155
|
// Commit the active query with the selection so the debounced
|
|
156
|
-
// q-mirror (
|
|
156
|
+
// q-mirror (`useSearchMirror`) — which strips the selection when the query
|
|
157
157
|
// goes active — is already satisfied and leaves the opened result
|
|
158
158
|
// alone. Use the *live* `searchInput`: the row can be tapped before
|
|
159
159
|
// the debounce settles, when the committed query is still empty.
|
|
@@ -172,7 +172,7 @@ function BriefPaneProvider({ selectedMessageId, children }: BriefPaneProps) {
|
|
|
172
172
|
if (!selectedMessageId) return;
|
|
173
173
|
if (!removedIds.includes(selectedMessageId)) return;
|
|
174
174
|
navigate({
|
|
175
|
-
to: "/mail",
|
|
175
|
+
to: "/mail/brief",
|
|
176
176
|
search: (prev) => ({
|
|
177
177
|
...prev,
|
|
178
178
|
selectedMessageId: undefined,
|
|
@@ -191,7 +191,7 @@ function BriefPaneProvider({ selectedMessageId, children }: BriefPaneProps) {
|
|
|
191
191
|
|
|
192
192
|
const handleCloseThread = useCallback(() => {
|
|
193
193
|
navigate({
|
|
194
|
-
to: "/mail",
|
|
194
|
+
to: "/mail/brief",
|
|
195
195
|
search: (prev) => ({
|
|
196
196
|
...prev,
|
|
197
197
|
selectedMessageId: undefined,
|
|
@@ -182,7 +182,7 @@ function FlaggedPaneProvider({
|
|
|
182
182
|
if (!triageTarget) return;
|
|
183
183
|
toggleReadFor([triageTarget.messageId], !triageTarget.isRead);
|
|
184
184
|
},
|
|
185
|
-
goBrief: () => navigate({ to: "/mail" }),
|
|
185
|
+
goBrief: () => navigate({ to: "/mail/brief" }),
|
|
186
186
|
goSettings: () => navigate({ to: "/settings" }),
|
|
187
187
|
},
|
|
188
188
|
});
|
|
@@ -117,8 +117,8 @@ function toNavMailbox(
|
|
|
117
117
|
* component can highlight the right item.
|
|
118
118
|
* - /mail/outbox → "outbox"
|
|
119
119
|
* - /mail/flagged → "flagged"
|
|
120
|
+
* - /mail/brief → "brief"
|
|
120
121
|
* - /mail/$mailboxId → mailboxId
|
|
121
|
-
* - /mail (daily brief) → "brief"
|
|
122
122
|
*/
|
|
123
123
|
function useSelectedNavId(): string {
|
|
124
124
|
const location = useLocation();
|
|
@@ -127,9 +127,8 @@ function useSelectedNavId(): string {
|
|
|
127
127
|
if (location.pathname.startsWith("/settings")) return "settings";
|
|
128
128
|
if (location.pathname.startsWith("/mail/outbox")) return "outbox";
|
|
129
129
|
if (location.pathname.startsWith("/mail/flagged")) return "flagged";
|
|
130
|
+
if (location.pathname.startsWith("/mail/brief")) return "brief";
|
|
130
131
|
if (params.mailboxId) return params.mailboxId;
|
|
131
|
-
if (location.pathname === "/mail" || location.pathname === "/mail/")
|
|
132
|
-
return "brief";
|
|
133
132
|
return "";
|
|
134
133
|
}
|
|
135
134
|
|
|
@@ -229,11 +228,9 @@ export function MailSidebarAdapter({
|
|
|
229
228
|
if (navId === "brief") {
|
|
230
229
|
return (
|
|
231
230
|
<NavLink
|
|
232
|
-
to="/mail"
|
|
231
|
+
to="/mail/brief"
|
|
233
232
|
search={{ q: undefined, selectedMessageId: undefined }}
|
|
234
|
-
|
|
235
|
-
// match would leave the brief marked current on all of them.
|
|
236
|
-
activeOptions={{ exact: true, includeSearch: false }}
|
|
233
|
+
activeOptions={{ includeSearch: false }}
|
|
237
234
|
onClick={() => onClick?.()}
|
|
238
235
|
className={className}
|
|
239
236
|
aria-label={ariaLabel}
|
|
@@ -344,7 +341,7 @@ export function MailSidebarAdapter({
|
|
|
344
341
|
(query: string) => {
|
|
345
342
|
onSearchChange(query);
|
|
346
343
|
navigate({
|
|
347
|
-
to: "/mail",
|
|
344
|
+
to: "/mail/brief",
|
|
348
345
|
search: { q: query, selectedMessageId: undefined },
|
|
349
346
|
});
|
|
350
347
|
onMailboxSelect?.();
|
|
@@ -803,7 +803,7 @@ function MailboxPaneProvider({
|
|
|
803
803
|
}, [intelligenceOpen, onToggleIntelligence]);
|
|
804
804
|
|
|
805
805
|
const goToRoute = useCallback(
|
|
806
|
-
(to: "/mail" | "/mail/flagged" | "/settings") => {
|
|
806
|
+
(to: "/mail/brief" | "/mail/flagged" | "/settings") => {
|
|
807
807
|
navigate({ to });
|
|
808
808
|
},
|
|
809
809
|
[navigate],
|
|
@@ -851,9 +851,9 @@ function MailboxPaneProvider({
|
|
|
851
851
|
markJunk: triageMarkJunk,
|
|
852
852
|
toggleIntelligence: selectedThread ? onToggleIntelligence : undefined,
|
|
853
853
|
compose: handleNewCompose,
|
|
854
|
-
goBrief: () => goToRoute("/mail"),
|
|
855
|
-
goInbox: () => goToRoute("/mail"),
|
|
856
|
-
goSent: () => goToRoute("/mail"),
|
|
854
|
+
goBrief: () => goToRoute("/mail/brief"),
|
|
855
|
+
goInbox: () => goToRoute("/mail/brief"),
|
|
856
|
+
goSent: () => goToRoute("/mail/brief"),
|
|
857
857
|
goFlagged: () => goToRoute("/mail/flagged"),
|
|
858
858
|
goSettings: () => goToRoute("/settings"),
|
|
859
859
|
},
|
|
@@ -1047,7 +1047,7 @@ function MailboxList() {
|
|
|
1047
1047
|
search: (prev: Record<string, unknown>) => ({
|
|
1048
1048
|
...prev,
|
|
1049
1049
|
// Commit the active query alongside the selection. The debounced
|
|
1050
|
-
// q-mirror (
|
|
1050
|
+
// q-mirror (`useSearchMirror`) strips the selection whenever it sees the
|
|
1051
1051
|
// query go active; the row can be tapped before the debounce settles
|
|
1052
1052
|
// (it shows in the still-unfiltered list), so use the *live*
|
|
1053
1053
|
// `searchInput` here — committing `q` makes the mirror a no-op and
|
|
@@ -12,7 +12,10 @@ import {
|
|
|
12
12
|
subscribeFatalError,
|
|
13
13
|
} from "@/lib/fatal-error";
|
|
14
14
|
|
|
15
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* Safe route to escape a deterministically-crashing page. It names no list, so
|
|
17
|
+
* it redirects on to the brief.
|
|
18
|
+
*/
|
|
16
19
|
const SAFE_ROUTE = "/mail";
|
|
17
20
|
|
|
18
21
|
/**
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { useNavigate, useRouterState, useSearch } from "@tanstack/react-router";
|
|
2
|
+
import { useEffect, useRef } from "react";
|
|
3
|
+
import { useMailContext } from "@/lib/mail-context";
|
|
4
|
+
import { shouldMirrorQuery } from "@/lib/search-view";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The list the mirror writes to. Each list calls the hook with its own route,
|
|
8
|
+
* so the query lands on the list rather than on whatever is open below it.
|
|
9
|
+
*/
|
|
10
|
+
export type SearchMirrorTarget =
|
|
11
|
+
| { to: "/mail/brief" | "/mail/flagged" | "/mail/outbox" }
|
|
12
|
+
| { to: "/mail/$mailboxId"; params: { mailboxId: string } };
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Mirrors the settled search into the URL so links are shareable and a refresh
|
|
16
|
+
* restores the query.
|
|
17
|
+
*
|
|
18
|
+
* Within a view this is one-directional — the URL is not read back into state,
|
|
19
|
+
* so there is no sync loop — and it writes only once the debounce agrees with
|
|
20
|
+
* the field, so a query arriving by URL is never overwritten mid-debounce, and
|
|
21
|
+
* the query the user just left behind is never written onto the view they landed
|
|
22
|
+
* on (`shouldMirrorQuery`).
|
|
23
|
+
*
|
|
24
|
+
* It also writes only while the reader is still on this list. A list stays
|
|
25
|
+
* mounted, effects and all, until the list they navigated to is ready to paint,
|
|
26
|
+
* and by then the address is already the new one — so a debounce settling in
|
|
27
|
+
* that window would fire a navigation to *this* list, superseding the load in
|
|
28
|
+
* flight and replacing the entry the reader had just pushed. They would click
|
|
29
|
+
* Inbox and land back on the brief.
|
|
30
|
+
*
|
|
31
|
+
* When a query *goes* active it also strips the selection so the reading pane
|
|
32
|
+
* closes (#539): an open message from the pre-search list is not meaningful in
|
|
33
|
+
* the search result set. Only on that transition though — tapping a search
|
|
34
|
+
* result commits the same `q` with the selection, so when `prev.q` already
|
|
35
|
+
* equals the query the result was opened under it (not a pre-search leftover)
|
|
36
|
+
* and must survive. The strip otherwise raced the tap: the row shows before the
|
|
37
|
+
* debounce settles, so this mirror can land just after the open and close it
|
|
38
|
+
* again.
|
|
39
|
+
*/
|
|
40
|
+
export function useSearchMirror(target: SearchMirrorTarget): void {
|
|
41
|
+
const navigate = useNavigate();
|
|
42
|
+
const { searchInput, searchQuery: committedQuery } = useMailContext();
|
|
43
|
+
const { q: urlQuery = "" } = useSearch({ from: "/mail" });
|
|
44
|
+
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
|
45
|
+
|
|
46
|
+
// Read at effect time rather than depended on: the URL is what the mirror
|
|
47
|
+
// compares against, not what re-triggers it.
|
|
48
|
+
const urlQueryRef = useRef(urlQuery);
|
|
49
|
+
urlQueryRef.current = urlQuery;
|
|
50
|
+
|
|
51
|
+
const { to } = target;
|
|
52
|
+
const mailboxId = "params" in target ? target.params.mailboxId : undefined;
|
|
53
|
+
const listPath = mailboxId ? `/mail/${mailboxId}` : to;
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
const mayWrite = shouldMirrorQuery({
|
|
57
|
+
searchInput,
|
|
58
|
+
committedQuery,
|
|
59
|
+
urlQuery: urlQueryRef.current,
|
|
60
|
+
pathname,
|
|
61
|
+
listPath,
|
|
62
|
+
});
|
|
63
|
+
if (!mayWrite) return;
|
|
64
|
+
const search = (prev: Record<string, unknown>) => {
|
|
65
|
+
const queryAlreadyActive = prev.q === committedQuery;
|
|
66
|
+
return {
|
|
67
|
+
...prev,
|
|
68
|
+
q: committedQuery || undefined,
|
|
69
|
+
...(committedQuery && !queryAlreadyActive
|
|
70
|
+
? {
|
|
71
|
+
selectedMessageId: undefined,
|
|
72
|
+
selectedThreadId: undefined,
|
|
73
|
+
selectedMailboxId: undefined,
|
|
74
|
+
}
|
|
75
|
+
: {}),
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
if (to === "/mail/$mailboxId") {
|
|
79
|
+
if (!mailboxId) return;
|
|
80
|
+
navigate({ to, params: { mailboxId }, search, replace: true });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
navigate({ to, search, replace: true });
|
|
84
|
+
}, [
|
|
85
|
+
searchInput,
|
|
86
|
+
committedQuery,
|
|
87
|
+
navigate,
|
|
88
|
+
to,
|
|
89
|
+
mailboxId,
|
|
90
|
+
listPath,
|
|
91
|
+
pathname,
|
|
92
|
+
]);
|
|
93
|
+
}
|
|
@@ -38,7 +38,7 @@ export function useSearchScope(accounts: RemitImapAccountResponse[]): {
|
|
|
38
38
|
(chipId: string) => {
|
|
39
39
|
if (chipId !== SEARCH_SCOPE_CHIP_ID) return;
|
|
40
40
|
navigate({
|
|
41
|
-
to: "/mail",
|
|
41
|
+
to: "/mail/brief",
|
|
42
42
|
search: { q: searchInput || undefined, selectedMessageId: undefined },
|
|
43
43
|
});
|
|
44
44
|
},
|
|
@@ -15,11 +15,12 @@ describe("hostsComposeSurface", () => {
|
|
|
15
15
|
});
|
|
16
16
|
|
|
17
17
|
it("is false for the virtual views, which mount no surface", () => {
|
|
18
|
+
assert.equal(hostsComposeSurface("/mail/brief"), false);
|
|
18
19
|
assert.equal(hostsComposeSurface("/mail/outbox"), false);
|
|
19
20
|
assert.equal(hostsComposeSurface("/mail/flagged"), false);
|
|
20
21
|
});
|
|
21
22
|
|
|
22
|
-
it("is false for
|
|
23
|
+
it("is false for /mail itself, which only redirects to the brief", () => {
|
|
23
24
|
assert.equal(hostsComposeSurface("/mail"), false);
|
|
24
25
|
assert.equal(hostsComposeSurface("/mail/"), false);
|
|
25
26
|
});
|
|
@@ -34,6 +35,7 @@ describe("hostsComposeSurface", () => {
|
|
|
34
35
|
assert.equal(hostsComposeSurface("/mail/outbox-2024"), true);
|
|
35
36
|
assert.equal(hostsComposeSurface("/mail/flagged-archive"), true);
|
|
36
37
|
assert.equal(hostsComposeSurface("/mail/outboxes"), true);
|
|
38
|
+
assert.equal(hostsComposeSurface("/mail/briefing"), true);
|
|
37
39
|
});
|
|
38
40
|
|
|
39
41
|
it("ignores a query string or hash on the path", () => {
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
/** `/mail/<segment>` values that name a view rather than a mailbox. */
|
|
13
|
-
const VIRTUAL_MAIL_VIEWS = new Set(["outbox", "flagged"]);
|
|
13
|
+
const VIRTUAL_MAIL_VIEWS = new Set(["brief", "outbox", "flagged"]);
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
16
|
* True for `/mail/<id>` where `<id>` is a real mailbox.
|
|
@@ -1,80 +1,180 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
2
|
+
* Three contracts, each of which has already cost a regression.
|
|
3
|
+
*
|
|
4
|
+
* The list is read off a matched route id, never the parent /mail layout's
|
|
5
|
+
* pathname: that pathname is "/mail" on every child route, and keying off it
|
|
6
|
+
* routed every mailbox through the brief pane, so the message-row anchors
|
|
7
|
+
* vanished.
|
|
8
|
+
*
|
|
9
|
+
* The view key of a list equals the view key of anything nested under it. A
|
|
10
|
+
* thread is a child route of the list it was opened from, so a key that moved
|
|
11
|
+
* when the thread opened would tell `lib/search-view.ts` the reader had left
|
|
12
|
+
* the view — and the query they had just typed would be re-seeded from the URL
|
|
13
|
+
* and disappear the moment they opened a hit.
|
|
14
|
+
*
|
|
15
|
+
* And "am I the current list" is answered by the pathname, not by the matches,
|
|
16
|
+
* because the matches lag a navigation by as long as the destination takes to
|
|
17
|
+
* mount.
|
|
10
18
|
*/
|
|
11
19
|
import assert from "node:assert/strict";
|
|
12
20
|
import { describe, it } from "node:test";
|
|
13
21
|
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
22
|
+
locationIsOnList,
|
|
23
|
+
MAIL_BRIEF_ROUTE_ID,
|
|
24
|
+
MAIL_FLAGGED_ROUTE_ID,
|
|
25
|
+
MAIL_MAILBOX_ROUTE_ID,
|
|
26
|
+
MAIL_OUTBOX_ROUTE_ID,
|
|
18
27
|
type MailRouteMatch,
|
|
28
|
+
mailListRoute,
|
|
29
|
+
mailViewKey,
|
|
19
30
|
} from "./mail-route.js";
|
|
20
31
|
|
|
21
|
-
/** Matches as TanStack Router reports them:
|
|
22
|
-
const
|
|
32
|
+
/** Matches as TanStack Router reports them: outermost first, leaf last. */
|
|
33
|
+
const matches = (
|
|
34
|
+
routeIds: readonly string[],
|
|
35
|
+
params?: Record<string, string>,
|
|
36
|
+
): MailRouteMatch[] => [
|
|
23
37
|
{ routeId: "__root__" },
|
|
24
38
|
{ routeId: "/mail" },
|
|
25
|
-
{ routeId:
|
|
26
|
-
];
|
|
27
|
-
const mailboxMatches: MailRouteMatch[] = [
|
|
28
|
-
{ routeId: "__root__" },
|
|
29
|
-
{ routeId: "/mail" },
|
|
30
|
-
{ routeId: "/mail/$mailboxId" },
|
|
31
|
-
];
|
|
32
|
-
const outboxMatches: MailRouteMatch[] = [
|
|
33
|
-
{ routeId: "__root__" },
|
|
34
|
-
{ routeId: "/mail" },
|
|
35
|
-
{ routeId: "/mail/outbox" },
|
|
36
|
-
];
|
|
37
|
-
const flaggedMatches: MailRouteMatch[] = [
|
|
38
|
-
{ routeId: "__root__" },
|
|
39
|
-
{ routeId: "/mail" },
|
|
40
|
-
{ routeId: "/mail/flagged" },
|
|
39
|
+
...routeIds.map((routeId) => ({ routeId, ...(params ? { params } : {}) })),
|
|
41
40
|
];
|
|
42
41
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
42
|
+
const brief = matches([MAIL_BRIEF_ROUTE_ID]);
|
|
43
|
+
const flagged = matches([MAIL_FLAGGED_ROUTE_ID]);
|
|
44
|
+
const outbox = matches([MAIL_OUTBOX_ROUTE_ID]);
|
|
45
|
+
const mailbox = matches([MAIL_MAILBOX_ROUTE_ID], { mailboxId: "inbox-1" });
|
|
46
|
+
|
|
47
|
+
describe("mailListRoute", () => {
|
|
48
|
+
it("names each of the four lists", () => {
|
|
49
|
+
assert.deepEqual(mailListRoute(brief), { list: "brief" });
|
|
50
|
+
assert.deepEqual(mailListRoute(flagged), { list: "flagged" });
|
|
51
|
+
assert.deepEqual(mailListRoute(outbox), { list: "outbox" });
|
|
52
|
+
assert.deepEqual(mailListRoute(mailbox), {
|
|
53
|
+
list: "mailbox",
|
|
54
|
+
mailboxId: "inbox-1",
|
|
55
|
+
});
|
|
56
|
+
assert.deepEqual(mailListRoute(matches([MAIL_MAILBOX_ROUTE_ID])), {
|
|
57
|
+
list: "mailbox",
|
|
58
|
+
mailboxId: undefined,
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("never reads the parent /mail layout as a list", () => {
|
|
63
|
+
assert.equal(mailListRoute(matches([])), undefined);
|
|
64
|
+
assert.equal(mailListRoute([{ routeId: "__root__" }]), undefined);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("names the list a thread was opened from, not the thread", () => {
|
|
68
|
+
assert.deepEqual(
|
|
69
|
+
mailListRoute(
|
|
70
|
+
matches([MAIL_BRIEF_ROUTE_ID, `${MAIL_BRIEF_ROUTE_ID}/$threadId`]),
|
|
71
|
+
),
|
|
72
|
+
{ list: "brief" },
|
|
73
|
+
);
|
|
74
|
+
assert.deepEqual(
|
|
75
|
+
mailListRoute(
|
|
76
|
+
matches([MAIL_MAILBOX_ROUTE_ID, `${MAIL_MAILBOX_ROUTE_ID}/$threadId`], {
|
|
77
|
+
mailboxId: "inbox-1",
|
|
78
|
+
}),
|
|
79
|
+
),
|
|
80
|
+
{ list: "mailbox", mailboxId: "inbox-1" },
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("mailViewKey", () => {
|
|
86
|
+
it("gives the four lists four distinct keys", () => {
|
|
87
|
+
const keys = [brief, flagged, outbox, mailbox].map(mailViewKey);
|
|
88
|
+
assert.equal(new Set(keys).size, 4);
|
|
48
89
|
});
|
|
49
90
|
|
|
50
|
-
it("
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
assert.equal(isOutboxRoute(mailboxMatches), false);
|
|
91
|
+
it("distinguishes two mailboxes", () => {
|
|
92
|
+
assert.notEqual(
|
|
93
|
+
mailViewKey(matches([MAIL_MAILBOX_ROUTE_ID], { mailboxId: "inbox-1" })),
|
|
94
|
+
mailViewKey(matches([MAIL_MAILBOX_ROUTE_ID], { mailboxId: "archive-1" })),
|
|
95
|
+
);
|
|
56
96
|
});
|
|
57
97
|
|
|
58
|
-
it("
|
|
59
|
-
assert.equal(
|
|
60
|
-
assert.equal(isMailboxRoute(outboxMatches), false);
|
|
61
|
-
assert.equal(isOutboxRoute(outboxMatches), true);
|
|
98
|
+
it("is empty outside the mail shell", () => {
|
|
99
|
+
assert.equal(mailViewKey([{ routeId: "__root__" }]), "");
|
|
62
100
|
});
|
|
63
101
|
|
|
64
|
-
it("
|
|
65
|
-
assert.equal(
|
|
66
|
-
assert.equal(isBriefRoute(flaggedMatches), false);
|
|
67
|
-
assert.equal(isMailboxRoute(flaggedMatches), false);
|
|
68
|
-
assert.equal(isOutboxRoute(flaggedMatches), false);
|
|
102
|
+
it("is empty on a mailbox route whose param has not resolved", () => {
|
|
103
|
+
assert.equal(mailViewKey(matches([MAIL_MAILBOX_ROUTE_ID])), "");
|
|
69
104
|
});
|
|
70
105
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
106
|
+
// The trap: opening a thread must not read as leaving the list, or the
|
|
107
|
+
// search field re-seeds and the typed query is gone.
|
|
108
|
+
it("gives a list and its open thread the same key", () => {
|
|
109
|
+
const lists: [readonly string[], Record<string, string> | undefined][] = [
|
|
110
|
+
[[MAIL_BRIEF_ROUTE_ID], undefined],
|
|
111
|
+
[[MAIL_FLAGGED_ROUTE_ID], undefined],
|
|
112
|
+
[[MAIL_OUTBOX_ROUTE_ID], undefined],
|
|
113
|
+
[[MAIL_MAILBOX_ROUTE_ID], { mailboxId: "inbox-1" }],
|
|
77
114
|
];
|
|
78
|
-
|
|
115
|
+
|
|
116
|
+
for (const [routeIds, params] of lists) {
|
|
117
|
+
const list = routeIds[0];
|
|
118
|
+
const thread = `${list}/$threadId`;
|
|
119
|
+
const message = `${thread}/$messageId`;
|
|
120
|
+
assert.equal(
|
|
121
|
+
mailViewKey(matches([list, thread], params)),
|
|
122
|
+
mailViewKey(matches([list], params)),
|
|
123
|
+
`${list} changes view key when a thread opens`,
|
|
124
|
+
);
|
|
125
|
+
assert.equal(
|
|
126
|
+
mailViewKey(matches([list, thread, message], params)),
|
|
127
|
+
mailViewKey(matches([list], params)),
|
|
128
|
+
`${list} changes view key when a message expands`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("gives a list and its reading-pane index child the same key", () => {
|
|
134
|
+
assert.equal(
|
|
135
|
+
mailViewKey(matches([MAIL_BRIEF_ROUTE_ID, `${MAIL_BRIEF_ROUTE_ID}/`])),
|
|
136
|
+
mailViewKey(brief),
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The router commits the new location before it swaps the matches, so a list on
|
|
143
|
+
* its way off the screen sees its own matches under the destination's address.
|
|
144
|
+
* Anything that acts on "am I the current list" has to ask the pathname.
|
|
145
|
+
*/
|
|
146
|
+
describe("locationIsOnList", () => {
|
|
147
|
+
it("is true on the list itself", () => {
|
|
148
|
+
assert.equal(locationIsOnList("/mail/brief", "/mail/brief"), true);
|
|
149
|
+
assert.equal(locationIsOnList("/mail/inbox-1", "/mail/inbox-1"), true);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("is true for anything the list has open below it", () => {
|
|
153
|
+
assert.equal(
|
|
154
|
+
locationIsOnList("/mail/brief/thread-1/message-1", "/mail/brief"),
|
|
155
|
+
true,
|
|
156
|
+
);
|
|
157
|
+
assert.equal(locationIsOnList("/mail/brief/", "/mail/brief"), true);
|
|
158
|
+
assert.equal(
|
|
159
|
+
locationIsOnList("/mail/inbox-1/compose", "/mail/inbox-1"),
|
|
160
|
+
true,
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("is false once the address names another list", () => {
|
|
165
|
+
assert.equal(locationIsOnList("/mail/inbox-1", "/mail/brief"), false);
|
|
166
|
+
assert.equal(locationIsOnList("/mail/brief", "/mail/inbox-1"), false);
|
|
167
|
+
assert.equal(locationIsOnList("/mail/archive-1", "/mail/inbox-1"), false);
|
|
168
|
+
assert.equal(locationIsOnList("/mail/flagged", "/mail/brief"), false);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("is false outside the mail shell", () => {
|
|
172
|
+
assert.equal(locationIsOnList("/settings/accounts", "/mail/brief"), false);
|
|
173
|
+
assert.equal(locationIsOnList("/onboarding", "/mail/brief"), false);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("compares whole segments, so a folder may be named after a list", () => {
|
|
177
|
+
assert.equal(locationIsOnList("/mail/briefing", "/mail/brief"), false);
|
|
178
|
+
assert.equal(locationIsOnList("/mail/outbox-2024", "/mail/outbox"), false);
|
|
79
179
|
});
|
|
80
180
|
});
|