@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.155",
|
|
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": {
|
|
@@ -49,6 +49,7 @@ import {
|
|
|
49
49
|
import type { AddressEntry } from "./AddressField";
|
|
50
50
|
import { AddressField } from "./AddressField";
|
|
51
51
|
import { ComposeSmtpMissingBanner } from "./ComposeSmtpMissingBanner";
|
|
52
|
+
import { composeSpellcheck } from "./compose-spellcheck.js";
|
|
52
53
|
|
|
53
54
|
const LazyComposeBody = lazy(() =>
|
|
54
55
|
import("@remit/ui/rich-text").then((m) => ({ default: m.ComposeBody })),
|
|
@@ -533,6 +534,11 @@ export const ComposeForm = ({
|
|
|
533
534
|
[configured],
|
|
534
535
|
);
|
|
535
536
|
|
|
537
|
+
// The editor reopens the checker whenever the composer's language changes,
|
|
538
|
+
// so the tag the chip and detection settle on is the only one it is ever
|
|
539
|
+
// asked for.
|
|
540
|
+
const spellcheck = useMemo(() => composeSpellcheck(pushError), [pushError]);
|
|
541
|
+
|
|
536
542
|
// The action bar refuses a second press while one is in flight, but the
|
|
537
543
|
// editor's own Cmd+Enter goes straight to `attemptSend`, and the write that
|
|
538
544
|
// now precedes the request widens the window a second press lands in.
|
|
@@ -805,6 +811,7 @@ export const ComposeForm = ({
|
|
|
805
811
|
languages={accountLanguages}
|
|
806
812
|
initialLanguage={draftLanguage}
|
|
807
813
|
onLanguageChange={setComposeLanguage}
|
|
814
|
+
spellcheck={spellcheck}
|
|
808
815
|
/>
|
|
809
816
|
</Suspense>
|
|
810
817
|
</ComposeFormShell>
|
|
@@ -115,15 +115,11 @@ export const ComposeProvider = ({
|
|
|
115
115
|
// mounts `FullCompose`, and only with no thread in the pane it takes over.
|
|
116
116
|
const openCompose = useCallback(
|
|
117
117
|
(params: Omit<ComposeState, "isOpen">) => {
|
|
118
|
-
|
|
119
|
-
//
|
|
120
|
-
// navigation up to the list; the lists still to move name it in the query.
|
|
118
|
+
// Every list names its open conversation in the path, so leaving one is a
|
|
119
|
+
// navigation up to the list.
|
|
121
120
|
const threadMailboxId = locationOpensDetail(location.pathname)
|
|
122
121
|
? routeParams?.mailboxId
|
|
123
122
|
: undefined;
|
|
124
|
-
const showsThread =
|
|
125
|
-
Boolean(threadMailboxId) ||
|
|
126
|
-
Boolean(search.selectedMessageId ?? search.selectedThreadId);
|
|
127
123
|
if (!hostsComposeSurface(location.pathname)) {
|
|
128
124
|
if (target.status === "loading") {
|
|
129
125
|
pushError({
|
|
@@ -150,33 +146,15 @@ export const ComposeProvider = ({
|
|
|
150
146
|
return;
|
|
151
147
|
}
|
|
152
148
|
setState({ ...params, isOpen: true });
|
|
153
|
-
if (!
|
|
149
|
+
if (!threadMailboxId) return;
|
|
154
150
|
// A push, so Back reopens the message.
|
|
155
|
-
if (threadMailboxId) {
|
|
156
|
-
navigate({
|
|
157
|
-
to: "/mail/$mailboxId",
|
|
158
|
-
params: { mailboxId: threadMailboxId },
|
|
159
|
-
search: (prev) => prev,
|
|
160
|
-
});
|
|
161
|
-
return;
|
|
162
|
-
}
|
|
163
151
|
navigate({
|
|
164
|
-
to: "
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
selectedMessageId: undefined,
|
|
168
|
-
selectedThreadId: undefined,
|
|
169
|
-
}),
|
|
152
|
+
to: "/mail/$mailboxId",
|
|
153
|
+
params: { mailboxId: threadMailboxId },
|
|
154
|
+
search: (prev) => prev,
|
|
170
155
|
});
|
|
171
156
|
},
|
|
172
|
-
[
|
|
173
|
-
navigate,
|
|
174
|
-
location.pathname,
|
|
175
|
-
location.search,
|
|
176
|
-
routeParams?.mailboxId,
|
|
177
|
-
target,
|
|
178
|
-
pushError,
|
|
179
|
-
],
|
|
157
|
+
[navigate, location.pathname, routeParams?.mailboxId, target, pushError],
|
|
180
158
|
);
|
|
181
159
|
|
|
182
160
|
const closeCompose = useCallback(() => {
|
|
@@ -62,8 +62,29 @@ const RootLayout = () =>
|
|
|
62
62
|
createElement(Outlet),
|
|
63
63
|
);
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* The folder's real shape: the thread and the message are segments under the
|
|
67
|
+
* list, so an open conversation is something the address holds and compose has
|
|
68
|
+
* to navigate out of.
|
|
69
|
+
*/
|
|
65
70
|
const routerAt = (href: string, layout = RootLayout): AnyRouter => {
|
|
66
71
|
const rootRoute = createRootRoute({ component: layout });
|
|
72
|
+
const mailboxRoute = createRoute({
|
|
73
|
+
getParentRoute: () => rootRoute,
|
|
74
|
+
path: "/mail/$mailboxId",
|
|
75
|
+
validateSearch: (search: Record<string, unknown>) => search,
|
|
76
|
+
component: Outlet,
|
|
77
|
+
});
|
|
78
|
+
const threadRoute = createRoute({
|
|
79
|
+
getParentRoute: () => mailboxRoute,
|
|
80
|
+
path: "/$threadId",
|
|
81
|
+
component: Outlet,
|
|
82
|
+
});
|
|
83
|
+
const messageRoute = createRoute({
|
|
84
|
+
getParentRoute: () => threadRoute,
|
|
85
|
+
path: "/$messageId",
|
|
86
|
+
component: () => null,
|
|
87
|
+
});
|
|
67
88
|
const routeTree = rootRoute.addChildren([
|
|
68
89
|
createRoute({
|
|
69
90
|
getParentRoute: () => rootRoute,
|
|
@@ -71,12 +92,7 @@ const routerAt = (href: string, layout = RootLayout): AnyRouter => {
|
|
|
71
92
|
validateSearch: (search: Record<string, unknown>) => search,
|
|
72
93
|
component: () => null,
|
|
73
94
|
}),
|
|
74
|
-
|
|
75
|
-
getParentRoute: () => rootRoute,
|
|
76
|
-
path: "/mail/$mailboxId",
|
|
77
|
-
validateSearch: (search: Record<string, unknown>) => search,
|
|
78
|
-
component: () => null,
|
|
79
|
-
}),
|
|
95
|
+
mailboxRoute.addChildren([threadRoute.addChildren([messageRoute])]),
|
|
80
96
|
createRoute({
|
|
81
97
|
getParentRoute: () => rootRoute,
|
|
82
98
|
path: "/settings",
|
|
@@ -145,11 +161,11 @@ const press = async (mounted: DomHarness): Promise<HTMLElement> => {
|
|
|
145
161
|
return button;
|
|
146
162
|
};
|
|
147
163
|
|
|
164
|
+
const THREAD_HREF = `/mail/${INBOX_ID}/th-1/msg-1`;
|
|
165
|
+
|
|
148
166
|
describe("opening compose over an open message (#703)", () => {
|
|
149
|
-
it("
|
|
150
|
-
const router = routerAt(
|
|
151
|
-
`/mail/${INBOX_ID}?selectedMessageId=msg-1&selectedThreadId=th-1`,
|
|
152
|
-
);
|
|
167
|
+
it("walks up to the list so the pane can render the surface", async () => {
|
|
168
|
+
const router = routerAt(THREAD_HREF);
|
|
153
169
|
const mounted = await mount(router);
|
|
154
170
|
|
|
155
171
|
const button = mounted.byText("button", "Compose");
|
|
@@ -159,33 +175,27 @@ describe("opening compose over an open message (#703)", () => {
|
|
|
159
175
|
|
|
160
176
|
assert.equal(button.getAttribute("data-open"), "true");
|
|
161
177
|
assert.equal(router.history.location.pathname, `/mail/${INBOX_ID}`);
|
|
162
|
-
const search = router.history.location.search;
|
|
163
|
-
assert.equal(search.includes("selectedMessageId"), false);
|
|
164
|
-
assert.equal(search.includes("selectedThreadId"), false);
|
|
165
178
|
});
|
|
166
179
|
|
|
167
|
-
it("keeps the
|
|
168
|
-
const router = routerAt(
|
|
169
|
-
`/mail/${INBOX_ID}?q=invoice&selectedMessageId=msg-1`,
|
|
170
|
-
);
|
|
180
|
+
it("keeps the query, so the search the user typed survives", async () => {
|
|
181
|
+
const router = routerAt(`${THREAD_HREF}?q=invoice`);
|
|
171
182
|
const mounted = await mount(router);
|
|
172
183
|
|
|
173
184
|
await press(mounted);
|
|
174
185
|
|
|
175
|
-
|
|
176
|
-
assert.
|
|
177
|
-
assert.match(search, /q=invoice/);
|
|
186
|
+
assert.equal(router.history.location.pathname, `/mail/${INBOX_ID}`);
|
|
187
|
+
assert.match(router.history.location.search, /q=invoice/);
|
|
178
188
|
});
|
|
179
189
|
|
|
180
190
|
it("leaves the message one Back away rather than erasing it", async () => {
|
|
181
|
-
const router = routerAt(
|
|
191
|
+
const router = routerAt(THREAD_HREF);
|
|
182
192
|
const mounted = await mount(router);
|
|
183
193
|
|
|
184
194
|
await press(mounted);
|
|
185
195
|
router.history.back();
|
|
186
196
|
await mounted.flush();
|
|
187
197
|
|
|
188
|
-
assert.
|
|
198
|
+
assert.equal(router.history.location.pathname, THREAD_HREF);
|
|
189
199
|
});
|
|
190
200
|
|
|
191
201
|
it("adds no history entry when the pane had nothing open", async () => {
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the composer hands the editor (#707): a worker opened for the language
|
|
3
|
+
* the message is being written in, and a failure nobody has to guess at.
|
|
4
|
+
*
|
|
5
|
+
* The two ways checking stops look identical on screen — no squiggles — and
|
|
6
|
+
* mean opposite things. A language with no dictionary is expected, and the
|
|
7
|
+
* browser carries on checking. A checker that was supposed to start and did not
|
|
8
|
+
* is a fault, and it is named on screen with a way to report it.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { afterEach, describe, it } from "node:test";
|
|
13
|
+
import type { PushErrorInput } from "../ui/error-banners.js";
|
|
14
|
+
import { composeSpellcheck, spellcheckFailure } from "./compose-spellcheck.js";
|
|
15
|
+
|
|
16
|
+
interface WorkerMessage {
|
|
17
|
+
readonly type: string;
|
|
18
|
+
readonly language?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const opened: { url: string; messages: WorkerMessage[] }[] = [];
|
|
22
|
+
|
|
23
|
+
class FakeWorker {
|
|
24
|
+
readonly messages: WorkerMessage[] = [];
|
|
25
|
+
constructor(url: URL) {
|
|
26
|
+
opened.push({ url: url.href, messages: this.messages });
|
|
27
|
+
}
|
|
28
|
+
postMessage(message: WorkerMessage): void {
|
|
29
|
+
this.messages.push(message);
|
|
30
|
+
}
|
|
31
|
+
addEventListener(): void {}
|
|
32
|
+
terminate(): void {}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const withWorker = <T>(run: () => Promise<T>): Promise<T> => {
|
|
36
|
+
Object.defineProperty(globalThis, "Worker", {
|
|
37
|
+
value: FakeWorker,
|
|
38
|
+
configurable: true,
|
|
39
|
+
});
|
|
40
|
+
return run();
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
opened.length = 0;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("the checker the composer opens", () => {
|
|
48
|
+
it("asks the worker for the language the message is in", async () => {
|
|
49
|
+
const provider = await withWorker(() =>
|
|
50
|
+
composeSpellcheck(() => undefined).provider("en"),
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
assert.equal(provider?.language, "en");
|
|
54
|
+
assert.equal(opened.length, 1);
|
|
55
|
+
assert.match(opened[0].url, /rich-text-spellcheck-worker/);
|
|
56
|
+
assert.deepEqual(opened[0].messages, [{ type: "open", language: "en" }]);
|
|
57
|
+
provider?.close();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("starts no worker for a language the build carries no words for", async () => {
|
|
61
|
+
const provider = await withWorker(() =>
|
|
62
|
+
composeSpellcheck(() => undefined).provider("nl"),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
assert.equal(provider, null);
|
|
66
|
+
assert.deepEqual(opened, []);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("reports nothing while the checker is coming up or running", () => {
|
|
70
|
+
const reported: PushErrorInput[] = [];
|
|
71
|
+
const { onStatus } = composeSpellcheck((input) => reported.push(input));
|
|
72
|
+
|
|
73
|
+
onStatus?.({ state: "opening", language: "en" });
|
|
74
|
+
onStatus?.({ state: "ready", language: "en" });
|
|
75
|
+
onStatus?.({ state: "unavailable", language: "nl" });
|
|
76
|
+
|
|
77
|
+
assert.deepEqual(reported, []);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("says what stopped, and offers the report prefilled", () => {
|
|
81
|
+
const reported: PushErrorInput[] = [];
|
|
82
|
+
const { onStatus } = composeSpellcheck((input) => reported.push(input));
|
|
83
|
+
|
|
84
|
+
onStatus?.({
|
|
85
|
+
state: "failed",
|
|
86
|
+
language: "en",
|
|
87
|
+
reason: "worker",
|
|
88
|
+
detail: "Worker is not defined",
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
assert.equal(reported.length, 1);
|
|
92
|
+
const banner = reported[0];
|
|
93
|
+
assert.match(banner.detail ?? "", /Spellcheck for en is off/);
|
|
94
|
+
assert.match(banner.detail ?? "", /browser is checking this message/);
|
|
95
|
+
assert.match(banner.detail ?? "", /Worker is not defined/);
|
|
96
|
+
const report = new URL(banner.action?.href ?? "https://example.invalid");
|
|
97
|
+
assert.match(report.href, /github\.com\/.+\/issues\/new\?/);
|
|
98
|
+
assert.match(
|
|
99
|
+
report.searchParams.get("body") ?? "",
|
|
100
|
+
/Worker is not defined/,
|
|
101
|
+
);
|
|
102
|
+
assert.match(report.searchParams.get("title") ?? "", /Spellcheck stopped/);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("names the download and the engine apart from the worker", () => {
|
|
106
|
+
const download = spellcheckFailure({
|
|
107
|
+
state: "failed",
|
|
108
|
+
language: "en",
|
|
109
|
+
reason: "download",
|
|
110
|
+
detail: "404",
|
|
111
|
+
});
|
|
112
|
+
const engine = spellcheckFailure({
|
|
113
|
+
state: "failed",
|
|
114
|
+
language: "en",
|
|
115
|
+
reason: "engine",
|
|
116
|
+
detail: "bad wasm",
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
assert.match(download.detail ?? "", /dictionary could not be downloaded/);
|
|
120
|
+
assert.match(engine.detail ?? "", /checker could not start/);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The checker the composer writes against. The engine runs in a worker, opened
|
|
3
|
+
* once per language and taken down with the surface, and the module holding it
|
|
4
|
+
* is loaded on demand so the worker chunk stays out of every other screen.
|
|
5
|
+
*
|
|
6
|
+
* A language with no dictionary is not a failure: the provider answers null,
|
|
7
|
+
* the editor leaves the browser's own checking switched on, and the writer
|
|
8
|
+
* still gets underlines. A checker that was supposed to come up and did not is
|
|
9
|
+
* a failure, and it says so — an editor that has quietly stopped marking
|
|
10
|
+
* anything reads exactly like text with nothing wrong in it.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ProviderStatus, SpellcheckOptions } from "@remit/ui/rich-text";
|
|
14
|
+
import { buildBugReportContext, buildGitHubIssueUrl } from "@/lib/bug-report";
|
|
15
|
+
import type { PushErrorInput } from "../ui/error-banners.js";
|
|
16
|
+
|
|
17
|
+
const WHAT_FAILED: Record<"download" | "engine" | "worker", string> = {
|
|
18
|
+
download: "its dictionary could not be downloaded",
|
|
19
|
+
engine: "the checker could not start",
|
|
20
|
+
worker: "the checker stopped running",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const spellcheckFailure = (
|
|
24
|
+
status: Extract<ProviderStatus, { state: "failed" }>,
|
|
25
|
+
): PushErrorInput => {
|
|
26
|
+
const summary = `Spellcheck for ${status.language} is off: ${WHAT_FAILED[status.reason]}.`;
|
|
27
|
+
return {
|
|
28
|
+
severity: "warning",
|
|
29
|
+
title: "Spellcheck stopped",
|
|
30
|
+
detail: `${summary} Your browser is checking this message instead. ${status.detail}`,
|
|
31
|
+
action: {
|
|
32
|
+
label: "Report this",
|
|
33
|
+
href: buildGitHubIssueUrl(
|
|
34
|
+
buildBugReportContext({
|
|
35
|
+
title: `Spellcheck stopped: ${status.reason} (${status.language})`,
|
|
36
|
+
errorMessage: `${summary} ${status.detail}`,
|
|
37
|
+
}),
|
|
38
|
+
),
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const composeSpellcheck = (
|
|
44
|
+
report: (input: PushErrorInput) => void,
|
|
45
|
+
): SpellcheckOptions => ({
|
|
46
|
+
provider: async (language) => {
|
|
47
|
+
const { openSpellcheckWorker } = await import(
|
|
48
|
+
"@remit/ui/spellcheck-worker"
|
|
49
|
+
);
|
|
50
|
+
return openSpellcheckWorker(language);
|
|
51
|
+
},
|
|
52
|
+
onStatus: (status) => {
|
|
53
|
+
if (status.state !== "failed") return;
|
|
54
|
+
report(spellcheckFailure(status));
|
|
55
|
+
},
|
|
56
|
+
});
|
|
@@ -14,9 +14,7 @@ import { locationOpensDetail } from "@/lib/mail-route";
|
|
|
14
14
|
* `lg:hidden` class covers the pre-hydration frame.
|
|
15
15
|
* - The compose surface is already open.
|
|
16
16
|
* - The user is reading a thread — the single pane is the conversation, and
|
|
17
|
-
* its reply bar is under this corner.
|
|
18
|
-
* their path; the flagged list, still to move, says so in
|
|
19
|
-
* `?selectedMessageId=…`.
|
|
17
|
+
* its reply bar is under this corner. Every list says so in its path.
|
|
20
18
|
* - The user is off `/mail`, which is every route with no mail in it.
|
|
21
19
|
*/
|
|
22
20
|
export const ComposeFab = () => {
|
|
@@ -26,10 +24,7 @@ export const ComposeFab = () => {
|
|
|
26
24
|
openCompose({ mode: "new" });
|
|
27
25
|
}, [openCompose]);
|
|
28
26
|
|
|
29
|
-
const
|
|
30
|
-
const isReadingThread =
|
|
31
|
-
Boolean(search?.selectedMessageId) ||
|
|
32
|
-
locationOpensDetail(location.pathname);
|
|
27
|
+
const isReadingThread = locationOpensDetail(location.pathname);
|
|
33
28
|
|
|
34
29
|
if (!location.pathname.startsWith("/mail") || state.isOpen || isReadingThread)
|
|
35
30
|
return null;
|
|
@@ -3,19 +3,20 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Reads the starred listing through `useStarredThreads` — GET /threads with
|
|
5
5
|
* `starred=true`, served by the `byStarred` index — which returns every starred
|
|
6
|
-
* thread in the config across all non-muted mailboxes, paged.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* neither re-filters nor caps the set,
|
|
10
|
-
* inbox page still appears. Rendered as
|
|
11
|
-
* sections). The shared `MailViewChrome` owns
|
|
12
|
-
* expando; the kit `MessageListPane` (flat, no
|
|
13
|
-
* / empty / error chrome and keyboard hints,
|
|
14
|
-
* `listBody` so the real rows render at every width.
|
|
6
|
+
* thread in the config across all non-muted mailboxes, paged. Every row names
|
|
7
|
+
* its thread, which is the whole address a conversation opens by, so a starred
|
|
8
|
+
* message filed outside the inbox opens like any other. Starredness is decided
|
|
9
|
+
* server-side from `hasStars`; the client neither re-filters nor caps the set,
|
|
10
|
+
* so a starred thread outside the newest inbox page still appears. Rendered as
|
|
11
|
+
* one continuous list (no category sections). The shared `MailViewChrome` owns
|
|
12
|
+
* the `MailHeader` + filter expando; the kit `MessageListPane` (flat, no
|
|
13
|
+
* `briefFilters`) owns the loading / empty / error chrome and keyboard hints,
|
|
14
|
+
* with a consumer-supplied `listBody` so the real rows render at every width.
|
|
15
15
|
*/
|
|
16
16
|
import {
|
|
17
17
|
flaggedFilterConfig,
|
|
18
18
|
MessageListPane,
|
|
19
|
+
type SearchResult,
|
|
19
20
|
type ThreadRowData,
|
|
20
21
|
type Verb,
|
|
21
22
|
} from "@remit/ui";
|
|
@@ -37,6 +38,7 @@ import { rowToSearchResult } from "@/lib/search-result";
|
|
|
37
38
|
import { parseSearchTokens } from "@/lib/search-tokens";
|
|
38
39
|
import { dedupeByThread } from "@/lib/starred-rows";
|
|
39
40
|
import { useSelectionWizard } from "@/lib/wizard-history";
|
|
41
|
+
import type { OpenThreadTarget } from "@/routing";
|
|
40
42
|
import { MailViewChrome } from "./MailViewChrome";
|
|
41
43
|
import type { MessageListCommands } from "./MessageList";
|
|
42
44
|
import { MessageRow } from "./MessageRow";
|
|
@@ -101,7 +103,14 @@ function StarredWizardHost({
|
|
|
101
103
|
|
|
102
104
|
interface FlaggedListProps {
|
|
103
105
|
selectedMessageId?: string;
|
|
104
|
-
|
|
106
|
+
/**
|
|
107
|
+
* Opens a row. Every starred row names its thread, so a message filed outside
|
|
108
|
+
* the inbox opens by exactly the same route as one inside it.
|
|
109
|
+
*/
|
|
110
|
+
onOpenThread?: (
|
|
111
|
+
target: OpenThreadTarget,
|
|
112
|
+
options?: OpenMessageOptions,
|
|
113
|
+
) => void;
|
|
105
114
|
/** Where the list publishes the commands the keyboard layer drives. */
|
|
106
115
|
commandsRef?: RefObject<MessageListCommands | null>;
|
|
107
116
|
/** Cursor / selection / display order, reported up to the triage layer. */
|
|
@@ -111,7 +120,7 @@ interface FlaggedListProps {
|
|
|
111
120
|
|
|
112
121
|
export function FlaggedList({
|
|
113
122
|
selectedMessageId,
|
|
114
|
-
|
|
123
|
+
onOpenThread,
|
|
115
124
|
commandsRef,
|
|
116
125
|
onTriageContextChange,
|
|
117
126
|
onDeleteMessages,
|
|
@@ -171,6 +180,27 @@ export function FlaggedList({
|
|
|
171
180
|
);
|
|
172
181
|
}, [threads, selectedCategory, activeFilters, sq, queryTokens]);
|
|
173
182
|
|
|
183
|
+
const openRow = useCallback(
|
|
184
|
+
(id: string, options?: OpenMessageOptions) => {
|
|
185
|
+
const threadId = rows.find((row) => row.id === id)?.threadId;
|
|
186
|
+
if (!threadId) return;
|
|
187
|
+
onOpenThread?.({ threadId, messageId: id }, options);
|
|
188
|
+
},
|
|
189
|
+
[rows, onOpenThread],
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
// The two-engine results panel, whose semantic hits are in no list at all —
|
|
193
|
+
// each one carries the thread it belongs to.
|
|
194
|
+
const openResult = useCallback(
|
|
195
|
+
(result: SearchResult) => {
|
|
196
|
+
const threadId =
|
|
197
|
+
result.threadId ?? rows.find((row) => row.id === result.id)?.threadId;
|
|
198
|
+
if (!threadId) return;
|
|
199
|
+
onOpenThread?.({ threadId, messageId: result.id });
|
|
200
|
+
},
|
|
201
|
+
[rows, onOpenThread],
|
|
202
|
+
);
|
|
203
|
+
|
|
174
204
|
const preset = useMemo(() => flaggedFilterConfig(), []);
|
|
175
205
|
|
|
176
206
|
const searchResults = useMemo(
|
|
@@ -206,7 +236,7 @@ export function FlaggedList({
|
|
|
206
236
|
key={thread.id}
|
|
207
237
|
thread={thread}
|
|
208
238
|
active={thread.id === selectedMessageId}
|
|
209
|
-
onClick={() =>
|
|
239
|
+
onClick={() => openRow(thread.id)}
|
|
210
240
|
/>
|
|
211
241
|
))}
|
|
212
242
|
</div>
|
|
@@ -236,7 +266,7 @@ export function FlaggedList({
|
|
|
236
266
|
onClearFilters={clearFilters}
|
|
237
267
|
searchResults={searchResults}
|
|
238
268
|
searchLoading={isLoading}
|
|
239
|
-
onSelectSearchResult={
|
|
269
|
+
onSelectSearchResult={openResult}
|
|
240
270
|
// A committed search renders in this view's own rows (`rows` already
|
|
241
271
|
// narrows to the query above), so the multi-select toolbar stays
|
|
242
272
|
// reachable exactly as it does on the mailbox route (#212). The
|
|
@@ -245,7 +275,7 @@ export function FlaggedList({
|
|
|
245
275
|
>
|
|
246
276
|
<ThreadListInteraction
|
|
247
277
|
selectedMessageId={selectedMessageId}
|
|
248
|
-
onOpen={
|
|
278
|
+
onOpen={openRow}
|
|
249
279
|
onDeleteMessages={onDeleteMessages}
|
|
250
280
|
onSelectionVerb={wizard.start}
|
|
251
281
|
wizardOpen={wizard.isOpen}
|
|
@@ -263,7 +293,7 @@ export function FlaggedList({
|
|
|
263
293
|
onRetry={() => refetch()}
|
|
264
294
|
onReportError={handleReportError}
|
|
265
295
|
selectedThreadId={selectedMessageId}
|
|
266
|
-
onSelectThread={
|
|
296
|
+
onSelectThread={openRow}
|
|
267
297
|
isDesktop={isDesktop}
|
|
268
298
|
selectionBar={<ThreadListSelectionBar title="Starred" />}
|
|
269
299
|
listBody={
|