From 290f7e09d437502dfd03f66e3e656ebd94d3cec9 Mon Sep 17 00:00:00 2001 From: Ivan Date: Mon, 13 Apr 2026 22:58:04 -0500 Subject: [PATCH] feat(conversation-viewer): update message rendering with single emoji detection and dynamic font sizing for improved display --- .../messages/ConversationViewer.vue | 32 ++++++++++++++++++- meshchatx/src/frontend/js/MarkdownRenderer.js | 24 ++++++++++++++ tests/frontend/MarkdownRenderer.test.js | 16 ++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue index 26a28f6..e942176 100644 --- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue +++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue @@ -818,10 +818,11 @@ 'markdown-content--outbound-solid': chatItem.is_outbound && !isThemeOutboundBubble(chatItem), 'markdown-content--inbound': !chatItem.is_outbound, + 'markdown-content--single-emoji': messageMarkdownSingleEmoji(chatItem), }" :style="{ 'font-family': 'inherit', - 'font-size': (config?.message_font_size || 14) + 'px', + 'font-size': messageMarkdownFontSizePx(chatItem) + 'px', }" @click="handleMessageClick" v-html="renderMarkdown(chatItem.lxmf_message.content)" @@ -2989,6 +2990,26 @@ export default { renderMarkdown(text) { return MarkdownRenderer.render(text); }, + messageMarkdownSingleEmoji(chatItem) { + const c = chatItem?.lxmf_message?.content; + if (!c) { + return false; + } + if (this.getParsedItems(chatItem)?.isOnlyPaperMessage) { + return false; + } + if (this.shouldHideAutoImageCaption(chatItem)) { + return false; + } + return MarkdownRenderer.isSingleEmojiMessage(c); + }, + messageMarkdownFontSizePx(chatItem) { + const base = Number(this.config?.message_font_size) || 14; + if (this.messageMarkdownSingleEmoji(chatItem)) { + return Math.round(base * 2.75); + } + return base; + }, handleMessageClick(event) { const nomadnetLink = event.target.closest(".nomadnet-link"); if (nomadnetLink) { @@ -5724,6 +5745,15 @@ export default { margin: 0.5rem 0; } +.markdown-content--single-emoji { + line-height: 1; +} + +.markdown-content--single-emoji :deep(p) { + margin: 0; + line-height: 1; +} + .markdown-content :deep(strong) { font-weight: 700; } diff --git a/meshchatx/src/frontend/js/MarkdownRenderer.js b/meshchatx/src/frontend/js/MarkdownRenderer.js index 2d87388..1c39d20 100644 --- a/meshchatx/src/frontend/js/MarkdownRenderer.js +++ b/meshchatx/src/frontend/js/MarkdownRenderer.js @@ -80,6 +80,30 @@ export default class MarkdownRenderer { return processed_parts.join("\n"); } + /** + * True when the body is only a single emoji (after markdown strip), for large bubble rendering. + */ + static isSingleEmojiMessage(raw) { + if (raw == null || typeof raw !== "string") { + return false; + } + let plain = MarkdownRenderer.strip(raw); + plain = plain.replace(/\s+/g, ""); + if (!plain) { + return false; + } + if (typeof Intl === "undefined" || typeof Intl.Segmenter !== "function") { + return false; + } + const seg = new Intl.Segmenter(undefined, { granularity: "grapheme" }); + const clusters = [...seg.segment(plain)].map((s) => s.segment); + if (clusters.length !== 1) { + return false; + } + const g = clusters[0]; + return /\p{Extended_Pictographic}/u.test(g) || /\p{Emoji}/u.test(g); + } + /** * Strips markdown from text for previews. */ diff --git a/tests/frontend/MarkdownRenderer.test.js b/tests/frontend/MarkdownRenderer.test.js index 2185608..c8804c6 100644 --- a/tests/frontend/MarkdownRenderer.test.js +++ b/tests/frontend/MarkdownRenderer.test.js @@ -226,6 +226,22 @@ describe("MarkdownRenderer.js", () => { }); }); + describe("isSingleEmojiMessage", () => { + it("is true for one emoji and false for text or multiple emojis", () => { + expect(MarkdownRenderer.isSingleEmojiMessage("\u{1F600}")).toBe(true); + expect(MarkdownRenderer.isSingleEmojiMessage(" \u{1F600} ")).toBe(true); + expect(MarkdownRenderer.isSingleEmojiMessage("**\u{1F600}**")).toBe(true); + expect(MarkdownRenderer.isSingleEmojiMessage("\u{1F600}\u{1F600}")).toBe(false); + expect(MarkdownRenderer.isSingleEmojiMessage("hi")).toBe(false); + expect(MarkdownRenderer.isSingleEmojiMessage("\u{1F600} a")).toBe(false); + }); + + it("treats ZWJ family and skin tone as a single emoji", () => { + expect(MarkdownRenderer.isSingleEmojiMessage("\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}")).toBe(true); + expect(MarkdownRenderer.isSingleEmojiMessage("\u{1F44D}\u{1F3FD}")).toBe(true); + }); + }); + describe("strip", () => { it("strips markdown correctly", () => { const md = "# Header\n**Bold** *Italic* `code` ```\nblock\n```";