Skip to content

Commit e08937c

Browse files
wesbillmanPinky
andauthored
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>
1 parent ba2fdbf commit e08937c

6 files changed

Lines changed: 244 additions & 14 deletions

File tree

desktop/src/features/messages/lib/customEmojiNode.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -195,8 +195,7 @@ export const CustomEmojiNode = Node.create<CustomEmojiNodeOptions>({
195195
"data-shortcode": shortcode,
196196
draggable: "false",
197197
// Match the message-view <img data-custom-emoji> sizing exactly.
198-
class:
199-
"mx-px inline-block h-[1.25em] w-auto max-w-none align-text-bottom",
198+
class: "mx-px inline-block h-[1.25em] w-auto max-w-none align-middle",
200199
}),
201200
];
202201
},

desktop/src/features/messages/ui/MessageRow.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { UserAvatar } from "@/shared/ui/UserAvatar";
1111
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
1212
import { parseImetaTags } from "@/features/messages/lib/parseImeta";
1313
import { customEmojiFromTags } from "@/shared/api/customEmoji";
14+
import { isEmojiOnlyMessage } from "@/shared/lib/emojiOnly";
1415
import {
1516
resolveMentionNames,
1617
resolveMentionPubkeysByName,
@@ -95,6 +96,10 @@ export const MessageRow = React.memo(
9596
() => (message.tags ? customEmojiFromTags(message.tags) : undefined),
9697
[message.tags],
9798
);
99+
const emojiOnly = React.useMemo(
100+
() => isEmojiOnlyMessage(message.body, customEmoji),
101+
[message.body, customEmoji],
102+
);
98103

99104
const { channels } = useChannelNavigation();
100105
const channelNames = React.useMemo(
@@ -151,7 +156,11 @@ export const MessageRow = React.memo(
151156
return (
152157
<Markdown
153158
channelNames={channelNames}
154-
className="max-w-full text-[15px] leading-6"
159+
className={cn(
160+
"max-w-full text-[15px] leading-6",
161+
emojiOnly &&
162+
"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",
163+
)}
155164
content={message.body}
156165
customEmoji={customEmoji}
157166
imetaByUrl={imetaByUrl}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
4+
import { isEmojiOnlyMessage } from "./emojiOnly.ts";
5+
6+
const CUSTOM_EMOJI = [
7+
{ shortcode: "sprout", url: "https://relay/sprout.png" },
8+
{ shortcode: "party_parrot", url: "https://relay/parrot.gif" },
9+
];
10+
11+
test("detects unicode emoji-only messages", () => {
12+
assert.equal(isEmojiOnlyMessage("😀", CUSTOM_EMOJI), true);
13+
assert.equal(isEmojiOnlyMessage("😀 👍🏽\n❤️", CUSTOM_EMOJI), true);
14+
assert.equal(isEmojiOnlyMessage("🏳️‍🌈 👨‍👩‍👧‍👦", CUSTOM_EMOJI), true);
15+
});
16+
17+
test("detects known custom emoji-only shortcode messages", () => {
18+
assert.equal(isEmojiOnlyMessage(":sprout:", CUSTOM_EMOJI), true);
19+
assert.equal(
20+
isEmojiOnlyMessage(":sprout: :party_parrot:", CUSTOM_EMOJI),
21+
true,
22+
);
23+
assert.equal(isEmojiOnlyMessage(":Sprout:", CUSTOM_EMOJI), true);
24+
});
25+
26+
test("allows mixed unicode and custom emoji", () => {
27+
assert.equal(isEmojiOnlyMessage("😀 :sprout: ❤️", CUSTOM_EMOJI), true);
28+
});
29+
30+
test("rejects prose, markdown, and unknown shortcodes", () => {
31+
assert.equal(isEmojiOnlyMessage("hello 😀", CUSTOM_EMOJI), false);
32+
assert.equal(isEmojiOnlyMessage("😀!", CUSTOM_EMOJI), false);
33+
assert.equal(isEmojiOnlyMessage("**😀**", CUSTOM_EMOJI), false);
34+
assert.equal(isEmojiOnlyMessage(":unknown:", CUSTOM_EMOJI), false);
35+
assert.equal(isEmojiOnlyMessage("", CUSTOM_EMOJI), false);
36+
});
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import data from "@emoji-mart/data/sets/15/native.json" with { type: "json" };
2+
3+
import type { CustomEmoji } from "./remarkCustomEmoji";
4+
5+
type EmojiMartData = {
6+
emojis?: Record<
7+
string,
8+
{
9+
skins?: Array<{ native?: string }>;
10+
}
11+
>;
12+
};
13+
14+
let nativeEmojiSet: Set<string> | null = null;
15+
16+
function buildNativeEmojiSet(): Set<string> {
17+
const set = new Set<string>();
18+
const emojis = (data as EmojiMartData).emojis ?? {};
19+
for (const emoji of Object.values(emojis)) {
20+
for (const skin of emoji.skins ?? []) {
21+
if (skin.native) {
22+
set.add(skin.native);
23+
}
24+
}
25+
}
26+
return set;
27+
}
28+
29+
function isNativeEmojiCluster(cluster: string): boolean {
30+
nativeEmojiSet ??= buildNativeEmojiSet();
31+
return (
32+
nativeEmojiSet.has(cluster) || /\p{Extended_Pictographic}/u.test(cluster)
33+
);
34+
}
35+
36+
function readGrapheme(text: string, start: number): string {
37+
const firstCodePoint = text.codePointAt(start);
38+
if (firstCodePoint === undefined) {
39+
return "";
40+
}
41+
42+
let end = start + (firstCodePoint > 0xffff ? 2 : 1);
43+
44+
const nextCodePoint = text.codePointAt(end);
45+
if (
46+
isRegionalIndicator(firstCodePoint) &&
47+
nextCodePoint !== undefined &&
48+
isRegionalIndicator(nextCodePoint)
49+
) {
50+
return text.slice(start, end + (nextCodePoint > 0xffff ? 2 : 1));
51+
}
52+
53+
while (end < text.length) {
54+
const codePoint = text.codePointAt(end);
55+
if (codePoint === undefined) {
56+
break;
57+
}
58+
59+
if (
60+
codePoint === 0xfe0f ||
61+
codePoint === 0x200d ||
62+
codePoint === 0x20e3 ||
63+
isEmojiModifier(codePoint) ||
64+
isEmojiTag(codePoint)
65+
) {
66+
end += codePoint > 0xffff ? 2 : 1;
67+
continue;
68+
}
69+
70+
if (text.codePointAt(end - 1) === 0x200d) {
71+
end += codePoint > 0xffff ? 2 : 1;
72+
continue;
73+
}
74+
75+
break;
76+
}
77+
78+
return text.slice(start, end);
79+
}
80+
81+
function isEmojiModifier(codePoint: number): boolean {
82+
return codePoint >= 0x1f3fb && codePoint <= 0x1f3ff;
83+
}
84+
85+
function isRegionalIndicator(codePoint: number): boolean {
86+
return codePoint >= 0x1f1e6 && codePoint <= 0x1f1ff;
87+
}
88+
89+
function isEmojiTag(codePoint: number): boolean {
90+
return codePoint >= 0xe0020 && codePoint <= 0xe007f;
91+
}
92+
93+
export function isEmojiOnlyMessage(
94+
content: string,
95+
customEmoji: CustomEmoji[] = [],
96+
): boolean {
97+
const trimmed = content.trim();
98+
if (!trimmed) {
99+
return false;
100+
}
101+
102+
const shortcodeSet = new Set(
103+
customEmoji.map((emoji) => emoji.shortcode.toLowerCase()),
104+
);
105+
let sawEmoji = false;
106+
107+
for (let index = 0; index < trimmed.length; ) {
108+
const char = trimmed[index];
109+
110+
if (/\s/u.test(char)) {
111+
index += char.length;
112+
continue;
113+
}
114+
115+
if (char === ":") {
116+
const end = trimmed.indexOf(":", index + 1);
117+
if (end > index + 1) {
118+
const shortcode = trimmed.slice(index + 1, end).toLowerCase();
119+
if (shortcodeSet.has(shortcode)) {
120+
sawEmoji = true;
121+
index = end + 1;
122+
continue;
123+
}
124+
}
125+
return false;
126+
}
127+
128+
const cluster = readGrapheme(trimmed, index);
129+
if (!isNativeEmojiCluster(cluster)) {
130+
return false;
131+
}
132+
133+
sawEmoji = true;
134+
index += cluster.length;
135+
}
136+
137+
return sawEmoji;
138+
}

desktop/src/shared/ui/markdown.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -356,7 +356,7 @@ function InlineEmojiPopover({
356356
<PopoverTrigger asChild>
357357
<button
358358
type="button"
359-
className="inline-flex border-0 bg-transparent p-0 align-baseline text-inherit"
359+
className="inline-flex border-0 bg-transparent p-0 align-middle text-inherit"
360360
aria-label={label}
361361
onMouseEnter={handleMouseEnter}
362362
onMouseLeave={scheduleClose}
@@ -368,7 +368,7 @@ function InlineEmojiPopover({
368368
title={label}
369369
src={resolvedSrc}
370370
data-custom-emoji=""
371-
className="mx-px inline-block h-[1.25em] w-auto max-w-none align-text-bottom"
371+
className="mx-px inline-block h-[1.25em] w-auto max-w-none align-middle"
372372
draggable={false}
373373
onContextMenu={(e) => e.preventDefault()}
374374
/>

desktop/tests/e2e/custom-emoji-screenshots.spec.ts

Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,17 @@ const SHOTS = "test-results/custom-emoji";
1212
test.beforeEach(async ({ page }) => {
1313
await installMockBridge(page);
1414
// The mock emoji sets point at example.com placeholder URLs that don't
15-
// resolve, so the <img> would render broken in screenshots. Serve a real
16-
// 1x1-scaled magenta PNG for any example.com emoji image so the captures
17-
// actually show a rendered glyph. (Screenshot-only; the bridge fixtures stay
18-
// honest for the union/collapse unit + e2e assertions.)
19-
const PNG = Buffer.from(
20-
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
21-
"base64",
22-
);
15+
// resolve, so the <img> would render broken in screenshots. Serve a visible
16+
// square glyph for any example.com emoji image so the captures show the
17+
// custom-emoji sizing/alignment rather than a broken-image icon.
18+
const SVG = `
19+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
20+
<rect width="32" height="32" rx="7" fill="#22c55e"/>
21+
<circle cx="16" cy="12" r="5" fill="#fef3c7"/>
22+
<path d="M8 25c2-7 14-7 16 0" fill="#fef3c7"/>
23+
</svg>`;
2324
await page.route("https://example.com/e2e/**", (route) =>
24-
route.fulfill({ contentType: "image/png", body: PNG }),
25+
route.fulfill({ contentType: "image/svg+xml", body: SVG }),
2526
);
2627
});
2728

@@ -68,3 +69,50 @@ test("settings card splits My emoji from read-only Workspace emoji", async ({
6869
fullPage: true,
6970
});
7071
});
72+
73+
test("message list renders inline and emoji-only messages with Slack-style emoji sizing", async ({
74+
page,
75+
}) => {
76+
await page.goto("/");
77+
await page.getByTestId("channel-general").click();
78+
await expect(page.getByTestId("chat-title")).toHaveText("general");
79+
80+
const input = page.getByTestId("message-input");
81+
82+
await input.click();
83+
await input.pressSequentially(`inline :${SHORTCODE}: message`);
84+
await page.getByTestId("send-message").click();
85+
86+
await input.click();
87+
await input.pressSequentially(`:${SHORTCODE}: 😀 ❤️`);
88+
await page.getByTestId("send-message").click();
89+
90+
const rows = page.getByTestId("message-row");
91+
const inlineRow = rows
92+
.filter({
93+
has: page.locator(`img[data-custom-emoji][alt=":${SHORTCODE}:"]`),
94+
hasText: "inline message",
95+
})
96+
.last();
97+
const emojiOnlyRow = rows
98+
.filter({
99+
has: page.locator(`img[data-custom-emoji][alt=":${SHORTCODE}:"]`),
100+
})
101+
.last();
102+
103+
await expect(inlineRow).toBeVisible();
104+
await expect(emojiOnlyRow).toBeVisible();
105+
106+
const inlineBox = await inlineRow
107+
.locator(`img[data-custom-emoji][alt=":${SHORTCODE}:"]`)
108+
.boundingBox();
109+
const emojiOnlyBox = await emojiOnlyRow
110+
.locator(`img[data-custom-emoji][alt=":${SHORTCODE}:"]`)
111+
.boundingBox();
112+
expect(inlineBox?.height).toBeGreaterThan(10);
113+
expect(emojiOnlyBox?.height).toBeGreaterThan((inlineBox?.height ?? 0) * 1.8);
114+
115+
await page.screenshot({
116+
path: `${SHOTS}/03-message-list-emoji-sizing.png`,
117+
});
118+
});

0 commit comments

Comments
 (0)