diff --git a/desktop/src/shared/lib/initials.test.mjs b/desktop/src/shared/lib/initials.test.mjs index 19e9f8dbcc8..8e82df07dc7 100644 --- a/desktop/src/shared/lib/initials.test.mjs +++ b/desktop/src/shared/lib/initials.test.mjs @@ -20,3 +20,63 @@ describe("getInitials", () => { assert.equal(getInitials("()"), ""); }); }); + +describe("getInitials beyond the BMP", () => { + it("keeps a whole astral letter instead of half a surrogate pair", () => { + // U+20000, CJK Extension B — an ordinary character in some names. + const initials = getInitials("\u{20000}明"); + assert.equal(initials, "\u{20000}"); + assert.equal([...initials].length, 1); + }); + + it("keeps both initials whole when both are astral", () => { + const initials = getInitials("\u{20000}\u{20001} \u{20002}\u{20003}"); + assert.equal(initials, "\u{20000}\u{20002}"); + assert.equal([...initials].length, 2); + }); + + it("mixes an astral first name with an ordinary surname", () => { + assert.equal(getInitials("\u{1D400}da Lovelace"), "\u{1D400}L"); + }); +}); + +describe("getInitials with combining marks", () => { + it("does not split a word at a vowel sign", () => { + // अनिल कुमार — the vowel sign in अनिल used to split the word, so the + // second initial came from the middle of the first name. कु is one + // cluster: the surname's vowel sign belongs to its consonant. + assert.equal(getInitials("अनिल कुमार"), "अकु"); + }); + + it("gives a one-word name one initial", () => { + assert.equal(getInitials("नमस्ते"), "न"); + }); + + it("handles a Burmese name the same way", () => { + assert.equal(getInitials("မောင်မောင်"), "မေ"); + }); + + it("still strips punctuation that is not a mark", () => { + assert.equal(getInitials("B (relay)"), "BR"); + }); +}); + +describe("getInitials takes a grapheme cluster, not a code point", () => { + it("keeps a decomposed accent with its letter", () => { + // NFD: E + U+0301. A code point initial dropped the accent entirely. + // The result stays decomposed — the initial is the input's own cluster, + // not a renormalized one — so compare against the decomposed form. + assert.equal(getInitials("E\u0301lodie Durand"), "E\u0301D"); + assert.equal(getInitials("E\u0301lodie Durand").normalize("NFC"), "ÉD"); + }); + + it("does not split a cluster joined by a zero-width joiner", () => { + // क्‍ष is one cluster; ZWJ is neither a letter nor a mark, so it used to + // act as a word separator and produce two initials from one word. + assert.equal(getInitials("\u0915\u094D\u200D\u0937 Name"), "क्‍षN"); + }); + + it("still returns nothing for a name with no letters", () => { + assert.equal(getInitials("()"), ""); + }); +}); diff --git a/desktop/src/shared/lib/initials.ts b/desktop/src/shared/lib/initials.ts index 23dbf2ff93b..26325b36898 100644 --- a/desktop/src/shared/lib/initials.ts +++ b/desktop/src/shared/lib/initials.ts @@ -1,11 +1,42 @@ -/** Derive up to two uppercase initials from a display name. */ +const graphemeSegmenter = + typeof Intl.Segmenter === "function" + ? new Intl.Segmenter(undefined, { granularity: "grapheme" }) + : null; + +/** The first user-perceived character of a word, or "" when it has none. */ +function firstGrapheme(word: string): string { + if (graphemeSegmenter) { + for (const { segment } of graphemeSegmenter.segment(word)) { + return segment; + } + return ""; + } + // Older engines without Intl.Segmenter degrade to a code point, which is + // still whole — never half a surrogate pair. Mirrors `MessageLinkPill`. + return Array.from(word)[0] ?? ""; +} + +/** + * Derive up to two uppercase initials from a display name. + * + * An initial is a grapheme cluster, not a code unit and not a code point. + * Taking `word[0]` returned half a surrogate pair for a name outside the + * Basic Multilingual Plane; taking one code point returned a bare consonant + * for `कुमार` or `မောင်`, and dropped the accent from a decomposed `Élodie`. + * Only a cluster keeps a letter together with what belongs to it. + * + * Word separation keeps combining marks and join controls, which are neither + * `\p{L}` nor `\p{N}`: replacing them with a separator cut words apart from + * the inside, splitting `अनिल` at its vowel sign and the joined cluster + * `क्‍ष` at its ZWJ. + */ export function getInitials(name: string): string { return name - .replace(/[^\p{L}\p{N}\s]/gu, " ") + .replace(/[^\p{L}\p{M}\p{N}\p{Join_Control}\s]/gu, " ") .trim() .split(/\s+/) - .map((part) => part[0] ?? "") - .join("") + .map(firstGrapheme) .slice(0, 2) + .join("") .toUpperCase(); }