diff options
| author | Adam Soltys <[email protected]> | 2026-10-19 12:21:40 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-19 12:21:47 -0700 |
| commit | eca472c893fd128fa863b5d92835b6d9e71b445f (patch) | |
| tree | 5eb66ba68522969402dfc9aef7a4291d4b914ca4 | |
| parent | 38d99f6e7f61bfc854e0349ac75d33f2b76ae1f8 (diff) | |
| download | coinosv3-eca472c893fd128fa863b5d92835b6d9e71b445f.tar.gz coinosv3-eca472c893fd128fa863b5d92835b6d9e71b445f.zip | |
Add timestamp comments, reactions and zaps with audio waveform seeking
| -rw-r--r-- | src/features/ark.js | 17 | ||||
| -rw-r--r-- | src/features/messages.js | 84 | ||||
| -rw-r--r-- | src/features/zaps.js | 14 | ||||
| -rw-r--r-- | src/i18n.js | 3 | ||||
| -rw-r--r-- | src/lnurl.js | 4 | ||||
| -rw-r--r-- | src/media-timeline.js | 140 | ||||
| -rw-r--r-- | src/style.css | 27 | ||||
| -rw-r--r-- | tools/media-moments-feed-test.js | 171 | ||||
| -rw-r--r-- | tools/media-timeline-test.js | 80 |
9 files changed, 514 insertions, 26 deletions
diff --git a/src/features/ark.js b/src/features/ark.js index abb297a..3ef6cea 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -1,3 +1,4 @@ +import { momentTags } from '../media-timeline.js'; // Ark feature — off-chain payments via an ASP (Second's bark/captaind // protocol, spoken natively by ../ark). Receive address + boarding, instant // sends, history entries, refresh/consolidation, server settings. @@ -1731,8 +1732,8 @@ export function arkFeature(ctx) { // 2. lightning: profile lud16/lud06, via the zap flow // 3. an on-chain address from the BIP-353 record const NAMES_REGISTRAR = 'https://names.coinos.io'; - function startNpubPay(pk, npub, eventId = null, autoSat = 0) { - const z = { npub, pk, eventId, amount: '', comment: '', status: 'lookup', autoSat }; + function startNpubPay(pk, npub, eventId = null, autoSat = 0, moment = null) { + const z = { npub, pk, eventId, moment, amount: '', comment: '', status: 'lookup', autoSat }; // A one-tap post zap has no card: it resolves and pays off-screen, so a // second tap — the same post again, or another — never evicts the one // already in the air. Everything else takes the send screen's zap slot. @@ -1814,7 +1815,7 @@ export function arkFeature(ctx) { z.lnOk = true; await connectArk().catch(() => {}); if (!live()) return; - if (ctx.hook('canLnZap')) { if (ui.arkZap === z) ui.arkZap = null; ctx.hook('lnZapNpub', pk, npub, z.eventId, z.autoSat); return; } + if (ctx.hook('canLnZap')) { if (ui.arkZap === z) ui.arkZap = null; ctx.hook('lnZapNpub', pk, npub, z.eventId, z.autoSat, z.moment); return; } } // 3. on-chain fallback (a one-tap zap can't take the send form) if (onchain && !(z.autoSat && z.eventId)) { @@ -1934,7 +1935,7 @@ export function arkFeature(ctx) { await wallet.nostrPublish({ kind: ARK_ZAP_KIND, content: (z.comment || '').slice(0, 280), - tags: [['p', z.pk], ...(z.eventId ? [['e', z.eventId]] : []), ...(zapAddrOf(z.eventId) ? [['a', zapAddrOf(z.eventId)]] : []), + tags: [...momentTags(z.moment), ['p', z.pk], ...(z.eventId ? [['e', z.eventId]] : []), ...(zapAddrOf(z.eventId) ? [['a', zapAddrOf(z.eventId)]] : []), ['amount', String(sats)], ['vtxo', vtxoId], ['network', getNetwork()], ...zapSenderTag()], }).catch(() => {}); } @@ -1977,7 +1978,7 @@ export function arkFeature(ctx) { wallet.nostrPublish({ kind: ARK_ZAP_KIND, content: (z.comment || '').slice(0, 280), - tags: [['p', z.pk], ...(z.eventId ? [['e', z.eventId]] : []), ...(zapAddrOf(z.eventId) ? [['a', zapAddrOf(z.eventId)]] : []), + tags: [...momentTags(z.moment), ['p', z.pk], ...(z.eventId ? [['e', z.eventId]] : []), ...(zapAddrOf(z.eventId) ? [['a', zapAddrOf(z.eventId)]] : []), ['amount', String(sats)], ['network', getNetwork()], ['gift', locked.url], ...zapSenderTag()], }).catch(() => {}); const dmText = t('giftDmText', { amount: fmtAmount(sats) + ' ' + unitLabel(), link: locked.url, code: locked.claimCode }); @@ -2065,7 +2066,7 @@ export function arkFeature(ctx) { // Only someone with a Lightning address gets the Lightning door (the // lookup normally hands such a person straight to the zaps feature). noAddr && z.lnOk && ctx.hook('canLnZap') - ? h('button', { class: 'btn-block', disabled: !!ui.busy, onClick: () => { ctx.hook('lnZapNpub', z.pk, z.npub, z.eventId); } }, '⚡ ' + t('lnZapFallback')) + ? h('button', { class: 'btn-block', disabled: !!ui.busy, onClick: () => { ctx.hook('lnZapNpub', z.pk, z.npub, z.eventId, 0, z.moment); } }, '⚡ ' + t('lnZapFallback')) : null, payable && spendable < 330 ? h('div', { class: 'notice info' }, t(spendable + renewingSat() >= 330 ? 'spendingRenewingPayment' : 'zapNoBalance')) @@ -3929,9 +3930,9 @@ export function arkFeature(ctx) { // ⚡ button) — through the same resolution ladder as a pasted npub. The // event id rides into the receipt's e tag; callers fall back to the // lnZapNpub hook when ark can't serve this build/wallet. - zapNpub(pk, npub, eventId, autoSat) { + zapNpub(pk, npub, eventId, autoSat, moment) { if (!arkAvailable() || !wallet.nostrFetch) return false; - startNpubPay(pk, npub, eventId || null, autoSat || 0); + startNpubPay(pk, npub, eventId || null, autoSat || 0, moment); return true; }, sendFormExtras() { diff --git a/src/features/messages.js b/src/features/messages.js index 1f728e5..65507b0 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -1,3 +1,4 @@ +import { mediaMoment, momentTags, readMoment, mediaClock, mediaTimeline } from '../media-timeline.js'; import { mediaServers } from '../media-servers.js'; // Messages — Concord community chat (CORD-01..05) + NIP-17 private DMs. // @@ -4507,7 +4508,56 @@ export function messagesFeature(ctx) { // Where an event about a note goes. A zap receipt is money and has its own // bookkeeping (see below); a like and a boost are just tallies of who. + const timedActivity = new Map(); + function rememberMoment(ev) { + const moment = readMoment(ev); + if (!moment || ![1, 1111, 7, 9735, 9737].includes(ev.kind)) return; + const target = ev.tags.filter(x => x[0] === 'e' && x[3] !== 'mention').at(-1)?.[1]; + if (!target) return; + if (!timedActivity.has(target)) timedActivity.set(target, new Map()); + const items = timedActivity.get(target); + if (items.has(ev.id)) return; + items.set(ev.id, { id: ev.id, ev, moment, + icon: ev.kind === 7 ? ev.content || '♡' : [9735, 9737].includes(ev.kind) ? '⚡' : '↩', + text: [9735, 9737].includes(ev.kind) ? zapText(ev) : ev.content }); + if (items.size > 300) items.delete(items.keys().next().value); + if (timedActivity.size > 200) timedActivity.delete(timedActivity.keys().next().value); + queueMicrotask(() => document.querySelectorAll('audio, video').forEach(v => { if (v._timeline) v.dispatchEvent(new Event('mediaactivity')); })); + } + function attachMediaTimeline(player, url, explicitEvent) { + if (player._timeline) return; + player._timeline = true; + const event = () => { + const ev = explicitEvent || noteInHand(player.closest('[data-zap-post]')?.getAttribute('data-zap-post')); + return ev && (String(ev.content || '').includes(url) || ev.tags.some(tag => tag.includes('url ' + url) || tag[1] === url)) ? ev : null; + }; + const box = player.parentElement; + box._skipMorph = true; + box.append(mediaTimeline(player, url, { h, event, label: t, + moments: (id, file) => [...(timedActivity.get(id)?.values() || [])] + .filter(x => x.moment.url === mediaMoment(file, 0)?.url && !hiddenPk(x.ev.pubkey) && !isDeleted(x.ev)) + .sort((a, b) => a.moment.seconds - b.moment.seconds) + .map(x => ({ ...x, text: displayName(x.ev.kind === 9735 || x.ev.kind === 9737 ? zapperOf(x.ev) || x.ev.pubkey : x.ev.pubkey) + ' · ' + String(x.text || x.icon).slice(0, 240) })), + action: (kind, ev, moment, button) => { + if (signinAsk()) return; + if (kind === 'comment') { + player.pause(); + if (pager) leavePager(); + replyToNote(ev, moment); + } else if (kind === 'reaction') { + ui.reactPick = { ...ev, mediaMoment: moment }; render(); + } else { + if (wallet.watchOnly) { zapLockedAsk(); return; } + if (pager && !(ctx.zapDefaultSat && ctx.zapDefaultSat())) leavePager(); + zapNote(ev.pubkey, ev, button.getBoundingClientRect(), moment); + recheckZap(ev.id); + } + }, + })); + player.dispatchEvent(new Event('timeupdate')); + } function noteEvent(ev) { + rememberMoment(ev); if (ev.kind === 1 || ev.kind === 1111) { noteReply(ev); return; } if (EDIT_KINDS.includes(ev.kind)) { notePostEdit(ev); return; } if (ev.kind === 7 || ev.kind === 6) { @@ -4545,6 +4595,7 @@ export function messagesFeature(ctx) { // root and reply (or the bare positional e tags of older clients), NIP-22's // E and e. A 'mention' e tag is a quote, not a reply. function noteReply(ev) { + rememberMoment(ev); let changed = false; for (const x of ev.tags || []) { if (!x[1] || x[1] === ev.id) continue; @@ -4565,6 +4616,7 @@ export function messagesFeature(ctx) { } function noteReceipt(ev) { + rememberMoment(ev); const sats = receiptSats(ev); if (!sats) return; const my = myPubkeys(); @@ -8069,7 +8121,7 @@ export function messagesFeature(ctx) { preload: 'none', playsinline: true, muted: loud ? undefined : true, style: stable ? videoBoxStyle(feedMedia(url)) : undefined, // learned now, used by the next paint of any row with this clip - onLoadedmetadata: (e) => rememberVideo(url, e.target.videoWidth, e.target.videoHeight), + onLoadedmetadata: (e) => { rememberVideo(url, e.target.videoWidth, e.target.videoHeight); attachMediaTimeline(e.target, url); }, // a touch's tap was taken at pointerup (below): its click, if one comes, is spent onClick: (e) => { if (Date.now() - (e.currentTarget._tapAt || 0) < 800) { e.stopPropagation(); e.preventDefault(); return; } videoTap(e, url); }, onError: (e) => { if (!stable && !(e.target.getAttribute('src') || '').startsWith(VMEDIA)) { const b = e.target.parentElement; if (b) b.style.display = 'none'; } } }); @@ -10004,6 +10056,7 @@ export function messagesFeature(ctx) { h('span', { class: 'note-audio-ico', 'aria-hidden': 'true' }, '\u{1F3A7}'), h('a', { href: url, target: '_blank', rel: 'noopener noreferrer', class: 'small muted note-audio-name' }, name)), h('audio', { + onLoadedmetadata: e => attachMediaTimeline(e.target, url), controls: true, preload: 'none', src: url, onPlay: (e) => { const box = e.target.closest('.note-audio'); if (box) box._skipMorph = true; }, })); @@ -10400,15 +10453,15 @@ export function messagesFeature(ctx) { ui.screen = 'accounts'; render(); } - function zapNote(pk, ev, origin) { + function zapNote(pk, ev, origin, moment = null) { const npubStr = npubOf(pk); const def = ctx.zapDefaultSat ? ctx.zapDefaultSat() : 0; if (!def) { // the first tap ever sets the amount; the reader's place is kept for the way back let scrollY = 0; try { scrollY = window.scrollY || 0; } catch {} - ui.zapSetup = { pk, npub: npubStr, eventId: ev.id, amount: '21', origin, scrollY }; render(); return; + ui.zapSetup = { pk, npub: npubStr, eventId: ev.id, amount: '21', origin, scrollY, moment }; render(); return; } markZapPending(ev.id, def, origin); // strike now; settlement only corrects the tally - if (!hook('zapNpub', pk, npubStr, ev.id, def) && !hook('lnZapNpub', pk, npubStr, ev.id, def)) { + if (!hook('zapNpub', pk, npubStr, ev.id, def, moment) && !hook('lnZapNpub', pk, npubStr, ev.id, def, moment)) { settleZap(ev.id, false); toast('⚡ ' + t('lnZapFailed'), 4000); } @@ -10450,7 +10503,7 @@ export function messagesFeature(ctx) { const { pk, npub, eventId } = s; closeZapSetup(); markZapPending(eventId, n, s.origin); - if (!hook('zapNpub', pk, npub, eventId, n) && !hook('lnZapNpub', pk, npub, eventId, n)) { + if (!hook('zapNpub', pk, npub, eventId, n, s.moment) && !hook('lnZapNpub', pk, npub, eventId, n, s.moment)) { settleZap(eventId, false); toast('⚡ ' + t('lnZapFailed'), 4000); } @@ -10504,13 +10557,14 @@ export function messagesFeature(ctx) { // A like is kind 7 on the note (NIP-25). Tapping it again withdraws it the // only way nostr has: a deletion request for the reaction we sent. // Reply: open (or re-aim) the thread and put the cursor in the box. - function replyToNote(ev) { + function replyToNote(ev, moment = null) { if (ui.noteThread && ui.noteThread.rootId === rootIdOf(ev)) { ui.noteThread.focusId = ev.id; ui.noteThread.seed = ev; } else ui.noteThread = { rootId: rootIdOf(ev), focusId: ev.id, seed: ev }; const s = ui.noteThread; ui.profOverThread = false; + s.mediaMoment = moment; s.replying = true; // the box appears under this note s.scrollPending = false; // focus the composer, without a competing note scroll s.refocus = true; @@ -10654,7 +10708,7 @@ export function messagesFeature(ctx) { const id = await requireIdentity(); const partial = { kind: 7, content: emoji || '❤️', created_at: Math.floor(Date.now() / 1000), - tags: [['e', ev.id], ['p', ev.pubkey], ...reactEmojiTags(emoji), CLIENT_TAG], + tags: [['e', ev.id], ['p', ev.pubkey], ...momentTags(ev.mediaMoment), ...reactEmojiTags(emoji), CLIENT_TAG], }; const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial); myReactEv.set(ev.id, evt.id); @@ -11830,6 +11884,12 @@ export function messagesFeature(ctx) { : foreign ? h('div', { class: 'note-text small muted', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, (ev.kind === 64 ? '\u265f ' : '') + altText) : h('div', { class: 'note-text', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, ...(ev.kind === ARTICLE ? (open ? [articleCard(ev)] : articleBody(ev)) : noteBody(shownTranslation(ev)?.text ?? noteText(ev), 0, noteEmojiTags(ev)))), + readMoment(ev) ? h('a', { class: 'small media-time-link', href: readMoment(ev).url + '#t=' + readMoment(ev).seconds, target: '_blank', rel: 'noopener noreferrer', onClick: e => { + e.stopPropagation(); + const m = readMoment(ev); + const player = [...document.querySelectorAll('audio, video')].find(v => mediaMoment(v.dataset.clip || v.getAttribute('src'), 0)?.url === m.url); + if (player) { e.preventDefault(); const seek = () => { player.currentTime = Math.min(m.seconds, Number.isFinite(player.duration) ? player.duration : m.seconds); }; if (player.readyState) seek(); else player.addEventListener('loadedmetadata', seek, { once: true }); player.play().catch(() => {}); player.scrollIntoView({ block: 'center' }); } + } }, '▶ ' + mediaClock(readMoment(ev).seconds)) : null, foreign || pending ? null : translateLine(ev), ev.kind === POLL ? pollBox(ev) : null, pending ? null : noteActions(pk, ev, { canZap }), @@ -12183,6 +12243,8 @@ export function messagesFeature(ctx) { CLIENT_TAG, ], }; + note.tags.push(...momentTags(s.mediaMoment)); + if (s.mediaMoment) note.content = '[' + mediaClock(s.mediaMoment.seconds) + '] ' + note.content; const evt = id.signer instanceof Uint8Array ? finalizeEvent(note, id.signer) : await id.signer.signEvent(note); const relays = [...new Set([...(await notesRelays(target.pubkey)), ...wallet.nostrRelays()])]; const ok = await publishOn(relays, evt); @@ -12249,6 +12311,7 @@ export function messagesFeature(ctx) { class: 'col thread-reply' + (joined ? ' joined' : ''), style: 'gap:8px;margin:0 -8px;padding:' + (joined ? '2px' : '8px') + ' 8px 10px;background:var(--accent-soft,rgba(128,128,128,.08));border-radius:' + (joined ? '0 0 8px 8px' : '8px'), }, + s.mediaMoment ? h('div', { class: 'small muted' }, t('mediaCommentAt') + ' ' + mediaClock(s.mediaMoment.seconds)) : null, s.preview ? draftPreview(s.draft) : null, mediaBar(), // A textarea that grows with the reply, like the chat composer: Enter @@ -12314,7 +12377,7 @@ export function messagesFeature(ctx) { previewBtn(!!s.preview, () => { s.preview = !s.preview; render(); }), h('button', { class: 'btn-ghost btn-sm thread-reply-cancel', type: 'button', - onClick: () => { s.replying = false; s.preview = false; render(); }, + onClick: () => { s.replying = false; s.preview = false; s.mediaMoment = null; render(); }, }, t('cancel'))), h('button', { class: 'btn-primary btn-sm thread-reply-send', disabled: !!s.sending || !!ui.postUploading, @@ -12330,7 +12393,7 @@ export function messagesFeature(ctx) { // focused field's value — clear it by hand const inp = document.querySelector('.thread-reply-input'); if (inp) inp.value = ''; - s.replying = false; s.preview = false; // sent: the box folds away + s.replying = false; s.preview = false; s.mediaMoment = null; // sent: the box folds away toast(t('threadReplied')); } catch (e) { if (!e.silent) toast(e.message); } s.sending = false; render(); @@ -14306,6 +14369,7 @@ export function messagesFeature(ctx) { media.muted = !pagerLoud; media.addEventListener('click', () => {}); // a tap is the pager's (overlay), not the player's slide.append(seekBar(media)); + media.addEventListener('loadedmetadata', () => attachMediaTimeline(media, url, ev)); } return slide; } @@ -17890,7 +17954,7 @@ export function messagesFeature(ctx) { stCache = null; stAt = 0; // the next account's state, not this one's if (profFlush) { clearTimeout(profFlush); flushProfiles(); } // this account's faces, written before the blob is dropped profBlob = null; - reacts.clear(); boosts.clear(); seenNoteEv.clear(); myReactEv.clear(); quoted.clear(); + timedActivity.clear(); reacts.clear(); boosts.clear(); seenNoteEv.clear(); myReactEv.clear(); quoted.clear(); noteCountsReady.clear(); // what happened to THEIR posts stays with them — the next identity // starts its list empty and asks the relays under its own key diff --git a/src/features/zaps.js b/src/features/zaps.js index d364738..b501c76 100644 --- a/src/features/zaps.js +++ b/src/features/zaps.js @@ -132,7 +132,7 @@ export function zapsFeature(ctx) { if (profile && profile.name) z.name = profile.name; const lnTarget = zapTargetFromProfile(profile, target.pk); if (!lnTarget) { z.status = 'error'; z.error = t('lnZapNoAddress'); render(); return; } - z.target = target = { ...lnTarget, eventId: target.eventId || null }; // keep the note being zapped + z.target = target = { ...lnTarget, eventId: target.eventId || null, moment: target.moment }; // keep the note being zapped z.address = lnTarget.address || z.address; } @@ -203,7 +203,7 @@ export function zapsFeature(ctx) { const profile = await wallet.nostrProfile(target.pk); const lnTarget = zapTargetFromProfile(profile, target.pk); if (!lnTarget) throw new Error(t('lnZapNoAddress')); - const t2 = { ...lnTarget, eventId: target.eventId || null }; + const t2 = { ...lnTarget, eventId: target.eventId || null, moment: target.moment }; const p = await fetchPayParams(t2.url); const msat = sats * 1000; if (msat < p.minSendable || msat > p.maxSendable) { @@ -216,7 +216,7 @@ export function zapsFeature(ctx) { if (p.allowsNostr && t2.pk && wallet.nostrSign) { zapRequest = buildZapRequest({ amountMsat: msat, relays: wallet.nostrRelays(), lnurlBech32: t2.lnurlBech32, - recipientPk: t2.pk, eventId: t2.eventId || null, comment: '', + recipientPk: t2.pk, eventId: t2.eventId || null, moment: t2.moment, comment: '', signFn: (partial) => wallet.nostrSign(partial), }); } @@ -274,7 +274,7 @@ export function zapsFeature(ctx) { if (canZap) { zapRequest = buildZapRequest({ amountMsat: msat, relays: wallet.nostrRelays(), lnurlBech32: z.target.lnurlBech32, - recipientPk: z.target.pk, eventId: z.target.eventId || null, comment: z.comment, + recipientPk: z.target.pk, eventId: z.target.eventId || null, moment: z.target.moment, comment: z.comment, signFn: (partial) => wallet.nostrSign(partial), }); } @@ -585,12 +585,12 @@ export function zapsFeature(ctx) { // Ark's "no Ark address published" screen offers a Lightning fallback, // which lands here with the recipient's pubkey (and, when the zap was // aimed at a note, its event id for the zap request's e tag). - lnZapNpub(pk, npub, eventId, autoSat) { + lnZapNpub(pk, npub, eventId, autoSat, moment) { if (!canPay() || !wallet.nostrProfile) return false; if (autoSat && hook('arkReady')) { // the chip on the zapped note is pulsing since the tap — tell it how // this went, either way - autoZap({ kind: 'npub', pk, eventId: eventId || null }, autoSat) + autoZap({ kind: 'npub', pk, eventId: eventId || null, moment }, autoSat) .then(() => hook('zapSettled', eventId, true, autoSat)) .catch((e) => { hook('zapSettled', eventId, false); toast('⚡ ' + (e.message || t('lnZapFailed')), 4000); }); return true; @@ -599,7 +599,7 @@ export function zapsFeature(ctx) { hook('zapSettled', eventId, false); if (eventId) { toast('⚡ ' + t('lnZapFailed'), 4000); return true; } } - begin({ kind: 'npub', pk, eventId: eventId || null }, shortNpub(npub || npubOf(pk))); + begin({ kind: 'npub', pk, eventId: eventId || null, moment }, shortNpub(npub || npubOf(pk))); return true; }, resetSend() { ui.zap = null; ui.lnAuth = null; ui.lnWithdraw = null; }, diff --git a/src/i18n.js b/src/i18n.js index f04dd82..ecc5bb4 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -35,6 +35,9 @@ export const isRTL = (l) => RTL.has(l || lang); const STR = { en: { + mediaSeek: 'Seek', + mediaAtTime: 'at this time', + mediaCommentAt: 'Comment at', arkHistoryIncomplete: 'History incomplete', arkDetailsNotSynced: 'Payment details not synced', arkHistoryIncompleteHelp: 'These coins have been used, but the payment details and any remaining change have not synced to this device. Open the wallet that made the payments and let it sync.', diff --git a/src/lnurl.js b/src/lnurl.js index 7b1a798..592d143 100644 --- a/src/lnurl.js +++ b/src/lnurl.js @@ -1,3 +1,4 @@ +import { momentTags } from './media-timeline.js'; // LNURL-pay + NIP-57 ("lightning zap") logic — no DOM, so it stays testable. // // The wallet already pays bolt11 invoices over a Boltz submarine swap; this @@ -123,13 +124,14 @@ export async function fetchPayParams(url) { } // A signed kind-9734 zap request (NIP-57). signFn(partial) → finalized event. -export function buildZapRequest({ amountMsat, relays, lnurlBech32, recipientPk, eventId, comment, signFn }) { +export function buildZapRequest({ amountMsat, relays, lnurlBech32, recipientPk, eventId, comment, signFn, moment }) { const tags = [['relays', ...relays], ['amount', String(amountMsat)], ['p', recipientPk]]; if (lnurlBech32) tags.push(['lnurl', lnurlBech32]); if (eventId) tags.push(['e', eventId]); // a live stream's address too, so its chat and zap.stream count the zap const addr = zapAddrOf(eventId); if (addr) tags.push(['a', addr]); + tags.push(...momentTags(moment)); return signFn({ kind: 9734, content: comment || '', tags }); } diff --git a/src/media-timeline.js b/src/media-timeline.js new file mode 100644 index 0000000..bc9f4c7 --- /dev/null +++ b/src/media-timeline.js @@ -0,0 +1,140 @@ +// Timestamp annotations retain the usual Nostr event target. `media` is a +// client extension; the r tag is a portable media-fragment link. +export function mediaMoment(url, seconds) { + try { + const u = new URL(url); + if (!/^https?:$/.test(u.protocol) || !Number.isFinite(seconds) || seconds < 0 || seconds > Number.MAX_SAFE_INTEGER) return null; + u.hash = ''; + return { url: u.href, seconds: Math.floor(seconds) }; + } catch { return null; } +} +export function momentTags(moment) { + const m = moment && mediaMoment(moment.url, moment.seconds); + return m ? [['media', m.url, String(m.seconds)], ['r', m.url + '#t=' + m.seconds]] : []; +} +export function readMoment(event, depth = 0) { + if (depth > 1) return null; + if (event?.kind === 9735) { + try { return readMoment(JSON.parse(event.tags.find(t => t[0] === 'description')?.[1]), depth + 1); } catch { return null; } + } + const tag = event?.tags?.find(t => t[0] === 'media'); + return tag && /^\d+$/.test(tag[2] || '') ? mediaMoment(tag[1], Number(tag[2])) : null; +} +export function mediaClock(seconds) { + const s = Math.max(0, Math.floor(seconds || 0)); + return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); +} +export function waveformFromTags(event, url) { + const tag = event?.tags?.find(t => t[0] === 'imeta' && t.includes('url ' + url)); + const raw = tag?.find(t => t.startsWith('waveform '))?.slice(9); + if (!raw) return null; + const values = raw.trim().split(/\s+/).map(Number); + return values.length > 1 && values.length <= 4096 && values.every(n => Number.isFinite(n) && n >= 0) ? values : null; +} +export function waveformPeaks(buffer, count = 96) { + const peaks = new Array(count).fill(0); + for (let ch = 0; ch < buffer.numberOfChannels; ch++) { + const data = buffer.getChannelData(ch); + for (let i = 0; i < count; i++) { + const end = Math.floor((i + 1) * data.length / count); + for (let j = Math.floor(i * data.length / count); j < end; j++) peaks[i] = Math.max(peaks[i], Math.abs(data[j])); + } + } + return peaks; +} +const waves = new Map(); +let decoding = Promise.resolve(); +export function loadWaveform(url) { + if (waves.has(url)) return waves.get(url); + const job = decoding.then(async () => { + const limit = 20 * 1024 * 1024; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 30000); + let ctx; + try { + const response = await fetch(url, { signal: controller.signal, credentials: 'omit' }); + if (!response.ok || Number(response.headers.get('content-length')) > limit) throw new Error('size'); + const reader = response.body.getReader(), parts = []; + let size = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > limit) { await reader.cancel(); throw new Error('size'); } + parts.push(value); + } + const Audio = globalThis.AudioContext || globalThis.webkitAudioContext; + ctx = new Audio({ sampleRate: 8000 }); + const buffer = await ctx.decodeAudioData(await new Blob(parts).arrayBuffer()); + return waveformPeaks(buffer); + } catch { return null; } + finally { clearTimeout(timer); controller.abort(); if (ctx) await ctx.close().catch(() => {}); } + }); + decoding = job.then(() => {}); + waves.set(url, job); + if (waves.size > 24) waves.delete(waves.keys().next().value); + return job; +} + +// The live player owns this subtree, so morphing cannot replace its handlers +// with closures pointing at a detached player. +export function mediaTimeline(player, url, { h, event, moments, action, label }) { + const audio = player.tagName === 'AUDIO'; + const box = h('div', { class: 'media-timeline', onClick: e => e.stopPropagation(), onPointerdown: e => e.stopPropagation() }); + box._skipMorph = true; + const bars = h('div', { class: 'media-wave', 'aria-hidden': 'true' }); + const range = h('input', { type: 'range', min: 0, max: 1000, value: 0, step: 1, 'aria-label': label('mediaSeek'), disabled: true }); + const seek = h('div', { class: 'media-seek' }, bars, range); + const time = h('span', { class: 'small muted' }, '0:00'); + const marks = h('div', { class: 'media-marks' }); + const detail = h('div', { class: 'small media-moment-detail', 'aria-live': 'polite' }); + const actions = h('div', { class: 'row media-moment-actions' }, time); + for (const [kind, icon] of [['comment', '↩'], ['reaction', '♡'], ['zap', '⚡']]) { + actions.append(h('button', { type: 'button', class: 'btn-sm', 'aria-label': label(kind === 'comment' ? 'msgReply' : kind === 'reaction' ? 'postLike' : 'zapTitle') + ' ' + label('mediaAtTime'), onClick: e => { + e.stopPropagation(); + const ev = event(); + const m = mediaMoment(url, player.currentTime); + if (ev && m) action(kind, ev, m, e.currentTarget); + } }, icon + ' ' + label('mediaAtTime'))); + } + box.append(seek, actions, marks, detail); + let waveStarted = false, signature = ''; + const draw = values => { + if (!values) return; + const max = Math.max(...values) || 1; + const stride = Math.max(1, Math.ceil(values.length / 96)); + bars.replaceChildren(); + for (let i = 0; i < values.length; i += stride) bars.append(h('i', { style: 'height:' + Math.max(3, 100 * Math.max(...values.slice(i, i + stride)) / max) + '%' })); + seek.classList.add('has-wave'); + }; + const jump = seconds => { if (Number.isFinite(player.duration)) player.currentTime = Math.min(seconds, player.duration); }; + range.addEventListener('input', () => jump(Number(range.value) * player.duration / 1000)); + const update = () => { + const ev = event(); + const finite = Number.isFinite(player.duration) && player.duration > 0; + actions.hidden = !ev || !finite; + range.disabled = !finite; + range.value = finite ? player.currentTime / player.duration * 1000 : 0; + range.setAttribute('aria-valuetext', mediaClock(player.currentTime) + ' / ' + mediaClock(finite ? player.duration : 0)); + seek.style.setProperty('--played', (finite ? player.currentTime / player.duration * 100 : 0) + '%'); + time.textContent = mediaClock(player.currentTime) + ' / ' + mediaClock(finite ? player.duration : 0); + for (const b of actions.querySelectorAll('button')) b.title = b.textContent + ' ' + mediaClock(player.currentTime); + if (audio && !waveStarted && finite) { + waveStarted = true; + const published = waveformFromTags(ev, url); + if (published) draw(published); + else if (player.duration <= 1200 && !navigator.connection?.saveData) loadWaveform(url).then(draw); + } + const items = ev ? moments(ev.id, url) : []; + const next = items.map(x => x.id).join(','); + if (signature !== next) { + signature = next; + marks.replaceChildren(...items.map(x => h('button', { type: 'button', class: 'media-mark btn-sm', + title: x.text, onClick: () => { jump(x.moment.seconds); detail.textContent = x.text || x.icon; } }, mediaClock(x.moment.seconds) + ' ' + x.icon))); + } + }; + // Fetching/decoding starts only once the user plays (or explicitly seeks). + for (const type of ['timeupdate', 'playing', 'seeked', 'durationchange', 'mediaactivity']) player.addEventListener(type, update); + actions.hidden = true; + return box; +} diff --git a/src/style.css b/src/style.css index c4e5202..a7a176e 100644 --- a/src/style.css +++ b/src/style.css @@ -1960,3 +1960,30 @@ html:has(.call-overlay:not(.call-min)) { overflow: hidden; scrollbar-gutter: aut .call-log-pill .chat-time { margin: 0; font-size: 11px; opacity: .7; } .call-log-pill svg { width: 16px; height: 16px; } .chat-row.call-log.missed .call-log-pill { color: var(--red); border-color: color-mix(in srgb, var(--red) 40%, var(--line)); } + +/* Timestamp actions and the audio waveform share the player's lifetime. */ +.media-timeline { padding: 8px 0; white-space: normal; color: var(--text); } +.media-moment-actions { gap: 6px; flex-wrap: wrap; align-items: center; } +.media-moment-actions[hidden] { display: none; } +.media-moment-actions > span { margin-right: auto; font-variant-numeric: tabular-nums; } +.media-moment-actions button { font-size: 12px; min-height: 32px; } +.media-seek { position: relative; height: 42px; --played: 0%; } +.media-wave { position: absolute; inset: 3px 0; display: flex; align-items: center; gap: 2px; + background: linear-gradient(to right, var(--accent) var(--played), var(--line) var(--played)); } +.media-wave:empty { height: 4px; top: 19px; } +.media-wave i { flex: 1; background: var(--accent); border-radius: 2px; min-width: 1px; } +.has-wave .media-wave { background: none; } +.has-wave .media-wave::after { content: ''; position: absolute; inset: 0 0 0 var(--played); background: var(--bg, #fff); opacity: .55; pointer-events: none; } +.media-seek input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: .01; cursor: pointer; } +.media-seek:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; } +.media-marks { display: flex; gap: 6px; overflow-x: auto; padding-top: 5px; } +.media-mark { flex-shrink: 0; max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.media-marks:empty { display: none; } +.mp-slide > .media-timeline { position: absolute; left: 16px; right: 16px; bottom: calc(var(--mp-seek-lift) + 30px); z-index: 4; + padding: 6px; border-radius: 8px; background: rgba(0,0,0,.7); color: #fff; display: none; } +.media-pager.show .mp-cur > .media-timeline { display: block; } +.mp-slide > .media-timeline .media-seek { display: none; } +.media-pager.videos.show .mp-meta { padding-bottom: calc(var(--mp-seek-lift) + 130px); } + +.media-moment-detail { overflow-wrap: anywhere; max-height: 72px; overflow-y: auto; margin-top: 5px; } +.media-moment-detail:empty { display: none; } diff --git a/tools/media-moments-feed-test.js b/tools/media-moments-feed-test.js new file mode 100644 index 0000000..f2d2774 --- /dev/null +++ b/tools/media-moments-feed-test.js @@ -0,0 +1,171 @@ +// Full feed integration with local media and relay acknowledgements; no events leave the test. +// Run: bun tools/media-moments-feed-test.js +import puppeteer from './puppeteer.js'; +import { cacheKeyFor } from '../src/wallet.js'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { buildHtml } from '../build.js'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +const dir = mkdtempSync(tmpdir() + '/media-'); +const ff = (args) => { const p = Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', ...args]); if (p.exitCode !== 0) throw new Error('ffmpeg failed'); }; +for (const n of ['a', 'b', 'c']) ff(['-f', 'lavfi', '-i', 'testsrc=size=360x640:rate=10', '-t', '2', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', `${dir}/${n}.mp4`]); +// index last (no faststart), from a server that ignores ranges +ff(['-f', 'lavfi', '-i', 'testsrc=size=480x270:rate=10', '-t', '2', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', `${dir}/slow.mp4`]); +for (const n of ['p1', 'p2', 'p3']) ff(['-f', 'lavfi', '-i', 'testsrc=size=800x600', '-frames:v', '1', `${dir}/${n}.jpg`]); + +ff(['-f', 'lavfi', '-i', 'sine=frequency=440:duration=12', `${dir}/audio.mp3`]); +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 0, idleTimeout: 30, fetch: (req) => { + const path = new URL(req.url).pathname; + if (path === '/audio.mp3') { + const f = Bun.file(dir + '/audio.mp3'), m = /bytes=(\d+)-(\d*)/.exec(req.headers.get('range') || ''); + if (m) { const a = +m[1], b = Math.min(m[2] ? +m[2] : f.size - 1, f.size - 1); return new Response(f.slice(a, b + 1), { status: 206, headers: { 'content-type': 'audio/mpeg', 'content-range': `bytes ${a}-${b}/${f.size}`, 'accept-ranges': 'bytes' } }); } + return new Response(f, { headers: { 'content-type': 'audio/mpeg' } }); + } + if (path.startsWith('/v/')) { + const f = Bun.file(dir + path.slice(2)); + const m = /bytes=(\d+)-(\d*)/.exec(req.headers.get('range') || ''); + if (m && path !== '/v/slow.mp4') { + const a = +m[1], b = Math.min(m[2] ? +m[2] : f.size - 1, f.size - 1); + // c.mp4 stands in for a 50 MB file: its server says so + const total = path === '/v/c.mp4' && m[2] === '65535' ? 50000000 : f.size; // (to the probe; a player gets the real file) + return new Response(f.slice(a, b + 1), { status: 206, headers: { 'content-type': 'video/mp4', 'content-range': `bytes ${a}-${b}/${total}`, 'accept-ranges': 'bytes' } }); + } + return new Response(f, { headers: { 'content-type': 'video/mp4' } }); + } + if (path.startsWith('/i/')) return new Response(Bun.file(dir + path.slice(2)), { headers: { 'content-type': 'image/jpeg' } }); + if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path)); + return new Response(html, { headers: { 'content-type': 'text/html' } }); +} }); +const base = server.url.origin; +const SK = generateSecretKey(), PK = getPublicKey(SK); +const FAN = generateSecretKey(); +const now = Math.floor(Date.now() / 1000); +const post = (i, content, tags = []) => finalizeEvent({ kind: 1, created_at: now - 100 - i * 60, tags, content }, SK); +const notes = [ + post(0, 'first clip, look at this ' + base + '/v/a.mp4'), + post(1, 'a plain post with no media at all'), + post(2, 'sunset over the bay ' + base + '/i/p1.jpg'), + post(3, base + '/v/b.mp4'), + post(4, 'two at once ' + base + '/i/p2.jpg ' + base + '/i/p3.jpg'), + // the post says this one is 50 MB (it isn't; the post is believed) + post(5, 'a huge clip ' + base + '/v/c.mp4', [['imeta', 'url ' + base + '/v/c.mp4', 'm video/mp4', 'size 50000000', 'dim 360x640']]), + post(6, 'index last ' + base + '/v/slow.mp4'), +]; +notes.unshift(post(9, 'Listen ' + base + '/audio.mp3', [['imeta', 'url ' + base + '/audio.mp3', 'waveform 1 5 2 8 4 10']])); +const LIKE = finalizeEvent({ kind: 7, created_at: now - 50, tags: [['e', notes[2].id], ['p', PK]], content: '+' }, FAN); + +const browser = await puppeteer.launch({ executablePath: process.env.CHROME || '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] }); +const page = await browser.newPage(); +await page.evaluateOnNewDocument((like) => { + const Real = window.WebSocket; + window.WebSocket = function (...a) { + const ws = new Real(...a); + const send = ws.send.bind(ws); + ws.send = (data) => { + try { + const m = JSON.parse(data); + if (m[0] === 'REQ') { + const f = m[2] || {}; + const evs = (f.kinds || []).includes(7) && (f['#e'] || []).includes(like.tags[0][1]) ? [like] : []; + setTimeout(() => { + for (const ev of evs) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', m[1], ev]) })); + ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) })); + }, 0); + return; + } + if (m[0] === 'EVENT') return send(data); + } catch {} + return send(data); + }; + return ws; + }; + window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real); +}, LIKE); +const errs = []; +page.on('pageerror', (e) => errs.push(String(e))); +const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t); +const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; }; +const waitFor = async (fn, ms = 10000) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn)) return true; await sleep(200); } return false; }; +const mode = (m) => page.evaluate((x) => document.querySelector(`.media-mode[data-mode="${x}"]`).click(), m); +const shot = async (name) => { if (process.env.SHOTS) await page.screenshot({ path: `${process.env.SHOTS}/${name}.png` }); }; +// which slide fills the screen, and what's playing +const pagerState = () => page.evaluate(() => { + const p = document.querySelector('.media-pager'); + if (!p) return null; + const sc = p.querySelector('.mp-scroll'); + const slides = [...sc.querySelectorAll('.mp-slide')]; // (not the loading screen at the end) + const i = slides.findIndex((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); // the slide on screen + const vids = slides.map((s) => s.querySelector('video')); + return { n: slides.length, i, src: (slides[i]?.querySelector('.mp-media')?.getAttribute('data-src') || '').split('/').pop(), + playing: vids.map((v) => !!(v && !v.paused)), show: p.classList.contains('show'), + meta: getComputedStyle(slides[i].querySelector('.mp-meta')).opacity }; +}); + +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto(base, { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); + await click('create a new wallet'); await sleep(600); + await page.waitForSelector('.words .w .t'); + const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); + await click('skip verification'); + await waitText('receive', 20000); + const key = cacheKeyFor(mn + '\n'); + await page.evaluate(([k, pk, ns]) => { + localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) })); + localStorage.setItem(k + ':feedNotes', JSON.stringify(ns)); + localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Shutterbug', t: Date.now() } })); + localStorage.setItem('btc-wallet-feed:' + k, 'following'); + }, [key, PK, notes]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => [...document.querySelectorAll('.app-nav-button, button')].find((b) => b.textContent.trim() === 'Feed')?.click()); + await page.waitForSelector('.notes-feed:not([data-booting])', { timeout: 20000 }); + + + await page.waitForSelector('.note-audio audio'); + await page.evaluate(() => document.querySelector('.note-audio audio').play()); + await page.waitForSelector('.note-audio .media-timeline'); + await page.waitForFunction(() => document.querySelectorAll('.note-audio .media-wave i').length === 6); + check('published audio waveform renders in the actual feed', true); + await page.evaluate(() => { const a = document.querySelector('.note-audio audio'); a.pause(); a.currentTime = 5; }); + await sleep(100); + await page.evaluate(() => document.querySelectorAll('.note-audio .media-moment-actions button')[1].click()); + await page.waitForSelector('.msg-sheet'); + await page.evaluate(() => document.querySelector('.msg-sheet button').click()); + await sleep(400); + const reaction = await page.evaluate(() => window.__relayGuard.events.find(e => e.kind === 7 && e.tags.some(t => t[0] === 'media'))); + check('reaction is signed with the selected file and position', reaction?.tags.some(t => t[0] === 'media' && t[2] === '5')); + check('reaction marker appears while paused', await page.evaluate(() => !!document.querySelector('.note-audio .media-mark'))); + await page.screenshot({ path: '/tmp/media-timeline-playing.png' }); + await page.evaluate(() => document.querySelector('.note-audio .media-moment-actions button').click()); + await page.waitForSelector('.thread-reply-input'); + check('timestamp is visible in the reply composer', (await page.evaluate(() => document.body.innerText)).includes('Comment at 0:05')); + await page.evaluate(() => { const input = document.querySelector('.thread-reply-input'); input.value = 'Great moment'; input.dispatchEvent(new Event('input', { bubbles: true })); }); + await page.click('.thread-reply-send'); + await sleep(500); + const comment = await page.evaluate(() => window.__relayGuard.events.find(e => e.kind === 1 && e.content.includes('Great moment'))); + check('comment includes a readable timestamp and signed media context', comment?.content === '[0:05] Great moment' && comment.tags.some(t => t[0] === 'media' && t[2] === '5')); + check('comment shows a jump-to-time link', await page.evaluate(() => !!document.querySelector('.media-time-link'))); + await page.evaluate(() => document.querySelector('.media-time-link').click()); + await page.waitForFunction(() => document.querySelector('.note-audio audio')?.currentTime >= 5); + check('the comment link starts playback at its timestamp', true); + await click('back'); await sleep(250); + await page.evaluate(() => document.querySelector('video.note-video').play()); + await page.waitForSelector('.note-video-box .media-moment-actions'); + await page.evaluate(() => { const v = document.querySelector('video.note-video'); v.pause(); v.currentTime = 1; }); + await sleep(150); + await page.evaluate(() => document.querySelectorAll('.note-video-box .media-moment-actions button')[1].click()); + await page.waitForSelector('.msg-sheet'); + await page.evaluate(() => document.querySelector('.msg-sheet button').click()); + await sleep(300); + check('video reaction carries the video URL and selected second', await page.evaluate(() => window.__relayGuard.events.some(e => e.kind === 7 && e.tags.some(t => t[0] === 'media' && t[1].endsWith('.mp4') && t[2] === '1')))); + check('no browser exceptions', errs.length === 0, errs.join(' | ')); + await page.screenshot({ path: '/tmp/media-timeline-feed.png' }); +} finally { await browser.close(); server.stop(); } +process.exit(ok ? 0 : 1); diff --git a/tools/media-timeline-test.js b/tools/media-timeline-test.js new file mode 100644 index 0000000..c8f1fd9 --- /dev/null +++ b/tools/media-timeline-test.js @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import { mediaMoment, momentTags, readMoment, waveformFromTags, waveformPeaks } from '../src/media-timeline.js'; +import { buildZapRequest } from '../src/lnurl.js'; +import puppeteer from './puppeteer.js'; +const url = 'https://example.com/song.mp3'; +const moment = mediaMoment(url, 65.9); +assert.deepEqual(moment, { url, seconds: 65 }); +for (const value of [-1, Infinity, NaN]) assert.equal(mediaMoment(url, value), null); +assert.equal(mediaMoment('javascript:alert(1)', 0), null); +assert.deepEqual(readMoment({ tags: momentTags(moment) }), moment); +assert.equal(readMoment({ tags: [['media', url, '']] }), null); +const request = buildZapRequest({ amountMsat: 21000, relays: [], recipientPk: 'p', eventId: 'e', moment, signFn: e => e }); +assert.equal(request.tags.find(t => t[0] === 'e')[1], 'e'); +assert.deepEqual(readMoment({ kind: 9735, tags: [['description', JSON.stringify(request)]] }), moment); +assert.equal(readMoment(buildZapRequest({ amountMsat: 21000, relays: [], recipientPk: 'p', eventId: 'e', signFn: e => e })), null); +assert.deepEqual(waveformFromTags({ tags: [['imeta', 'url ' + url, 'waveform 1 5 2']] }, url), [1, 5, 2]); +assert.equal(waveformFromTags({ tags: [['imeta', 'url ' + url, 'waveform -1 2']] }, url), null); +assert.deepEqual(waveformPeaks({ numberOfChannels: 2, getChannelData: n => n ? [0, 0, .8, 0] : [.2, -.5, .1, 0] }, 2), [.5, .8]); +const wav = new Uint8Array(44 + 16000); +const dv = new DataView(wav.buffer); +const word = (at, text) => wav.set(new TextEncoder().encode(text), at); +word(0, 'RIFF'); dv.setUint32(4, wav.length - 8, true); word(8, 'WAVE'); word(12, 'fmt '); +dv.setUint32(16, 16, true); dv.setUint16(20, 1, true); dv.setUint16(22, 1, true); dv.setUint32(24, 8000, true); dv.setUint32(28, 16000, true); dv.setUint16(32, 2, true); dv.setUint16(34, 16, true); word(36, 'data'); dv.setUint32(40, 16000, true); +for (let i = 0; i < 8000; i++) dv.setInt16(44 + i * 2, Math.sin(i * .3) * 20000, true); +const server = Bun.serve({ port: 0, fetch(req) { + const path = new URL(req.url).pathname; + if (path === '/wave.wav') return new Response(wav, { headers: { 'content-type': 'audio/wav' } }); + if (path === '/large.wav') return new Response('', { headers: { 'content-length': String(21 * 1024 * 1024) } }); + if (path === '/module.js') return new Response(Bun.file('src/media-timeline.js'), { headers: { 'content-type': 'text/javascript' } }); + if (path === '/style.css') return new Response(Bun.file('src/style.css')); + return new Response('<!doctype html><link rel="stylesheet" href="/style.css"><body>', { headers: { 'content-type': 'text/html' } }); +} }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] }); +try { + const page = await browser.newPage(); + await page.goto(server.url.href); + const decoded = await page.evaluate(async () => { + const { loadWaveform } = await import('/module.js'); + const peaks = await loadWaveform('/wave.wav'); + return { count: peaks?.length, nonzero: peaks?.every(x => x > .5), fallback: await loadWaveform('/large.wav') }; + }); + assert.equal(decoded.count, 96); assert.equal(decoded.nonzero, true); assert.equal(decoded.fallback, null); + const result = await page.evaluate(async () => { + const { mediaTimeline } = await import('/module.js'); + const h = (tag, attrs = {}, ...children) => { + const el = document.createElement(tag); + for (const [k, v] of Object.entries(attrs)) { if (k.startsWith('on')) el.addEventListener(k.slice(2).toLowerCase(), v); else if (v === true) el.setAttribute(k, ''); else if (v !== false) el.setAttribute(k, v); } + el.append(...children); return el; + }; + const player = h('audio'); + Object.defineProperty(player, 'duration', { value: 120 }); + let at = 0; + Object.defineProperty(player, 'currentTime', { get: () => at, set: x => { at = x; } }); + const url = 'https://example.com/song.mp3'; + const ev = { id: 'post', tags: [['imeta', 'url ' + url, 'waveform 1 10 4 8']] }; + const calls = [], items = [{ id: 'a', moment: { seconds: 30 }, icon: '♡', text: 'Nice!' }]; + const box = mediaTimeline(player, url, { h, event: () => ev, moments: () => items, action: (...a) => calls.push(a.slice(0, 3)), label: x => ({ mediaAtTime: 'at this time' }[x] || x) }); + document.body.append(player, box); + player.dispatchEvent(new Event('timeupdate')); + const wave = box.querySelectorAll('.media-wave i').length; + const range = box.querySelector('input'); range.value = 500; range.dispatchEvent(new Event('input')); + const seek = at; + for (const b of box.querySelectorAll('.media-moment-actions button')) b.click(); + box.querySelector('.media-mark').click(); + const marker = at; + items.push({ id: 'b', moment: { seconds: 70 }, icon: '⚡', text: '' }); + player.dispatchEvent(new Event('mediaactivity')); + window.fixture = box; + return { wave, seek, marker, calls, markers: box.querySelectorAll('.media-mark').length, aria: range.getAttribute('aria-valuetext') }; + }); + assert.equal(result.wave, 4); + assert.equal(result.seek, 60); + assert.equal(result.marker, 30); + assert.equal(result.markers, 2); + assert.deepEqual(result.calls.map(c => [c[0], c[1].id, c[2].seconds]), [['comment', 'post', 60], ['reaction', 'post', 60], ['zap', 'post', 60]]); + await page.setViewport({ width: 360, height: 740 }); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true); + await page.screenshot({ path: '/tmp/media-timeline-mobile.png' }); + console.log('✓ Timestamp validation, Lightning receipt round-trip, untimed zap isolation, waveform peaks, browser seeking, all three actions, marker updates and mobile layout'); +} finally { await browser.close(); server.stop(); } |
