@remit/web-client 0.0.202 → 0.0.203
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/ComposeSmtpMissingBanner.tsx +3 -6
- package/src/components/layout/ComposeFab.tsx +3 -5
- package/src/components/layout/MailTopBar.tsx +3 -4
- package/src/components/mail/BriefPane.tsx +14 -31
- package/src/components/mail/DailyBrief.tsx +9 -12
- package/src/components/mail/DraftsView.tsx +3 -17
- package/src/components/mail/FlaggedList.tsx +1 -1
- package/src/components/mail/FlaggedPane.tsx +14 -31
- package/src/components/mail/MailListHeader.tsx +10 -11
- package/src/components/mail/MailSidebarAdapter.tsx +4 -25
- package/src/components/mail/MailboxPane.tsx +21 -59
- package/src/components/mail/MessageActionMenu.tsx +4 -11
- package/src/components/mail/MessageList.tsx +9 -13
- package/src/components/mail/OutboxPane.tsx +9 -26
- package/src/components/mail/SelectionWizardHost.tsx +1 -1
- package/src/components/mail/SwipeableMessageRow.tsx +4 -15
- package/src/hooks/useCurrentMailboxName.ts +5 -9
- package/src/hooks/useSearchScope.ts +6 -12
- package/src/hooks/useSearchTokenContext.ts +3 -2
- package/src/hooks/useSemanticSearch.ts +3 -3
- package/src/lib/mail-route.ts +10 -0
- package/src/lib/search-scope.test.ts +45 -32
- package/src/lib/search-scope.ts +14 -19
- package/src/lib/search-view.test.ts +1 -1
- package/src/lib/search-view.ts +2 -2
- package/src/routes/mail/$mailboxId.tsx +1 -2
- package/src/routes/mail/brief.tsx +1 -2
- package/src/routes/mail/flagged.tsx +1 -2
- package/src/routes/mail/outbox.tsx +1 -2
- package/src/routes/mail.tsx +7 -3
- package/src/routing/browsed-list.ts +60 -9
- package/src/routing/compose.ts +2 -27
- package/src/routing/index.ts +40 -1
- package/src/routing/nav-selection.ts +24 -0
- package/src/routing/open-thread.ts +89 -2
- package/src/routing/outbox-draft.ts +27 -1
- package/src/{hooks/useSearchField.render.test.ts → routing/search-field.render.test.ts} +1 -1
- package/src/{hooks/useSearchField.ts → routing/search-field.ts} +1 -1
- package/src/{hooks → routing}/search-mirror-convergence.render.test.ts +1 -1
- package/src/{hooks → routing}/search-mirror-detail.render.test.ts +1 -1
- package/src/{hooks/useSearchMirror.ts → routing/search-mirror.ts} +2 -1
- package/src/routing/search-navigation.ts +44 -0
- package/src/routing/sections.ts +53 -0
- /package/src/{lib → routing}/wizard-history.test.ts +0 -0
- /package/src/{lib → routing}/wizard-history.ts +0 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
3
|
import {
|
|
4
|
+
type BrowsedList,
|
|
4
5
|
MAIL_BRIEF_ROUTE_ID,
|
|
5
6
|
MAIL_FLAGGED_ROUTE_ID,
|
|
6
7
|
MAIL_MAILBOX_ROUTE_ID,
|
|
@@ -15,18 +16,28 @@ import {
|
|
|
15
16
|
semanticMailboxScope,
|
|
16
17
|
} from "./search-scope";
|
|
17
18
|
|
|
18
|
-
const
|
|
19
|
+
const listFor: Record<string, BrowsedList["list"]> = {
|
|
20
|
+
[MAIL_BRIEF_ROUTE_ID]: "brief",
|
|
21
|
+
[MAIL_FLAGGED_ROUTE_ID]: "flagged",
|
|
22
|
+
[MAIL_OUTBOX_ROUTE_ID]: "outbox",
|
|
23
|
+
[MAIL_MAILBOX_ROUTE_ID]: "mailbox",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const browsing = (routeId: string): BrowsedList => ({
|
|
27
|
+
list: listFor[routeId],
|
|
28
|
+
mailboxId: undefined,
|
|
29
|
+
});
|
|
19
30
|
|
|
20
31
|
describe("searchScopeForRoute", () => {
|
|
21
32
|
it("gives the daily brief no scope — it is the global view", () => {
|
|
22
|
-
assert.deepEqual(searchScopeForRoute(
|
|
33
|
+
assert.deepEqual(searchScopeForRoute(browsing(MAIL_BRIEF_ROUTE_ID)), {
|
|
23
34
|
kind: "global",
|
|
24
35
|
});
|
|
25
36
|
});
|
|
26
37
|
|
|
27
38
|
it("scopes a mailbox route to its sidebar label", () => {
|
|
28
39
|
assert.deepEqual(
|
|
29
|
-
searchScopeForRoute(
|
|
40
|
+
searchScopeForRoute(browsing(MAIL_MAILBOX_ROUTE_ID), "Spam"),
|
|
30
41
|
{
|
|
31
42
|
kind: "scoped",
|
|
32
43
|
chip: { id: SEARCH_SCOPE_CHIP_ID, label: "in:spam" },
|
|
@@ -39,23 +50,23 @@ describe("searchScopeForRoute", () => {
|
|
|
39
50
|
// uuid is worse than no chip, but the field must not claim to search
|
|
40
51
|
// everything either.
|
|
41
52
|
assert.deepEqual(
|
|
42
|
-
searchScopeForRoute(
|
|
53
|
+
searchScopeForRoute(browsing(MAIL_MAILBOX_ROUTE_ID), null),
|
|
43
54
|
{ kind: "pending" },
|
|
44
55
|
);
|
|
45
56
|
});
|
|
46
57
|
|
|
47
58
|
it("scopes the starred view", () => {
|
|
48
|
-
const scope = searchScopeForRoute(
|
|
59
|
+
const scope = searchScopeForRoute(browsing(MAIL_FLAGGED_ROUTE_ID));
|
|
49
60
|
assert.equal(scope.kind === "scoped" && scope.chip.label, "is:starred");
|
|
50
61
|
});
|
|
51
62
|
|
|
52
63
|
it("scopes the outbox", () => {
|
|
53
|
-
const scope = searchScopeForRoute(
|
|
64
|
+
const scope = searchScopeForRoute(browsing(MAIL_OUTBOX_ROUTE_ID));
|
|
54
65
|
assert.equal(scope.kind === "scoped" && scope.chip.label, "in:outbox");
|
|
55
66
|
});
|
|
56
67
|
|
|
57
68
|
it("prefers the virtual views over a lingering mailbox name", () => {
|
|
58
|
-
const scope = searchScopeForRoute(
|
|
69
|
+
const scope = searchScopeForRoute(browsing(MAIL_OUTBOX_ROUTE_ID), "Spam");
|
|
59
70
|
assert.equal(scope.kind === "scoped" && scope.chip.label, "in:outbox");
|
|
60
71
|
});
|
|
61
72
|
});
|
|
@@ -70,12 +81,12 @@ describe("isScopedRoute", () => {
|
|
|
70
81
|
MAIL_OUTBOX_ROUTE_ID,
|
|
71
82
|
]) {
|
|
72
83
|
it(`${routeId} carries a scope`, () => {
|
|
73
|
-
assert.equal(isScopedRoute(
|
|
84
|
+
assert.equal(isScopedRoute(browsing(routeId)), true);
|
|
74
85
|
});
|
|
75
86
|
}
|
|
76
87
|
|
|
77
88
|
it("the daily brief carries none", () => {
|
|
78
|
-
assert.equal(isScopedRoute(
|
|
89
|
+
assert.equal(isScopedRoute(browsing(MAIL_BRIEF_ROUTE_ID)), false);
|
|
79
90
|
});
|
|
80
91
|
|
|
81
92
|
it("agrees with the chip on every route", () => {
|
|
@@ -85,9 +96,9 @@ describe("isScopedRoute", () => {
|
|
|
85
96
|
MAIL_FLAGGED_ROUTE_ID,
|
|
86
97
|
MAIL_OUTBOX_ROUTE_ID,
|
|
87
98
|
]) {
|
|
88
|
-
const scope = searchScopeForRoute(
|
|
99
|
+
const scope = searchScopeForRoute(browsing(routeId), "Spam");
|
|
89
100
|
assert.equal(
|
|
90
|
-
isScopedRoute(
|
|
101
|
+
isScopedRoute(browsing(routeId)),
|
|
91
102
|
scope.kind === "scoped",
|
|
92
103
|
`${routeId} disagrees about whether it has a scope`,
|
|
93
104
|
);
|
|
@@ -109,14 +120,14 @@ describe("scopeLabelForMailboxName", () => {
|
|
|
109
120
|
// respects it. The semantic engine used to run unscoped inside a mailbox under
|
|
110
121
|
// an "Everywhere" heading, contradicting the `in:` chip the same bar showed.
|
|
111
122
|
describe("semanticMailboxScope — a chip binds every engine on the route", () => {
|
|
112
|
-
const mailbox = (mailboxId: string) =>
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
123
|
+
const mailbox = (mailboxId: string): BrowsedList => ({
|
|
124
|
+
list: "mailbox",
|
|
125
|
+
mailboxId,
|
|
126
|
+
});
|
|
116
127
|
|
|
117
128
|
it("pins a mailbox route to its own mailbox", () => {
|
|
118
129
|
assert.equal(
|
|
119
|
-
semanticMailboxScope({
|
|
130
|
+
semanticMailboxScope({ browsed: mailbox("mbx-spam") }),
|
|
120
131
|
"mbx-spam",
|
|
121
132
|
);
|
|
122
133
|
});
|
|
@@ -124,7 +135,7 @@ describe("semanticMailboxScope — a chip binds every engine on the route", () =
|
|
|
124
135
|
it("a mailbox route ignores a caller asking for somewhere else", () => {
|
|
125
136
|
assert.equal(
|
|
126
137
|
semanticMailboxScope({
|
|
127
|
-
|
|
138
|
+
browsed: mailbox("mbx-spam"),
|
|
128
139
|
callerMailboxId: "mbx-archive",
|
|
129
140
|
}),
|
|
130
141
|
"mbx-spam",
|
|
@@ -134,7 +145,7 @@ describe("semanticMailboxScope — a chip binds every engine on the route", () =
|
|
|
134
145
|
it("a mailbox route ignores a typed in: term", () => {
|
|
135
146
|
assert.equal(
|
|
136
147
|
semanticMailboxScope({
|
|
137
|
-
|
|
148
|
+
browsed: mailbox("mbx-spam"),
|
|
138
149
|
inTokenMailboxId: "mbx-archive",
|
|
139
150
|
}),
|
|
140
151
|
"mbx-spam",
|
|
@@ -142,7 +153,7 @@ describe("semanticMailboxScope — a chip binds every engine on the route", () =
|
|
|
142
153
|
});
|
|
143
154
|
|
|
144
155
|
it("a mailbox route never widens to global", () => {
|
|
145
|
-
const scope = semanticMailboxScope({
|
|
156
|
+
const scope = semanticMailboxScope({ browsed: mailbox("mbx-spam") });
|
|
146
157
|
assert.notEqual(scope, undefined, "a scoped route must carry a scope");
|
|
147
158
|
});
|
|
148
159
|
|
|
@@ -153,7 +164,7 @@ describe("semanticMailboxScope — a chip binds every engine on the route", () =
|
|
|
153
164
|
it(`${routeId} never honours a typed in: term`, () => {
|
|
154
165
|
assert.equal(
|
|
155
166
|
semanticMailboxScope({
|
|
156
|
-
|
|
167
|
+
browsed: browsing(routeId),
|
|
157
168
|
inTokenMailboxId: "mbx-archive",
|
|
158
169
|
}),
|
|
159
170
|
undefined,
|
|
@@ -163,7 +174,7 @@ describe("semanticMailboxScope — a chip binds every engine on the route", () =
|
|
|
163
174
|
|
|
164
175
|
it("the daily brief is global — no route scope, no chip", () => {
|
|
165
176
|
assert.equal(
|
|
166
|
-
semanticMailboxScope({
|
|
177
|
+
semanticMailboxScope({ browsed: browsing(MAIL_BRIEF_ROUTE_ID) }),
|
|
167
178
|
undefined,
|
|
168
179
|
);
|
|
169
180
|
});
|
|
@@ -171,7 +182,7 @@ describe("semanticMailboxScope — a chip binds every engine on the route", () =
|
|
|
171
182
|
it("the daily brief is the one route a typed in: narrows", () => {
|
|
172
183
|
assert.equal(
|
|
173
184
|
semanticMailboxScope({
|
|
174
|
-
|
|
185
|
+
browsed: browsing(MAIL_BRIEF_ROUTE_ID),
|
|
175
186
|
inTokenMailboxId: "mbx-archive",
|
|
176
187
|
}),
|
|
177
188
|
"mbx-archive",
|
|
@@ -183,14 +194,14 @@ describe("semanticMailboxScope — a chip binds every engine on the route", () =
|
|
|
183
194
|
// time: on a scoped route the resolved scope is either the route's own
|
|
184
195
|
// mailbox, or nothing that came from the typed query.
|
|
185
196
|
const scoped = [
|
|
186
|
-
{
|
|
187
|
-
{
|
|
188
|
-
{
|
|
197
|
+
{ browsed: mailbox("mbx-spam"), expected: "mbx-spam" },
|
|
198
|
+
{ browsed: browsing(MAIL_FLAGGED_ROUTE_ID), expected: undefined },
|
|
199
|
+
{ browsed: browsing(MAIL_OUTBOX_ROUTE_ID), expected: undefined },
|
|
189
200
|
];
|
|
190
|
-
for (const {
|
|
201
|
+
for (const { browsed: m, expected } of scoped) {
|
|
191
202
|
assert.equal(isScopedRoute(m), true);
|
|
192
203
|
assert.equal(
|
|
193
|
-
semanticMailboxScope({
|
|
204
|
+
semanticMailboxScope({ browsed: m, inTokenMailboxId: "mbx-elsewhere" }),
|
|
194
205
|
expected,
|
|
195
206
|
);
|
|
196
207
|
}
|
|
@@ -200,7 +211,7 @@ describe("semanticMailboxScope — a chip binds every engine on the route", () =
|
|
|
200
211
|
describe("resultsScopeForRoute", () => {
|
|
201
212
|
it("passes the global search through as global", () => {
|
|
202
213
|
assert.deepEqual(
|
|
203
|
-
resultsScopeForRoute(
|
|
214
|
+
resultsScopeForRoute(browsing(MAIL_BRIEF_ROUTE_ID), { kind: "global" }),
|
|
204
215
|
{
|
|
205
216
|
kind: "global",
|
|
206
217
|
},
|
|
@@ -209,7 +220,9 @@ describe("resultsScopeForRoute", () => {
|
|
|
209
220
|
|
|
210
221
|
it("treats a mailbox whose name has not loaded as a folder, not global", () => {
|
|
211
222
|
assert.deepEqual(
|
|
212
|
-
resultsScopeForRoute(
|
|
223
|
+
resultsScopeForRoute(browsing(MAIL_MAILBOX_ROUTE_ID), {
|
|
224
|
+
kind: "pending",
|
|
225
|
+
}),
|
|
213
226
|
{
|
|
214
227
|
kind: "folder",
|
|
215
228
|
},
|
|
@@ -219,7 +232,7 @@ describe("resultsScopeForRoute", () => {
|
|
|
219
232
|
it("carries the mailbox's role so a Spam search shows its rows", () => {
|
|
220
233
|
assert.deepEqual(
|
|
221
234
|
resultsScopeForRoute(
|
|
222
|
-
|
|
235
|
+
browsing(MAIL_MAILBOX_ROUTE_ID),
|
|
223
236
|
{
|
|
224
237
|
kind: "scoped",
|
|
225
238
|
chip: { id: SEARCH_SCOPE_CHIP_ID, label: "in:spam" },
|
|
@@ -232,7 +245,7 @@ describe("resultsScopeForRoute", () => {
|
|
|
232
245
|
|
|
233
246
|
it("calls starred a collection, so its spam is not dropped", () => {
|
|
234
247
|
assert.deepEqual(
|
|
235
|
-
resultsScopeForRoute(
|
|
248
|
+
resultsScopeForRoute(browsing(MAIL_FLAGGED_ROUTE_ID), {
|
|
236
249
|
kind: "scoped",
|
|
237
250
|
chip: { id: SEARCH_SCOPE_CHIP_ID, label: "is:starred" },
|
|
238
251
|
}),
|
|
@@ -242,7 +255,7 @@ describe("resultsScopeForRoute", () => {
|
|
|
242
255
|
|
|
243
256
|
it("keeps the outbox a folder — it is one queue, and never holds spam", () => {
|
|
244
257
|
assert.deepEqual(
|
|
245
|
-
resultsScopeForRoute(
|
|
258
|
+
resultsScopeForRoute(browsing(MAIL_OUTBOX_ROUTE_ID), {
|
|
246
259
|
kind: "scoped",
|
|
247
260
|
chip: { id: SEARCH_SCOPE_CHIP_ID, label: "in:outbox" },
|
|
248
261
|
}),
|
package/src/lib/search-scope.ts
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* Pure functions only. `useSearchScope` binds these to the router.
|
|
23
23
|
*/
|
|
24
24
|
import type { FolderRole, SearchScope as ResultsScope } from "@remit/ui";
|
|
25
|
-
import
|
|
25
|
+
import type { BrowsedList } from "./mail-route";
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
28
|
* Chip id of the scope chip. The top bar owns exactly one, so a fixed id is
|
|
@@ -64,19 +64,15 @@ export function scopeLabelForMailboxName(name: string): string {
|
|
|
64
64
|
* These carry a scope; the daily brief (and any route not recognized here) does
|
|
65
65
|
* not.
|
|
66
66
|
*/
|
|
67
|
-
export function isScopedRoute(
|
|
68
|
-
|
|
69
|
-
return route !== undefined && route.list !== "brief";
|
|
67
|
+
export function isScopedRoute(browsed: BrowsedList): boolean {
|
|
68
|
+
return browsed.list !== undefined && browsed.list !== "brief";
|
|
70
69
|
}
|
|
71
70
|
|
|
72
71
|
/**
|
|
73
72
|
* The mailbox id carried by a mailbox route, or `undefined` on any other route.
|
|
74
73
|
*/
|
|
75
|
-
export function routeMailboxId(
|
|
76
|
-
|
|
77
|
-
): string | undefined {
|
|
78
|
-
const route = mailListRoute(matches);
|
|
79
|
-
return route?.list === "mailbox" ? route.mailboxId : undefined;
|
|
74
|
+
export function routeMailboxId(browsed: BrowsedList): string | undefined {
|
|
75
|
+
return browsed.list === "mailbox" ? browsed.mailboxId : undefined;
|
|
80
76
|
}
|
|
81
77
|
|
|
82
78
|
/**
|
|
@@ -98,13 +94,13 @@ export function routeMailboxId(
|
|
|
98
94
|
* it, which is the one place `useSearchTokenContext` resolves that term.
|
|
99
95
|
*/
|
|
100
96
|
export function semanticMailboxScope(args: {
|
|
101
|
-
|
|
97
|
+
browsed: BrowsedList;
|
|
102
98
|
callerMailboxId?: string;
|
|
103
99
|
inTokenMailboxId?: string;
|
|
104
100
|
}): string | undefined {
|
|
105
|
-
const fromRoute = routeMailboxId(args.
|
|
101
|
+
const fromRoute = routeMailboxId(args.browsed);
|
|
106
102
|
if (fromRoute) return fromRoute;
|
|
107
|
-
if (isScopedRoute(args.
|
|
103
|
+
if (isScopedRoute(args.browsed)) return args.callerMailboxId;
|
|
108
104
|
return args.callerMailboxId ?? args.inTokenMailboxId;
|
|
109
105
|
}
|
|
110
106
|
|
|
@@ -117,23 +113,22 @@ export function semanticMailboxScope(args: {
|
|
|
117
113
|
* either, because the list underneath is already narrowed.
|
|
118
114
|
*/
|
|
119
115
|
export function searchScopeForRoute(
|
|
120
|
-
|
|
116
|
+
browsed: BrowsedList,
|
|
121
117
|
mailboxName?: string | null,
|
|
122
118
|
): SearchScopeState {
|
|
123
|
-
|
|
124
|
-
if (route?.list === "flagged") {
|
|
119
|
+
if (browsed.list === "flagged") {
|
|
125
120
|
return {
|
|
126
121
|
kind: "scoped",
|
|
127
122
|
chip: { id: SEARCH_SCOPE_CHIP_ID, label: "is:starred" },
|
|
128
123
|
};
|
|
129
124
|
}
|
|
130
|
-
if (
|
|
125
|
+
if (browsed.list === "outbox") {
|
|
131
126
|
return {
|
|
132
127
|
kind: "scoped",
|
|
133
128
|
chip: { id: SEARCH_SCOPE_CHIP_ID, label: "in:outbox" },
|
|
134
129
|
};
|
|
135
130
|
}
|
|
136
|
-
if (
|
|
131
|
+
if (browsed.list === "mailbox") {
|
|
137
132
|
if (!mailboxName) return { kind: "pending" };
|
|
138
133
|
return {
|
|
139
134
|
kind: "scoped",
|
|
@@ -164,11 +159,11 @@ export function searchScopeForRoute(
|
|
|
164
159
|
* folder nobody appointed carries no role.
|
|
165
160
|
*/
|
|
166
161
|
export function resultsScopeForRoute(
|
|
167
|
-
|
|
162
|
+
browsed: BrowsedList,
|
|
168
163
|
state: SearchScopeState,
|
|
169
164
|
role?: FolderRole,
|
|
170
165
|
): ResultsScope {
|
|
171
166
|
if (state.kind === "global") return { kind: "global" };
|
|
172
|
-
if (
|
|
167
|
+
if (browsed.list === "flagged") return { kind: "collection" };
|
|
173
168
|
return { kind: "folder", ...(role ? { role } : {}) };
|
|
174
169
|
}
|
|
@@ -165,7 +165,7 @@ describe("shouldMirrorQuery", () => {
|
|
|
165
165
|
interface Shell {
|
|
166
166
|
/** The view the address names, which is what a render reads. */
|
|
167
167
|
viewKey: string;
|
|
168
|
-
/** The view the text in the field was typed in (`
|
|
168
|
+
/** The view the text in the field was typed in (`routing/search-field.ts`). */
|
|
169
169
|
typedInView: string;
|
|
170
170
|
/** The path of the list whose mirror is running. */
|
|
171
171
|
listPath: string;
|
package/src/lib/search-view.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* "Every view change" is the move the reader made, not every render that
|
|
14
14
|
* notices one. Text typed once the address already names the destination was
|
|
15
15
|
* typed in the destination, so it is that view's query and survives
|
|
16
|
-
* (`
|
|
16
|
+
* (`routing/search-field.ts`).
|
|
17
17
|
*/
|
|
18
18
|
import { locationIsOnList } from "./mail-route";
|
|
19
19
|
|
|
@@ -82,7 +82,7 @@ export interface MirrorDecision {
|
|
|
82
82
|
* the mirror fight a query arriving by URL — that arrives mid-debounce, where
|
|
83
83
|
* the first rule already refuses, and by the time the debounce settles the
|
|
84
84
|
* field has been re-seeded from the address it landed on
|
|
85
|
-
* (`
|
|
85
|
+
* (`routing/search-field.ts`).
|
|
86
86
|
*/
|
|
87
87
|
export function shouldMirrorQuery({
|
|
88
88
|
searchInput,
|
|
@@ -11,9 +11,8 @@
|
|
|
11
11
|
import { createFileRoute, Outlet } from "@tanstack/react-router";
|
|
12
12
|
import { MailShell } from "@/components/layout/MailShell";
|
|
13
13
|
import { MailboxPane } from "@/components/mail/MailboxPane";
|
|
14
|
-
import { useSearchMirror } from "@/hooks/useSearchMirror";
|
|
15
14
|
import { mailboxSearchSchema } from "@/lib/mail-search";
|
|
16
|
-
import { useOpenThreadPath } from "@/routing";
|
|
15
|
+
import { useOpenThreadPath, useSearchMirror } from "@/routing";
|
|
17
16
|
|
|
18
17
|
function MailboxLayout() {
|
|
19
18
|
const { mailboxId } = Route.useParams();
|
|
@@ -15,9 +15,8 @@ import {
|
|
|
15
15
|
import { MailShell } from "@/components/layout/MailShell";
|
|
16
16
|
import { BriefPane } from "@/components/mail/BriefPane";
|
|
17
17
|
import { ErrorState } from "@/components/ui/ErrorState";
|
|
18
|
-
import { useSearchMirror } from "@/hooks/useSearchMirror";
|
|
19
18
|
import { briefSearchSchema } from "@/lib/mail-search";
|
|
20
|
-
import { useOpenThreadPath } from "@/routing";
|
|
19
|
+
import { useOpenThreadPath, useSearchMirror } from "@/routing";
|
|
21
20
|
|
|
22
21
|
const BriefError = ({ error, reset }: ErrorComponentProps) => (
|
|
23
22
|
<div className="flex h-full items-center justify-center bg-canvas p-4">
|
|
@@ -14,9 +14,8 @@ import {
|
|
|
14
14
|
import { MailShell } from "@/components/layout/MailShell";
|
|
15
15
|
import { FlaggedPane } from "@/components/mail/FlaggedPane";
|
|
16
16
|
import { ErrorState } from "@/components/ui/ErrorState";
|
|
17
|
-
import { useSearchMirror } from "@/hooks/useSearchMirror";
|
|
18
17
|
import { flaggedSearchSchema } from "@/lib/mail-search";
|
|
19
|
-
import { useOpenThreadPath } from "@/routing";
|
|
18
|
+
import { useOpenThreadPath, useSearchMirror } from "@/routing";
|
|
20
19
|
|
|
21
20
|
const FlaggedError = ({ error, reset }: ErrorComponentProps) => (
|
|
22
21
|
<div className="flex h-full items-center justify-center bg-canvas p-4">
|
|
@@ -8,9 +8,8 @@
|
|
|
8
8
|
import { createFileRoute, Outlet } from "@tanstack/react-router";
|
|
9
9
|
import { MailShell } from "@/components/layout/MailShell";
|
|
10
10
|
import { OutboxPane } from "@/components/mail/OutboxPane";
|
|
11
|
-
import { useSearchMirror } from "@/hooks/useSearchMirror";
|
|
12
11
|
import { outboxSearchSchema } from "@/lib/mail-search";
|
|
13
|
-
import { useOutboxDraftId } from "@/routing";
|
|
12
|
+
import { useOutboxDraftId, useSearchMirror } from "@/routing";
|
|
14
13
|
|
|
15
14
|
function OutboxLayout() {
|
|
16
15
|
const outboxMessageId = useOutboxDraftId();
|
package/src/routes/mail.tsx
CHANGED
|
@@ -23,14 +23,18 @@ import { isSinglePaneTier, useLayoutTier } from "@/hooks/useLayoutTier";
|
|
|
23
23
|
import { useMailboxNameIndex } from "@/hooks/useMailboxNameIndex";
|
|
24
24
|
import { useRailPanels } from "@/hooks/useRailPanels";
|
|
25
25
|
import { useResultFolderIndex } from "@/hooks/useResultFolderIndex";
|
|
26
|
-
import { useSearchField } from "@/hooks/useSearchField";
|
|
27
26
|
import { useStaleAccountSync } from "@/hooks/useStaleAccountSync";
|
|
28
27
|
import { MailContext } from "@/lib/mail-context";
|
|
29
28
|
import { MailFreshnessProvider } from "@/lib/mail-freshness";
|
|
30
29
|
import { mailListRoute } from "@/lib/mail-route";
|
|
31
30
|
import { buildAccountNameIndex } from "@/lib/search-token-index";
|
|
32
|
-
import {
|
|
33
|
-
|
|
31
|
+
import {
|
|
32
|
+
useIsComposing,
|
|
33
|
+
useOpenCompose,
|
|
34
|
+
useSearchField,
|
|
35
|
+
wizardEntryValue,
|
|
36
|
+
wizardStepValue,
|
|
37
|
+
} from "@/routing";
|
|
34
38
|
import "@/lib/client";
|
|
35
39
|
|
|
36
40
|
// `MailContext` / `useMailContext` live in `@/lib/mail-context` so the provider
|
|
@@ -1,17 +1,24 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
useLocation,
|
|
3
|
+
useNavigate,
|
|
4
|
+
useParams,
|
|
5
|
+
useRouterState,
|
|
6
|
+
} from "@tanstack/react-router";
|
|
7
|
+
import { useCallback } from "react";
|
|
8
|
+
import {
|
|
9
|
+
type BrowsedList,
|
|
10
|
+
locationOpensDetail,
|
|
11
|
+
mailListRoute,
|
|
12
|
+
} from "@/lib/mail-route";
|
|
13
|
+
import { useRetainOpenPanels } from "./fragment";
|
|
3
14
|
|
|
4
|
-
export
|
|
5
|
-
list: MailListRoute["list"] | undefined;
|
|
6
|
-
mailboxId: string | undefined;
|
|
7
|
-
}
|
|
15
|
+
export type { BrowsedList };
|
|
8
16
|
|
|
9
17
|
/**
|
|
10
18
|
* The list the address is browsing, as the two values a navigation to it needs.
|
|
11
19
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* the four lists cannot disagree about which one is underneath.
|
|
20
|
+
* Every surface a list can open — compose, a reply, a conversation — reads the
|
|
21
|
+
* same answer, so the four lists cannot disagree about which one is underneath.
|
|
15
22
|
*/
|
|
16
23
|
export function useBrowsedList(): BrowsedList {
|
|
17
24
|
const list = useRouterState({
|
|
@@ -20,3 +27,47 @@ export function useBrowsedList(): BrowsedList {
|
|
|
20
27
|
const mailbox = useParams({ from: "/mail/$mailboxId", shouldThrow: false });
|
|
21
28
|
return { list, mailboxId: mailbox?.mailboxId };
|
|
22
29
|
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Whether the address names something the list has open below it — a
|
|
33
|
+
* conversation, a draft, the compose surface.
|
|
34
|
+
*
|
|
35
|
+
* Answers off the pathname, which is what says where the reader is going while
|
|
36
|
+
* the destination mounts; see `locationOpensDetail`.
|
|
37
|
+
*/
|
|
38
|
+
export function useOpensDetail(): boolean {
|
|
39
|
+
const { pathname } = useLocation();
|
|
40
|
+
return locationOpensDetail(pathname);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Back up to the list, leaving whatever it had open.
|
|
45
|
+
*
|
|
46
|
+
* A push, like the open was: leaving is a move the reader makes, and Back is how
|
|
47
|
+
* they undo it. Every branch lands somewhere real — the brief is the fallback,
|
|
48
|
+
* because a list this cannot name still has to put them on one rather than
|
|
49
|
+
* strand them inside a surface they just dismissed.
|
|
50
|
+
*/
|
|
51
|
+
export function useNavigateToBrowsedList(): () => void {
|
|
52
|
+
const navigate = useNavigate();
|
|
53
|
+
const retainPanels = useRetainOpenPanels();
|
|
54
|
+
const { list, mailboxId } = useBrowsedList();
|
|
55
|
+
|
|
56
|
+
return useCallback(() => {
|
|
57
|
+
const search = (prev: Record<string, unknown>) => prev;
|
|
58
|
+
const hash = retainPanels;
|
|
59
|
+
if (list === "flagged") {
|
|
60
|
+
navigate({ to: "/mail/flagged", search, hash });
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (list === "outbox") {
|
|
64
|
+
navigate({ to: "/mail/outbox", search, hash });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (list === "mailbox" && mailboxId) {
|
|
68
|
+
navigate({ to: "/mail/$mailboxId", params: { mailboxId }, search, hash });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
navigate({ to: "/mail/brief", search, hash });
|
|
72
|
+
}, [navigate, retainPanels, list, mailboxId]);
|
|
73
|
+
}
|
package/src/routing/compose.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useNavigate, useParams } from "@tanstack/react-router";
|
|
2
2
|
import { useCallback } from "react";
|
|
3
|
-
import { useBrowsedList } from "./browsed-list";
|
|
3
|
+
import { useBrowsedList, useNavigateToBrowsedList } from "./browsed-list";
|
|
4
4
|
import { useRetainOpenPanels } from "./fragment";
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -176,30 +176,5 @@ export function useAdoptComposeDraft(): (outboxMessageId: string) => void {
|
|
|
176
176
|
* return and strand them inside a surface they just dismissed.
|
|
177
177
|
*/
|
|
178
178
|
export function useCloseCompose(): () => void {
|
|
179
|
-
|
|
180
|
-
const retainPanels = useRetainOpenPanels();
|
|
181
|
-
const { list, mailboxId } = useBrowsedList();
|
|
182
|
-
|
|
183
|
-
return useCallback(() => {
|
|
184
|
-
const search = (prev: Record<string, unknown>) => prev;
|
|
185
|
-
const hash = retainPanels;
|
|
186
|
-
if (list === "flagged") {
|
|
187
|
-
navigate({ to: "/mail/flagged", search, hash });
|
|
188
|
-
return;
|
|
189
|
-
}
|
|
190
|
-
if (list === "outbox") {
|
|
191
|
-
navigate({ to: "/mail/outbox", search, hash });
|
|
192
|
-
return;
|
|
193
|
-
}
|
|
194
|
-
if (list === "mailbox" && mailboxId) {
|
|
195
|
-
navigate({
|
|
196
|
-
to: "/mail/$mailboxId",
|
|
197
|
-
params: { mailboxId },
|
|
198
|
-
search,
|
|
199
|
-
hash,
|
|
200
|
-
});
|
|
201
|
-
return;
|
|
202
|
-
}
|
|
203
|
-
navigate({ to: "/mail/brief", search, hash });
|
|
204
|
-
}, [navigate, retainPanels, list, mailboxId]);
|
|
179
|
+
return useNavigateToBrowsedList();
|
|
205
180
|
}
|
package/src/routing/index.ts
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
export {
|
|
2
|
+
type BrowsedList,
|
|
3
|
+
useBrowsedList,
|
|
4
|
+
useOpensDetail,
|
|
5
|
+
} from "./browsed-list";
|
|
1
6
|
export {
|
|
2
7
|
useAdoptComposeDraft,
|
|
3
8
|
useCloseCompose,
|
|
@@ -20,12 +25,20 @@ export {
|
|
|
20
25
|
useSetOpenPanels,
|
|
21
26
|
} from "./fragment";
|
|
22
27
|
export { NavLink, type NavLinkProps } from "./nav-link";
|
|
28
|
+
export { useSelectedNavId } from "./nav-selection";
|
|
23
29
|
export {
|
|
30
|
+
type OpenThreadOptions,
|
|
24
31
|
type OpenThreadPath,
|
|
25
32
|
type OpenThreadTarget,
|
|
33
|
+
useCloseThread,
|
|
34
|
+
useOpenThread,
|
|
26
35
|
useOpenThreadPath,
|
|
27
36
|
} from "./open-thread";
|
|
28
|
-
export {
|
|
37
|
+
export {
|
|
38
|
+
useCloseOutboxDraft,
|
|
39
|
+
useOpenOutboxDraft,
|
|
40
|
+
useOutboxDraftId,
|
|
41
|
+
} from "./outbox-draft";
|
|
29
42
|
export {
|
|
30
43
|
type ReplyAddress,
|
|
31
44
|
type ReplyMode,
|
|
@@ -39,3 +52,29 @@ export {
|
|
|
39
52
|
useOpenReply,
|
|
40
53
|
useReplySurface,
|
|
41
54
|
} from "./reply";
|
|
55
|
+
export { type SearchField, useSearchField } from "./search-field";
|
|
56
|
+
export { type SearchMirrorTarget, useSearchMirror } from "./search-mirror";
|
|
57
|
+
export {
|
|
58
|
+
useScopeSearchToMailbox,
|
|
59
|
+
useSearchEverything,
|
|
60
|
+
} from "./search-navigation";
|
|
61
|
+
export {
|
|
62
|
+
type AppSection,
|
|
63
|
+
useConfigureAccountSmtp,
|
|
64
|
+
useGoToSection,
|
|
65
|
+
} from "./sections";
|
|
66
|
+
export {
|
|
67
|
+
ownedHistoryEntries,
|
|
68
|
+
type SelectionWizardControl,
|
|
69
|
+
useOpenWizard,
|
|
70
|
+
useSelectionWizard,
|
|
71
|
+
useWizardEntryValue,
|
|
72
|
+
useWizardStep,
|
|
73
|
+
useWizardStepValue,
|
|
74
|
+
type WizardEntry,
|
|
75
|
+
type WizardStepNavigation,
|
|
76
|
+
wizardEntryFromParam,
|
|
77
|
+
wizardEntryValue,
|
|
78
|
+
wizardStepFromParam,
|
|
79
|
+
wizardStepValue,
|
|
80
|
+
} from "./wizard-history";
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { useLocation, useParams } from "@tanstack/react-router";
|
|
2
|
+
import { locationIsOnList } from "@/lib/mail-route";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The nav entry the address selects, or `""` where none does.
|
|
6
|
+
*
|
|
7
|
+
* - `/settings/*` → "settings"
|
|
8
|
+
* - `/mail/outbox`, `/mail/flagged`, `/mail/brief` → that list
|
|
9
|
+
* - `/mail/$mailboxId` → the mailbox id, which is what the sidebar keys on
|
|
10
|
+
*
|
|
11
|
+
* Read off the pathname rather than the matches: the router commits the new
|
|
12
|
+
* location before it swaps them, so the entry the reader just tapped highlights
|
|
13
|
+
* on the tap instead of when its list finishes loading.
|
|
14
|
+
*/
|
|
15
|
+
export function useSelectedNavId(): string {
|
|
16
|
+
const { pathname } = useLocation();
|
|
17
|
+
const mailbox = useParams({ from: "/mail/$mailboxId", shouldThrow: false });
|
|
18
|
+
|
|
19
|
+
if (locationIsOnList(pathname, "/settings")) return "settings";
|
|
20
|
+
if (locationIsOnList(pathname, "/mail/outbox")) return "outbox";
|
|
21
|
+
if (locationIsOnList(pathname, "/mail/flagged")) return "flagged";
|
|
22
|
+
if (locationIsOnList(pathname, "/mail/brief")) return "brief";
|
|
23
|
+
return mailbox?.mailboxId ?? "";
|
|
24
|
+
}
|