Fix emoji message rendering (#938)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Wes
2026-06-09 20:52:37 -07:00
committed by GitHub
co-authored by Pinky
parent ba2fdbf697
commit e08937cdde
6 changed files with 244 additions and 14 deletions
@@ -195,8 +195,7 @@ export const CustomEmojiNode = Node.create<CustomEmojiNodeOptions>({
"data-shortcode": shortcode,
draggable: "false",
// Match the message-view <img data-custom-emoji> sizing exactly.
class:
"mx-px inline-block h-[1.25em] w-auto max-w-none align-text-bottom",
class: "mx-px inline-block h-[1.25em] w-auto max-w-none align-middle",
}),
];
},
@@ -11,6 +11,7 @@ import { UserAvatar } from "@/shared/ui/UserAvatar";
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
import { parseImetaTags } from "@/features/messages/lib/parseImeta";
import { customEmojiFromTags } from "@/shared/api/customEmoji";
import { isEmojiOnlyMessage } from "@/shared/lib/emojiOnly";
import {
resolveMentionNames,
resolveMentionPubkeysByName,
@@ -95,6 +96,10 @@ export const MessageRow = React.memo(
() => (message.tags ? customEmojiFromTags(message.tags) : undefined),
[message.tags],
);
const emojiOnly = React.useMemo(
() => isEmojiOnlyMessage(message.body, customEmoji),
[message.body, customEmoji],
);
const { channels } = useChannelNavigation();
const channelNames = React.useMemo(
@@ -151,7 +156,11 @@ export const MessageRow = React.memo(
return (
<Markdown
channelNames={channelNames}
className="max-w-full text-[15px] leading-6"
className={cn(
"max-w-full text-[15px] leading-6",
emojiOnly &&
"text-4xl leading-tight [&_img[data-custom-emoji]]:h-[1.45em] [&_img[data-custom-emoji]]:align-middle [&_button:has(img[data-custom-emoji])]:align-middle",
)}
content={message.body}
customEmoji={customEmoji}
imetaByUrl={imetaByUrl}
+36
View File
@@ -0,0 +1,36 @@
import assert from "node:assert/strict";
import test from "node:test";
import { isEmojiOnlyMessage } from "./emojiOnly.ts";
const CUSTOM_EMOJI = [
{ shortcode: "sprout", url: "https://relay/sprout.png" },
{ shortcode: "party_parrot", url: "https://relay/parrot.gif" },
];
test("detects unicode emoji-only messages", () => {
assert.equal(isEmojiOnlyMessage("😀", CUSTOM_EMOJI), true);
assert.equal(isEmojiOnlyMessage("😀 👍🏽\n❤️", CUSTOM_EMOJI), true);
assert.equal(isEmojiOnlyMessage("🏳️‍🌈 👨‍👩‍👧‍👦", CUSTOM_EMOJI), true);
});
test("detects known custom emoji-only shortcode messages", () => {
assert.equal(isEmojiOnlyMessage(":sprout:", CUSTOM_EMOJI), true);
assert.equal(
isEmojiOnlyMessage(":sprout: :party_parrot:", CUSTOM_EMOJI),
true,
);
assert.equal(isEmojiOnlyMessage(":Sprout:", CUSTOM_EMOJI), true);
});
test("allows mixed unicode and custom emoji", () => {
assert.equal(isEmojiOnlyMessage("😀 :sprout: ❤️", CUSTOM_EMOJI), true);
});
test("rejects prose, markdown, and unknown shortcodes", () => {
assert.equal(isEmojiOnlyMessage("hello 😀", CUSTOM_EMOJI), false);
assert.equal(isEmojiOnlyMessage("😀!", CUSTOM_EMOJI), false);
assert.equal(isEmojiOnlyMessage("**😀**", CUSTOM_EMOJI), false);
assert.equal(isEmojiOnlyMessage(":unknown:", CUSTOM_EMOJI), false);
assert.equal(isEmojiOnlyMessage("", CUSTOM_EMOJI), false);
});
+138
View File
@@ -0,0 +1,138 @@
import data from "@emoji-mart/data/sets/15/native.json" with { type: "json" };
import type { CustomEmoji } from "./remarkCustomEmoji";
type EmojiMartData = {
emojis?: Record<
string,
{
skins?: Array<{ native?: string }>;
}
>;
};
let nativeEmojiSet: Set<string> | null = null;
function buildNativeEmojiSet(): Set<string> {
const set = new Set<string>();
const emojis = (data as EmojiMartData).emojis ?? {};
for (const emoji of Object.values(emojis)) {
for (const skin of emoji.skins ?? []) {
if (skin.native) {
set.add(skin.native);
}
}
}
return set;
}
function isNativeEmojiCluster(cluster: string): boolean {
nativeEmojiSet ??= buildNativeEmojiSet();
return (
nativeEmojiSet.has(cluster) || /\p{Extended_Pictographic}/u.test(cluster)
);
}
function readGrapheme(text: string, start: number): string {
const firstCodePoint = text.codePointAt(start);
if (firstCodePoint === undefined) {
return "";
}
let end = start + (firstCodePoint > 0xffff ? 2 : 1);
const nextCodePoint = text.codePointAt(end);
if (
isRegionalIndicator(firstCodePoint) &&
nextCodePoint !== undefined &&
isRegionalIndicator(nextCodePoint)
) {
return text.slice(start, end + (nextCodePoint > 0xffff ? 2 : 1));
}
while (end < text.length) {
const codePoint = text.codePointAt(end);
if (codePoint === undefined) {
break;
}
if (
codePoint === 0xfe0f ||
codePoint === 0x200d ||
codePoint === 0x20e3 ||
isEmojiModifier(codePoint) ||
isEmojiTag(codePoint)
) {
end += codePoint > 0xffff ? 2 : 1;
continue;
}
if (text.codePointAt(end - 1) === 0x200d) {
end += codePoint > 0xffff ? 2 : 1;
continue;
}
break;
}
return text.slice(start, end);
}
function isEmojiModifier(codePoint: number): boolean {
return codePoint >= 0x1f3fb && codePoint <= 0x1f3ff;
}
function isRegionalIndicator(codePoint: number): boolean {
return codePoint >= 0x1f1e6 && codePoint <= 0x1f1ff;
}
function isEmojiTag(codePoint: number): boolean {
return codePoint >= 0xe0020 && codePoint <= 0xe007f;
}
export function isEmojiOnlyMessage(
content: string,
customEmoji: CustomEmoji[] = [],
): boolean {
const trimmed = content.trim();
if (!trimmed) {
return false;
}
const shortcodeSet = new Set(
customEmoji.map((emoji) => emoji.shortcode.toLowerCase()),
);
let sawEmoji = false;
for (let index = 0; index < trimmed.length; ) {
const char = trimmed[index];
if (/\s/u.test(char)) {
index += char.length;
continue;
}
if (char === ":") {
const end = trimmed.indexOf(":", index + 1);
if (end > index + 1) {
const shortcode = trimmed.slice(index + 1, end).toLowerCase();
if (shortcodeSet.has(shortcode)) {
sawEmoji = true;
index = end + 1;
continue;
}
}
return false;
}
const cluster = readGrapheme(trimmed, index);
if (!isNativeEmojiCluster(cluster)) {
return false;
}
sawEmoji = true;
index += cluster.length;
}
return sawEmoji;
}
+2 -2
View File
@@ -356,7 +356,7 @@ function InlineEmojiPopover({
<PopoverTrigger asChild>
<button
type="button"
className="inline-flex border-0 bg-transparent p-0 align-baseline text-inherit"
className="inline-flex border-0 bg-transparent p-0 align-middle text-inherit"
aria-label={label}
onMouseEnter={handleMouseEnter}
onMouseLeave={scheduleClose}
@@ -368,7 +368,7 @@ function InlineEmojiPopover({
title={label}
src={resolvedSrc}
data-custom-emoji=""
className="mx-px inline-block h-[1.25em] w-auto max-w-none align-text-bottom"
className="mx-px inline-block h-[1.25em] w-auto max-w-none align-middle"
draggable={false}
onContextMenu={(e) => e.preventDefault()}
/>
@@ -12,16 +12,17 @@ const SHOTS = "test-results/custom-emoji";
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
// The mock emoji sets point at example.com placeholder URLs that don't
// resolve, so the <img> would render broken in screenshots. Serve a real
// 1x1-scaled magenta PNG for any example.com emoji image so the captures
// actually show a rendered glyph. (Screenshot-only; the bridge fixtures stay
// honest for the union/collapse unit + e2e assertions.)
const PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
"base64",
);
// resolve, so the <img> would render broken in screenshots. Serve a visible
// square glyph for any example.com emoji image so the captures show the
// custom-emoji sizing/alignment rather than a broken-image icon.
const SVG = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="7" fill="#22c55e"/>
<circle cx="16" cy="12" r="5" fill="#fef3c7"/>
<path d="M8 25c2-7 14-7 16 0" fill="#fef3c7"/>
</svg>`;
await page.route("https://example.com/e2e/**", (route) =>
route.fulfill({ contentType: "image/png", body: PNG }),
route.fulfill({ contentType: "image/svg+xml", body: SVG }),
);
});
@@ -68,3 +69,50 @@ test("settings card splits My emoji from read-only Workspace emoji", async ({
fullPage: true,
});
});
test("message list renders inline and emoji-only messages with Slack-style emoji sizing", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const input = page.getByTestId("message-input");
await input.click();
await input.pressSequentially(`inline :${SHORTCODE}: message`);
await page.getByTestId("send-message").click();
await input.click();
await input.pressSequentially(`:${SHORTCODE}: 😀 ❤️`);
await page.getByTestId("send-message").click();
const rows = page.getByTestId("message-row");
const inlineRow = rows
.filter({
has: page.locator(`img[data-custom-emoji][alt=":${SHORTCODE}:"]`),
hasText: "inline message",
})
.last();
const emojiOnlyRow = rows
.filter({
has: page.locator(`img[data-custom-emoji][alt=":${SHORTCODE}:"]`),
})
.last();
await expect(inlineRow).toBeVisible();
await expect(emojiOnlyRow).toBeVisible();
const inlineBox = await inlineRow
.locator(`img[data-custom-emoji][alt=":${SHORTCODE}:"]`)
.boundingBox();
const emojiOnlyBox = await emojiOnlyRow
.locator(`img[data-custom-emoji][alt=":${SHORTCODE}:"]`)
.boundingBox();
expect(inlineBox?.height).toBeGreaterThan(10);
expect(emojiOnlyBox?.height).toBeGreaterThan((inlineBox?.height ?? 0) * 1.8);
await page.screenshot({
path: `${SHOTS}/03-message-list-emoji-sizing.png`,
});
});