Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions desktop/src/shared/lib/initials.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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("()"), "");
});
});
39 changes: 35 additions & 4 deletions desktop/src/shared/lib/initials.ts
Original file line number Diff line number Diff line change
@@ -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();
}