@remit/ui 0.0.101 → 0.0.103

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.
Files changed (35) hide show
  1. package/package.json +2 -1
  2. package/src/components/button.tsx +4 -0
  3. package/src/components/compose-address-field.stories.tsx +138 -0
  4. package/src/components/compose-address-field.tsx +206 -0
  5. package/src/components/compose-body.stories.tsx +463 -0
  6. package/src/components/compose-body.tsx +210 -0
  7. package/src/components/compose-header.stories.tsx +147 -0
  8. package/src/components/compose-header.tsx +109 -0
  9. package/src/components/compose-language-chip.stories.tsx +136 -0
  10. package/src/components/compose-language-chip.tsx +151 -0
  11. package/src/components/compose-language-setting.stories.tsx +82 -0
  12. package/src/components/compose-language-setting.tsx +106 -0
  13. package/src/components/compose-mode-toggle.stories.tsx +53 -0
  14. package/src/components/compose-smtp-missing-banner.stories.tsx +30 -0
  15. package/src/components/compose-smtp-missing-banner.tsx +39 -0
  16. package/src/components/compose-subject-field.tsx +22 -0
  17. package/src/components/confirm-dialog.stories.tsx +54 -0
  18. package/src/components/confirm-dialog.tsx +140 -0
  19. package/src/components/plain-text-editor.stories.tsx +14 -4
  20. package/src/components/plain-text-editor.tsx +13 -1
  21. package/src/components/rich-text-editor.stories.tsx +36 -12
  22. package/src/components/rich-text-editor.tsx +8 -0
  23. package/src/components/rich-text-toolbar.tsx +8 -4
  24. package/src/components/use-compose-language.ts +86 -0
  25. package/src/index.ts +39 -0
  26. package/src/lib/adopted-html.test.ts +9 -0
  27. package/src/lib/adopted-html.ts +13 -1
  28. package/src/lib/compose-language.test.ts +193 -0
  29. package/src/lib/compose-language.ts +206 -0
  30. package/src/lib/compose-mode.test.ts +76 -0
  31. package/src/lib/compose-mode.ts +50 -0
  32. package/src/lib/detect-compose-language.test.ts +51 -0
  33. package/src/lib/detect-compose-language.ts +42 -0
  34. package/src/rich-text.ts +22 -0
  35. package/src/components/compose-form-shell.stories.tsx +0 -96
