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:
@@ -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()}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user