From 8f3ef61e4fbaf8c7cf586ed1280fd8f4bdc624ae Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Tue, 15 Sep 2026 09:33:36 -0700 Subject: A reply's quoted line shows its custom emoji as pictures. The quote above a reply bubble, the composer's reply bar, and the same on the public /chat page rendered the raw :shortcode:. They now run the one-line snippet through the same NIP-30 tokenizer, cutting on text so a shortcode is never sliced in half. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JuwQqNWtDJcxPWtEm9134z --- src/features/messages.js | 36 ++++++++++++++++++++++++++++++------ src/public-chat.js | 9 +++++++-- src/style.css | 2 ++ 3 files changed, 39 insertions(+), 8 deletions(-) (limited to 'src') diff --git a/src/features/messages.js b/src/features/messages.js index 3e45b34..cae2ba7 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -1299,12 +1299,36 @@ export function messagesFeature(ctx) { } // The displayed text of a message after its edit fold, trimmed to one line. - function msgSnippet(room, m, max = 90) { + const foldedRumor = (room, m) => { const edit = room.edits.get(m.rumor.id); - const text = edit && edit.author === m.author ? edit.rumor.content : m.rumor.content; + return edit && edit.author === m.author ? edit.rumor : m.rumor; + }; + const oneLine = (text, max = 90) => { const one = String(text || '').replace(/\s+/g, ' ').trim(); return one.length > max ? one.slice(0, max - 1) + '…' : one; + }; + function msgSnippet(room, m, max = 90) { + return oneLine(foldedRumor(room, m).content, max); + } + // The snippet as nodes: its custom emoji (NIP-30 tags on the rumor) show + // as small pictures, as they do in the bubble being quoted. The cut lands + // on the text first, so a shortcode is never sliced in half. + function snippetNodes(rumor, max = 90) { + const em = emojiTagMap(rumor && rumor.tags); + const text = oneLine(rumor && rumor.content, max + 40); + if (!em.size) return [oneLine(text, max)]; + const out = []; + let left = max; + for (const p of splitEmoji(text, (c) => em.get(c))) { + if (left <= 0) { out.push('…'); break; } + if (typeof p === 'string') { + if (p.length > left) { out.push(p.slice(0, Math.max(0, left - 1)) + '…'); break; } + out.push(p); left -= p.length; + } else { out.push(emojiImg(p.code, p.url)); left -= 2; } + } + return out; } + const msgSnippetNodes = (room, m, max = 90) => snippetNodes(foldedRumor(room, m), max); // A kind-9 that e-tags another message is a REPLY — render the quoted // context above its text, Telegram-style. Quotes of deleted (or not-yet- @@ -1316,7 +1340,7 @@ export function messagesFeature(ctx) { if (!src) return null; return h('div', { class: 'chat-quote' }, h('span', { class: 'chat-quote-name' }, displayName(src.author)), - h('span', { class: 'chat-quote-text' }, msgSnippet(room, src))); + h('span', { class: 'chat-quote-text' }, ...msgSnippetNodes(room, src))); } // Telegram-style message action sheet: quick reactions on top, then the @@ -1357,7 +1381,7 @@ export function messagesFeature(ctx) { return h('div', { class: 'reply-bar' }, h('div', { class: 'col grow', style: 'gap:1px;min-width:0' }, h('span', { class: 'small', style: 'font-weight:650' }, '↩ ', displayName(m.author)), - h('span', { class: 'small muted chat-quote-text' }, msgSnippet(room, m))), + h('span', { class: 'small muted chat-quote-text' }, ...msgSnippetNodes(room, m))), h('button', { class: 'chat-del', style: 'position:static;display:flex;flex-shrink:0', onClick: () => { ui.msgReplyTo = null; render(); } }, '×')); } @@ -6071,7 +6095,7 @@ export function messagesFeature(ctx) { if (!src) return null; return h('div', { class: 'chat-quote' }, h('span', { class: 'chat-quote-name' }, displayName(src.rumor.pubkey)), - h('span', { class: 'chat-quote-text' }, String(src.rumor.content || '').replace(/\s+/g, ' ').slice(0, 90))); + h('span', { class: 'chat-quote-text' }, ...snippetNodes(src.rumor))); }; watchZaps(msgs.slice(-150).map((m) => m.rumor.id)); const dmChips = (m) => { @@ -6094,7 +6118,7 @@ export function messagesFeature(ctx) { return h('div', { class: 'reply-bar' }, h('div', { class: 'col grow', style: 'gap:1px;min-width:0' }, h('span', { class: 'small', style: 'font-weight:650' }, '↩ ', displayName(m.rumor.pubkey)), - h('span', { class: 'small muted chat-quote-text' }, String(m.rumor.content || '').replace(/\s+/g, ' ').slice(0, 90))), + h('span', { class: 'small muted chat-quote-text' }, ...snippetNodes(m.rumor))), h('button', { class: 'chat-del', style: 'position:static;display:flex;flex-shrink:0', onClick: () => { ui.msgReplyTo = null; render(); } }, '×')); }; return h('div', { class: 'card col chat-card chat-page' }, diff --git a/src/public-chat.js b/src/public-chat.js index 4d79010..91ded5b 100644 --- a/src/public-chat.js +++ b/src/public-chat.js @@ -45,6 +45,12 @@ const LINK_SPLIT = /(https?:\/\/[^\s]+|nostr:(?:npub|nprofile|note|nevent|naddr) const emojiImg = (code, url) => h('img', { class: 'cemoji', src: url, alt: ':' + code + ':', title: ':' + code + ':', loading: 'lazy' }); // `em` is the message's ["emoji", code, url] map; a :code: it names renders // as its picture, anything else stays text +// a reply's quoted line: one line, cut at 90, its custom emoji as pictures +function quoteNodes(rumor) { + const em = emojiTagMap(rumor && rumor.tags); + const one = String((rumor && rumor.content) || '').replace(/\s+/g, ' ').trim().slice(0, 90); + return splitEmoji(one, (c) => em.get(c)).map((p) => (typeof p === 'string' ? p : emojiImg(p.code, p.url))); +} function linkedBody(text, em = null) { const out = []; const emojiAt = em && em.size ? (c) => em.get(c) : null; @@ -263,8 +269,7 @@ export function mountPublicChat() { ? (byChannel.get(activeChannel) || new Map()).get(replyId) : null; const quote = replySrc ? h('div', { class: 'chat-quote' }, h('span', { class: 'chat-quote-name' }, nameOf(replySrc.author)), - h('span', { class: 'chat-quote-text' }, - String(replySrc.rumor.content || '').replace(/\s+/g, ' ').slice(0, 90))) : null; + h('span', { class: 'chat-quote-text' }, ...quoteNodes(replySrc.rumor))) : null; log.append(h('div', { class: 'chat-row' + (grouped ? ' grouped' : '') }, grouped ? h('div', { class: 'chat-avatar spacer' }) : avatar(m.author), h('div', { class: 'chat-body' }, diff --git a/src/style.css b/src/style.css index f969cee..f1670ce 100644 --- a/src/style.css +++ b/src/style.css @@ -1043,3 +1043,5 @@ body::after { in the 40px grid cell that text must not spill into the next cell */ .emoji-grid button { overflow: hidden; } .emoji-grid .cemoji { font-size: 8px; } +/* quoted replies and the composer's reply bar run small; their emoji follow */ +.chat-quote-text .cemoji { height: 1.25em; vertical-align: -0.3em; } -- cgit v1.2.3