@@ -0,0 +1,86 @@
1
+ import { useCallback, useEffect, useMemo, useState } from "react";
2
+ import { detectComposeLanguage } from "../lib/detect-compose-language.js";
3
+
4
+ /**
5
+ * Where the composer's current language came from. A value rather than an
6
+ * `isManual` flag: the chip reads it, and the spellchecker slice (#692) will
7
+ * want to know whether a language was chosen or guessed before it spends a
8
+ * worker on it.
9
+ */
10
+ export type ComposeLanguageSource = "account" | "detected" | "manual";
11
+
12
+ export interface ComposeLanguageState {
13
+ language: string;
14
+ source: ComposeLanguageSource;
15
+ }
16
+
17
+ export interface ComposeLanguageControl extends ComposeLanguageState {
18
+ /** The user picked this language. Detection stops for the rest of the message. */
19
+ choose: (tag: string) => void;
20
+ }
21
+
22
+ export interface UseComposeLanguageInput {
23
+ /** The account's configured tags, most-used first. The first is the default. */
24
+ languages: readonly string[];
25
+ /** The body as plain text. The quoted reply block is not part of it. */
26
+ text: string;
27
+ /**
28
+ * The tag a reopened draft was written under. Treated as a choice already
29
+ * made: it is the only record of a manual pick a draft carries, and detection
30
+ * would otherwise overwrite it the moment the draft came back.
31
+ */
32
+ initialLanguage?: string;
33
+ debounceMs?: number;
34
+ }
35
+
36
+ /**
37
+ * The composer's language while a message is being written: the account default
38
+ * until detection has enough text to say otherwise, and whatever the user
39
+ * picked from the moment they pick one.
40
+ */
41
+ export const useComposeLanguage = ({
42
+ languages,
43
+ text,
44
+ initialLanguage,
45
+ debounceMs = 400,
46
+ }: UseComposeLanguageInput): ComposeLanguageControl => {
47
+ // A caller building this array inline hands over a new identity on every
48
+ // render, and an effect keyed on it would restart its timer forever.
49
+ const candidateKey = languages.join(",");
50
+ const candidates = useMemo(
51
+ () => candidateKey.split(",").filter((tag) => tag !== ""),
52
+ [candidateKey],
53
+ );
54
+ const accountDefault = candidates[0] ?? "en";
55
+
56
+ const [state, setState] = useState<ComposeLanguageState>(() =>
57
+ initialLanguage
58
+ ? { language: initialLanguage, source: "manual" }
59
+ : { language: accountDefault, source: "account" },
60
+ );
61
+
62
+ const settled = state.source === "manual";
63
+
64
+ useEffect(() => {
65
+ if (settled) return;
66
+ const timer = setTimeout(() => {
67
+ const detected = detectComposeLanguage(text, candidates);
68
+ const language = detected ?? accountDefault;
69
+ const source: ComposeLanguageSource = detected ? "detected" : "account";
70
+ setState((current) => {
71
+ if (current.source === "manual") return current;
72
+ if (current.language === language && current.source === source) {
73
+ return current;
74
+ }
75
+ return { language, source };
76
+ });
77
+ }, debounceMs);
78
+ return () => clearTimeout(timer);
79
+ }, [settled, text, candidates, accountDefault, debounceMs]);
80
+
81
+ const choose = useCallback((tag: string) => {
82
+ setState({ language: tag, source: "manual" });
83
+ }, []);
84
+
85
+ return { ...state, choose };
86
+ };
package/src/index.ts CHANGED
@@ -130,12 +130,38 @@ export {
130
130
  type ComposeActionBarProps,
131
131
  type ComposeSaveStatus,
132
132
  } from "./components/compose-action-bar.js";
133
+ export {
134
+ type AddressEntry,
135
+ ComposeAddressField,
136
+ type ComposeAddressFieldProps,
137
+ } from "./components/compose-address-field.js";
133
138
  export {
134
139
  ComposeFormShell,
135
140
  type ComposeFormShellProps,
136
141
  type ComposeMode,
137
142
  composeModeLabels,
138
143
  } from "./components/compose-form-shell.js";
