mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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`,
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user