Skip to content
Merged
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
46 changes: 46 additions & 0 deletions frontend/src/v2/__tests__/avatarInitials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,52 @@ describe('initialsFor', () => {
expect(initialsFor('ε₯ΆιΎ™')).toBe('ε₯ΆιΎ™');
});

test('an astral character is taken whole, never cut in half', () => {
// A JS string indexes UTF-16 units, so a name starting with an astral
// character used to yield the high surrogate ALONE β€” "\ud801", which is not
// a character and renders as a replacement box or nothing at all. Deseret
// capital 𐐀 is U+10400, two units.
expect(initialsFor('𐐀lpha Beta')).toBe('𐐀B');
expect(initialsFor('Deseret 𐐀team')).toBe('D𐐀');
// The one-word branch cuts after TWO characters, so here the second code
// point is the one at risk: 'Aπ”˜' is three units, and slice(0, 2) was "A"
// plus half of π”˜.
expect(initialsFor('Aπ”˜')).toBe('Aπ”˜');
});

test('no result is ever a lone surrogate', () => {
// The class, not the three instances above: a lone surrogate is a single
// UTF-16 unit inside the surrogate range, whereas a whole astral character
// iterates as a two-unit string. Names with no letters at all reach the
// fallback branch, so an emoji-only name is the input that pins THAT read.
//
// 'β€”πŸ˜€' is the shape that makes the fallback read visible to THIS assertion
// rather than only to its own test, and it is the only shape that does both
// of the two things the fallback needs. Reaching the fallback takes a name
// with no letter or digit token at all; splitting a pair there takes the
// second code point to sit across the cut. 'πŸ˜€πŸ˜€' does the first and not the
// second, so with its read reverted it comes back as 'πŸ˜€' β€” a truncation, and
// zero lone surrogates, which is a green class assertion over a regression.
// Measured: fixed gives 'β€”πŸ˜€', reverted gives 'β€”' + '\ud83d'.
const loneSurrogates = (s: string) =>
Array.from(s).filter((ch) => ch.length === 1 && ch >= '\uD800' && ch <= '\uDFFF');
const names = [
'𐐀lpha Beta', 'Deseret 𐐀team', 'Aπ”˜', 'π”˜', 'πŸ˜€πŸ˜€', 'πŸ˜€ Launch', 'β€”πŸ˜€',
'ε₯ΆιΎ™', 'Sprint Review', 'Fable (lead)', '(lead)', 'β€”', '', 'Aβ€”π”˜',
];
for (const name of names) {
expect({ name, lone: loneSurrogates(initialsFor(name)) }).toEqual({ name, lone: [] });
}
});

test('a name with nothing strippable left still reads two whole characters', () => {
// The third UTF-16 read was the fallback: a slice(0, 2) of the raw string.
// An emoji-only name has no letter or digit token, so it lands here, and
// the slice used to take one emoji where two were meant.
expect(initialsFor('πŸ˜€πŸ˜€')).toBe('πŸ˜€πŸ˜€');
expect(initialsFor('πŸ˜€')).toBe('πŸ˜€');
});

test('empty and punctuation-only names fall back rather than throwing', () => {
expect(initialsFor('')).toBe('?');
expect(initialsFor(null)).toBe('?');
Expand Down
20 changes: 17 additions & 3 deletions frontend/src/v2/utils/avatars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,23 @@ export const initialsFor = (name: string | undefined | null): string => {
.map((part) => part.replace(/[^\p{L}\p{N}]/gu, ''))
.filter(Boolean);

if (parts.length === 0) return trimmed.slice(0, 2).toUpperCase() || '?';
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase();
// Every read below is by CODE POINT, not by UTF-16 unit. A JS string indexes
// UTF-16 units, so `parts[0][0]` on a name that starts with an astral
// character β€” Deseret 𐐀, a CJK extension glyph, an emoji β€” returns the high
// surrogate ALONE: not a character, and drawn as a replacement box or a blank
// by fonts that have nothing to render. The same cut lands in a two-character
// slice that falls between the halves of the second code point: "Aπ”˜" is three
// units, so `slice(0, 2)` was "A" plus half of π”˜. Array.from splits on code
// points, so a glyph is either whole or absent. Note the strip above is
// already code-point aware (`\p{L}`/`\p{N}`) β€” the mismatch was in the
// picking, not the matching, which is why it never showed up as mojibake in a
// name that was merely non-Latin.
const firstCodePoints = (part: string, count: number): string =>
Array.from(part).slice(0, count).join('');

if (parts.length === 0) return firstCodePoints(trimmed, 2).toUpperCase() || '?';
if (parts.length === 1) return firstCodePoints(parts[0], 2).toUpperCase();
return `${firstCodePoints(parts[0], 1)}${firstCodePoints(parts[parts.length - 1], 1)}`.toUpperCase();
};

// ── Character tier ──────────────────────────────────────────────────────────
Expand Down
Loading