@remit/ui 0.0.119 → 0.0.121

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.
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Hunspell, over the WebAssembly build in `docker/hunspell/`. The engine
3
+ * and the two dictionary files are fetched, never bundled: a language costs
4
+ * nothing until someone writes in it, and the bytes are reported as they arrive
5
+ * because the first Dutch download is fourteen seconds on a bad link and a
6
+ * composer that says nothing for fourteen seconds looks broken.
7
+ *
8
+ * Dictionaries are handed to Hunspell as paths, so the two files go into the
9
+ * module's own in-memory filesystem exactly as downloaded. Nothing rewrites
10
+ * them — the served bytes are the upstream source, which is what keeps every
11
+ * dictionary licence at its verbatim-redistribution minimum.
12
+ */
13
+
14
+ export interface SpellEngine {
15
+ spell(word: string): boolean;
16
+ suggest(word: string): readonly string[];
17
+ add(word: string): void;
18
+ close(): void;
19
+ }
20
+
21
+ export interface DownloadProgress {
22
+ readonly bytesLoaded: number;
23
+ readonly bytesTotal: number;
24
+ }
25
+
26
+ export interface EngineFailure {
27
+ readonly ok: false;
28
+ /** Never arrived, or arrived and was rejected — different remedies. */
29
+ readonly reason: "download" | "engine";
30
+ readonly detail: string;
31
+ }
32
+
33
+ /**
34
+ * Opening is two designed failures and a success, so it answers with which one
35
+ * rather than throwing: what the composer puts in front of the writer, and what
36
+ * the report link carries, is this string.
37
+ */
38
+ export type EngineResult =
39
+ | { readonly ok: true; readonly engine: SpellEngine }
40
+ | EngineFailure;
41
+
42
+ type Attempt<T> = { readonly ok: true; readonly value: T } | EngineFailure;
43
+
44
+ interface HunspellModule {
45
+ readonly FS: { writeFile(path: string, data: Uint8Array): void };
46
+ _free(pointer: number): void;
47
+ _remit_open(affPath: number, dicPath: number): number;
48
+ _remit_close(handle: number): void;
49
+ _remit_spell(handle: number, word: number): number;
50
+ _remit_add(handle: number, word: number): number;
51
+ _remit_suggest(handle: number, word: number): number;
52
+ stringToNewUTF8(text: string): number;
53
+ UTF8ToString(pointer: number): string;
54
+ }
55
+
56
+ interface HunspellOptions {
57
+ instantiateWasm(
58
+ imports: WebAssembly.Imports,
59
+ ready: (instance: WebAssembly.Instance, module: WebAssembly.Module) => void,
60
+ ): Record<string, never>;
61
+ }
62
+
63
+ type HunspellFactory = (options: HunspellOptions) => Promise<HunspellModule>;
64
+
65
+ const isFactory = (value: unknown): value is { default: HunspellFactory } =>
66
+ typeof value === "object" &&
67
+ value !== null &&
68
+ typeof (value as { default?: unknown }).default === "function";
69
+
70
+ export interface EngineAssets {
71
+ /** The Emscripten loader, as an ES module with the factory as its default. */
72
+ loadFactory(url: string): Promise<unknown>;
73
+ fetch(url: string): Promise<Response>;
74
+ }
75
+
76
+ const browserAssets: EngineAssets = {
77
+ loadFactory: (url) => import(/* @vite-ignore */ url),
78
+ fetch: (url) => fetch(url),
79
+ };
80
+
81
+ const failure = (
82
+ reason: "download" | "engine",
83
+ detail: string,
84
+ ): EngineFailure => ({ ok: false, reason, detail });
85
+
86
+ const said = (error: unknown): string =>
87
+ error instanceof Error ? error.message : String(error);
88
+
89
+ const attempt = <T>(
90
+ work: Promise<T>,
91
+ reason: "download" | "engine",
92
+ describe: (message: string) => string,
93
+ ): Promise<Attempt<T>> =>
94
+ work.then(
95
+ (value) => ({ ok: true as const, value }),
96
+ (error: unknown) => failure(reason, describe(said(error))),
97
+ );
98
+
99
+ /**
100
+ * A failure a person can act on: the file, the status, and nothing about
101
+ * promises.
102
+ */
103
+ const download = async (
104
+ assets: EngineAssets,
105
+ url: string,
106
+ ): Promise<Attempt<Response>> => {
107
+ const fetched = await attempt(
108
+ assets.fetch(url),
109
+ "download",
110
+ (message) => `${url} could not be reached: ${message}`,
111
+ );
112
+ if (!fetched.ok) return fetched;
113
+ if (!fetched.value.ok) {
114
+ return failure("download", `${url} answered ${fetched.value.status}`);
115
+ }
116
+ return fetched;
117
+ };
118
+
119
+ const readBody = async (
120
+ response: Response,
121
+ counted: (added: number) => void,
122
+ ): Promise<Uint8Array<ArrayBuffer>> => {
123
+ const body = response.body;
124
+ if (!body) {
125
+ const whole = new Uint8Array(await response.arrayBuffer());
126
+ counted(whole.byteLength);
127
+ return whole;
128
+ }
129
+ const reader = body.getReader();
130
+ const chunks: Uint8Array[] = [];
131
+ let size = 0;
132
+ for (;;) {
133
+ const step = await reader.read();
134
+ if (step.done) break;
135
+ chunks.push(step.value);
136
+ size += step.value.byteLength;
137
+ counted(step.value.byteLength);
138
+ }
139
+ const whole = new Uint8Array(size);
140
+ let at = 0;
141
+ for (const chunk of chunks) {
142
+ whole.set(chunk, at);
143
+ at += chunk.byteLength;
144
+ }
145
+ return whole;
146
+ };
147
+
148
+ export interface EngineRequest {
149
+ /** Where `hunspell.mjs`, `hunspell.wasm` and `dictionaries/` are served. */
150
+ readonly base: string;
151
+ /** The tag the build staged, which is not always the tag the writer picked. */
152
+ readonly tag: string;
153
+ /**
154
+ * What the build says these files weigh. `content-length` is the wrong
155
+ * number: the files are served brotli-compressed and counted here
156
+ * decompressed, so believing the header names a size a quarter of the truth
157
+ * and then walks past it while the writer watches.
158
+ */
159
+ readonly bytesExpected: number;
160
+ onProgress(progress: DownloadProgress): void;
161
+ assets?: EngineAssets;
162
+ }
163
+
164
+ export const openEngine = async ({
165
+ base,
166
+ tag,
167
+ bytesExpected,
168
+ onProgress,
169
+ assets = browserAssets,
170
+ }: EngineRequest): Promise<EngineResult> => {
171
+ const dictionary = `${base}dictionaries/${tag}`;
172
+ const answers = await Promise.all([
173
+ download(assets, `${base}hunspell.wasm`),
174
+ download(assets, `${dictionary}/index.aff`),
175
+ download(assets, `${dictionary}/index.dic`),
176
+ ]);
177
+ for (const answer of answers) if (!answer.ok) return answer;
178
+ const responses = answers
179
+ .filter((answer) => answer.ok)
180
+ .map((answer) => answer.value);
181
+
182
+ let bytesLoaded = 0;
183
+ const report = (added: number): void => {
184
+ bytesLoaded += added;
185
+ onProgress({
186
+ bytesLoaded,
187
+ bytesTotal: Math.max(bytesExpected, bytesLoaded),
188
+ });
189
+ };
190
+ report(0);
191
+
192
+ const [wasm, aff, dic] = await Promise.all(
193
+ responses.map((response) => readBody(response, report)),
194
+ );
195
+
196
+ const loaded = await attempt(
197
+ assets.loadFactory(`${base}hunspell.mjs`),
198
+ "download",
199
+ (message) => `${base}hunspell.mjs could not be loaded: ${message}`,
200
+ );
201
+ if (!loaded.ok) return loaded;
202
+ if (!isFactory(loaded.value)) {
203
+ return failure("engine", `${base}hunspell.mjs exports no engine`);
204
+ }
205
+
206
+ const started = await attempt(
207
+ loaded.value.default({
208
+ instantiateWasm: (imports, ready) => {
209
+ WebAssembly.instantiate(wasm, imports).then((built) => {
210
+ ready(built.instance, built.module);
211
+ });
212
+ return {};
213
+ },
214
+ }),
215
+ "engine",
216
+ (message) => `the engine did not start: ${message}`,
217
+ );
218
+ if (!started.ok) return started;
219
+ const engine = started.value;
220
+
221
+ engine.FS.writeFile("/index.aff", aff);
222
+ engine.FS.writeFile("/index.dic", dic);
223
+ const affPath = engine.stringToNewUTF8("/index.aff");
224
+ const dicPath = engine.stringToNewUTF8("/index.dic");
225
+ const handle = engine._remit_open(affPath, dicPath);
226
+ engine._free(affPath);
227
+ engine._free(dicPath);
228
+ if (handle === 0) {
229
+ return failure("engine", `the ${tag} dictionary did not load`);
230
+ }
231
+
232
+ const withWord = <T>(word: string, use: (pointer: number) => T): T => {
233
+ const pointer = engine.stringToNewUTF8(word);
234
+ try {
235
+ return use(pointer);
236
+ } finally {
237
+ engine._free(pointer);
238
+ }
239
+ };
240
+
241
+ let open = true;
242
+ return {
243
+ ok: true,
244
+ engine: {
245
+ spell: (word) =>
246
+ open && withWord(word, (at) => engine._remit_spell(handle, at) !== 0),
247
+ suggest: (word) => {
248
+ if (!open) return [];
249
+ const answer = withWord(word, (at) =>
250
+ engine._remit_suggest(handle, at),
251
+ );
252
+ if (answer === 0) return [];
253
+ const joined = engine.UTF8ToString(answer);
254
+ engine._free(answer);
255
+ return joined === "" ? [] : joined.split("\n");
256
+ },
257
+ add: (word) => {
258
+ if (open) withWord(word, (at) => engine._remit_add(handle, at));
259
+ },
260
+ close: () => {
261
+ if (!open) return;
262
+ open = false;
263
+ engine._remit_close(handle);
264
+ },
265
+ },
266
+ };
267
+ };
@@ -0,0 +1,62 @@
1
+ /**
2
+ * What this build carries, and where it serves it from. Both are decided by
3
+ * `REMIT_SPELLCHECK_LANGUAGES` at build time and written in by the web client's
4
+ * spellcheck plugin, which stages exactly the same set — so there is no second
5
+ * list to fall out of step with the files on disk.
6
+ *
7
+ * A build that stages nothing leaves both undefined, and every language is
8
+ * `unavailable`: no worker starts, and the browser keeps checking.
9
+ */
10
+
11
+ declare const __REMIT_SPELLCHECK_LANGUAGES__: readonly string[];
12
+ declare const __REMIT_SPELLCHECK_BASE__: string;
13
+ declare const __REMIT_SPELLCHECK_BYTES__: Readonly<Record<string, number>>;
14
+
15
+ export const spellcheckLanguages = (): readonly string[] =>
16
+ typeof __REMIT_SPELLCHECK_LANGUAGES__ === "undefined"
17
+ ? []
18
+ : __REMIT_SPELLCHECK_LANGUAGES__;
19
+
20
+ /**
21
+ * Absolute, and resolved here rather than baked in: a Storybook build is
22
+ * published under a path it cannot know at build time, so what the plugin
23
+ * writes in is relative and the document says where it lands. The app's own
24
+ * base is already absolute and survives the same resolution unchanged.
25
+ */
26
+ export const spellcheckBase = (): string => {
27
+ const staged =
28
+ typeof __REMIT_SPELLCHECK_BASE__ === "undefined" ||
29
+ __REMIT_SPELLCHECK_BASE__ === ""
30
+ ? "/spellcheck/"
31
+ : __REMIT_SPELLCHECK_BASE__;
32
+ if (typeof document === "undefined") return staged;
33
+ return new URL(staged, document.baseURI).href;
34
+ };
35
+
36
+ /**
37
+ * What opening a language costs, counted by the build over the bytes it staged.
38
+ * `content-length` cannot answer this: the engine and the dictionaries are
39
+ * served brotli-compressed, so the header is the compressed length while what
40
+ * arrives — and what the composer counts — is the decompressed file.
41
+ */
42
+ export const spellcheckBytes = (tag: string): number =>
43
+ typeof __REMIT_SPELLCHECK_BYTES__ === "undefined"
44
+ ? 0
45
+ : (__REMIT_SPELLCHECK_BYTES__[tag] ?? 0);
46
+
47
+ /**
48
+ * The staged dictionary that answers for a tag. `en-GB` takes the British one
49
+ * where the build carries it and the American one where it does not, because
50
+ * being checked against the wrong English is a long way better than not being
51
+ * checked at all.
52
+ */
53
+ export const dictionaryTagFor = (
54
+ language: string,
55
+ built: readonly string[] = spellcheckLanguages(),
56
+ ): string | null => {
57
+ const wanted = language.toLowerCase();
58
+ const exact = built.find((tag) => tag.toLowerCase() === wanted);
59
+ if (exact) return exact;
60
+ const base = wanted.split("-")[0];
61
+ return built.find((tag) => tag.toLowerCase().split("-")[0] === base) ?? null;
62
+ };
@@ -27,10 +27,10 @@ import type {
27
27
  SuggestRequest,
28
28
  } from "./rich-text-spellcheck.js";
