| ... | @@ -459,11 +459,33 @@ const ProjectLucideIconName = TrimmedNonEmptyString.check( | ... | @@ -459,11 +459,33 @@ const ProjectLucideIconName = TrimmedNonEmptyString.check( |
| 459 | | 459 | |
| 460 | const ProjectEmoji = TrimmedNonEmptyString.check(Schema.isMaxLength(32)); | 460 | const ProjectEmoji = TrimmedNonEmptyString.check(Schema.isMaxLength(32)); |
| 461 | | 461 | |
| 462 | const monogramSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); | 462 | // Hermes (the Android/iOS JS runtime) has no Intl.Segmenter, and this module |
| | 463 | // loads at app startup, so `new Intl.Segmenter()` crashes mobile on launch. |
| | 464 | // Count grapheme clusters with a small approximation instead: a code point |
| | 465 | // continues the current cluster when it is a combining mark, joiner, |
| | 466 | // variation selector, or emoji modifier, or follows a ZWJ. This must stay |
| | 467 | // runtime-independent (not `typeof Intl.Segmenter` feature detection) so the |
| | 468 | // shared contract validates identically on server, web, and mobile. |
| | 469 | const GRAPHEME_CONTINUATION = /[\p{M}\u200c\u200d\ufe0f\u{1F3FB}-\u{1F3FF}]/u; |
| | 470 | |
| | 471 | const countGraphemes = (text: string): number => { |
| | 472 | let clusters = 0; |
| | 473 | let prevJoiner = false; |
| | 474 | for (const char of text) { |
| | 475 | if (clusters > 0 && (prevJoiner || GRAPHEME_CONTINUATION.test(char))) { |
| | 476 | prevJoiner = char === "\u200d"; |
| | 477 | continue; |
| | 478 | } |
| | 479 | clusters += 1; |
| | 480 | prevJoiner = char === "\u200d"; |
| | 481 | } |
| | 482 | return clusters; |
| | 483 | }; |
| | 484 | |
| 463 | export const ProjectMonogramText = TrimmedNonEmptyString.check( | 485 | export const ProjectMonogramText = TrimmedNonEmptyString.check( |
| 464 | Schema.isMaxLength(32), | 486 | Schema.isMaxLength(32), |
| 465 | Schema.isPattern(/^[\p{L}\p{N}][\p{L}\p{N}\p{M}\u200c\u200d]*$/u), | 487 | Schema.isPattern(/^[\p{L}\p{N}][\p{L}\p{N}\p{M}\u200c\u200d]*$/u), |
| 466 | Schema.makeFilter((text) => Array.from(monogramSegmenter.segment(text)).length <= 2), | 488 | Schema.makeFilter((text) => countGraphemes(text) <= 2), |
| 467 | ); | 489 | ); |
| 468 | | 490 | |
| 469 | export const ProjectIconOverride = Schema.Union([ | 491 | export const ProjectIconOverride = Schema.Union([ |