aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/features/ark.js17
-rw-r--r--src/features/messages.js84
-rw-r--r--src/features/zaps.js14
-rw-r--r--src/i18n.js3
-rw-r--r--src/lnurl.js4
-rw-r--r--src/media-timeline.js140
-rw-r--r--src/style.css27
7 files changed, 263 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; }