From 37522861ca98dc4d367bf89856b20642a8e566e2 Mon Sep 17 00:00:00 2001 From: Bozo Date: Wed, 5 Aug 2026 17:54:03 -0700 Subject: [PATCH 1/5] fix(desktop): render text reaction fallbacks safely Co-authored-by: Bozo Signed-off-by: Bozo --- .../lib/reactionGlyphPresentation.test.mjs | 31 ++++++++ .../messages/lib/reactionGlyphPresentation.ts | 22 ++++++ .../features/messages/ui/MessageReactions.tsx | 64 ++++++++-------- desktop/src/shared/lib/emojiOnly.ts | 10 ++- desktop/tests/e2e/reaction-names.spec.ts | 74 +++++++++++++++++++ 5 files changed, 166 insertions(+), 35 deletions(-) create mode 100644 desktop/src/features/messages/lib/reactionGlyphPresentation.test.mjs create mode 100644 desktop/src/features/messages/lib/reactionGlyphPresentation.ts diff --git a/desktop/src/features/messages/lib/reactionGlyphPresentation.test.mjs b/desktop/src/features/messages/lib/reactionGlyphPresentation.test.mjs new file mode 100644 index 0000000000..5aa0b62fd4 --- /dev/null +++ b/desktop/src/features/messages/lib/reactionGlyphPresentation.test.mjs @@ -0,0 +1,31 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { reactionGlyphPresentation } from "./reactionGlyphPresentation.ts"; + +test("uses compact layout only for one native emoji cluster", () => { + for (const emoji of ["πŸ˜€", "❀️", "πŸ‘πŸ½", "πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦", "πŸ‡ΊπŸ‡Έ"]) { + assert.deepEqual(reactionGlyphPresentation(emoji), { + kind: "native", + text: emoji, + }); + } + + for (const text of ["a", "ship it", "πŸ˜€πŸ˜€"]) { + assert.deepEqual(reactionGlyphPresentation(text), { kind: "text", text }); + } +}); + +test("only unwraps valid outer shortcode delimiters for text fallbacks", () => { + assert.deepEqual(reactionGlyphPresentation(":bozo:"), { + kind: "text", + text: "bozo", + }); + assert.deepEqual(reactionGlyphPresentation(":party_parrot:"), { + kind: "text", + text: "party_parrot", + }); + for (const text of [":ship it:", "::", ":bozo", "bozo:"]) { + assert.deepEqual(reactionGlyphPresentation(text), { kind: "text", text }); + } +}); diff --git a/desktop/src/features/messages/lib/reactionGlyphPresentation.ts b/desktop/src/features/messages/lib/reactionGlyphPresentation.ts new file mode 100644 index 0000000000..dc71215c4e --- /dev/null +++ b/desktop/src/features/messages/lib/reactionGlyphPresentation.ts @@ -0,0 +1,22 @@ +import { isSingleNativeEmoji } from "@/shared/lib/emojiOnly"; + +const WRAPPED_SHORTCODE = /^:([a-z0-9_-]+):$/i; + +export type ReactionGlyphPresentation = + | { kind: "native"; text: string } + | { kind: "text"; text: string }; + +/** + * Chooses the no-image reaction fallback. A native emoji gets the compact glyph + * treatment; every other relay-valid reaction value gets text layout instead. + */ +export function reactionGlyphPresentation( + emoji: string, +): ReactionGlyphPresentation { + if (isSingleNativeEmoji(emoji)) { + return { kind: "native", text: emoji }; + } + + const shortcode = emoji.match(WRAPPED_SHORTCODE)?.[1]; + return { kind: "text", text: shortcode ?? emoji }; +} diff --git a/desktop/src/features/messages/ui/MessageReactions.tsx b/desktop/src/features/messages/ui/MessageReactions.tsx index d4bec8db6c..defa217966 100644 --- a/desktop/src/features/messages/ui/MessageReactions.tsx +++ b/desktop/src/features/messages/ui/MessageReactions.tsx @@ -3,6 +3,7 @@ import * as React from "react"; import { EmojiPicker } from "@/features/custom-emoji/ui/EmojiPicker"; import type { TimelineReaction } from "@/features/messages/types"; +import { reactionGlyphPresentation } from "@/features/messages/lib/reactionGlyphPresentation"; import { recordQuickReactionEmoji } from "@/features/messages/ui/useQuickReactionEmojis"; import { cn } from "@/shared/lib/cn"; import { emojiDisplayName } from "@/shared/lib/emojiName"; @@ -19,9 +20,11 @@ const REACTION_PILL_BASE_CLASSES = "inline-flex h-7 items-center rounded-full border text-xs font-medium leading-none transition-colors"; const REACTION_CUSTOM_GLYPH_CLASSES = "h-3.5 w-3.5"; const REACTION_NATIVE_GLYPH_CLASSES = "h-3 w-3 text-xs"; +const REACTION_TEXT_GLYPH_CLASSES = "max-w-32 shrink-0 truncate text-xs"; const REACTION_COUNT_CLASSES = "text-muted-foreground"; const REACTION_NATIVE_COUNT_CLASSES = "text-muted-foreground translate-y-[0.5px]"; +const REACTION_TEXT_COUNT_CLASSES = "text-muted-foreground shrink-0"; const REACTION_PILL_HOVER_CLASSES = "hover:bg-primary/10 hover:text-foreground focus-visible:bg-primary/10 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"; const BADGE_BURST_STABLE_FRAMES = 2; @@ -67,9 +70,11 @@ function isSameBadgeBurstRect( function EmojiGlyph({ reaction, className, + text, }: { reaction: TimelineReaction; className?: string; + text?: string; }) { const displayName = emojiDisplayName(reaction.emoji); if (reaction.emojiUrl) { @@ -94,7 +99,7 @@ function EmojiGlyph({ )} title={displayName} > - {reaction.emoji} + {text ?? reaction.emoji} ); } @@ -430,6 +435,29 @@ function ReactionPill({ }; const displayName = emojiDisplayName(reaction.emoji); + const presentation = reaction.emojiUrl + ? null + : reactionGlyphPresentation(reaction.emoji); + const glyphClasses = reaction.emojiUrl + ? REACTION_CUSTOM_GLYPH_CLASSES + : presentation?.kind === "native" + ? REACTION_NATIVE_GLYPH_CLASSES + : REACTION_TEXT_GLYPH_CLASSES; + const countClasses = reaction.emojiUrl + ? REACTION_COUNT_CLASSES + : presentation?.kind === "native" + ? REACTION_NATIVE_COUNT_CLASSES + : REACTION_TEXT_COUNT_CLASSES; + const pillContents = ( + <> + + + + ); if (reaction.users.length === 0) { return ( @@ -443,22 +471,7 @@ function ReactionPill({ ref={setPillRef} type="button" > - - + {pillContents} ); } @@ -484,22 +497,7 @@ function ReactionPill({ ref={setPillRef} type="button" > - - + {pillContents} diff --git a/desktop/src/shared/lib/emojiOnly.ts b/desktop/src/shared/lib/emojiOnly.ts index 5ab473f17b..8f6aba5a9a 100644 --- a/desktop/src/shared/lib/emojiOnly.ts +++ b/desktop/src/shared/lib/emojiOnly.ts @@ -26,14 +26,14 @@ function buildNativeEmojiSet(): Set { return set; } -function isNativeEmojiCluster(cluster: string): boolean { +export function isNativeEmojiCluster(cluster: string): boolean { nativeEmojiSet ??= buildNativeEmojiSet(); return ( nativeEmojiSet.has(cluster) || /\p{Extended_Pictographic}/u.test(cluster) ); } -function readGrapheme(text: string, start: number): string { +export function readGrapheme(text: string, start: number): string { const firstCodePoint = text.codePointAt(start); if (firstCodePoint === undefined) { return ""; @@ -136,3 +136,9 @@ export function isEmojiOnlyMessage( return sawEmoji; } +/** True only when the entire value is one native emoji grapheme cluster. */ +export function isSingleNativeEmoji(value: string): boolean { + if (!value) return false; + const cluster = readGrapheme(value, 0); + return cluster === value && isNativeEmojiCluster(cluster); +} diff --git a/desktop/tests/e2e/reaction-names.spec.ts b/desktop/tests/e2e/reaction-names.spec.ts index 028bb1645d..d7b5ec9d7b 100644 --- a/desktop/tests/e2e/reaction-names.spec.ts +++ b/desktop/tests/e2e/reaction-names.spec.ts @@ -55,6 +55,53 @@ async function capturePopover( }); } +async function emitReaction( + page: import("@playwright/test").Page, + content: string, + pubkey: string, +): Promise { + await page.evaluate( + ({ content, pubkey, targetId }) => { + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "general", + content, + extraTags: [["e", targetId]], + kind: 7, + pubkey, + }); + }, + { content, pubkey, targetId: REACTION_TARGET_EVENT_ID }, + ); +} + +async function expectFallbackPill( + page: import("@playwright/test").Page, + reaction: string, + visibleText: string, +): Promise { + const pill = reactionTargetRow(page).getByRole("button", { + name: `Toggle ${reaction} reaction`, + }); + await expect(pill).toBeVisible(); + await expect(pill).toHaveAttribute("title", reaction); + await expect(pill.locator("img")).toHaveCount(0); + + const glyph = pill.locator("span[title]"); + await expect(glyph).toHaveText(visibleText); + const [pillRect, glyphRect, countRect] = await Promise.all([ + pill.boundingBox(), + glyph.boundingBox(), + pill.locator(".buzz-animated-count").boundingBox(), + ]); + expect(pillRect && glyphRect && countRect).toBeTruthy(); + if (!pillRect || !glyphRect || !countRect) return; + expect(glyphRect.x + glyphRect.width).toBeLessThanOrEqual(countRect.x); + expect( + glyphRect.x >= pillRect.x && + countRect.x + countRect.width <= pillRect.x + pillRect.width, + ).toBeTruthy(); +} + test.beforeEach(async ({ page }) => { await installMockBridge(page, { searchProfiles: [ @@ -168,3 +215,30 @@ test("maximum-length reaction name wraps inside a fixed-width popover", async ({ await waitForImage(avatar); await capturePopover(page, popover, "max-length-after.png"); }); + +test("literal fallback reactions do not overlap their counts", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await page.waitForFunction( + () => + window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName: "general", + kind: 7, + }) === true, + ); + + for (const reaction of [":bozo:", "ship it"]) { + await emitReaction(page, reaction, BOB_PUBKEY); + await emitReaction(page, reaction, "c".repeat(64)); + } + + await expectFallbackPill(page, ":bozo:", "bozo"); + await expectFallbackPill(page, "ship it", "ship it"); + await reactionTargetRow(page).screenshot({ + animations: "disabled", + path: "test-results/reaction-text-fallback.png", + }); +}); From 23c5c240dfafe6196ec0658f1bfa56e72de140ef Mon Sep 17 00:00:00 2001 From: Bozo Date: Wed, 5 Aug 2026 18:03:02 -0700 Subject: [PATCH 2/5] test(desktop): cover long text reactions Co-authored-by: Bozo Signed-off-by: Bozo --- desktop/tests/e2e/reaction-names.spec.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/desktop/tests/e2e/reaction-names.spec.ts b/desktop/tests/e2e/reaction-names.spec.ts index d7b5ec9d7b..1a2b67f73d 100644 --- a/desktop/tests/e2e/reaction-names.spec.ts +++ b/desktop/tests/e2e/reaction-names.spec.ts @@ -15,6 +15,8 @@ const MAX_REACTION_AVATAR_URL = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"%3E%3Crect width="16" height="16" rx="4" fill="%23e5484d"/%3E%3C/svg%3E'; const SHORT_REACTION_AVATAR_URL = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"%3E%3Crect width="16" height="16" rx="4" fill="%2300a36c"/%3E%3C/svg%3E'; +const LONG_LITERAL_REACTION = + "this-is-a-deliberately-long-literal-reaction-that-must-truncate-without-moving-or-overlapping-the-count"; const SCREENSHOT_DIR = process.env.REACTION_POPOVER_SCREENSHOT_DIR ?? "test-results/reaction-popover-screenshots"; @@ -230,13 +232,26 @@ test("literal fallback reactions do not overlap their counts", async ({ }) === true, ); - for (const reaction of [":bozo:", "ship it"]) { + for (const reaction of [":bozo:", "ship it", LONG_LITERAL_REACTION]) { await emitReaction(page, reaction, BOB_PUBKEY); await emitReaction(page, reaction, "c".repeat(64)); } await expectFallbackPill(page, ":bozo:", "bozo"); await expectFallbackPill(page, "ship it", "ship it"); + const longPill = reactionTargetRow(page).getByRole("button", { + name: `Toggle ${LONG_LITERAL_REACTION} reaction`, + }); + const longGlyph = longPill.locator("span[title]"); + await expectFallbackPill(page, LONG_LITERAL_REACTION, LONG_LITERAL_REACTION); + await expect(longGlyph).toHaveCSS("max-width", "128px"); + await expect + .poll(() => + longGlyph.evaluate( + (element) => element.scrollWidth > element.clientWidth, + ), + ) + .toBe(true); await reactionTargetRow(page).screenshot({ animations: "disabled", path: "test-results/reaction-text-fallback.png", From 382155ac650c3d0030ca1fe1667adcbfb2456ddb Mon Sep 17 00:00:00 2001 From: Bozo Date: Wed, 5 Aug 2026 18:08:54 -0700 Subject: [PATCH 3/5] fix(desktop): left-align text reaction fallbacks Co-authored-by: Bozo Signed-off-by: Bozo --- desktop/src/features/messages/ui/MessageReactions.tsx | 3 ++- desktop/tests/e2e/reaction-names.spec.ts | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/desktop/src/features/messages/ui/MessageReactions.tsx b/desktop/src/features/messages/ui/MessageReactions.tsx index defa217966..7d800599e5 100644 --- a/desktop/src/features/messages/ui/MessageReactions.tsx +++ b/desktop/src/features/messages/ui/MessageReactions.tsx @@ -20,7 +20,8 @@ const REACTION_PILL_BASE_CLASSES = "inline-flex h-7 items-center rounded-full border text-xs font-medium leading-none transition-colors"; const REACTION_CUSTOM_GLYPH_CLASSES = "h-3.5 w-3.5"; const REACTION_NATIVE_GLYPH_CLASSES = "h-3 w-3 text-xs"; -const REACTION_TEXT_GLYPH_CLASSES = "max-w-32 shrink-0 truncate text-xs"; +const REACTION_TEXT_GLYPH_CLASSES = + "max-w-32 shrink-0 justify-start truncate text-left text-xs"; const REACTION_COUNT_CLASSES = "text-muted-foreground"; const REACTION_NATIVE_COUNT_CLASSES = "text-muted-foreground translate-y-[0.5px]"; diff --git a/desktop/tests/e2e/reaction-names.spec.ts b/desktop/tests/e2e/reaction-names.spec.ts index 1a2b67f73d..b5ff75d0e2 100644 --- a/desktop/tests/e2e/reaction-names.spec.ts +++ b/desktop/tests/e2e/reaction-names.spec.ts @@ -245,6 +245,7 @@ test("literal fallback reactions do not overlap their counts", async ({ const longGlyph = longPill.locator("span[title]"); await expectFallbackPill(page, LONG_LITERAL_REACTION, LONG_LITERAL_REACTION); await expect(longGlyph).toHaveCSS("max-width", "128px"); + await expect(longGlyph).toHaveCSS("text-align", "left"); await expect .poll(() => longGlyph.evaluate( From e0515862188ad62175b80d9dbdd020027a7257e9 Mon Sep 17 00:00:00 2001 From: SmartyP Date: Wed, 5 Aug 2026 19:52:13 -0700 Subject: [PATCH 4/5] fix(desktop): bound reaction popover fallbacks Co-authored-by: SmartyP Co-authored-by: Smarty Signed-off-by: SmartyP --- .../lib/reactionGlyphPresentation.test.mjs | 13 +++- .../features/messages/ui/MessageReactions.tsx | 19 ++++- desktop/src/shared/lib/emojiOnly.ts | 12 ++- desktop/tests/e2e/reaction-names.spec.ts | 73 ++++++++++++++++++- 4 files changed, 108 insertions(+), 9 deletions(-) diff --git a/desktop/src/features/messages/lib/reactionGlyphPresentation.test.mjs b/desktop/src/features/messages/lib/reactionGlyphPresentation.test.mjs index 5aa0b62fd4..a6e3ae096a 100644 --- a/desktop/src/features/messages/lib/reactionGlyphPresentation.test.mjs +++ b/desktop/src/features/messages/lib/reactionGlyphPresentation.test.mjs @@ -11,21 +11,26 @@ test("uses compact layout only for one native emoji cluster", () => { }); } - for (const text of ["a", "ship it", "πŸ˜€πŸ˜€"]) { + for (const text of ["a", "ship it", "πŸ˜€πŸ˜€", "πŸ‘©β€a"]) { assert.deepEqual(reactionGlyphPresentation(text), { kind: "text", text }); } }); test("only unwraps valid outer shortcode delimiters for text fallbacks", () => { - assert.deepEqual(reactionGlyphPresentation(":bozo:"), { + assert.deepEqual(reactionGlyphPresentation(":missing_reaction:"), { kind: "text", - text: "bozo", + text: "missing_reaction", }); assert.deepEqual(reactionGlyphPresentation(":party_parrot:"), { kind: "text", text: "party_parrot", }); - for (const text of [":ship it:", "::", ":bozo", "bozo:"]) { + for (const text of [ + ":ship it:", + "::", + ":missing_reaction", + "missing_reaction:", + ]) { assert.deepEqual(reactionGlyphPresentation(text), { kind: "text", text }); } }); diff --git a/desktop/src/features/messages/ui/MessageReactions.tsx b/desktop/src/features/messages/ui/MessageReactions.tsx index 7d800599e5..0cf2c650ff 100644 --- a/desktop/src/features/messages/ui/MessageReactions.tsx +++ b/desktop/src/features/messages/ui/MessageReactions.tsx @@ -22,6 +22,9 @@ const REACTION_CUSTOM_GLYPH_CLASSES = "h-3.5 w-3.5"; const REACTION_NATIVE_GLYPH_CLASSES = "h-3 w-3 text-xs"; const REACTION_TEXT_GLYPH_CLASSES = "max-w-32 shrink-0 justify-start truncate text-left text-xs"; +const REACTION_POPOVER_NATIVE_GLYPH_CLASSES = "text-4xl"; +const REACTION_POPOVER_TEXT_GLYPH_CLASSES = + "w-full min-w-0 justify-start truncate text-left text-sm leading-snug"; const REACTION_COUNT_CLASSES = "text-muted-foreground"; const REACTION_NATIVE_COUNT_CLASSES = "text-muted-foreground translate-y-[0.5px]"; @@ -120,13 +123,25 @@ function formatReactionUsers(reaction: TimelineReaction): string { function ReactionPopoverContent({ reaction }: { reaction: TimelineReaction }) { const displayName = emojiDisplayName(reaction.emoji); const userText = formatReactionUsers(reaction); + const presentation = reaction.emojiUrl + ? null + : reactionGlyphPresentation(reaction.emoji); + const glyphClasses = reaction.emojiUrl + ? "h-12 w-12" + : presentation?.kind === "native" + ? REACTION_POPOVER_NATIVE_GLYPH_CLASSES + : REACTION_POPOVER_TEXT_GLYPH_CLASSES; return (
-
+
diff --git a/desktop/src/shared/lib/emojiOnly.ts b/desktop/src/shared/lib/emojiOnly.ts index 8f6aba5a9a..a7a4f9f354 100644 --- a/desktop/src/shared/lib/emojiOnly.ts +++ b/desktop/src/shared/lib/emojiOnly.ts @@ -140,5 +140,15 @@ export function isEmojiOnlyMessage( export function isSingleNativeEmoji(value: string): boolean { if (!value) return false; const cluster = readGrapheme(value, 0); - return cluster === value && isNativeEmojiCluster(cluster); + if (cluster !== value) return false; + + nativeEmojiSet ??= buildNativeEmojiSet(); + if (nativeEmojiSet.has(cluster)) return true; + + // Keep future pictographs working without accepting arbitrary text that a + // malformed ZWJ sequence caused readGrapheme() to consume (for example, + // `πŸ‘©β€a`). Every ZWJ component must itself be pictographic. + return /^\p{Extended_Pictographic}(?:\ufe0f|[\u{1f3fb}-\u{1f3ff}])?(?:\u200d\p{Extended_Pictographic}(?:\ufe0f|[\u{1f3fb}-\u{1f3ff}])?)*$/u.test( + cluster, + ); } diff --git a/desktop/tests/e2e/reaction-names.spec.ts b/desktop/tests/e2e/reaction-names.spec.ts index b5ff75d0e2..9670c0690e 100644 --- a/desktop/tests/e2e/reaction-names.spec.ts +++ b/desktop/tests/e2e/reaction-names.spec.ts @@ -15,6 +15,7 @@ const MAX_REACTION_AVATAR_URL = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"%3E%3Crect width="16" height="16" rx="4" fill="%23e5484d"/%3E%3C/svg%3E'; const SHORT_REACTION_AVATAR_URL = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"%3E%3Crect width="16" height="16" rx="4" fill="%2300a36c"/%3E%3C/svg%3E'; +const UNRESOLVED_SHORTCODE = ":missing_reaction:"; const LONG_LITERAL_REACTION = "this-is-a-deliberately-long-literal-reaction-that-must-truncate-without-moving-or-overlapping-the-count"; const SCREENSHOT_DIR = @@ -104,6 +105,50 @@ async function expectFallbackPill( ).toBeTruthy(); } +async function expectFallbackPopover( + page: import("@playwright/test").Page, + reaction: string, + visibleText: string, + screenshotName: string, +): Promise { + const pill = reactionTargetRow(page).getByRole("button", { + name: `Toggle ${reaction} reaction`, + }); + await pill.hover(); + + const popover = page.locator("[data-radix-popper-content-wrapper]").filter({ + has: page + .getByTestId("reaction-popover-name") + .filter({ hasText: reaction }), + }); + await expect(popover).toBeVisible(); + await expect(popover.getByTestId("reaction-popover-name")).toHaveText( + reaction, + ); + + const container = popover.getByTestId("reaction-popover-glyph-container"); + const glyph = container.locator("span[title]"); + await expect(glyph).toHaveText(visibleText); + await expect(glyph).toHaveAttribute("title", reaction); + const [containerRect, glyphRect] = await Promise.all([ + container.boundingBox(), + glyph.boundingBox(), + ]); + expect(containerRect && glyphRect).toBeTruthy(); + if (containerRect && glyphRect) { + expect(glyphRect.x).toBeGreaterThanOrEqual(containerRect.x); + expect(glyphRect.x + glyphRect.width).toBeLessThanOrEqual( + containerRect.x + containerRect.width, + ); + expect(glyphRect.y).toBeGreaterThanOrEqual(containerRect.y); + expect(glyphRect.y + glyphRect.height).toBeLessThanOrEqual( + containerRect.y + containerRect.height, + ); + } + await expect(container).toHaveCSS("overflow", "hidden"); + await capturePopover(page, popover, screenshotName); +} + test.beforeEach(async ({ page }) => { await installMockBridge(page, { searchProfiles: [ @@ -232,12 +277,16 @@ test("literal fallback reactions do not overlap their counts", async ({ }) === true, ); - for (const reaction of [":bozo:", "ship it", LONG_LITERAL_REACTION]) { + for (const reaction of [ + UNRESOLVED_SHORTCODE, + "ship it", + LONG_LITERAL_REACTION, + ]) { await emitReaction(page, reaction, BOB_PUBKEY); await emitReaction(page, reaction, "c".repeat(64)); } - await expectFallbackPill(page, ":bozo:", "bozo"); + await expectFallbackPill(page, UNRESOLVED_SHORTCODE, "missing_reaction"); await expectFallbackPill(page, "ship it", "ship it"); const longPill = reactionTargetRow(page).getByRole("button", { name: `Toggle ${LONG_LITERAL_REACTION} reaction`, @@ -253,6 +302,26 @@ test("literal fallback reactions do not overlap their counts", async ({ ), ) .toBe(true); + + await expectFallbackPopover( + page, + UNRESOLVED_SHORTCODE, + "missing_reaction", + "unresolved-shortcode-after.png", + ); + await expectFallbackPopover( + page, + "ship it", + "ship it", + "literal-text-after.png", + ); + await expectFallbackPopover( + page, + LONG_LITERAL_REACTION, + LONG_LITERAL_REACTION, + "long-literal-after.png", + ); + await reactionTargetRow(page).screenshot({ animations: "disabled", path: "test-results/reaction-text-fallback.png", From 1efb2ca1f2a7c29a8080e0efdcf15e64f6df3134 Mon Sep 17 00:00:00 2001 From: SmartyP Date: Fri, 7 Aug 2026 13:21:41 -0700 Subject: [PATCH 5/5] fix(desktop): keep emoji helpers private Co-authored-by: AaronGoldsmith Co-authored-by: Smarty Signed-off-by: AaronGoldsmith --- desktop/src/shared/lib/emojiOnly.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/desktop/src/shared/lib/emojiOnly.ts b/desktop/src/shared/lib/emojiOnly.ts index a7a4f9f354..64ca002a03 100644 --- a/desktop/src/shared/lib/emojiOnly.ts +++ b/desktop/src/shared/lib/emojiOnly.ts @@ -26,14 +26,14 @@ function buildNativeEmojiSet(): Set { return set; } -export function isNativeEmojiCluster(cluster: string): boolean { +function isNativeEmojiCluster(cluster: string): boolean { nativeEmojiSet ??= buildNativeEmojiSet(); return ( nativeEmojiSet.has(cluster) || /\p{Extended_Pictographic}/u.test(cluster) ); } -export function readGrapheme(text: string, start: number): string { +function readGrapheme(text: string, start: number): string { const firstCodePoint = text.codePointAt(start); if (firstCodePoint === undefined) { return "";