diff options
| -rw-r--r-- | src/features/messages.js | 194 | ||||
| -rw-r--r-- | src/live.js | 19 | ||||
| -rw-r--r-- | src/style.css | 2 | ||||
| -rw-r--r-- | tools/live-chat-actions-test.js | 74 | ||||
| -rw-r--r-- | tools/live-reactions-test.js | 13 | ||||
| -rw-r--r-- | tools/live-stream-test.js | 44 |
6 files changed, 297 insertions, 49 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); } diff --git a/tools/live-chat-actions-test.js b/tools/live-chat-actions-test.js new file mode 100644 index 0000000..4e4d4ff --- /dev/null +++ b/tools/live-chat-actions-test.js @@ -0,0 +1,74 @@ +// Real live-chat controls with local relays and a fake payment handler. +import assert from 'node:assert/strict'; +import puppeteer from './puppeteer.js'; +const app = await Bun.file('src/app.js').text(); +const dom = app.slice(app.indexOf('function h(tag,'), app.indexOf('// ---------------------------------------------------------------- morphing')); +const entry = ` +import {messagesFeature} from './src/features/messages.js'; +import {getPublicKey} from 'nostr-tools/pure'; +import {liveReactions, zapAddrOf} from './src/live.js'; +${dom} +const sk = new Uint8Array(32).fill(1), me = getPublicKey(sk), fan = '2'.repeat(64); +const ev = {id:'3'.repeat(64),pubkey:fan,kind:1311,created_at:1,content:'A viewer comment',tags:[]}; +const w = {a:'30311:'+'4'.repeat(64)+':stream',lines:new Map([[ev.id,ev]]),actions:new Map(),reacting:new Set(),relays:['wss://fixture'],stops:[],sheet:null}; +const ui = {}, calls = [], toasts = []; +window.__published=[]; window.__publishOK=true; +let feature; +const wallet = {mnemonic:'test',nostr:{pk:me,sk},nostrRelays:()=>[],loadFeatureState:()=>({}),saveFeatureState(){},registerCacheExtension(){}}; +function render(){if(feature) document.querySelector('#app').replaceChildren(feature.testLine(ev,w,liveReactions([...w.actions.values()])), ...(w.sheet?[feature.testSheet(w)]:[]));} +feature = messagesFeature({h,ui,wallet,render,toast:s=>toasts.push(s),zapDefaultSat:()=>21,zapSound:()=>false, + hook(name,...args){if(name==='arkReady')return true;if(name==='zapNpub'){calls.push(args);return true;}return null;}}); +feature.testStart(w,ev); render(); +window.test={ui,w,ev,me,fan,calls,toasts,render,addr:()=>zapAddrOf(ev.id),self:()=>{ev.pubkey=me;render();},other:()=>{ev.pubkey=fan;render();},settle:()=>feature.zapSettled(ev.id,true,21)}; +`; +const built = await Bun.build({entrypoints:['test-entry'],target:'browser',plugins:[{name:'test',setup(b){ + b.onResolve({filter:/^test-entry$/},()=>({path:'entry',namespace:'test'})); + b.onLoad({filter:/.*/,namespace:'test'},()=>({contents:entry,loader:'js',resolveDir:process.cwd()})); + b.onLoad({filter:/src\/features\/messages\.js$/},async({path})=>({loader:'js',contents:(await Bun.file(path).text()).replace("id: 'messages',", "id: 'messages', testLine: liveLine, testSheet: liveMessageSheet, testStart(w,e){liveWatch=w;setZapAddr(e.id,w.a);},")})); + b.onLoad({filter:/src\/nostr\.js$/},async({path})=>({loader:'js',contents:(await Bun.file(path).text()) + .replace(/export async function publishOn\(relays, evt, \{[^}]*\} = \{\}\) \{/, (m) => m + ' window.__published.push(evt); return window.__publishOK;') + .replace('export function subscribeOn(relays, filter, onEvent, extra = {}) {','export function subscribeOn(relays, filter, onEvent, extra = {}) { return () => {};') + .replace('export async function queryOn(relays, filter, maxWait = 1500) {','export async function queryOn(relays, filter, maxWait = 1500) { return [];') })); +}}]}); +assert(built.success,built.logs.join('\n')); +const html = '<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>'+await Bun.file('src/style.css').text()+'</style><div id="app"></div><script type="module">'+await built.outputs[0].text()+'</script>'; +const server=Bun.serve({port:0,fetch:()=>new Response(html,{headers:{'content-type':'text/html'}})}); +const browser=await puppeteer.launch({executablePath:'/usr/bin/google-chrome',headless:true,args:['--no-sandbox']}); +const page=await browser.newPage(), errors=[]; +page.on('pageerror',e=>errors.push(e.message)); +await page.setRequestInterception(true); +page.on('request',r=>r.url().startsWith(server.url.origin)?r.continue():r.abort()); +const pause=ms=>new Promise(r=>setTimeout(r,ms)); +try { + await page.setViewport({width:390,height:844}); + await page.goto(server.url.href); + await page.waitForSelector('.chat-zap', {timeout:5000}).catch(async e => { throw new Error(errors.join(' | ') || e.message); }); + await page.click('.chat-zap'); + assert(await page.evaluate(()=>test.calls.length===1 && test.calls[0][0]===test.fan && test.calls[0][2]===test.ev.id && test.addr()===test.w.a),'zap must target the commenter, comment and stream'); + await page.evaluate(()=>{test.settle();test.self();}); + assert(!(await page.$('.chat-zap')),'no self-zap button'); + await page.evaluate(()=>test.other()); + await page.click('.chat-bubble'); + await page.waitForSelector('.msg-sheet-emojis'); + await page.evaluate(()=>[...document.querySelectorAll('.msg-sheet-emojis button')].find(b=>b.textContent==='❤️').click()); + await page.waitForSelector('.chat-react.on:not(.zap-tally)'); + const reaction=await page.evaluate(()=>window.__published.find(e=>e.kind===7)); + assert(reaction.tags.some(t=>t[0]==='k'&&t[1]==='1311')); + await page.click('.chat-react.on:not(.zap-tally)'); await pause(100); + assert(!(await page.$('.chat-react.on:not(.zap-tally)')),'tapping again removes our reaction'); + await page.click('.chat-bubble'); + await page.evaluate(()=>[...document.querySelectorAll('.msg-sheet-emojis button')].find(b=>b.textContent==='❤️').click()); + await page.waitForSelector('.chat-react.on:not(.zap-tally)'); + assert(await page.evaluate(()=>new Set(window.__published.filter(e=>e.kind===7).map(e=>e.id)).size===2),'immediately re-adding a removed reaction must have a new event id'); + await page.click('.chat-react.on:not(.zap-tally)'); await pause(100); + await page.evaluate(()=>window.__publishOK=false); + await page.click('.chat-bubble'); + await page.evaluate(()=>[...document.querySelectorAll('.msg-sheet-emojis button')].find(b=>b.textContent==='❤️').click()); + await page.waitForFunction(() => !document.querySelector('.chat-react.on:not(.zap-tally)'), {timeout:3000}); + assert(await page.evaluate(()=>test.toasts.length>0)); + const box = await (await page.$('.chat-zap')).boundingBox(); + await page.mouse.move(box.x+box.width/2,box.y+box.height/2); await page.mouse.down(); await pause(650); await page.mouse.up(); + assert(await page.evaluate(()=>test.ui.zapSetup.pk===test.fan && test.ui.zapSetup.eventId===test.ev.id && test.ui.zapSetup.edit),'hold opens the commenter’s zap amount settings'); + assert.deepEqual(errors,[]); + console.log('✓ Live chat zap recipient/event/stream, no self-zaps, emoji actions, toggle/re-add and rejection rollback'); +} finally {await browser.close();server.stop(true);} diff --git a/tools/live-reactions-test.js b/tools/live-reactions-test.js new file mode 100644 index 0000000..b142528 --- /dev/null +++ b/tools/live-reactions-test.js @@ -0,0 +1,13 @@ +import assert from 'node:assert/strict'; +import { liveReactions } from '../src/live.js'; +const one = {kind:7,id:'one',pubkey:'alice',created_at:1,content:'❤️',tags:[['e','parent'],['e','comment']]}; +const two = {...one,id:'two',created_at:2,content:'👍'}; +const other = {...one,id:'other',pubkey:'bob'}; +const del = {kind:5,id:'delete',pubkey:'alice',created_at:3,tags:[['e','one'],['e','two']]}; +assert.equal(liveReactions([two, one]).get('comment').get('alice').content,'👍'); +assert.equal(liveReactions([one, one, other]).get('comment').size,2); +assert.equal(liveReactions([del, two, one, other]).get('comment').size,1); +assert.equal(liveReactions([{...del,pubkey:'mallory'},two]).get('comment').size,1); +assert.equal(liveReactions([one,two,{...del,tags:[['e','one']]}]).get('comment').get('alice').id,'two'); +assert.equal(liveReactions([two,{...two,id:'zzz'}]).get('comment').get('alice').id,'zzz'); +console.log('✓ Live reaction deduplication, replacement, out-of-order deletion and author validation'); diff --git a/tools/live-stream-test.js b/tools/live-stream-test.js index 7ca4e4d..0deb48c 100644 --- a/tools/live-stream-test.js +++ b/tools/live-stream-test.js @@ -55,17 +55,23 @@ const pastPage = finalizeEvent({ kind: 30311, created_at: now - 50000, content: ['d', 'stream-p'], ['title', 'Zelda on Rumble'], ['status', 'ended'], ['streaming', STREAM], ['recording', 'https://rumble.com/v7gt2va-zelda.html'], ['ends', String(now - 50000)], ['p', HOST.pk, '', 'Host'], ] }, SERVICE.sk); -const FIXTURES = [live, stale, past, pastPage, chat, zap]; +const reaction = finalizeEvent({kind:7,created_at:now-10,content:'👍',tags:[['e',chat.id],['p',FAN.pk],['k','1311']]},HOST.sk); +const messageZap = finalizeEvent({kind:9737,created_at:now-9,content:'nice comment',tags:[['e',chat.id],['p',FAN.pk],['P',HOST.pk],['amount','5'],['network','regtest']]},HOST.sk); +const FIXTURES = [live, stale, past, pastPage, chat, zap, reaction, messageZap]; const ended = finalizeEvent({ ...live, id: undefined, sig: undefined, created_at: now - 5, tags: live.tags.map((x) => (x[0] === 'status' ? ['status', 'ended'] : x)) }, SERVICE.sk); // the site as built (the split build: hls.js arrives as its own chunk) if (!process.env.NO_BUILD && Bun.spawnSync(['bun', 'build.js']).exitCode !== 0) throw new Error('build failed'); -const server = Bun.serve({ port: PORT, fetch: async (req) => { +const server = Bun.serve({ port: PORT, fetch: async (req, srv) => { + if (new URL(req.url).pathname === "/relay" && srv.upgrade(req)) return; const path = new URL(req.url).pathname; if (path.startsWith('/live/')) return new Response(Bun.file(dir + path.slice(5)), { headers: { 'content-type': path.endsWith('.m3u8') ? 'application/vnd.apple.mpegurl' : 'video/mp2t', 'access-control-allow-origin': '*' } }); const f = Bun.file('dist' + path); return path.includes('.') && await f.exists() ? new Response(f) : new Response(Bun.file('dist/index.html')); -} }); +}, websocket: { message(ws, data) { + const m = JSON.parse(String(data)); + if (m[0] === 'EVENT') ws.send(JSON.stringify(['OK', m[1].id, true, ''])); +} } }); const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] }); // the relays: they hold the fixtures, answer by the filter, and keep what's published @@ -79,7 +85,7 @@ const relayStub = (fx) => { && Object.keys(f).filter((k) => k[0] === '#').every((k) => e.tags.some((x) => x[0] === k.slice(1) && f[k].includes(x[1]))); const Real = window.WebSocket; window.WebSocket = function (...a) { - const ws = new Real(...a); + const ws = new Real('ws://localhost:5302/relay'); // local fixture relay; never publish test events outside the test const send = ws.send.bind(ws); ws.send = (data) => { try { @@ -90,7 +96,8 @@ const relayStub = (fx) => { const hits = [...fx, ...window.__published].filter((e) => m.slice(2).some((f) => match(f, e))); if (hits.length) setTimeout(() => { for (const e of hits) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', m[1], e]) })); }, 0); } - if (m[0] === 'EVENT' && m[1]) window.__published.push(m[1]); + if (m[0] === 'EVENT' && m[1] && !window.__published.some(e => e.id === m[1].id)) { window.__published.push(m[1]); setTimeout(() => window.__push(m[1]), 0); } + if (m[0] === 'CLOSE') for (let i = subs.length - 1; i >= 0; i--) if (subs[i][0] === ws && subs[i][1] === m[1]) subs.splice(i, 1); } catch {} return send(data); }; @@ -179,13 +186,38 @@ try { check('...from its playlist, natively in Chrome', await page.evaluate((s) => document.querySelector('video.live-video').currentSrc === s, STREAM)); check('the chat shows what was said', await waitText('hello from the chat', 8000)); check('...and the zap, its amount and words', await waitText('zapped 21 sats', 5000) && await waitText('great stream', 1000)); - await page.focus('.live-input'); + const rowSel = '.live-message[data-key="' + chat.id + '"]'; + check('comments use the group-chat bubble and shared composer', await page.$(rowSel + ' .chat-bubble') && await page.$('.live-page coinos-text#msg-draft')); + check('another client’s e-only reaction arrives on the comment', await waitFor(id => document.querySelector('.live-message[data-key="'+id+'"] .chat-reacts')?.textContent.includes('👍'), 6000, chat.id)); + check('per-comment zap receipts show under the bubble', await waitFor(id => document.querySelector('.live-message[data-key="'+id+'"] .zap-tally')?.textContent.includes('5'), 6000, chat.id)); + await page.click(rowSel + ' .chat-bubble'); + check('tapping the bubble opens the shared emoji/action sheet', await waitFor(() => !!document.querySelector('.msg-sheet-emojis'), 2000)); + await page.evaluate(() => [...document.querySelectorAll('.msg-sheet-emojis button')].find(b => b.textContent === '❤️').click()); + check('a reaction is published against the comment and its author', await waitFor(id => window.__published.some(e => e.kind === 7 && e.tags.some(t => t[0] === 'e' && t[1] === id)), 5000, chat.id)); + const myReaction = await page.evaluate(id => window.__published.find(e => e.kind === 7 && e.tags.some(t => t[0] === 'e' && t[1] === id)), chat.id); + check('reaction carries the stream and kind for other clients', myReaction?.tags.some(t => t[0] === 'a' && t[1] === A) && myReaction.tags.some(t => t[0] === 'p' && t[1] === FAN.pk) && myReaction.tags.some(t => t[0] === 'k' && t[1] === '1311')); + check('our reaction highlights immediately', await waitFor(id => !!document.querySelector('.live-message[data-key="'+id+'"] .chat-react.on'), 3000, chat.id)); + await page.click(rowSel + ' .chat-react.on'); + check('tapping it again publishes its deletion', await waitFor(id => window.__published.some(e => e.kind === 5 && e.tags.some(t => t[0] === 'e' && t[1] === id)), 5000, myReaction.id)); + check('the removed reaction disappears', await waitFor(id => !document.querySelector('.live-message[data-key="'+id+'"] .chat-react.on'), 3000, chat.id)); + const remoteReaction = finalizeEvent({kind:7,created_at:now+1,content:'🔥',tags:[['e',chat.id],['p',FAN.pk]]},HOST.sk); + await page.evaluate(e => window.__push(e), remoteReaction); + check('a new remote reaction updates without reloading', await waitFor(id => document.querySelector('.live-message[data-key="'+id+'"] .chat-reacts')?.textContent.includes('🔥'), 3000, chat.id)); + // The existing zap button must select this fan's npub, not the host. + const zapButton = await page.$(rowSel + ' .chat-zap'); + check('the comment has the same one-tap zap control as a DM', !!zapButton); + await page.click(rowSel + ' .chat-bubble'); + await page.evaluate(() => [...document.querySelectorAll('.msg-sheet-item')].find(b => b.textContent.includes('Reply')).click()); + check('Reply opens the group-chat quote bar', await waitFor(() => !!document.querySelector('.live-page .reply-bar'), 2000)); + await page.focus('.live-page #msg-draft'); await page.keyboard.type('first time here'); await page.keyboard.press('Enter'); check('a line typed in the chat shows at once', await waitText('first time here', 3000)); const sent = await page.evaluate(() => window.__published.find((e) => e.kind === 1311)); check('...and goes out as a kind 1311 addressed to the stream', !!sent && sent.content === 'first time here' && sent.tags.some((x) => x[0] === 'a' && x[1] === A), JSON.stringify(sent && sent.tags)); + check('reply names the parent comment', !!sent && sent.tags.some(t => t[0] === 'e' && t[1] === chat.id) && sent.tags.some(t => t[0] === 'p' && t[1] === FAN.pk)); check('the player keeps playing through the renders', await waitFor(playing, 3000)); + if (process.env.SHOT) { await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); await sleep(400); await page.screenshot({ path: process.env.SHOT }); } console.log('\n[ways out]'); await page.evaluate(() => document.querySelector('.live-page .chat-avatar.mini.live-ring').click()); |
