aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/features/messages.js194
-rw-r--r--src/live.js19
-rw-r--r--src/style.css2
3 files changed, 172 insertions, 43 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index a1b49d0..7efbfd6 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -54,7 +54,7 @@ import { SIGNER_SILENT } from '../dm.js';
import { cacheStorage, onCacheReady, cacheIsReady } from '../cache-store.js';
import { dlog } from '../debug.js';
import { createCalls, openCall, wrapCall, CALL, CALL_WRAP, RING_S } from '../calls.js';
-import { LIVE_KIND, LIVE_CHAT_KIND, LIVE_RELAYS, liveAddr, liveHost, liveTitle, liveSummary, liveImage, liveViewers, liveStarts, liveStream, liveRecording, liveStatus as announcedStatus, isLiveNow as isLiveAnnounced, liveRelays, newestByAddr, zapLine, setZapAddr } from '../live.js';
+import { LIVE_KIND, LIVE_CHAT_KIND, LIVE_RELAYS, liveAddr, liveHost, liveTitle, liveSummary, liveImage, liveViewers, liveStarts, liveStream, liveRecording, liveStatus as announcedStatus, isLiveNow as isLiveAnnounced, liveRelays, newestByAddr, zapLine, setZapAddr, liveReactions } from '../live.js';
import { qrSvg } from '../qr.js';
// The coinos community's join material lives in ../community.js — shared
@@ -8780,18 +8780,79 @@ export function messagesFeature(ctx) {
const [, pk, ...rest] = s.a.split(':');
const d = rest.join(':');
takeLive([s.seed]);
- const w = liveWatch = { a: s.a, lines: new Map(), stops: [], video: null, hls: null, err: null, draft: '' };
+ const w = liveWatch = { a: s.a, lines: new Map(), stops: [], video: null, hls: null, err: null, actions: new Map(), actionTimer: null, actionStop: null, actionKey: '', sheet: null, reply: null, reacting: new Set() };
let mine = []; try { mine = wallet.nostrRelays(); } catch {}
- const relays = [...new Set([...liveRelays(s.seed), ...mine])];
+ const relays = w.relays = [...new Set([...liveRelays(s.seed), ...mine])];
const add = (e) => {
if (liveWatch !== w || w.lines.has(e.id)) return;
+ if (e.kind === 7 || e.kind === 5) { takeLiveAction(w, e); return; }
w.lines.set(e.id, e);
+ if (w.lines.size > 200) {
+ const oldest = [...w.lines.values()].sort((a, b) => a.created_at - b.created_at)[0];
+ w.lines.delete(oldest.id);
+ }
+ if (e.kind === LIVE_CHAT_KIND) { setZapAddr(e.id, w.a); watchLiveActions(w); }
+ else noteReceipt(e);
scheduleRepaint();
};
w.stops.push(subscribeOn(relays, { kinds: [LIVE_CHAT_KIND, 9735, 9737], '#a': [s.a], limit: 200 }, add));
+ w.stops.push(subscribeOn(relays, { kinds: [7, 5], '#a': [s.a], limit: 1000 }, e => takeLiveAction(w, e)));
w.stops.push(subscribeOn(relays, { kinds: [LIVE_KIND], authors: [pk], '#d': [d] }, (e) => { if (takeLive([e])) scheduleRepaint(); }));
return w;
}
+ // Include reactions/receipts from clients that only tag the comment's e,
+ // plus deletions of known reactions. Batch changes into one subscription.
+ function watchLiveActions(w) {
+ if (w.actionTimer || liveWatch !== w) return;
+ w.actionTimer = setTimeout(() => {
+ w.actionTimer = null;
+ if (liveWatch !== w) return;
+ const ids = [...w.lines.values()].filter(e => e.kind === LIVE_CHAT_KIND).map(e => e.id);
+ const reactions = [...w.actions.values()].filter(e => e.kind === 7 && ids.includes(e.tags.filter(t => t[0] === 'e').at(-1)?.[1])).slice(-400).map(e => e.id);
+ const all = [...ids, ...reactions];
+ const key = all.join(',');
+ if (key === w.actionKey) return;
+ w.actionKey = key;
+ if (w.actionStop) w.actionStop();
+ w.actionStop = all.length ? subscribeOn(w.relays, { kinds: [7, 5, 9735, 9737], '#e': all, limit: 1000 }, e => takeLiveAction(w, e)) : null;
+ }, 1000);
+ }
+ function takeLiveAction(w, ev) {
+ if (liveWatch !== w) return;
+ if (ev.kind === 9735 || ev.kind === 9737) { noteReceipt(ev); return; }
+ if (w.actions.has(ev.id)) return;
+ w.actions.set(ev.id, ev);
+ while (w.actions.size > 2000) w.actions.delete(w.actions.keys().next().value);
+ noteEmoji(ev);
+ watchLiveActions(w);
+ scheduleRepaint();
+ }
+ async function sendLiveReaction(w, ev, emoji) {
+ if (w.reacting.has(ev.id)) return;
+ w.reacting.add(ev.id);
+ try {
+ const id = await requireIdentity();
+ const current = liveReactions([...w.actions.values()]).get(ev.id)?.get(id.pubkey);
+ const old = [...w.actions.values()].filter(e => e.kind === 7 && e.pubkey === id.pubkey && e.tags.filter(t => t[0] === 'e').at(-1)?.[1] === ev.id);
+ const sign = partial => id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : id.signer.signEvent(partial);
+ const events = [];
+ const now = old.reduce((n, e) => Math.max(n, e.created_at + 1), Math.floor(Date.now() / 1000));
+ if (old.length) events.push(await sign({ kind: 5, content: '', created_at: now,
+ tags: [['a', w.a], ['k', '7'], ...old.map(e => ['e', e.id])] }));
+ const currentEmoji = current && (!current.content || current.content === '+' ? '❤️' : current.content);
+ if (currentEmoji !== emoji) events.push(await sign({ kind: 7, content: emoji, created_at: now,
+ tags: [['a', w.a], ['e', ev.id, w.relays[0] || ''], ['p', ev.pubkey], ['k', String(LIVE_CHAT_KIND)], ...reactEmojiTags(emoji), CLIENT_TAG] }));
+ if (liveWatch !== w) return;
+ const added = new Set(events.filter(e => !w.actions.has(e.id)).map(e => e.id));
+ for (const e of events) takeLiveAction(w, e);
+ render();
+ const results = await Promise.all(events.map(e => publishOn(w.relays, e).catch(() => false)));
+ // what no relay took is taken back off the screen
+ results.forEach((ok, i) => { if (!ok && added.has(events[i].id)) w.actions.delete(events[i].id); });
+ if (results.some(ok => !ok)) { toast(t('msgSendFailed')); render(); }
+ } catch (e) { if (!(e instanceof NoIdentity)) toast(t('msgSendFailed')); }
+ finally { w.reacting.delete(ev.id); scheduleRepaint(); }
+ }
function stopLivePlayer(w = liveWatch) {
if (!w) return;
if (w.hls) { try { w.hls.destroy(); } catch {} w.hls = null; }
@@ -8803,6 +8864,9 @@ export function messagesFeature(ctx) {
const w = liveWatch;
if (!w) return;
liveWatch = null;
+ clearTimeout(w.actionTimer);
+ if (w.actionStop) w.actionStop();
+ if (w.sheet) ui.emojiPick = null;
for (const stop of w.stops) { try { stop(); } catch {} }
stopLivePlayer(w);
}
@@ -8861,31 +8925,70 @@ export function messagesFeature(ctx) {
if (box && box._stick !== false) box.scrollTop = box.scrollHeight;
}
- async function sendLiveChat(w, text) {
+ async function sendLiveChat(w, text, reply) {
const id = await requireIdentity();
const ev = liveSeen.get(w.a);
const relays = [...new Set([...liveRelays(ev), ...wallet.nostrRelays()])];
const partial = { kind: LIVE_CHAT_KIND, content: text, created_at: Math.floor(Date.now() / 1000),
- tags: [['a', w.a, relays[0] || '', 'root'], ...outboundEmojiTags(text), CLIENT_TAG] };
+ tags: [['a', w.a, relays[0] || '', 'root'], ...(reply ? [['e', reply.id, relays[0] || '', 'reply'], ['p', reply.pubkey]] : []), ...outboundEmojiTags(text), CLIENT_TAG] };
const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
w.lines.set(evt.id, evt);
+ setZapAddr(evt.id, w.a); watchLiveActions(w);
render();
if (!(await publishOn(relays, evt))) {
w.lines.delete(evt.id);
- w.draft = text;
- toast(t('msgSendFailed'));
- render();
+ throw new Error(t('msgSendFailed'));
}
}
- function liveLine(ev) {
+ function liveMessageSheet(w) {
+ const ev = w.lines.get(w.sheet);
+ if (!ev || ev.kind !== LIVE_CHAT_KIND) return null;
+ const mine = liveReactions([...w.actions.values()]).get(ev.id)?.get(identityPk());
+ const close = () => { w.sheet = null; ui.emojiPick = null; render(); };
+ const item = (icon, label, onClick) => h('button', { class: 'msg-sheet-item', onClick }, h('span', { class: 'msg-sheet-ico' }, icon), label);
+ return h('div', { class: 'confirm-pop-backdrop', onClick: e => { if (e.target === e.currentTarget) close(); } },
+ h('div', { class: 'card col msg-sheet' },
+ reactRow(mine?.content, emoji => { close(); sendLiveReaction(w, ev, emoji); }),
+ ui.emojiPick ? null : item('↩', t('msgReply'), () => { w.reply = ev.id; close(); setTimeout(() => document.getElementById('msg-draft')?.focus(), 50); }),
+ !ui.emojiPick && canZapPk(ev.pubkey) && !isMe(ev.pubkey) ? item('⚡', t('msgZap'), () => { close(); zapMessage(ev.pubkey, ev.id); }) : null,
+ ui.emojiPick ? null : item('⧉', t('copy'), async () => { try { await navigator.clipboard.writeText(ev.content); toast(t('copied')); } catch {} close(); })));
+ }
+ function liveLine(ev, w, reactions) {
if (ev.kind === LIVE_CHAT_KIND) {
if (hiddenPk(ev.pubkey)) return null;
- return keyed(h('div', { class: 'live-line' },
- avatar(ev.pubkey, 'chat-avatar mini'),
- h('div', { class: 'live-line-body' },
- h('span', { class: 'quote-name' }, displayName(ev.pubkey)), ' ',
- ...noteBody(ev.content.slice(0, 500), 1, noteEmojiTags(ev)))), ev.id);
+ const em = noteEmojiTags(ev), mine = isMe(ev.pubkey);
+ const counts = new Map();
+ for (const r of reactions.get(ev.id)?.values() || []) {
+ if (hiddenPk(r.pubkey)) continue;
+ const emoji = !r.content || r.content === '+' ? '❤️' : r.content;
+ const url = emojiTagMap(r.tags).get(shortcodeOf(emoji)) || null;
+ const key = JSON.stringify([emoji, url]);
+ const c = counts.get(key) || { emoji, url, n: 0, mine: false };
+ c.n++; c.mine ||= isMe(r.pubkey); counts.set(key, c);
+ }
+ const canZap = canZapPk(ev.pubkey) && !mine;
+ const zc = zapChip(ev.id, { cls: 'chat-react', onClick: canZap ? () => zapMessage(ev.pubkey, ev.id) : null, onHold: () => openZapSettings(ev.pubkey, ev.id) });
+ const reply = w.lines.get(ev.tags.find(t => t[0] === 'e')?.[1]);
+ return keyed(h('div', { class: 'chat-row live-message' + (mine ? ' mine' : '') },
+ avatar(ev.pubkey),
+ h('div', { class: 'chat-body' },
+ h('div', { class: 'chat-meta' },
+ h('span', { class: 'chat-name clickable', onClick: () => openProfile(ev.pubkey) }, displayName(ev.pubkey)),
+ h('span', { class: 'chat-time' }, timeLabel(ev.created_at * 1000))),
+ h('div', { class: 'chat-line' },
+ h('div', { class: 'chat-bubble clickable' + (emojiJumbo(ev.content, em) ? ' jumbo' : ''),
+ onClick: chatBubbleClick(() => { if (!isVisitor()) { w.sheet = ev.id; ui.emojiPick = null; syncEmoji().catch(() => {}); render(); } }),
+ },
+ reply && !hiddenPk(reply.pubkey) ? h('div', { class: 'chat-quote' },
+ h('span', { class: 'chat-quote-name' }, displayName(reply.pubkey)),
+ h('span', { class: 'chat-quote-text' }, reply.content.slice(0, 160))) : null,
+ ...noteBody(ev.content, 0, em),
+ zc || counts.size ? h('div', { class: 'chat-reacts' }, zc,
+ [...counts.values()].map(c => h('button', { type: 'button', class: 'chat-react' + (c.mine ? ' on' : ''), title: t('msgReact'), disabled: isVisitor(),
+ onClick: e => { e.stopPropagation(); sendLiveReaction(w, ev, c.emoji); },
+ }, postReactNode(c), c.n > 1 ? ' ' + c.n : ''))) : null),
+ msgZapBtn(ev.pubkey, ev.id)))), ev.id);
}
const z = zapLine(ev, decodeBolt11);
if (!z.sats || (z.pk && hiddenPk(z.pk))) return null;
@@ -8907,16 +9010,25 @@ export function messagesFeature(ctx) {
const ev = liveSeen.get(s.a) || s.seed;
const host = liveHost(ev), status = liveStatus(ev), n = liveViewers(ev);
const src = livePlayable(ev);
- const lines = [...w.lines.values()].sort((a, b) => a.created_at - b.created_at).slice(-200).map(liveLine).filter(Boolean);
+ const reactions = liveReactions([...w.actions.values()]);
+ const lines = [...w.lines.values()].sort((a, b) => a.created_at - b.created_at).slice(-200).map(e => liveLine(e, w, reactions)).filter(Boolean);
+ const draftKey = 'live:' + w.a;
+ const reply = w.lines.get(w.reply);
const say = liveServerState(ev) === 'removed' ? t('liveRemoved') : w.err === 'offline' ? t('liveOffline') : w.err === 'noplayer' ? t('liveNoPlayer')
: !src ? (status === 'planned' ? t('liveStartsAt', { when: timeLabel(liveStarts(ev) * 1000) }) : t('liveOver')) : null;
const send = () => {
- const text = (w.draft || '').trim();
- if (!text) return;
- w.draft = '';
- const inp = document.querySelector('.live-input');
+ const text = draftFor(draftKey).trim();
+ if (!text || w.sending) return;
+ w.sending = true;
+ setDraft(draftKey, '');
+ w.reply = null;
+ const inp = document.getElementById('msg-draft');
if (inp) inp.value = '';
- sendLiveChat(w, text).catch(() => { w.draft = text; render(); });
+ sendLiveChat(w, text, reply).catch(e => {
+ setDraft(draftKey, text + (draftFor(draftKey) ? '\n' + draftFor(draftKey) : ''));
+ if (!w.reply) w.reply = reply?.id || null;
+ if (!(e instanceof NoIdentity)) toast(t('msgSendFailed'));
+ }).finally(() => { w.sending = false; render(); });
};
return h('div', { class: 'col live-page', style: 'gap:12px' },
ctx.brandHeader(!ui.pubProf && wallet.loaded),
@@ -8970,13 +9082,13 @@ export function messagesFeature(ctx) {
class: 'live-chat', 'data-key': 'live-chat:' + s.a,
onScroll: (e) => { const b = e.currentTarget; b._stick = b.scrollHeight - b.scrollTop - b.clientHeight < 40; },
}, ...(lines.length ? lines : [keyed(h('div', { class: 'small faint' }, t('liveChatEmpty')), 'empty')])),
- isVisitor() ? null : h('div', { class: 'row gap6' },
- h('input', {
- type: 'text', class: 'live-input grow', placeholder: t('liveChatHint'), value: w.draft, maxlength: '500',
- onInput: (e) => { w.draft = e.target.value; },
- onKeydown: (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } },
- }),
- h('button', { class: 'btn-sm', onClick: send }, t('liveSend')))),
+ reply ? h('div', { class: 'reply-bar' },
+ h('div', { class: 'col grow', style: 'min-width:0' },
+ h('span', { class: 'small' }, '↩ ', displayName(reply.pubkey)),
+ h('span', { class: 'small muted chat-quote-text' }, reply.content.slice(0, 160))),
+ h('button', { class: 'btn-ghost', onClick: () => { w.reply = null; render(); } }, '×')) : null,
+ isVisitor() ? null : composer(t('liveChatHint'), send, null, draftKey)),
+ w.sheet ? liveMessageSheet(w) : null,
...noteOverlays());
}
@@ -16146,6 +16258,13 @@ export function messagesFeature(ctx) {
render();
}
+ const chatBubbleClick = (open) => (e) => {
+ if (e.target.closest && e.target.closest('a, button, img:not(.cemoji)')) return;
+ const sel = window.getSelection && window.getSelection();
+ if (sel && String(sel).length) return;
+ open();
+ };
+
function messageRows(room, chId) {
const my = myPubkeys();
const msgs = [...(room.byChannel.get(chId)?.values() || [])]
@@ -16194,13 +16313,10 @@ export function messagesFeature(ctx) {
// ends in a click too, and must not pop the sheet over the copy.
// A custom emoji is an <img> too, but tapping it should open the
// sheet like tapping any other word.
- onClick: (e) => {
- if (e.target.closest && e.target.closest('a, button, img:not(.cemoji)')) return;
- const sel = window.getSelection && window.getSelection();
- if (sel && String(sel).length) return;
+ onClick: chatBubbleClick(() => {
ui.msgSheet = ui.msgSheet === m.rumor.id ? null : m.rumor.id;
render();
- },
+ }),
},
replyQuote(room, chId, m),
...noteBody(text, 0, em),
@@ -16591,13 +16707,10 @@ export function messagesFeature(ctx) {
h('span', { class: 'chat-time' }, timeLabel(tms))),
h('div', {
class: 'chat-bubble clickable' + (emojiJumbo(m.rumor.content, em) ? ' jumbo' : ''),
- onClick: (e) => {
- if (e.target.closest && e.target.closest('a, button, img:not(.cemoji)')) return;
- const sel = window.getSelection && window.getSelection();
- if (sel && String(sel).length) return;
+ onClick: chatBubbleClick(() => {
ui.msgSheet = ui.msgSheet === m.rumor.id ? null : m.rumor.id;
render();
- },
+ }),
},
src ? h('div', { class: 'chat-quote' },
h('span', { class: 'chat-quote-name' }, displayName(src.rumor.pubkey)),
@@ -16788,13 +16901,10 @@ export function messagesFeature(ctx) {
h('div', { class: 'chat-line' }, h('div', {
class: 'chat-bubble clickable' + (m.mine ? ' me' : '') + (emojiJumbo(m.rumor.content, emojiTagMap(m.rumor.tags)) ? ' jumbo' : ''),
// same tap-for-actions as community bubbles
- onClick: (e) => {
- if (e.target.closest && e.target.closest('a, button, img:not(.cemoji)')) return;
- const sel = window.getSelection && window.getSelection();
- if (sel && String(sel).length) return;
+ onClick: chatBubbleClick(() => {
ui.msgSheet = ui.msgSheet === m.rumor.id ? null : m.rumor.id;
render();
- },
+ }),
}, dmQuote(m), ...(m.rumor.kind === 15 ? [] : noteBody(m.rumor.content, 0, emojiTagMap(m.rumor.tags))), ...attachmentNodes(m.rumor, m.wn), dmChips(m)),
m.mine ? null : msgZapBtn(m.rumor.pubkey, m.rumor.id)),
h('div', { class: 'chat-time' }, timeLabel(m.rumor.created_at * 1000)))))
diff --git a/src/live.js b/src/live.js
index da18f19..d31c548 100644
--- a/src/live.js
+++ b/src/live.js
@@ -80,3 +80,22 @@ export function zapLine(ev, decode) {
const zapAddrs = new Map();
export const setZapAddr = (eventId, addr) => { if (eventId && addr) zapAddrs.set(eventId, addr); };
export const zapAddrOf = (eventId) => (eventId && zapAddrs.get(eventId)) || null;
+
+// One current reaction per person/message, independent of relay arrival order.
+// A deletion only removes reactions signed by its own author.
+export function liveReactions(events) {
+ const deleted = new Set();
+ for (const ev of events) if (ev.kind === 5)
+ for (const tag of ev.tags || []) if (tag[0] === 'e') deleted.add(ev.pubkey + ':' + tag[1]);
+ const out = new Map();
+ for (const ev of events) {
+ if (ev.kind !== 7 || deleted.has(ev.pubkey + ':' + ev.id)) continue;
+ const target = (ev.tags || []).filter(t => t[0] === 'e').at(-1)?.[1];
+ if (!target) continue;
+ const by = out.get(target) || new Map();
+ const old = by.get(ev.pubkey);
+ if (!old || ev.created_at > old.created_at || (ev.created_at === old.created_at && ev.id > old.id)) by.set(ev.pubkey, ev);
+ out.set(target, by);
+ }
+ return out;
+}
diff --git a/src/style.css b/src/style.css
index e462ad3..027f11e 100644
--- a/src/style.css
+++ b/src/style.css
@@ -931,7 +931,7 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; }
.live-line { display: flex; gap: 8px; align-items: flex-start; min-width: 0; }
.live-line-body { min-width: 0; overflow-wrap: anywhere; white-space: pre-wrap; line-height: 1.35; }
.live-zap .live-line-body b { color: var(--accent); }
-.live-input { min-width: 0; }
+.live-chat .chat-row { padding-right: 2px; }
.bc-preview.mirror { transform: scaleX(-1); }
.bc-bar .bc-clock { font-variant-numeric: tabular-nums; font-weight: 650; }
.go-live-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; background: #e0245e; box-shadow: 0 0 0 3px rgba(224, 36, 94, .25); }