29
29
  import {
30
- dictionaryFor,
31
- findMisspellings,
32
- suggestionsFor,
33
- } from "./rich-text-spellcheck-words.js";
30
+ stubKnows,
31
+ stubSuggestionsFor,
32
+ } from "./rich-text-spellcheck-double.js";
33
+ import { findMisspellings } from "./rich-text-spellcheck-words.js";
34
34
 
35
35
  const SENTENCE = "Ths report is redy today";
36
36
  const IDLE_MS = 400;
@@ -77,13 +77,12 @@ const stubSpellcheck = (
77
77
  ): Stub => {
78
78
  const wordsAsked: SuggestRequest[] = [];
79
79
  const held: (() => void)[] = [];
80
- const words = dictionaryFor("en") ?? new Set<string>();
81
80
 
82
81
  const answer = (request: CheckRequest): CheckResponse => ({
83
82
  requestId: request.requestId,
84
83
  revision: request.revision,
85
84
  findings: request.spans.flatMap((span) =>
86
- findMisspellings(span.text, words).map(
85
+ findMisspellings(span.text, stubKnows).map(
87
86
  (range): Finding => ({
88
87
  spanId: span.spanId,
89
88
  start: range.start,
@@ -109,7 +108,7 @@ const stubSpellcheck = (
109
108
  const settled = {
110
109
  requestId: request.requestId,
111
110
  word: request.word,
112
- suggestions: suggestionsFor(request.word, words),
111
+ suggestions: stubSuggestionsFor(request.word),
113
112
  };
114
113
  if (!tune.holdSuggestions) return Promise.resolve(settled);
115
114
  return new Promise((resolve) => {
@@ -177,6 +176,20 @@ const characters = (): Node => {
177
176
  return leaf;
178
177
  };
179
178
 
179
+ /**
180
+ * jsdom lays out nothing and moves no caret, so a selection Lexical set on its
181
+ * own — `select()`, or an edit that carries the caret with it — lives only in
182
+ * Lexical's own state. The next commit reconciles against the document's
183
+ * selection, finds none, and puts the anchor back at the start. Everything
184
+ * about a withheld mark is read off the caret at paint time, so a test that
185
+ * left the browser's selection behind is asserting on whichever landed first:
186
+ * the answer to the check, or the reconciliation that undoes the caret. The
187
+ * browser is the one that does this; here the test has to.
188
+ */
189
+ const putCaret = (offset: number): void => {
190
+ dom.window.document.getSelection()?.setPosition(characters(), offset);
191
+ };
192
+
180
193
  /**
181
194
  * The desktop popover portals to the document body, clear of whatever
182
195
  * ancestor would otherwise clip it, so the menu and its rows are found
@@ -217,7 +230,7 @@ const pressDownAt = async (
217
230
  pointerType: string,
218
231
  button = 0,
219
232
  ): Promise<void> => {
220
- dom.window.document.getSelection()?.setPosition(characters(), offset);
233
+ putCaret(offset);
221
234
  await act(async () => {
222
235
  editable().dispatchEvent(
223
236
  pointerEvent("pointerdown", pointerType, {
@@ -268,6 +281,7 @@ const caretAt = async (
268
281
  text?.select(offset, offset);
269
282
  });
270
283
  });
284
+ putCaret(offset);
271
285
  };
272
286
 
273
287
  /** A key on the writing surface, whatever it turns out to do. */
@@ -745,6 +759,7 @@ describe("the correction menu", () => {
745
759
  text?.spliceText(18, 0, "y", true);
746
760
  });
747
761
  });
762
+ putCaret(19);
748
763
  await settle();
749
764
 
750
765
  assert.equal(editable().textContent, "Ths report is redyy today");
@@ -0,0 +1,168 @@
1
+ import { AlertCircle, Loader2 } from "lucide-react";
2
+ import { useEffect, useState } from "react";
3
+ import { formatByteSize } from "../lib/attachment-file.js";
4
+ import { languageLabel } from "../lib/compose-language.js";
5
+ import type { ProviderStatus } from "./rich-text-spellcheck.js";
6
+
7
+ /**
8
+ * How long a dictionary may download before the composer says so. A first Dutch
9
+ * download is 435 ms on a fast link and fourteen seconds on a bad one (#692),
10
+ * and a composer that says nothing for fourteen seconds reads as a composer
11
+ * with no opinion about spelling.
12
+ */
13
+ export const SPELLCHECK_SLOW_MS = 5000;
14
+
15
+ export const SPELLCHECK_ISSUE_URL =
16
+ "https://github.com/remit-mail/reader/issues/new";
17
+
18
+ export const spellcheckReportUrl = (
19
+ language: string,
20
+ detail: string,
21
+ ): string => {
22
+ const title = `Spellcheck dictionary failed to load (${language})`;
23
+ const body = [
24
+ `Language: ${language}`,
25
+ `What happened: ${detail}`,
26
+ "",
27
+ "What I was doing:",
28
+ ].join("\n");
29
+ return `${SPELLCHECK_ISSUE_URL}?title=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}`;
30
+ };
31
+
32
+ export interface RichTextSpellcheckNoticeProps {
33
+ status: ProviderStatus;
34
+ /** The writer cancelled the download, so nothing is coming unless they ask. */
35
+ standDown: boolean;
36
+ onCancel: () => void;
37
+ onRetry: () => void;
38
+ }
39
+
40
+ const Row = ({
41
+ tone,
42
+ children,
43
+ }: {
44
+ tone: "quiet" | "danger";
45
+ children: React.ReactNode;
46
+ }) => (
47
+ <div
48
+ role="status"
49
+ data-testid="spellcheck-notice"
50
+ className={`flex items-start gap-2 border-t px-3 py-1.5 text-xs ${
51
+ tone === "danger"
52
+ ? "border-danger/30 bg-danger-soft"
53
+ : "border-border bg-surface-sunken"
54
+ }`}
55
+ >
56
+ {children}
57
+ </div>
58
+ );
59
+
60
+ /**
61
+ * What the composer says while a dictionary is on its way, and when it never
62
+ * arrives. The browser's own checker is on the whole time either way, so this
63
+ * never claims spelling has stopped — only that ours has not started.
64
+ *
65
+ * Quiet for the first five seconds, because a fast link finishes inside half of
66
+ * one and a banner that flashes on every composer open is worse than no banner.
67
+ */
68
+ export function RichTextSpellcheckNotice({
69
+ status,
70
+ standDown,
71
+ onCancel,
72
+ onRetry,
73
+ }: RichTextSpellcheckNoticeProps) {
74
+ const [waited, setWaited] = useState(false);
75
+ const opening = status.state === "opening";
76
+
77
+ useEffect(() => {
78
+ if (!opening) {
79
+ setWaited(false);
80
+ return;
81
+ }
82
+ const timer = setTimeout(() => setWaited(true), SPELLCHECK_SLOW_MS);
83
+ return () => clearTimeout(timer);
84
+ }, [opening]);
85
+
86
+ if (standDown) {
87
+ return (
88
+ <Row tone="quiet">
89
+ <p className="min-w-0 flex-1 text-fg-muted">
90
+ {languageLabel(status.language)} spelling is left to the browser.
91
+ </p>
92
+ <button
93
+ type="button"
94
+ onClick={onRetry}
95
+ data-testid="spellcheck-retry"
96
+ className="shrink-0 font-medium text-accent hover:underline"
97
+ >
98
+ Download again
99
+ </button>
100
+ </Row>
101
+ );
102
+ }
103
+
104
+ if (status.state === "failed") {
105
+ return (
106
+ <Row tone="danger">
107
+ <AlertCircle
108
+ className="mt-0.5 size-3.5 shrink-0 text-danger"
109
+ aria-hidden
110
+ />
111
+ <div className="min-w-0 flex-1">
112
+ <p className="font-medium text-danger">
113
+ The {languageLabel(status.language)} dictionary did not load.
114
+ </p>
115
+ <p className="mt-0.5 text-fg-muted" data-testid="spellcheck-detail">
116
+ {status.detail}. The browser is still checking your spelling.
117
+ </p>
118
+ </div>
119
+ <div className="flex shrink-0 items-center gap-3">
120
+ <button
121
+ type="button"
122
+ onClick={onRetry}
123
+ data-testid="spellcheck-retry"
124
+ className="font-medium text-accent hover:underline"
125
+ >
126
+ Try again
127
+ </button>
128
+ <a
129
+ href={spellcheckReportUrl(status.language, status.detail)}
130
+ target="_blank"
131
+ rel="noreferrer"
132
+ data-testid="spellcheck-report"
133
+ className="font-medium text-accent hover:underline"
134
+ >
135
+ Report this
136
+ </a>
137
+ </div>
138
+ </Row>
139
+ );
140
+ }
141
+
142
+ if (!opening || !waited) return null;
143
+
144
+ return (
145
+ <Row tone="quiet">
146
+ <Loader2
147
+ className="mt-0.5 size-3.5 shrink-0 animate-spin text-fg-subtle"
148
+ aria-hidden
149
+ />
150
+ <p className="min-w-0 flex-1 text-fg-muted">
151
+ Downloading the {languageLabel(status.language)} dictionary
152
+ {status.bytesTotal > 0 ? ` (${formatByteSize(status.bytesTotal)})` : ""}
153
+ {"… "}
154
+ <span data-testid="spellcheck-progress">
155
+ {formatByteSize(status.bytesLoaded)} so far
156
+ </span>
157
+ </p>
158
+ <button
159
+ type="button"
160
+ onClick={onCancel}
161
+ data-testid="spellcheck-cancel"
162
+ className="shrink-0 font-medium text-accent hover:underline"
163
+ >
164
+ Cancel
165
+ </button>
166
+ </Row>
167
+ );
168
+ }