diff --git a/package.json b/package.json index 6a231c9a..880ec00d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.18.126", + "version": "0.18.127", "type": "module", "scripts": { "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve", diff --git a/src/css/style.css b/src/css/style.css index c88a5ba3..029a57e4 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -203,6 +203,7 @@ html { /* sizing */ --font-size: 15px; + --rich-text-emoji-scale: 1.85; --hair: 1px; --chat-detail-header-height: 56px; --footer-height: 55px; @@ -409,6 +410,11 @@ plugin-slot:empty { display: none; } +.rich-text-emoji-only { + font-size: calc(1em * var(--rich-text-emoji-scale, 1.85)); + line-height: 1.15; +} + @media (hover: hover) { .rich-text a:hover { text-decoration: underline; @@ -4146,6 +4152,18 @@ image-carousel { border-bottom-left-radius: 4px; } +.message-text .rich-text-emoji-only { + --rich-text-emoji-scale: 3; +} + +.message-sent .message-bubble-emoji-only, +.message-received .message-bubble-emoji-only { + background: none; + padding: 0; + border-radius: 0; + color: var(--text-color); +} + .message-bubble a { text-decoration: underline; } @@ -4269,6 +4287,13 @@ image-carousel { animation: message-highlight-flash-sent 1500ms ease-out; } +.message-wrapper.message-highlighted .message-sent .message-bubble-emoji-only, +.message-wrapper.message-highlighted + .message-received + .message-bubble-emoji-only { + animation: none; +} + .message-wrapper.message-highlighted .message-sent .message-embed diff --git a/src/js/richTextHelpers.js b/src/js/richTextHelpers.js index f92f7b1a..4a26b20f 100644 --- a/src/js/richTextHelpers.js +++ b/src/js/richTextHelpers.js @@ -1,4 +1,4 @@ -import { sliceByByte, sortBy, getByteLength } from "/js/utils.js"; +import { sliceByByte, sortBy, getByteLength, isOnlyEmoji } from "/js/utils.js"; import { clampFacetIndex } from "/js/facetHelpers.js"; function facetOverlaps(facet1, facet2) { @@ -59,6 +59,16 @@ export function tokensHaveFacetType(tokens, facetTypes) { return false; } +export function isEmojiOnlyTokens(tokens) { + if (!Array.isArray(tokens) || tokens.length === 0) return false; + let text = ""; + for (const token of tokens) { + if (token.type !== "text") return false; + text += token.value; + } + return isOnlyEmoji(text); +} + export function validateRichTextTokens(tokens) { if (!Array.isArray(tokens)) return false; return tokens.every((token) => { diff --git a/src/js/templates/richText.template.js b/src/js/templates/richText.template.js index 46ae1a32..32668eca 100644 --- a/src/js/templates/richText.template.js +++ b/src/js/templates/richText.template.js @@ -1,6 +1,6 @@ import { html } from "/js/lib/lit-html.js"; import { sanitizeUri } from "/js/utils.js"; -import { tokenizeRichText } from "/js/richTextHelpers.js"; +import { tokenizeRichText, isEmojiOnlyTokens } from "/js/richTextHelpers.js"; import { linkToHashtag, linkToProfileByDid } from "/js/navigation.js"; const KNOWN_UNSUPPORTED_FACET_TYPES = [ @@ -67,6 +67,7 @@ export function richTextTokensTemplate({ renderNodeToken = () => null, placeholderFacetTypes = null, }) { + const isEmojiOnly = isEmojiOnlyTokens(tokens); const parts = []; tokens.forEach((token, index) => { switch (token.type) { @@ -119,6 +120,10 @@ export function richTextTokensTemplate({ } } }); + if (isEmojiOnly) { + // prettier-ignore + return html`
${parts}
`; + } // prettier-ignore return html`
${parts}
`; } diff --git a/src/js/utils.js b/src/js/utils.js index 8848cbd9..1e673afe 100644 --- a/src/js/utils.js +++ b/src/js/utils.js @@ -180,6 +180,13 @@ export function graphemeCount(str) { return [...str].length; } +const EMOJI_ONLY_RE = + /^[\p{Emoji_Presentation}\p{Extended_Pictographic}\uFE0F\u200D]+$/u; + +export function isOnlyEmoji(text) { + return text.length <= 15 && EMOJI_ONLY_RE.test(text); +} + export function formatLargeNumber(number) { if (number >= 1_000_000) { return formatWithSuffix(number / 1_000_000, "M"); diff --git a/src/js/views/chatDetail.view.js b/src/js/views/chatDetail.view.js index dc4e0451..01502e10 100644 --- a/src/js/views/chatDetail.view.js +++ b/src/js/views/chatDetail.view.js @@ -36,6 +36,7 @@ import { isMobileViewport, canHover, pinScrollPosition, + isOnlyEmoji, } from "/js/utils.js"; import { Signal, ReactiveStore } from "/js/signals.js"; import { ApiError } from "/js/api.js"; @@ -904,6 +905,10 @@ class ChatDetailView extends View { `; } + function isEmojiOnlyMessage(message) { + return isOnlyEmoji(message?.text ?? "") && !message?.facets?.length; + } + function messageTemplate({ message, isCurrentUser, @@ -921,6 +926,7 @@ class ChatDetailView extends View { replyTo && replyTo.sender ? getMemberProfile(convo, replyTo.sender.did) : null; + const isEmojiOnly = isEmojiOnlyMessage(message); return html`
` : null} ${message.text - ? html`
- ${replyTo + ? html`
+ ${replyTo && !isEmojiOnly ? messageReplyQuoteTemplate({ replyTo, senderProfile: replySenderProfile, @@ -1044,6 +1054,7 @@ class ChatDetailView extends View { ? null : getMemberProfile(convo, group.senderDid); const leadingReplyTo = group.messages[0]?.replyTo ?? null; + const isLeadingEmojiOnly = isEmojiOnlyMessage(group.messages[0]); const replierProfile = group.isCurrentUser ? null : getMemberProfile(convo, group.senderDid); @@ -1057,7 +1068,7 @@ class ChatDetailView extends View { ? "message-group-sent" : "message-group-received"}" > - ${leadingReplyTo && (isGroup || group.isCurrentUser) + ${leadingReplyTo ? messageReplyCaptionTemplate({ replyTo: leadingReplyTo, replierProfile, @@ -1067,7 +1078,10 @@ class ChatDetailView extends View { leadingReplyTo.sender?.did === currentUserDid, }) : ""} - ${isGroup && !group.isCurrentUser && !leadingReplyTo + ${isGroup && + !group.isCurrentUser && + !leadingReplyTo && + !isLeadingEmojiOnly ? html`
{ ); }); + test("should render emoji-only messages enlarged without a bubble", async ({ + page, + }) => { + const mockServer = new MockServer(); + const alice = createProfile({ + did: "did:plc:alice1", + handle: "alice.bsky.social", + displayName: "Alice", + }); + const convo = createConvo({ + id: "convo-1", + otherMember: alice, + }); + const original = createMessage({ + id: "msg-1", + text: "Hey there!", + senderDid: userProfile.did, + sentAt: "2025-01-15T12:00:00.000Z", + }); + const messages = [ + createMessage({ + id: "msg-4", + text: "πŸŽ‰", + senderDid: alice.did, + sentAt: "2025-01-15T12:03:00.000Z", + replyTo: original, + }), + createMessage({ + id: "msg-3", + text: "πŸ˜€", + senderDid: alice.did, + sentAt: "2025-01-15T12:02:00.000Z", + facets: [ + { + index: { byteStart: 0, byteEnd: 4 }, + features: [ + { + $type: "app.bsky.richtext.facet#link", + uri: "https://example.com", + }, + ], + }, + ], + }), + createMessage({ + id: "msg-2", + text: "πŸ˜€", + senderDid: alice.did, + sentAt: "2025-01-15T12:01:00.000Z", + }), + original, + ]; + mockServer.addConvos([convo]); + mockServer.addConvoMessages("convo-1", messages); + await mockServer.setup(page); + + await login(page); + await page.goto("/messages/convo-1"); + + const chatDetailView = page.locator("#chat-detail-view"); + const emojiMessage = chatDetailView.locator('[data-message-id="msg-2"]'); + const emojiRichText = emojiMessage.locator(".rich-text-emoji-only"); + await expect(emojiRichText).toBeVisible({ timeout: 10000 }); + await expect(emojiRichText).toHaveAttribute("data-teststate", "emoji-only"); + + // 3x the normal message text size, asserted as a ratio (not a px literal) + // so the base font size can change without breaking the test. + const normalFontSize = await chatDetailView + .locator('[data-message-id="msg-1"] .rich-text') + .evaluate((element) => parseFloat(getComputedStyle(element).fontSize)); + const emojiFontSize = await emojiRichText.evaluate((element) => + parseFloat(getComputedStyle(element).fontSize), + ); + expect(emojiFontSize / normalFontSize).toBeCloseTo(3, 1); + + const emojiBubble = emojiMessage.locator(".message-bubble"); + await expect(emojiBubble).toHaveClass(/message-bubble-emoji-only/); + await expect(emojiBubble).toHaveCSS("background-color", "rgba(0, 0, 0, 0)"); + await expect(emojiBubble).toHaveCSS("padding", "0px"); + + // Emoji text with a facet keeps its normal size and bubble. + const facetMessage = chatDetailView.locator('[data-message-id="msg-3"]'); + await expect(facetMessage.locator(".message-bubble")).toBeVisible(); + await expect(facetMessage.locator(".message-bubble")).not.toHaveClass( + /message-bubble-emoji-only/, + ); + await expect(facetMessage.locator(".rich-text-emoji-only")).toHaveCount(0); + + // An emoji-only reply drops the in-bubble quote but keeps the reply + // caption, so the reply context and tap-to-jump affordance survive. + const replyMessage = chatDetailView.locator('[data-message-id="msg-4"]'); + await expect(replyMessage.locator(".message-bubble")).toHaveClass( + /message-bubble-emoji-only/, + ); + await expect( + replyMessage.locator('[data-testid="message-reply-quote"]'), + ).toHaveCount(0); + const caption = chatDetailView.locator( + '[data-testid="message-reply-caption"]', + ); + await expect(caption).toHaveCount(1); + await expect(caption).toContainText("Alice replied to you"); + }); + test("should display messages from both users", async ({ page }) => { const mockServer = new MockServer(); const alice = createProfile({ @@ -233,6 +337,54 @@ test.describe("Chat detail view", () => { await expect(caption).toContainText("You replied to Alice"); }); + test("should render a reply caption for received replies in 1:1 chats", async ({ + page, + }) => { + const mockServer = new MockServer(); + const alice = createProfile({ + did: "did:plc:alice1", + handle: "alice.bsky.social", + displayName: "Alice", + }); + const convo = createConvo({ + id: "convo-1", + otherMember: alice, + }); + const original = createMessage({ + id: "msg-1", + text: "What time are we meeting?", + senderDid: userProfile.did, + sentAt: "2025-01-15T12:00:00.000Z", + }); + const reply = createMessage({ + id: "msg-2", + text: "Around 7pm", + senderDid: alice.did, + sentAt: "2025-01-15T12:01:00.000Z", + replyTo: original, + }); + mockServer.addConvos([convo]); + mockServer.addConvoMessages("convo-1", [reply, original]); + await mockServer.setup(page); + + await login(page); + await page.goto("/messages/convo-1"); + + const chatDetailView = page.locator("#chat-detail-view"); + await expect(chatDetailView.locator(".message-bubble")).toHaveCount(2, { + timeout: 10000, + }); + const caption = chatDetailView.locator( + '[data-testid="message-reply-caption"]', + ); + await expect(caption).toHaveCount(1); + await expect(caption).toContainText("Alice replied to you"); + // The in-bubble quote renders alongside the caption + await expect( + chatDetailView.locator('[data-testid="message-reply-quote"]'), + ).toHaveCount(1); + }); + test("groups a follow-up message into the preceding reply's group", async ({ page, }) => { @@ -1365,6 +1517,42 @@ test.describe("Chat detail view", () => { ).toHaveCount(2); }); + test("hides the author name on emoji-only received clusters", async ({ + page, + }) => { + const mockServer = setupGroupConvo({ + messages: [ + createMessage({ + id: "msg-2", + text: "πŸ˜€", + senderDid: bob.did, + sentAt: "2025-01-15T12:01:00.000Z", + }), + createMessage({ + id: "msg-1", + text: "Hi from Alice", + senderDid: alice.did, + sentAt: "2025-01-15T12:00:00.000Z", + }), + ], + }); + await mockServer.setup(page); + + await login(page); + await page.goto("/messages/group-1"); + + const chatDetailView = page.locator("#chat-detail-view"); + await expect(chatDetailView.locator(".rich-text-emoji-only")).toBeVisible( + { timeout: 10000 }, + ); + // Bob's emoji-only cluster gets no author label; Alice's still does + const authorNames = chatDetailView.locator( + '[data-testid="message-author-name"]', + ); + await expect(authorNames).toHaveCount(1); + await expect(authorNames).toContainText("Alice"); + }); + test("should render a reply caption above a reply bubble in group chats", async ({ page, }) => { diff --git a/tests/e2e/specs/views/postThread.view.test.js b/tests/e2e/specs/views/postThread.view.test.js index a562669e..490a7073 100644 --- a/tests/e2e/specs/views/postThread.view.test.js +++ b/tests/e2e/specs/views/postThread.view.test.js @@ -85,6 +85,36 @@ test.describe("Post thread view", () => { await expect(view).toContainText("This is the main post"); }); + test("should render an emoji-only post enlarged", async ({ page }) => { + const mockServer = new MockServer(); + const emojiPost = createPost({ + uri: postUri, + text: "πŸ˜€", + authorHandle: "author1.bsky.social", + authorDisplayName: "Author One", + }); + mockServer.addPosts([emojiPost]); + await mockServer.setup(page); + + await login(page); + await page.goto("/profile/author1.bsky.social/post/abc123"); + + const view = page.locator("#post-detail-view"); + const richText = view.locator( + '[data-testid="large-post"] .rich-text-emoji-only', + ); + await expect(richText).toBeVisible({ timeout: 10000 }); + + // 1.85x the surface's inherited size, asserted as a ratio (not a px + // literal) so the post text size can change without breaking the test. + const ratio = await richText.evaluate( + (element) => + parseFloat(getComputedStyle(element).fontSize) / + parseFloat(getComputedStyle(element.parentElement).fontSize), + ); + expect(ratio).toBeCloseTo(1.85, 2); + }); + test("should display parent post in thread context", async ({ page }) => { const parentPost = createPost({ uri: "at://did:plc:parent1/app.bsky.feed.post/parent1", diff --git a/tests/shared/factories.js b/tests/shared/factories.js index 3449f829..509377eb 100644 --- a/tests/shared/factories.js +++ b/tests/shared/factories.js @@ -137,13 +137,21 @@ export function createSystemMessage({ id, dataType, data = {}, sentAt }) { }; } -export function createMessage({ id, text, senderDid, sentAt, embed, replyTo }) { +export function createMessage({ + id, + text, + senderDid, + sentAt, + embed, + replyTo, + facets = [], +}) { return { $type: "chat.bsky.convo.defs#messageView", id, rev: "rev" + id, text, - facets: [], + facets, sender: { did: senderDid }, sentAt: sentAt || "2025-01-15T12:00:00.000Z", reactions: [], diff --git a/tests/unit/specs/components/detected-rich-text.test.js b/tests/unit/specs/components/detected-rich-text.test.js index 8655df04..5027f7d6 100644 --- a/tests/unit/specs/components/detected-rich-text.test.js +++ b/tests/unit/specs/components/detected-rich-text.test.js @@ -41,6 +41,25 @@ describe("detected-rich-text", () => { assert.deepEqual(richText.textContent.trim(), ""); }); + it("marks emoji-only text as enlarged", () => { + const element = document.createElement("detected-rich-text"); + element.setAttribute("text", "πŸ˜€"); + document.body.appendChild(element); + const richText = element.querySelector("[data-testid='rich-text']"); + assert(richText.classList.contains("rich-text-emoji-only")); + }); + + it("does not mark text with detected facets as enlarged", async () => { + const element = document.createElement("detected-rich-text"); + element.identityResolver = makeIdentityResolver(); + element.setAttribute("text", "πŸ˜€ example.com"); + document.body.appendChild(element); + await flushMicrotasks(); + assert(element.querySelector("a") !== null); + const richText = element.querySelector("[data-testid='rich-text']"); + assert(!richText.classList.contains("rich-text-emoji-only")); + }); + it("updates rendered text when the text attribute changes", async () => { const element = document.createElement("detected-rich-text"); element.setAttribute("text", "first"); diff --git a/tests/unit/specs/components/plugin-rich-text.test.js b/tests/unit/specs/components/plugin-rich-text.test.js index cd249ebf..03432535 100644 --- a/tests/unit/specs/components/plugin-rich-text.test.js +++ b/tests/unit/specs/components/plugin-rich-text.test.js @@ -311,6 +311,61 @@ describe("plugin-rich-text", () => { }); }); + describe("emoji-only enlargement", () => { + function getRichText(element) { + return element.querySelector("[data-testid='rich-text']"); + } + + it("marks emoji-only base text as enlarged", () => { + const element = mount({ text: "πŸ˜€" }); + assert(getRichText(element).classList.contains("rich-text-emoji-only")); + }); + + it("does not enlarge while a claimed facet is pending", () => { + const claimedType = "blue.moji.richtext.facet"; + const pluginService = makePluginService({ + claimedFacetTypes: new Set([claimedType]), + }); + pluginService.transformRichTextTokens = () => new Promise(() => {}); + const shortcode = ":blobcat:"; + const facets = [ + { + index: { byteStart: 0, byteEnd: shortcode.length }, + features: [{ $type: claimedType, did: "did:test", name: "blobcat" }], + }, + ]; + const element = mount({ pluginService, text: shortcode, facets }); + assert(element.querySelector(".rich-text-facet-pending") !== null); + assert(!getRichText(element).classList.contains("rich-text-emoji-only")); + }); + + it("does not enlarge a transform result containing node tokens", async () => { + const pluginService = makePluginService({ + result: [ + { + type: "inline", + pluginId: "p1", + node: { tag: "img", text: "" }, + }, + ], + }); + const element = mount({ pluginService, text: "πŸ˜€" }); + await flushEffects(); + assert(element.querySelector("img") !== null); + assert(!getRichText(element).classList.contains("rich-text-emoji-only")); + }); + + it("enlarges a transform result that is emoji-only text", async () => { + const pluginService = makePluginService({ + result: [{ type: "text", value: "πŸŽ‰" }], + }); + const element = mount({ text: "not emoji", pluginService }); + assert(!getRichText(element).classList.contains("rich-text-emoji-only")); + await flushEffects(); + assert(getRichText(element).classList.contains("rich-text-emoji-only")); + }); + }); + it("stops rendering after disconnect and resumes with the latest text on reconnect", async () => { const pluginService = makePluginService({ result: null }); const element = mount({ pluginService }); diff --git a/tests/unit/specs/richTextHelpers.test.js b/tests/unit/specs/richTextHelpers.test.js index ec67b7b1..f33db742 100644 --- a/tests/unit/specs/richTextHelpers.test.js +++ b/tests/unit/specs/richTextHelpers.test.js @@ -1,6 +1,6 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { tokenizeRichText } from "/js/richTextHelpers.js"; +import { tokenizeRichText, isEmojiOnlyTokens } from "/js/richTextHelpers.js"; describe("tokenizeRichText", () => { it("returns a single text token for plain text", () => { @@ -43,3 +43,56 @@ describe("tokenizeRichText", () => { ); }); }); + +describe("isEmojiOnlyTokens", () => { + it("is true for a single emoji-only text token", () => { + assert.deepEqual(isEmojiOnlyTokens([{ type: "text", value: "πŸ˜€" }]), true); + }); + + it("concatenates adjacent text tokens before testing", () => { + assert.deepEqual( + isEmojiOnlyTokens([ + { type: "text", value: "πŸ˜€" }, + { type: "text", value: "πŸŽ‰" }, + ]), + true, + ); + }); + + it("is false when the concatenated text is not emoji-only", () => { + assert.deepEqual( + isEmojiOnlyTokens([ + { type: "text", value: "πŸ˜€" }, + { type: "text", value: "a" }, + ]), + false, + ); + }); + + it("is false when any token is a facet", () => { + assert.deepEqual( + isEmojiOnlyTokens([ + { type: "text", value: "πŸ˜€" }, + { type: "facet", facet: { index: {} }, text: "πŸ˜€" }, + ]), + false, + ); + }); + + it("is false when any token is an inline or block node", () => { + assert.deepEqual( + isEmojiOnlyTokens([{ type: "inline", node: { tag: "img" } }]), + false, + ); + assert.deepEqual( + isEmojiOnlyTokens([{ type: "block", node: { tag: "pre" } }]), + false, + ); + }); + + it("is false for an empty array and non-arrays", () => { + assert.deepEqual(isEmojiOnlyTokens([]), false); + assert.deepEqual(isEmojiOnlyTokens(null), false); + assert.deepEqual(isEmojiOnlyTokens(undefined), false); + }); +}); diff --git a/tests/unit/specs/templates/richText.template.test.js b/tests/unit/specs/templates/richText.template.test.js index 9114f869..533303d0 100644 --- a/tests/unit/specs/templates/richText.template.test.js +++ b/tests/unit/specs/templates/richText.template.test.js @@ -214,6 +214,37 @@ describe("richTextTemplate", () => { assert.deepEqual(richText.textContent, text); }); + it("should mark emoji-only text as enlarged", () => { + const result = richTextTemplate({ text: "πŸ˜€", facets: [] }); + const container = document.createElement("div"); + render(result, container); + const richText = container.querySelector("[data-testid='rich-text']"); + assert(richText.classList.contains("rich-text")); + assert(richText.classList.contains("rich-text-emoji-only")); + assert.deepEqual(richText.getAttribute("data-teststate"), "emoji-only"); + }); + + it("should not mark emoji text with a facet as enlarged", () => { + const facets = [ + { + index: { byteStart: 0, byteEnd: 4 }, + features: [ + { + $type: "app.bsky.richtext.facet#link", + uri: "https://example.com", + }, + ], + }, + ]; + const result = richTextTemplate({ text: "πŸ˜€", facets }); + const container = document.createElement("div"); + render(result, container); + const richText = container.querySelector("[data-testid='rich-text']"); + assert(richText.classList.contains("rich-text")); + assert(!richText.classList.contains("rich-text-emoji-only")); + assert.deepEqual(richText.getAttribute("data-teststate"), null); + }); + it("should render a facet with an unknown type as plain text", (t) => { t.mock.method(console, "warn", () => {}); const text = "before unknown after"; @@ -285,6 +316,23 @@ describe("richTextTokensTemplate", () => { assert.deepEqual(richText.textContent, "before" + "const a = 1;" + "after"); }); + it("does not mark a stream containing node tokens as enlarged", () => { + const result = richTextTokensTemplate({ + tokens: [ + { + type: "inline", + pluginId: "p1", + node: { tag: "img", text: "" }, + }, + ], + renderNodeToken: renderTokenAsElement, + }); + const container = document.createElement("div"); + render(result, container); + const richText = container.querySelector("[data-testid='rich-text']"); + assert(!richText.classList.contains("rich-text-emoji-only")); + }); + it("skips inline/block tokens the renderer returns null for", () => { const result = richTextTokensTemplate({ tokens: [ diff --git a/tests/unit/specs/utils.test.js b/tests/unit/specs/utils.test.js index 6bef804b..07ba8bdb 100644 --- a/tests/unit/specs/utils.test.js +++ b/tests/unit/specs/utils.test.js @@ -26,6 +26,7 @@ import { TimeoutError, pinScrollPosition, KVIndexedDB, + isOnlyEmoji, } from "/js/utils.js"; import { installFakeIndexedDB } from "../testHelpers.js"; @@ -248,6 +249,62 @@ describe("sliceByByte", () => { }); }); +describe("isOnlyEmoji", () => { + // Parity with social-app's isOnlyEmoji (alf/typography.tsx), including its + // quirks: Extended_Pictographic false-positives like β„’ enlarge, keycaps and + // tag-sequence flags don't, and the cap is 15 UTF-16 code units (not + // graphemes). Don't "fix" a row here without deciding to diverge upstream. + const emojiOnlyCases = [ + ["single emoji", "πŸ˜€"], + ["two emoji", "πŸ˜€πŸ˜€"], + ["seven astral emoji (14 units)", "πŸ˜€".repeat(7)], + ["regional-indicator flag", "πŸ‡ΊπŸ‡Έ"], + ["three flags (12 units)", "πŸ‡ΊπŸ‡Έ".repeat(3)], + ["single ZWJ family", "πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦"], + ["skin-tone modified emoji", "πŸ‘πŸ½"], + ["ZWJ + double skin tone", "πŸ«±πŸΌβ€πŸ«²πŸ½"], + ["VS16 flag sequence", "πŸ³οΈβ€πŸŒˆ"], + ["heart with VS16", "❀️"], + ["bare pictographic heart", "❀"], + ["text-default smiley", "☺"], + ["trademark sign", "β„’"], + ["copyright sign", "Β©"], + ["heavy check mark", "βœ”"], + ["lone ZWJ", "‍"], + ["lone VS16", "️"], + ["lone skin-tone modifier", "🏽"], + ]; + const notEmojiOnlyCases = [ + ["empty string", ""], + ["plain letter", "a"], + ["emoji plus letter", "πŸ˜€a"], + ["emoji separated by space", "πŸ˜€ πŸ˜€"], + ["trailing space", "πŸ˜€ "], + ["trailing newline", "πŸŽ‰\n"], + ["keycap sequence", "1️⃣"], + [ + "tag-sequence flag (Scotland)", + "\u{1F3F4}\u{E0067}\u{E0062}\u{E0073}\u{E0063}\u{E0074}\u{E007F}", + ], + ["heart with VS15 text selector", "❀︎"], + ["eight astral emoji (16 units)", "πŸ˜€".repeat(8)], + ["four flags (16 units)", "πŸ‡ΊπŸ‡Έ".repeat(4)], + ["two ZWJ families", "πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦".repeat(2)], + ]; + + for (const [label, text] of emojiOnlyCases) { + it(`is true for ${label}`, () => { + assert.deepEqual(isOnlyEmoji(text), true); + }); + } + + for (const [label, text] of notEmojiOnlyCases) { + it(`is false for ${label}`, () => { + assert.deepEqual(isOnlyEmoji(text), false); + }); + } +}); + describe("formatLargeNumber", () => { it("should format numbers >= 1000 with K suffix", () => { assert.deepEqual(formatLargeNumber(1500), "1.5K");