144
+ export {
145
+ ComposeHeader,
146
+ type ComposeHeaderProps,
147
+ composeHeaderSummary,
148
+ } from "./components/compose-header.js";
149
+ export {
150
+ ComposeLanguageSetting,
151
+ type ComposeLanguageSettingProps,
152
+ } from "./components/compose-language-setting.js";
153
+ export {
154
+ ComposeSmtpMissingBanner,
155
+ type ComposeSmtpMissingBannerProps,
156
+ } from "./components/compose-smtp-missing-banner.js";
157
+ export {
158
+ ComposeSubjectField,
159
+ type ComposeSubjectFieldProps,
160
+ } from "./components/compose-subject-field.js";
161
+ export {
162
+ ConfirmDialog,
163
+ type ConfirmDialogProps,
164
+ } from "./components/confirm-dialog.js";
139
165
  export {
140
166
  DangerZoneSection,
141
167
  type DangerZoneSectionProps,
@@ -658,6 +684,19 @@ export {
658
684
  type CidResolver,
659
685
  } from "./lib/cid-resolver.js";
660
686
  export { cn } from "./lib/cn.js";
687
+ export {
688
+ browserSpellcheckHelp,
689
+ COMPOSE_LANGUAGE_OPTIONS,
690
+ type ComposeLanguageOption,
691
+ defaultComposeLanguages,
692
+ detectionCodeFor,
693
+ languageChipLabel,
694
+ languageLabel,
695
+ primaryLanguageSubtag,
696
+ unwrapLanguage,
697
+ wrapWithLanguage,
698
+ } from "./lib/compose-language.js";
699
+ export { modeOfDraft } from "./lib/compose-mode.js";
661
700
  export { generateLayoutClampCSS } from "./lib/email-layout-clamp.js";
662
701
  export {
663
702
  classifyEmailRenderTreatment,
@@ -55,6 +55,15 @@ describe("sanitizeAdoptedHtml", () => {
55
55
  assert.match(result, /<div>first<\/div><div>second<\/div>/);
56
56
  });
57
57
 
58
+ it("keeps the language and direction a passage was written in", () => {
59
+ const result = sanitizeAdoptedHtml(
60
+ '<p>Hoi</p><p lang="fr">Bonjour</p><p dir="rtl" lang="ar">مرحبا</p>',
61
+ );
62
+ assert.match(result, /<p lang="fr">Bonjour<\/p>/);
63
+ assert.match(result, /lang="ar"/);
64
+ assert.match(result, /dir="rtl"/);
65
+ });
66
+
58
67
  it("drops script, style and the presentation a receiving client rewrites", () => {
59
68
  const result = sanitizeAdoptedHtml(
60
69
  [
@@ -46,7 +46,19 @@ const ADOPTED_TAGS = [
46
46
  "img",
47
47
  ];
48
48
 
49
- const ADOPTED_ATTR = ["href", "src", "alt", "colspan", "rowspan"];
49
+ // `lang` and `dir` are structure, not presentation: they are what a recipient's
50
+ // client and every screen reader read the language and the writing direction
51
+ // off, and they are what keeps a quoted passage in another language marked as
52
+ // one inside a message written in this one (#686).
53
+ const ADOPTED_ATTR = [
54
+ "href",
55
+ "src",
56
+ "alt",
57
+ "colspan",
58
+ "rowspan",
59
+ "lang",
60
+ "dir",
61
+ ];
50
62
 
51
63
  const LINK_SCHEMES = /^(?:https?:|mailto:)/i;
52
64
  const IMAGE_SCHEMES = /^(?:https:|data:image\/)/i;
@@ -0,0 +1,193 @@
1
+ import assert from "node:assert/strict";
2
+ import { before, describe, it } from "node:test";
3
+ import { data } from "franc-min/data.js";
4
+ import { JSDOM } from "jsdom";
5
+ import {
6
+ browserSpellcheckHelp,
7
+ COMPOSE_LANGUAGE_OPTIONS,
8
+ defaultComposeLanguages,
9
+ detectionCodeFor,
10
+ languageChipLabel,
11
+ languageLabel,
12
+ unwrapLanguage,
13
+ wrapWithLanguage,
14
+ } from "./compose-language.js";
15
+
16
+ before(() => {
17
+ const dom = new JSDOM("");
18
+ globalThis.DOMParser = dom.window.DOMParser;
19
+ });
20
+
21
+ describe("detectionCodeFor", () => {
22
+ it("resolves a region through its language", () => {
23
+ assert.equal(detectionCodeFor("en-GB"), "eng");
24
+ assert.equal(detectionCodeFor("nl"), "nld");
25
+ });
26
+
27
+ it("declines a language nothing can detect", () => {
28
+ assert.equal(detectionCodeFor("ja"), null);
29
+ });
30
+ });
31
+
32
+ describe("languageChipLabel", () => {
33
+ it("drops the region", () => {
34
+ assert.equal(languageChipLabel("en-GB"), "EN");
35
+ });
36
+ });
37
+
38
+ describe("languageLabel", () => {
39
+ it("names a language in its own words", () => {
40
+ assert.equal(languageLabel("nl"), "Nederlands");
41
+ assert.equal(languageLabel("de"), "Deutsch");
42
+ });
43
+
44
+ it("falls back to the tag the platform cannot name", () => {
45
+ assert.equal(languageLabel("qq"), "qq");
46
+ });
47
+
48
+ it("does not throw on a tag a hand-edited setting could hold", () => {
49
+ assert.equal(languageLabel("not a tag"), "not a tag");
50
+ assert.equal(languageLabel(""), "");
51
+ });
52
+ });
53
+
54
+ describe("defaultComposeLanguages", () => {
55
+ it("follows the browser and always offers a second row", () => {
56
+ assert.deepEqual(defaultComposeLanguages(["nl-NL", "nl", "en-US"]), [
57
+ "nl",
58
+ "en",
59
+ ]);
60
+ });
61
+
62
+ it("skips a language detection has no table for", () => {
63
+ assert.deepEqual(defaultComposeLanguages(["ja-JP", "de-DE"]), ["de", "en"]);
64
+ });
65
+
66
+ it("falls back to English when the browser offers nothing usable", () => {
67
+ assert.deepEqual(defaultComposeLanguages([]), ["en"]);
68
+ });
69
+ });
70
+
71
+ describe("browserSpellcheckHelp", () => {
72
+ it("names the setting Chrome keeps it under", () => {
73
+ const help = browserSpellcheckHelp(
74
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36",
75
+ );
76
+ assert.match(help, /^Chrome checks every language/);
77
+ });
78
+
79
+ it("names macOS for Safari, not Chrome", () => {
80
+ const help = browserSpellcheckHelp(
81
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15",
82
+ );
83
+ assert.match(help, /^macOS decides this/);
84
+ });
85
+
86
+ it("tells a Firefox user the setting is actually used", () => {
87
+ const help = browserSpellcheckHelp(
88
+ "Mozilla/5.0 (X11; Linux x86_64; rv:130.0) Gecko/20100101 Firefox/130.0",
89
+ );
90
+ assert.match(help, /^Firefox uses this setting/);
91
+ });
92
+
93
+ it("names the keyboard on iOS, where every engine is WebKit", () => {
94
+ const help = browserSpellcheckHelp(
95
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/141.0.0.0 Mobile/15E148 Safari/604.1",
96
+ );
97
+ assert.match(help, /^On iPhone and iPad the keyboard/);
98
+ });
99
+
100
+ it("never claims the app changes the dictionary", () => {
101
+ for (const agent of ["Chrome/141", "Firefox/130", "Safari/605", "curl/8"]) {
102
+ assert.doesNotMatch(browserSpellcheckHelp(agent), /this app|we /i);
103
+ }
104
+ });
105
+ });
106
+
107
+ describe("wrapWithLanguage", () => {
108
+ it("puts the document under one tagged div", () => {
109
+ assert.equal(
110
+ wrapWithLanguage("<p>Hoi</p>", "nl"),
111
+ '<div lang="nl"><p>Hoi</p></div>',
112
+ );
113
+ });
114
+
115
+ it("leaves an empty document empty", () => {
116
+ assert.equal(wrapWithLanguage("", "nl"), "");
117
+ });
118
+
119
+ it("replaces the tag instead of nesting a second wrapper", () => {
120
+ const once = wrapWithLanguage("<p>Hoi</p>", "nl");
121
+ const twice = wrapWithLanguage(once, "de");
122
+ assert.equal(twice, '<div lang="de"><p>Hoi</p></div>');
123
+ assert.equal(wrapWithLanguage(twice, "de"), twice);
124
+ });
125
+
126
+ it("does not adopt a plain div the message happens to start with", () => {
127
+ const wrapped = wrapWithLanguage("<div>Hoi</div>", "nl");
128
+ assert.equal(wrapped, '<div lang="nl"><div>Hoi</div></div>');
129
+ });
130
+
131
+ it("cannot be talked out of the attribute by a hand-edited tag", () => {
132
+ const hostile = 'nl"><script>alert(1)</script><div lang="nl';
133
+ const parsed = new DOMParser().parseFromString(
134
+ wrapWithLanguage("<p>Hoi</p>", hostile),
135
+ "text/html",
136
+ );
137
+ assert.equal(parsed.querySelectorAll("script").length, 0);
138
+ assert.equal(parsed.body.children.length, 1);
139
+ assert.equal(parsed.body.children[0]?.getAttribute("lang"), hostile);
140
+ });
141
+ });
142
+
143
+ describe("unwrapLanguage", () => {
144
+ it("returns the document the editor should reopen on", () => {
145
+ assert.deepEqual(unwrapLanguage('<div lang="nl"><p>Hoi</p></div>'), {
146
+ html: "<p>Hoi</p>",
147
+ language: "nl",
148
+ });
149
+ });
150
+
151
+ it("leaves an untagged document alone", () => {
152
+ assert.deepEqual(unwrapLanguage("<p>Hoi</p>"), {
153
+ html: "<p>Hoi</p>",
154
+ language: null,
155
+ });
156
+ });
157
+
158
+ it("keeps a tagged passage that is not the whole message", () => {
159
+ const html = '<p>Hoi</p><p lang="fr">Bonjour</p>';
160
+ assert.deepEqual(unwrapLanguage(html), { html, language: null });
161
+ });
162
+
163
+ it("round-trips what the wrapper wrote", () => {
164
+ const html = '<p>Hoi</p><p lang="fr">Bonjour</p>';
165
+ assert.deepEqual(unwrapLanguage(wrapWithLanguage(html, "nl")), {
166
+ html,
167
+ language: "nl",
168
+ });
169
+ });
170
+ });
171
+
172
+ describe("COMPOSE_LANGUAGE_OPTIONS", () => {
173
+ it("offers no tag twice", () => {
174
+ const tags = COMPOSE_LANGUAGE_OPTIONS.map((option) => option.tag);
175
+ assert.equal(new Set(tags).size, tags.length);
176
+ });
177
+
178
+ /**
179
+ * Read against `franc-min`'s own trigram tables rather than against the list
180
+ * that produced them: a code the detector has never heard of is a menu row
181
+ * that can be picked by hand and never detected, and a table built the same
182
+ * way as the list would agree with it and prove nothing.
183
+ */
184
+ it("offers only languages the detector has a table for", () => {
185
+ const known = new Set(
186
+ Object.values(data).flatMap((byLanguage) => Object.keys(byLanguage)),
187
+ );
188
+ const unknown = COMPOSE_LANGUAGE_OPTIONS.filter(
189
+ (option) => !known.has(option.detectionCode),
190
+ );
191
+ assert.deepEqual(unknown, []);
192
+ });
193
+ });
@@ -0,0 +1,206 @@
1
+ /**
2
+ * The composer's language: which BCP 47 tag a message is being written in,
3
+ * what a browser will and will not do with it, and how it travels on the
4
+ * message.
5
+ *
6
+ * A page cannot choose the browser's spellcheck dictionary. Chrome and Safari
7
+ * ignore `lang` outright; Firefox reads it but only picks among dictionaries
8
+ * the user already installed. Nothing here claims otherwise — what the tag buys
9
+ * is a Firefox dictionary when one is present, a screen-reader voice, and a
10
+ * message the recipient's client can read the language off. See issue #686.
11
+ */
12
+
13
+ /**
14
+ * A language the composer offers, and the ISO 639-3 code `franc-min` knows it
15
+ * by. The two are separate alphabets: the tag is what goes on the document and
16
+ * on the wire, the code is what the detector's trigram tables are keyed on.
17
+ *
18
+ * The list is bounded by what `franc-min` can detect — a language it has no
19
+ * table for could be picked by hand but never detected, which is a menu entry
20
+ * that behaves differently from its neighbours for no reason a user can see.
21
+ */
22
+ export interface ComposeLanguageOption {
23
+ tag: string;
24
+ detectionCode: string;
25
+ }
26
+
27
+ export const COMPOSE_LANGUAGE_OPTIONS: readonly ComposeLanguageOption[] = [
28
+ { tag: "ar", detectionCode: "arb" },
29
+ { tag: "az", detectionCode: "azj" },
30
+ { tag: "be", detectionCode: "bel" },
31
+ { tag: "bg", detectionCode: "bul" },
32
+ { tag: "bs", detectionCode: "bos" },
33
+ { tag: "cs", detectionCode: "ces" },
34
+ { tag: "de", detectionCode: "deu" },
35
+ { tag: "en", detectionCode: "eng" },
36
+ { tag: "es", detectionCode: "spa" },
37
+ { tag: "fa", detectionCode: "pes" },
38
+ { tag: "fr", detectionCode: "fra" },
39
+ { tag: "ha", detectionCode: "hau" },
40
+ { tag: "hi", detectionCode: "hin" },
41
+ { tag: "hr", detectionCode: "hrv" },
42
+ { tag: "hu", detectionCode: "hun" },
43
+ { tag: "id", detectionCode: "ind" },
44
+ { tag: "ig", detectionCode: "ibo" },
45
+ { tag: "it", detectionCode: "ita" },
46
+ { tag: "jv", detectionCode: "jav" },
47
+ { tag: "kk", detectionCode: "kaz" },
48
+ { tag: "mr", detectionCode: "mar" },
49
+ { tag: "ms", detectionCode: "zlm" },
50
+ { tag: "ne", detectionCode: "npi" },
51
+ { tag: "nl", detectionCode: "nld" },
52
+ { tag: "pl", detectionCode: "pol" },
53
+ { tag: "ps", detectionCode: "pbu" },
54
+ { tag: "pt", detectionCode: "por" },
55
+ { tag: "ro", detectionCode: "ron" },
56
+ { tag: "ru", detectionCode: "rus" },
57
+ { tag: "so", detectionCode: "som" },
58
+ { tag: "sr", detectionCode: "srp" },
59
+ { tag: "su", detectionCode: "sun" },
60
+ { tag: "sv", detectionCode: "swe" },
61
+ { tag: "sw", detectionCode: "swh" },
62
+ { tag: "tl", detectionCode: "tgl" },
63
+ { tag: "tr", detectionCode: "tur" },
64
+ { tag: "uk", detectionCode: "ukr" },
65
+ { tag: "ur", detectionCode: "urd" },
66
+ { tag: "vi", detectionCode: "vie" },
67
+ { tag: "yo", detectionCode: "yor" },
68
+ { tag: "zu", detectionCode: "zul" },
69
+ ];
70
+
71
+ /** The language subtag of a BCP 47 tag: `en` out of `en-GB`, lower-cased. */
72
+ export const primaryLanguageSubtag = (tag: string): string =>
73
+ (tag.split("-")[0] ?? "").toLowerCase();
74
+
75
+ const byPrimarySubtag = new Map(
76
+ COMPOSE_LANGUAGE_OPTIONS.map((option) => [option.tag, option]),
77
+ );
78
+
79
+ /**
80
+ * The detector code for a tag, or null when nothing can detect it. A region
81
+ * resolves through its language — no trigram table separates `en-GB` from
82
+ * `en-US`, and pretending one does would put a tag on the message the text
83
+ * never supported.
84
+ */
85
+ export const detectionCodeFor = (tag: string): string | null =>
86
+ byPrimarySubtag.get(primaryLanguageSubtag(tag))?.detectionCode ?? null;
87
+
88
+ const WELL_FORMED_TAG = /^[a-z]{2,3}(-[a-z0-9]{2,8})*$/i;
89
+
90
+ /** The tag as the chip shows it: two or three letters, upper case. */
91
+ export const languageChipLabel = (tag: string): string =>
92
+ primaryLanguageSubtag(tag).toUpperCase();
93
+
94
+ /**
95
+ * The language's own name for itself — `Nederlands`, not `Dutch`. A menu of
96
+ * languages is read by someone who reads that language, and an endonym is the
97
+ * entry they recognise without translating it first. Falls back to the tag when
98
+ * the platform has no name for it.
99
+ */
100
+ export const languageLabel = (tag: string): string => {
101
+ // `Intl.DisplayNames` throws a RangeError on anything that is not a
102
+ // well-formed tag, and a stored setting is text a user can have edited.
103
+ if (!WELL_FORMED_TAG.test(tag)) return tag;
104
+ const names = new Intl.DisplayNames([tag], {
105
+ type: "language",
106
+ fallback: "none",
107
+ });
108
+ const named = names.of(tag);
109
+ if (!named) return tag;
110
+ return named.charAt(0).toLocaleUpperCase(tag) + named.slice(1);
111
+ };
112
+
113
+ /**
114
+ * The languages an account writes in when it has not said. The browser already
115
+ * holds an ordered answer; `en` follows it, so a browser set to one language
116
+ * nothing can detect still leaves the chip something to show.
117
+ */
118
+ export const defaultComposeLanguages = (
119
+ locales: readonly string[],
120
+ ): string[] => {
121
+ const known = locales.filter((locale) => detectionCodeFor(locale) !== null);
122
+ const tags = known.map(primaryLanguageSubtag);
123
+ const unique = [...new Set(tags)];
124
+ if (!unique.includes("en")) unique.push("en");
125
+ return unique;
126
+ };
127
+
128
+ /**
129
+ * The one sentence at the foot of the language menu naming where the browser
130
+ * keeps the setting that does fix spelling. A page cannot link to
131
+ * `chrome://settings/languages`, so it is text; and reading the user agent to
132
+ * name where a setting lives is not feature detection, because there is nothing
133
+ * to detect.
134
+ */
135
+ export const browserSpellcheckHelp = (userAgent: string): string => {
136
+ if (/iPhone|iPad|iPod/.test(userAgent)) {
137
+ return "On iPhone and iPad the keyboard decides this. Add the language under Settings, then General, then Keyboard.";
138
+ }
139
+ if (/Edg\//.test(userAgent)) {
140
+ return "Edge checks every language you add under Settings, then Languages. Adding one there checks it alongside the others.";
141
+ }
142
+ if (/Firefox\//.test(userAgent)) {
143
+ return "Firefox uses this setting. Right-click the message to add a dictionary for it.";
144
+ }
145
+ if (/Chrome\/|Chromium\//.test(userAgent)) {
146
+ return "Chrome checks every language you add under Settings, then Languages. Adding one there checks it alongside the others.";
147
+ }
148
+ if (/Safari\//.test(userAgent)) {
149
+ return "macOS decides this under Keyboard, then Text Input, then Spelling. Automatic by Language covers every language enabled there.";
150
+ }
151
+ return "Your browser decides which dictionaries it checks. Add this language in its own language settings to have it spellchecked.";
152
+ };
153
+
154
+ const parseHtml = (html: string): Document =>
155
+ new DOMParser().parseFromString(html, "text/html");
156
+
157
+ const singleRootElement = (body: HTMLElement): Element | null => {
158
+ const elements = [...body.children];
159
+ if (elements.length !== 1) return null;
160
+ const only = elements[0];
161
+ if (!only) return null;
162
+ const hasStrayText = [...body.childNodes].some(
163
+ (node) => node !== only && (node.textContent ?? "").trim() !== "",
164
+ );
165
+ return hasStrayText ? null : only;
166
+ };
167
+
168
+ /**
169
+ * The outgoing HTML under one `<div lang>`, so the recipient's client reads the
170
+ * language off the message rather than guessing it.
171
+ *
172
+ * Re-wrapping is idempotent: a document that already sits under a language
173
+ * wrapper has that wrapper's tag replaced. Autosave runs this on every keystroke
174
+ * batch, and a draft that gained a `<div>` per save would arrive nested forty
175
+ * deep.
176
+ */
177
+ export const wrapWithLanguage = (html: string, tag: string): string => {
178
+ if (html === "") return "";
179
+ const document = parseHtml(html);
180
+ const existing = singleRootElement(document.body);
181
+ if (existing?.tagName === "DIV" && existing.hasAttribute("lang")) {
182
+ existing.setAttribute("lang", tag);
183
+ return document.body.innerHTML;
184
+ }
185
+ // Built through the DOM rather than by string concatenation: a stored
186
+ // setting is text a user can have edited, and a tag carrying a quote would
187
+ // otherwise close the attribute and put markup into the message.
188
+ const wrapper = document.createElement("div");
189
+ wrapper.setAttribute("lang", tag);
190
+ while (document.body.firstChild) wrapper.append(document.body.firstChild);
191
+ document.body.append(wrapper);
192
+ return document.body.innerHTML;
193
+ };
194
+
195
+ /** What `wrapWithLanguage` wrote, taken back apart for the editor to reopen on. */
196
+ export const unwrapLanguage = (
197
+ html: string,
198
+ ): { html: string; language: string | null } => {
199
+ if (html === "") return { html, language: null };
200
+ const document = parseHtml(html);
201
+ const root = singleRootElement(document.body);
202
+ if (root?.tagName !== "DIV" || !root.hasAttribute("lang")) {
203
+ return { html, language: null };
204
+ }
205
+ return { html: root.innerHTML, language: root.getAttribute("lang") };
206
+ };
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Which surface a draft reopens in, and when a mode switch is refused.
3
+ *
4
+ * The mode is derived from `htmlBody`, with no field of its own. It has to be
5
+ * "a non-empty string" and not "truthy": the rich editor serializes an empty
6
+ * document to `<p><br></p>` and a plain draft clears the column to `""`, so a
7
+ * falsy check opens a plain draft correctly by accident and an absent column
8
+ * — an old draft, a partial write — the wrong way round.
9
+ */
10
+ import assert from "node:assert/strict";
11
+ import { describe, it } from "node:test";
12
+ import {
13
+ conversionOutcome,
14
+ modeOfDraft,
15
+ switchNeedsWarning,
16
+ } from "./compose-mode.js";
17
+
18
+ describe("the mode a draft reopens in", () => {
19
+ it("opens a draft with HTML as rich", () => {
20
+ assert.equal(modeOfDraft("<p>Hello</p>"), "rich");
21
+ });
22
+
23
+ it("opens an empty rich document as rich", () => {
24
+ assert.equal(modeOfDraft("<p><br></p>"), "rich");
25
+ });
26
+
27
+ it("opens a draft whose HTML was cleared as plain", () => {
28
+ assert.equal(modeOfDraft(""), "plain");
29
+ });
30
+
31
+ it("opens a draft that never had HTML as plain", () => {
32
+ assert.equal(modeOfDraft(undefined), "plain");
33
+ });
34
+ });
35
+
36
+ describe("whether the switch warns first", () => {
37
+ it("warns when the document holds formatting", () => {
38
+ assert.equal(switchNeedsWarning("plain", ["table"]), true);
39
+ });
40
+
41
+ it("says nothing over plain paragraphs", () => {
42
+ assert.equal(switchNeedsWarning("plain", []), false);
43
+ });
44
+
45
+ it("never warns on the way back to rich", () => {
46
+ assert.equal(switchNeedsWarning("rich", ["table", "bold"]), false);
47
+ });
48
+ });
49
+
50
+ describe("a conversion that would empty a written message", () => {
51
+ it("goes ahead when the conversion carried the message across", () => {
52
+ assert.deepEqual(conversionOutcome("plain", "Due Friday.", "Due Friday."), {
53
+ outcome: "switch",
54
+ });
55
+ });
56
+
57
+ it("goes ahead when there was nothing to carry", () => {
58
+ assert.deepEqual(conversionOutcome("plain", "", ""), { outcome: "switch" });
59
+ });
60
+
61
+ it("refuses, naming the direction, when plain text came back empty", () => {
62
+ assert.deepEqual(conversionOutcome("plain", "Due Friday.", " "), {
63
+ outcome: "blocked",
64
+ title: "Couldn't switch to plain text",
65
+ detail: "The conversion came back empty, so your message is unchanged.",
66
+ });
67
+ });
68
+
69
+ it("refuses, naming the direction, when rich text came back empty", () => {
70
+ assert.deepEqual(conversionOutcome("rich", "Due Friday.", ""), {
71
+ outcome: "blocked",
72
+ title: "Couldn't switch to rich text",
73
+ detail: "The conversion came back empty, so your message is unchanged.",
74
+ });
75
+ });
76
+ });