+ ${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");