aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/features/messages.js54
-rw-r--r--src/i18n.js1
-rw-r--r--src/public-chat.js26
-rw-r--r--src/style.css19
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;