From a4902548d311b542fd6becb81d1a94895fe68011 Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Tue, 21 Apr 2026 13:02:36 +0200 Subject: [PATCH] web: fix emoji pattern render in short names (#760) * web: fix emoji pattern render in short names * web: address review comments --- .../application/helpers/node_helpers.rb | 37 ++++++++---- web/spec/helpers/node_helpers_spec.rb | 56 +++++++++++++++++++ 2 files changed, 81 insertions(+), 12 deletions(-) diff --git a/web/lib/potato_mesh/application/helpers/node_helpers.rb b/web/lib/potato_mesh/application/helpers/node_helpers.rb index 6c1b644..8616297 100644 --- a/web/lib/potato_mesh/application/helpers/node_helpers.rb +++ b/web/lib/potato_mesh/application/helpers/node_helpers.rb @@ -71,6 +71,13 @@ module PotatoMesh # Symbols and Dingbats (U+2600–U+27BF), and Miscellaneous Symbols and # Arrows (U+2B00–U+2BFF). # + # Matching is intentionally single-codepoint: callers iterate grapheme + # clusters first and then test each cluster against this pattern, so + # multi-codepoint emoji (country flags like 🇩🇪 = 🇩 + 🇪, ZWJ family + # sequences like 👨‍👩‍👧, skin-tone modifiers like 👍🏽, the rainbow flag + # 🏳️‍🌈) come through intact instead of being shredded into their + # component codepoints. + # # @type [Regexp] MESHCORE_COMPANION_EMOJI_PATTERN = /[\u{1F000}-\u{1FFFF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}]/u @@ -79,16 +86,20 @@ module PotatoMesh # richer, human-readable variant for the API layer without touching the DB. # # Algorithm (applied in priority order): - # 1. If the long name contains an emoji character (see - # +MESHCORE_COMPANION_EMOJI_PATTERN+), use the first emoji embedded in a - # 4-column display slot: ``" E "`` (one leading space, emoji, one trailing - # space). Emoji are rendered double-width in monospace fonts, so one leading - # space keeps the badge at four visual columns. - # 2. If the long name contains two or more whitespace-separated words, use - # the capitalised first letters of the first two words: ``" XY "``. - # 3. Return +nil+ — single-word names fall back to the raw short name stored - # in the database (typically the first two bytes of the node ID). A single - # initial looked poor and carried no more information than the raw value. + # 1. If the long name contains an emoji grapheme cluster (anchored by + # +MESHCORE_COMPANION_EMOJI_PATTERN+), use that whole cluster in a + # 4-column display slot: ``" E "`` (one leading space, emoji, one + # trailing space). Emoji are rendered double-width in monospace fonts, + # so one leading space keeps the badge at four visual columns. + # Iterating grapheme clusters (rather than raw codepoints) preserves + # multi-codepoint sequences such as country flags 🇩🇪, ZWJ families + # 👨‍👩‍👧, and skin-tone-modified thumbs 👍🏽. + # 2. If the long name contains two or more whitespace-separated words, + # use the capitalised first letters of the first two words: ``" XY "``. + # 3. Return +nil+ — single-word names fall back to the raw short name + # stored in the database (typically the first two bytes of the node + # ID). A single initial looked poor and carried no more information + # than the raw value. # # @param long_name [String, nil] long name stored on the node. # @return [String, nil] derived display short name or +nil+. @@ -96,10 +107,12 @@ module PotatoMesh name = string_or_nil(long_name) return nil unless name - emoji = name.scan(MESHCORE_COMPANION_EMOJI_PATTERN).first + emoji_cluster = name.each_grapheme_cluster.find do |cluster| + cluster.match?(MESHCORE_COMPANION_EMOJI_PATTERN) + end # Wide emoji occupies two display columns, so use one leading space and # one trailing space to stay within the four-column badge width. - return " #{emoji} " if emoji + return " #{emoji_cluster} " if emoji_cluster words = name.strip.split(/\s+/).reject(&:empty?) return nil if words.empty? diff --git a/web/spec/helpers/node_helpers_spec.rb b/web/spec/helpers/node_helpers_spec.rb index bbdfcfa..fd41683 100644 --- a/web/spec/helpers/node_helpers_spec.rb +++ b/web/spec/helpers/node_helpers_spec.rb @@ -206,5 +206,61 @@ RSpec.describe PotatoMesh::App::Helpers do it "returns nil for a single-word name with no emoji (falls back to raw DB short name)" do expect(helper.meshcore_companion_display_short_name("Zigzag")).to be_nil end + + # Multi-codepoint emoji coverage — see the in-file comment on + # +MESHCORE_COMPANION_EMOJI_PATTERN+ for the grapheme-cluster rationale. + # Each of these cases shredded into its component codepoints before the + # fix and would otherwise render as a stray regional-indicator letter, a + # lone family member, or an unadorned thumbs-up. + + it "preserves a country-flag grapheme cluster (🇩🇪) instead of emitting just the first regional indicator" do + name = "sidux.user \u{1F1E9}\u{1F1EA}" + expect( + helper.meshcore_companion_display_short_name(name), + ).to eq(" \u{1F1E9}\u{1F1EA} ") + end + + it "preserves a ZWJ family sequence (👨‍👩‍👧) as one cluster" do + family = "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}" + name = "Home #{family}" + expect( + helper.meshcore_companion_display_short_name(name), + ).to eq(" #{family} ") + end + + it "preserves a skin-tone-modified emoji (👍🏽) as one cluster" do + thumb = "\u{1F44D}\u{1F3FD}" + name = "Ack #{thumb}" + expect( + helper.meshcore_companion_display_short_name(name), + ).to eq(" #{thumb} ") + end + + it "preserves the rainbow-flag ZWJ sequence (🏳️‍🌈) as one cluster" do + rainbow = "\u{1F3F3}\u{FE0F}\u{200D}\u{1F308}" + name = "Pride #{rainbow}" + expect( + helper.meshcore_companion_display_short_name(name), + ).to eq(" #{rainbow} ") + end + + it "picks the first emoji cluster when a flag is followed back-to-back by a plain emoji" do + # No separator between clusters — proves ``find`` stops at the flag's + # grapheme cluster rather than splitting on a subsequent codepoint that + # also falls in the pattern range. + name = "\u{1F1E9}\u{1F1EA}\u{1F600}" + expect( + helper.meshcore_companion_display_short_name(name), + ).to eq(" \u{1F1E9}\u{1F1EA} ") + end + + it "returns the cluster when the long name is only an emoji and nothing else" do + # Exercises the branch where the first cluster at index 0 matches and + # there is no surrounding ASCII to drive the initials fallback. + name = "\u{1F1E9}\u{1F1EA}" + expect( + helper.meshcore_companion_display_short_name(name), + ).to eq(" \u{1F1E9}\u{1F1EA} ") + end end end