@remit/web-client 0.0.154 → 0.0.156

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.156",
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,114 @@
1
+ /**
2
+ * The failure path of #707, end to end: a checker that will not start has to
3
+ * say so on screen. Each link in that chain is covered on its own elsewhere —
4
+ * what is asserted here is that they are actually joined, because every break
5
+ * in it looks identical from the writer's seat. A composer with no squiggles
6
+ * reads as a message with nothing wrong in it, and that is the one thing it
7
+ * must never come to mean by accident.
8
+ */
9
+
10
+ import assert from "node:assert/strict";
11
+ import { after, before, describe, it } from "node:test";
12
+ import type { ComposeBody as ComposeBodyType } from "@remit/ui/rich-text";
13
+ import { createElement, useMemo } from "react";
14
+ import { createDomHarness, type DomHarness } from "../../test-support/dom";
15
+ import { useErrorBanners } from "../ui/ErrorBannerProvider";
16
+ import { composeSpellcheck } from "./compose-spellcheck.js";
17
+
18
+ let harness: DomHarness | undefined;
19
+ let ComposeBody: typeof ComposeBodyType;
20
+
21
+ class Marks {
22
+ readonly ranges: Range[] = [];
23
+ add(range: Range): void {
24
+ this.ranges.push(range);
25
+ }
26
+ }
27
+
28
+ const FAILURE = "the worker could not be started";
29
+
30
+ before(async () => {
31
+ // The marks are drawn through the CSS Custom Highlight registry, which jsdom
32
+ // has neither half of. Without both the editor draws nothing and opens no
33
+ // provider at all, so there would be no failure to observe.
34
+ Object.defineProperty(globalThis, "CSS", {
35
+ value: { highlights: new Map<string, Marks>() },
36
+ configurable: true,
37
+ });
38
+ Object.defineProperty(globalThis, "Highlight", {
39
+ value: Marks,
40
+ configurable: true,
41
+ });
42
+ harness = createDomHarness();
43
+ // remit-ui's `.tsx` is transpiled here with the classic JSX runtime, which
44
+ // reads a global `React`. The harness installs it, so the editor is pulled
45
+ // in after one exists rather than at import time.
46
+ ({ ComposeBody } = await import("@remit/ui/rich-text"));
47
+ });
48
+
49
+ after(() => {
50
+ harness?.close();
51
+ harness = undefined;
52
+ });
53
+
54
+ /**
55
+ * The composer as `ComposeForm` wires it — the app's own options object, with
56
+ * only the module load behind the provider swapped for one that fails the way
57
+ * a missing chunk does.
58
+ */
59
+ const Composer = () => {
60
+ const { pushError } = useErrorBanners();
61
+ const spellcheck = useMemo(
62
+ () => ({
63
+ ...composeSpellcheck(pushError),
64
+ provider: () => Promise.reject(new Error(FAILURE)),
65
+ }),
66
+ [pushError],
67
+ );
68
+ return createElement(ComposeBody, {
69
+ mode: "rich",
70
+ onModeChange: () => undefined,
71
+ initialHtml: "<p>Ths is redy.</p>",
72
+ initialText: "Ths is redy.",
73
+ onChange: () => undefined,
74
+ onConversionError: () => undefined,
75
+ onLanguageChange: () => undefined,
76
+ languages: ["en"],
77
+ spellcheck,
78
+ });
79
+ };
80
+
81
+ const mounted = (): DomHarness => {
82
+ if (!harness) throw new Error("the harness is not mounted");
83
+ return harness;
84
+ };
85
+
86
+ describe("a checker the composer cannot start", () => {
87
+ it("says what stopped, offers the report, and gives the message back", async () => {
88
+ mounted().renderApp(createElement(Composer));
89
+ await mounted().flush();
90
+
91
+ const banner = mounted().query('[aria-label="Notifications"]');
92
+ assert.ok(
93
+ banner,
94
+ "a checker that failed to start is on screen, not silent",
95
+ );
96
+ assert.match(banner.textContent ?? "", /Spellcheck stopped/);
97
+ assert.match(banner.textContent ?? "", /Spellcheck for en is off/);
98
+ assert.match(banner.textContent ?? "", /checker stopped running/);
99
+
100
+ const report = banner.querySelector<HTMLAnchorElement>("a[href]");
101
+ assert.ok(report, "the failure carries a way to report it");
102
+ const href = new URL(report.href);
103
+ assert.match(href.href, /github\.com\/.+\/issues\/new\?/);
104
+ assert.match(href.searchParams.get("body") ?? "", new RegExp(FAILURE));
105
+
106
+ const editable = mounted().query("[data-testid=compose-body]");
107
+ assert.ok(editable, "the writing surface is mounted");
108
+ assert.equal(
109
+ editable.getAttribute("spellcheck"),
110
+ "true",
111
+ "the browser checks the message when ours cannot",
112
+ );
113
+ });
114
+ });
@@ -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
+ });
@@ -0,0 +1,86 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { ErrorBanner } from "./ErrorBanner";
3
+
4
+ /**
5
+ * The soft, dismissible notification (#55). It sits over the toolbar and the
6
+ * message list, so it is opaque and it names its own severity out loud rather
7
+ * than leaving colour to carry the meaning.
8
+ *
9
+ * A failure the user cannot act on still gets an action link, prefilled, so
10
+ * reporting it is one click instead of a form they have to assemble. The
11
+ * hrefs below stand in for the real report URL, which the app builds from
12
+ * build-time constants Storybook has no `define` for.
13
+ */
14
+ const meta: Meta<typeof ErrorBanner> = {
15
+ title: "Components/ErrorBanner",
16
+ component: ErrorBanner,
17
+ parameters: { layout: "padded" },
18
+ args: {
19
+ id: "banner-1",
20
+ onDismiss: () => undefined,
21
+ },
22
+ };
23
+ export default meta;
24
+
25
+ type Story = StoryObj<typeof ErrorBanner>;
26
+
27
+ const REPORT_URL =
28
+ "https://github.com/remit-mail/reader/issues/new?title=Spellcheck+stopped";
29
+
30
+ /** A mutation that failed, with the reason underneath. */
31
+ export const Failed: Story = {
32
+ name: "Error",
33
+ args: {
34
+ severity: "error",
35
+ title: "Couldn't move message",
36
+ detail: "Connection reset by peer",
37
+ },
38
+ };
39
+
40
+ /** Nothing more to say than the title. */
41
+ export const NoDetail: Story = {
42
+ args: {
43
+ severity: "error",
44
+ title: "Couldn't move message",
45
+ },
46
+ };
47
+
48
+ /** Something degraded rather than broke. */
49
+ export const Warning: Story = {
50
+ args: {
51
+ severity: "warning",
52
+ title: "Draft saved locally",
53
+ detail: "The server did not answer, so this draft has not been uploaded.",
54
+ },
55
+ };
56
+
57
+ /** A statement of fact, not a problem. */
58
+ export const Info: Story = {
59
+ args: {
60
+ severity: "info",
61
+ title: "Sync finished",
62
+ detail: "1,204 messages are up to date.",
63
+ },
64
+ };
65
+
66
+ /**
67
+ * The spellchecker stopping (#707): the writer cannot fix this one, so the
68
+ * banner says what stopped, what is happening instead, and offers the report
69
+ * already filled in.
70
+ */
71
+ export const WithActionLink: Story = {
72
+ args: {
73
+ severity: "warning",
74
+ title: "Spellcheck stopped",
75
+ detail:
76
+ "Spellcheck for en is off: the checker could not start. Your browser is checking this message instead. Failed to fetch dynamically imported module.",
77
+ action: { label: "Report this", href: REPORT_URL },
78
+ },
79
+ };
80
+
81
+ /** The action link on the dark theme. */
82
+ export const WithActionLinkDark: Story = {
83
+ name: "With Action Link (dark)",
84
+ parameters: { theme: "dark" },
85
+ args: WithActionLink.args,
86
+ };
@@ -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
  });