@remit/web-client 0.0.154 → 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.154",
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>
@@ -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
+ });
@@ -1,12 +1,16 @@
1
1
  import { AlertCircle, AlertTriangle, Info, X } from "lucide-react";
2
2
  import { cn } from "../../lib/utils";
3
- import type { ErrorBannerSeverity } from "./error-banners.js";
3
+ import type {
4
+ ErrorBannerAction,
5
+ ErrorBannerSeverity,
6
+ } from "./error-banners.js";
4
7
 
5
8
  interface ErrorBannerProps {
6
9
  id: string;
7
10
  severity: ErrorBannerSeverity;
8
11
  title: string;
9
12
  detail?: string;
13
+ action?: ErrorBannerAction;
10
14
  onDismiss: (id: string) => void;
11
15
  }
12
16
 
@@ -51,6 +55,7 @@ export const ErrorBanner = ({
51
55
  severity,
52
56
  title,
53
57
  detail,
58
+ action,
54
59
  onDismiss,
55
60
  }: ErrorBannerProps) => {
56
61
  const styles = SEVERITY_STYLES[severity];
@@ -77,6 +82,16 @@ export const ErrorBanner = ({
77
82
  {detail && (
78
83
  <p className="mt-0.5 text-xs text-fg-muted break-words">{detail}</p>
79
84
  )}
85
+ {action && (
86
+ <a
87
+ href={action.href}
88
+ target="_blank"
89
+ rel="noopener noreferrer"
90
+ className="mt-1 inline-block text-xs font-medium text-accent-2 underline underline-offset-2"
91
+ >
92
+ {action.label}
93
+ </a>
94
+ )}
80
95
  </div>
81
96
  <button
82
97
  type="button"
@@ -25,6 +25,7 @@ export const ErrorBannerStack = ({
25
25
  severity={entry.severity}
26
26
  title={entry.title}
27
27
  detail={entry.detail}
28
+ action={entry.action}
28
29
  onDismiss={onDismiss}
29
30
  />
30
31
  ))}
@@ -1,10 +1,20 @@
1
1
  export type ErrorBannerSeverity = "error" | "warning" | "info";
2
2
 
3
+ /**
4
+ * A way out of the banner. A failure the user cannot act on is still theirs to
5
+ * report, and a link they have to assemble themselves is one nobody follows.
6
+ */
7
+ export interface ErrorBannerAction {
8
+ label: string;
9
+ href: string;
10
+ }
11
+
3
12
  export interface ErrorBannerEntry {
4
13
  id: string;
5
14
  severity: ErrorBannerSeverity;
6
15
  title: string;
7
16
  detail?: string;
17
+ action?: ErrorBannerAction;
8
18
  createdAt: number;
9
19
  }
10
20
 
@@ -12,6 +22,7 @@ export interface PushErrorInput {
12
22
  severity?: ErrorBannerSeverity;
13
23
  title: string;
14
24
  detail?: string;
25
+ action?: ErrorBannerAction;
15
26
  /**
16
27
  * The error being reported, when there is one. Pass it: a banner is a soft
17
28
  * surface, and `pushError` uses this to refuse errors that are not soft —
@@ -116,5 +127,6 @@ export const buildEntry = (
116
127
  severity: input.severity ?? "error",
117
128
  title: input.title,
118
129
  detail: input.detail,
130
+ action: input.action,
119
131
  createdAt: now,
120
132
  });