diff options
| author | Adam Soltys <[email protected]> | 2026-09-04 09:07:52 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-04 09:07:52 -0700 |
| commit | e43e6dade447bf8ce5150323719937e28ebabe74 (patch) | |
| tree | 62f4a1d2582750fed3a1e8cd21d15f8f3b8df877 | |
| parent | a6851ae954d819b116aae6b19b175525f1488928 (diff) | |
| download | coinosv3-e43e6dade447bf8ce5150323719937e28ebabe74.tar.gz coinosv3-e43e6dade447bf8ce5150323719937e28ebabe74.zip | |
Chat renders links and sends emoji reactions. Links: community bubbles and DM bubbles now run through noteBody (URLs → anchors, image URLs inline, npub mentions clickable, all content kept as text nodes — nothing from a relay reaches innerHTML); the public /chat page gets its own linkedBody (same splitter, nostr refs stay dim stubs since there are no profile screens there). Reactions: a 🙂 opener on every bubble (rides the edge like the delete ×, steps left on own rows) unfolds a six-emoji picker; tapping an existing chip joins that vote, your own reaction is highlighted, and picking a different emoji replaces it (kind-7 rumor on the channel stream, one per author, fold keeps the latest — inbound rendering already existed, this adds the sending half). Optimistic like sendMessage: the chip lands instantly and is rolled back to whatever stood before if signing/wrapping fails
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MGzU12QrAA4G6BYGzGVdXz
| -rw-r--r-- | src/features/messages.js | 54 | ||||
| -rw-r--r-- | src/i18n.js | 1 | ||||
| -rw-r--r-- | src/public-chat.js | 26 | ||||
| -rw-r--r-- | src/style.css | 19 |
4 files changed, 95 insertions, 5 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index 6fc1011..b969d63 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -609,6 +609,38 @@ export function messagesFeature(ctx) { } } + // A reaction is a kind-7 rumor on the channel stream — same envelope as a + // message, content = the emoji. One reaction per author per message (the + // fold keeps the latest), so picking a different emoji replaces yours; + // tapping an existing chip joins that vote. Optimistic like sendMessage: + // the chip appears immediately, the wire catches up. + const REACT_EMOJIS = ['👍', '❤️', '😂', '🔥', '🎉', '🙏']; + async function sendReaction(room, chId, m, emoji) { + const id = await identity(); + if (!id) { noIdToast(); return; } + ui.msgReactFor = null; + const { created_at, ms } = msTags(Date.now()); + const rumor = rumorWithId({ + kind: 7, pubkey: id.pubkey, content: emoji, + tags: [['channel', chId], ['epoch', String(EPOCH)], ['e', m.rumor.id], ['k', '9'], ms], created_at, + }); + const r = room.reactions.get(m.rumor.id) || room.reactions.set(m.rumor.id, new Map()).get(m.rumor.id); + const prev = r.get(id.pubkey); + r.set(id.pubkey, emoji); + render(); + try { + const wrap = await wrapRumor(rumor, id.signer, room.chStream(chId)); + seenWraps.add(wrap.id); // our own echo has nothing to add + const ok = await publishOn(room.relays, wrap); + if (!ok) toast(t('msgSendFailed')); + } catch (e) { + // never went out — put back whatever stood before + if (prev) r.set(id.pubkey, prev); else r.delete(id.pubkey); + toast(e.message || String(e)); + render(); + } + } + async function deleteMessage(room, chId, m) { const id = await identity(); if (!id || id.pubkey !== m.author) return; @@ -2594,6 +2626,10 @@ export function messagesFeature(ctx) { const reacts = room.reactions.get(m.rumor.id); const counts = new Map(); if (reacts) for (const emoji of reacts.values()) counts.set(emoji, (counts.get(emoji) || 0) + 1); + // this wallet's own reaction (any of its identities) — highlighted, and + // choosing another emoji replaces it (one reaction per author, the fold + // keeps the latest) + const myReact = reacts && my.map((pk) => reacts.get(pk)).find(Boolean); return h( 'div', { class: 'chat-row' + (mine ? ' mine' : '') + (grouped ? ' grouped' : '') }, grouped ? h('div', { class: 'chat-avatar spacer' }) : avatar(m.author), @@ -2607,15 +2643,27 @@ export function messagesFeature(ctx) { m.author === room.jm.owner ? h('span', { class: 'chat-badge' }, t('msgAdmin')) : null), h('span', { class: 'chat-time' }, timeLabel(tms))), h('div', { class: 'chat-bubble' }, - text, + ...noteBody(text), edit ? h('span', { class: 'chat-edited' }, ' ', t('msgEdited')) : null, + h('button', { + class: 'chat-react-btn', title: t('msgReact'), + onClick: () => { ui.msgReactFor = ui.msgReactFor === m.rumor.id ? null : m.rumor.id; render(); }, + }, '🙂'), mine ? h('button', { class: 'chat-del', title: t('msgDelete'), onClick: () => deleteMessage(room, chId, m) }, '×') : null), + ui.msgReactFor === m.rumor.id + ? h('div', { class: 'chat-react-picker' }, + REACT_EMOJIS.map((e2) => h('button', { onClick: () => sendReaction(room, chId, m, e2) }, e2))) + : null, counts.size ? h('div', { class: 'chat-reacts' }, [...counts.entries()].map(([emoji, n]) => - h('span', { class: 'chat-react' }, emoji, n > 1 ? ' ' + n : ''))) + h('span', { + class: 'chat-react clickable' + (emoji === myReact ? ' on' : ''), + title: t('msgReact'), + onClick: () => sendReaction(room, chId, m, emoji), + }, emoji, n > 1 ? ' ' + n : ''))) : null) ); }); @@ -2794,7 +2842,7 @@ export function messagesFeature(ctx) { ? msgs.map((m) => h('div', { class: 'chat-row dm' + (m.mine ? ' mine' : '') }, h('div', { class: 'chat-body' }, - h('div', { class: 'chat-bubble' + (m.mine ? ' me' : '') }, m.rumor.content), + h('div', { class: 'chat-bubble' + (m.mine ? ' me' : '') }, ...noteBody(m.rumor.content)), h('div', { class: 'chat-time' }, timeLabel(m.rumor.created_at * 1000))))) : [h('div', { class: 'muted small', style: 'text-align:center;padding:24px 0' }, t('msgNoDmsYet'))])), composer(t('msgDmPlaceholder'), () => sendDM(peer), null, 'dm:' + peer)); diff --git a/src/i18n.js b/src/i18n.js index 1ed6abe..0910d1d 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -179,6 +179,7 @@ const STR = { msgNewChannel: 'New channel', msgAdmin: 'admin', msgEdited: '(edited)', + msgReact: 'React', publicChatTitle: '{name} community', publicChatDesc: 'Live chat — read along, sign in to join the conversation', publicChatSignIn: 'Sign in to chat', diff --git a/src/public-chat.js b/src/public-chat.js index 21b372a..5732f4a 100644 --- a/src/public-chat.js +++ b/src/public-chat.js @@ -35,6 +35,30 @@ function h(tag, attrs = {}, ...children) { return e; } +// Message text, safely: content stays text nodes (relay content must never +// reach innerHTML); URLs become links, image URLs render inline. The app's +// 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) { + const out = []; + for (const part of String(text || '').split(LINK_SPLIT)) { + if (!part) continue; + 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'; } })); + } else { + out.push(h('a', { href: part, target: '_blank', rel: 'noopener noreferrer' }, + part.length > 64 ? part.slice(0, 61) + '…' : part)); + } + } else if (/^nostr:/i.test(part)) { + out.push(h('span', { class: 'faint' }, part.slice(6, 18) + '…')); + } else out.push(part); + } + return out; +} + export function mountPublicChat() { const root = document.getElementById('app'); const rootBytes = hexToBytes(COMMUNITY.community_root); @@ -220,7 +244,7 @@ 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' }, content, + h('div', { class: 'chat-bubble' }, ...linkedBody(content), 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()] diff --git a/src/style.css b/src/style.css index ea9618e..ecf1338 100644 --- a/src/style.css +++ b/src/style.css @@ -419,11 +419,28 @@ hr.divider { margin: 16px 0; } background: var(--paper); color: var(--muted); font-size: 12px; line-height: 1; cursor: pointer; } .chat-row:hover .chat-del { display: flex; } -.chat-reacts { display: flex; gap: 5px; margin-top: 3px; } +/* the react opener rides the bubble edge like the delete ×; on a mine-row + both show, so it steps left to clear the × */ +.chat-react-btn { + position: absolute; top: -7px; right: -7px; width: 18px; height: 18px; + display: none; align-items: center; justify-content: center; + border: 1px solid var(--line-strong); border-radius: 50%; + background: var(--paper); font-size: 10px; line-height: 1; cursor: pointer; padding: 0; +} +.chat-row:hover .chat-react-btn { display: flex; } +.chat-row.mine .chat-react-btn { right: 14px; } +.chat-react-picker { display: flex; gap: 4px; margin-top: 4px; flex-wrap: wrap; } +.chat-react-picker button { + font-size: 15px; line-height: 1; padding: 4px 6px; + border: 1px solid var(--line); border-radius: 9px; background: var(--paper); cursor: pointer; +} +.chat-reacts { display: flex; gap: 5px; margin-top: 3px; flex-wrap: wrap; } .chat-react { font-size: 12px; background: var(--paper); border: 1px solid var(--line); border-radius: 9px; padding: 1px 7px; } +.chat-react.clickable { cursor: pointer; } +.chat-react.on { border-color: var(--accent); color: var(--accent); } .chat-compose { display: flex; gap: 8px; padding: 10px 12px; border-top: 1px solid var(--line); align-items: center; flex: 0 0 auto; |
