@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.153",
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
- const search = location.search as Record<string, unknown>;
119
- // A folder names its open conversation in the path, so leaving it is a
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 (!showsThread) return;
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
- search: (prev: Record<string, unknown>) => ({
166
- ...prev,
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
- createRoute({
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("drops the selected message so the pane can render the surface", async () => {
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 rest of the query, so the search the user typed survives", async () => {
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
- const search = router.history.location.search;
176
- assert.equal(search.includes("selectedMessageId"), false);
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(`/mail/${INBOX_ID}?selectedMessageId=msg-1`);
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.match(router.history.location.search, /selectedMessageId=msg-1/);
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. The brief and a folder say so in
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 search = location.search as Record<string, unknown> | undefined;
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. `FlaggedPane`
7
- * resolves the open thread from that same hook, so every row rendered here can
8
- * be opened. Starredness is decided server-side from `hasStars`; the client
9
- * neither re-filters nor caps the set, so a starred thread outside the newest
10
- * inbox page still appears. Rendered as one continuous list (no category
11
- * sections). The shared `MailViewChrome` owns the `MailHeader` + filter
12
- * expando; the kit `MessageListPane` (flat, no `briefFilters`) owns the loading
13
- * / empty / error chrome and keyboard hints, with a consumer-supplied
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
- onSelectMessage?: (id: string, options?: OpenMessageOptions) => void;
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
- onSelectMessage,
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={() => onSelectMessage?.(thread.id)}
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={(result) => onSelectMessage?.(result.id)}
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={(id, options) => onSelectMessage?.(id, options)}
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={onSelectMessage}
296
+ onSelectThread={openRow}
267
297
  isDesktop={isDesktop}
268
298
  selectionBar={<ThreadListSelectionBar title="Starred" />}
269
299
  listBody={