@remit/ui 0.0.120 → 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) => {
@@ -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
+ }