diff options
| author | Adam Soltys <[email protected]> | 2026-09-15 09:27:45 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-15 09:27:45 -0700 |
| commit | c90d0f3c600802b5b4b43959df5fd2d02f78012c (patch) | |
| tree | 0bb36a804eda41415e2d3e871bc6475836f8f99e /src/public-chat.js | |
| parent | feadbb008de9b2fe2332a7f0e190955ae545f07d (diff) | |
| download | coinosv3-c90d0f3c600802b5b4b43959df5fd2d02f78012c.tar.gz coinosv3-c90d0f3c600802b5b4b43959df5fd2d02f78012c.zip | |
Custom emoji in chat: :shortcodes: render as pictures, packs sync with Vector.
A message from Vector says :pika_wave: and carries a NIP-30
["emoji", code, url] tag for it; coinos showed the bare shortcode. Now the
bubble shows the picture (in rooms, DMs, the feed and the public /chat
page), a bubble that is only one to three of them shows them big, and a
:code: reaction chip is a picture too.
Sending: every :code: you type that resolves travels with its tag, so the
other side renders it whether or not they hold the pack. Shortcodes come
from packs you hold (NIP-51 kind 30030 sets) and from what you've seen in
chat, so anyone can answer a :pika_wave: with one. The composer
autocompletes on ":pi" (Tab/Enter takes the first), the reaction picker
lists custom emoji first, and Vector's pack share link renders with an
Add button; a pasted naddr works too.
Packs live on your kind 10030 list — the one Vector reads and writes — so
a pack added in either app appears in both. tools/emoji-test.js drives the
real coinos room headlessly and checks all of the above.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JuwQqNWtDJcxPWtEm9134z
Diffstat (limited to 'src/public-chat.js')
| -rw-r--r-- | src/public-chat.js | 28 |
1 files changed, 24 insertions, 4 deletions
diff --git a/src/public-chat.js b/src/public-chat.js index 0cdadc4..4d79010 100644 --- a/src/public-chat.js +++ b/src/public-chat.js @@ -18,6 +18,7 @@ import { COMMUNITY, EPOCH } from './community.js'; import { hexToBytes } from '@noble/hashes/utils'; import { timeAgo } from './format.js'; import { t } from './i18n.js'; +import { emojiTagMap, splitEmoji, emojiOnlyCount, shortcodeOf } from './emoji.js'; // Same shape as the app's h() for the attributes this page uses. function h(tag, attrs = {}, ...children) { @@ -40,11 +41,18 @@ function h(tag, attrs = {}, ...children) { // richer noteBody also resolves nostr: mentions — here they stay dim stubs, // since this page has no profile screens to open. const LINK_SPLIT = /(https?:\/\/[^\s]+|nostr:(?:npub|nprofile|note|nevent|naddr)1[a-z0-9]+)/gi; -function linkedBody(text) { +// a custom emoji (NIP-30): the picture a message's own emoji tag names +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 +function linkedBody(text, em = null) { const out = []; + const emojiAt = em && em.size ? (c) => em.get(c) : null; for (const part of String(text || '').split(LINK_SPLIT)) { if (!part) continue; - if (/^https?:\/\//i.test(part)) { + if (emojiAt && !/^(https?:\/\/|nostr:)/i.test(part) && part.includes(':')) { + for (const p of splitEmoji(part, emojiAt)) out.push(typeof p === 'string' ? p : emojiImg(p.code, p.url)); + } else if (/^https?:\/\//i.test(part)) { if (/\.(png|jpe?g|gif|webp|avif)(\?[^\s]*)?$/i.test(part)) { out.push(h('img', { src: part, class: 'note-img', loading: 'lazy', onError: (e) => { e.target.style.display = 'none'; } })); @@ -69,6 +77,7 @@ export function mountPublicChat() { const edits = new Map(); // rumorId -> { rumor, author } const deletes = new Map(); // rumorId -> Set(author) const reactions = new Map(); // rumorId -> Map(author -> emoji) + const reactEmoji = new Map(); // shortcode -> url, from reactions' own emoji tags const controlEntries = []; let folded = null; // foldControl result: banned set + channel renames let foldT = 0; // fold-once-per-burst debounce, same as the app's rooms @@ -143,6 +152,9 @@ export function mountPublicChat() { } else if (rumor.kind === 7) { const target2 = tag('e')?.[1]; if (target2) (reactions.get(target2) || reactions.set(target2, new Map()).get(target2)).set(author, rumor.content); + // a :code: reaction names its picture in its own tag; kept by code so + // the chip can show it + for (const [code, url] of emojiTagMap(rumor.tags)) reactEmoji.set(code, url); } else { return; } @@ -236,6 +248,14 @@ export function mountPublicChat() { prev = m; const edit = edits.get(m.rumor.id); const content = edit && edit.author === m.author ? edit.rumor.content : m.rumor.content; + const em = emojiTagMap((edit && edit.author === m.author ? edit.rumor : m.rumor).tags); + const jumboN = em.size ? emojiOnlyCount(splitEmoji(content, (c) => em.get(c))) : 0; + const jumbo = jumboN > 0 && jumboN <= 3; + const reactNode = (emoji) => { + const code = shortcodeOf(emoji); + const url = code && reactEmoji.get(code); + return url ? emojiImg(code, url) : emoji; + }; const reacts = reactions.get(m.rumor.id); // a reply e-tags its quoted message — render the context when we hold it const replyId = (m.rumor.tags || []).find((x) => x[0] === 'e')?.[1]; @@ -252,11 +272,11 @@ export function mountPublicChat() { h('span', { class: 'chat-name' + (m.author === COMMUNITY.owner ? ' owner' : '') }, nameOf(m.author)), m.author === COMMUNITY.owner ? h('span', { class: 'chat-badge' }, t('msgAdmin')) : null, h('span', { class: 'chat-time' }, timeAgo(ms / 1000))), - h('div', { class: 'chat-bubble' }, quote, ...linkedBody(content), + h('div', { class: 'chat-bubble' + (jumbo ? ' jumbo' : '') }, quote, ...linkedBody(content, em), edit && edit.author === m.author ? h('span', { class: 'chat-edited' }, ' ' + t('msgEdited')) : null, reacts && reacts.size ? h('div', { class: 'chat-reacts' }, ...[...[...reacts.values()].reduce((m2, e) => m2.set(e, (m2.get(e) || 0) + 1), new Map()).entries()] - .map(([emoji, n]) => h('span', { class: 'chat-react' }, emoji + (n > 1 ? ' ' + n : '')))) : null)))); + .map(([emoji, n]) => h('span', { class: 'chat-react' }, reactNode(emoji), n > 1 ? ' ' + n : ''))) : null)))); } if (stick) log.scrollTop = log.scrollHeight; } |
