mirror of
https://github.com/block/buzz.git
synced 2026-10-28 13:01:10 +00: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,
|
"data-shortcode": shortcode,
|
||||||
draggable: "false",
|
draggable: "false",
|
||||||
// Match the message-view <img data-custom-emoji> sizing exactly.
|
// Match the message-view <img data-custom-emoji> sizing exactly.
|
||||||
class:
|
class: "mx-px inline-block h-[1.25em] w-auto max-w-none align-middle",
|
||||||
"mx-px inline-block h-[1.25em] w-auto max-w-none align-text-bottom",
|
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { UserAvatar } from "@/shared/ui/UserAvatar";
|
|||||||
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
|
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
|
||||||
import { parseImetaTags } from "@/features/messages/lib/parseImeta";
|
import { parseImetaTags } from "@/features/messages/lib/parseImeta";
|
||||||
import { customEmojiFromTags } from "@/shared/api/customEmoji";
|
import { customEmojiFromTags } from "@/shared/api/customEmoji";
|
||||||
|
import { isEmojiOnlyMessage } from "@/shared/lib/emojiOnly";
|
||||||
import {
|
import {
|
||||||
resolveMentionNames,
|
resolveMentionNames,
|
||||||
resolveMentionPubkeysByName,
|
resolveMentionPubkeysByName,
|
||||||
@@ -95,6 +96,10 @@ export const MessageRow = React.memo(
|
|||||||
() => (message.tags ? customEmojiFromTags(message.tags) : undefined),
|
() => (message.tags ? customEmojiFromTags(message.tags) : undefined),
|
||||||
[message.tags],
|
[message.tags],
|
||||||
);
|
);
|
||||||
|
const emojiOnly = React.useMemo(
|
||||||
|
() => isEmojiOnlyMessage(message.body, customEmoji),
|
||||||
|
[message.body, customEmoji],
|
||||||
|
);
|
||||||
|
|
||||||
const { channels } = useChannelNavigation();
|
const { channels } = useChannelNavigation();
|
||||||
const channelNames = React.useMemo(
|
const channelNames = React.useMemo(
|
||||||
@@ -151,7 +156,11 @@ export const MessageRow = React.memo(
|
|||||||
return (
|
return (
|
||||||
<Markdown
|
<Markdown
|
||||||
channelNames={channelNames}
|
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}
|
content={message.body}
|
||||||
customEmoji={customEmoji}
|
customEmoji={customEmoji}
|
||||||
imetaByUrl={imetaByUrl}
|
imetaByUrl={imetaByUrl}
|
||||||
|
|||||||
36
desktop/src/shared/lib/emojiOnly.test.mjs
Normal file
36
desktop/src/shared/lib/emojiOnly.test.mjs
Normal 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
desktop/src/shared/lib/emojiOnly.ts
Normal file
138
desktop/src/shared/lib/emojiOnly.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -356,7 +356,7 @@ function InlineEmojiPopover({
|
|||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="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}
|
aria-label={label}
|
||||||
onMouseEnter={handleMouseEnter}
|
onMouseEnter={handleMouseEnter}
|
||||||
onMouseLeave={scheduleClose}
|
onMouseLeave={scheduleClose}
|
||||||
@@ -368,7 +368,7 @@ function InlineEmojiPopover({
|
|||||||
title={label}
|
title={label}
|
||||||
src={resolvedSrc}
|
src={resolvedSrc}
|
||||||
data-custom-emoji=""
|
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}
|
draggable={false}
|
||||||
onContextMenu={(e) => e.preventDefault()}
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -12,16 +12,17 @@ const SHOTS = "test-results/custom-emoji";
|
|||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await installMockBridge(page);
|
await installMockBridge(page);
|
||||||
// The mock emoji sets point at example.com placeholder URLs that don't
|
// 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
|
// resolve, so the <img> would render broken in screenshots. Serve a visible
|
||||||
// 1x1-scaled magenta PNG for any example.com emoji image so the captures
|
// square glyph for any example.com emoji image so the captures show the
|
||||||
// actually show a rendered glyph. (Screenshot-only; the bridge fixtures stay
|
// custom-emoji sizing/alignment rather than a broken-image icon.
|
||||||
// honest for the union/collapse unit + e2e assertions.)
|
const SVG = `
|
||||||
const PNG = Buffer.from(
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
|
<rect width="32" height="32" rx="7" fill="#22c55e"/>
|
||||||
"base64",
|
<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) =>
|
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,
|
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