aboutsummaryrefslogtreecommitdiffstats
path: root/src/features/messages.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-20 12:15:32 -0700
committerAdam Soltys <[email protected]>2026-10-20 12:15:32 -0700
commit1e0d551daae8d09f86dbf54ab67bf52610c0c9ac (patch)
tree7e118cc5a49a8308c77b17cbd291012f45cf0875 /src/features/messages.js
parent84b6a68000c942e17bfe75c1493eddad7ceb5b78 (diff)
downloadcoinosv3-1e0d551daae8d09f86dbf54ab67bf52610c0c9ac.tar.gz
coinosv3-1e0d551daae8d09f86dbf54ab67bf52610c0c9ac.zip
Timed reactions sit on the seek bar at their moment, and the usual buttons carry the time
The "at this time" reply/like/zap buttons are gone, and with them the panel they made over the pager. A reply, like or zap made from a post's own buttons (the feed's, or the pager's ♥ ⚡ and post) while its clip has played is about that moment; the reply box says so and its × makes it about the whole post. What was said, reacted or zapped at a moment sits over the bar at that moment (a custom :emoji: as its picture, from the reaction's own tag; it was showing as :cb-rofl: text), a tap goes there and says who and what, and in the pager it floats up off the bar as the clip plays past. Your reaction no longer turns the count red, only the heart. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_013ZDf1cyCQLkfjftYhymZAg
Diffstat (limited to 'src/features/messages.js')
-rw-r--r--src/features/messages.js130
1 files changed, 95 insertions, 35 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index 02e418d..7824a6f 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -1,4 +1,4 @@
-import { mediaMoment, momentTags, readMoment, mediaClock, mediaTimeline } from '../media-timeline.js';
+import { mediaMoment, momentTags, readMoment, mediaClock, mediaTimeline, markAt } from '../media-timeline.js';
import { mediaServers } from '../media-servers.js';
// Messages — Concord community chat (CORD-01..05) + NIP-17 private DMs.
//
@@ -4522,38 +4522,47 @@ export function messagesFeature(ctx) {
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')); }));
+ queueMicrotask(() => document.querySelectorAll('audio, video').forEach(v => { if (v._momentUrl) v.dispatchEvent(new Event('mediaactivity')); }));
}
+ // a post's timed activity on one of its clips, in order, each with who and what
+ const momentsOn = (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) }));
+ // how a moment shows on a bar: a reaction as its emoji (a :code: as its
+ // picture, from the reaction's own tag), a zap ⚡, a reply ↩
+ function momentGlyph(x) {
+ if (x.ev.kind !== 7) return x.icon;
+ const code = shortcodeOf(x.ev.content);
+ const tag = code && x.ev.tags.find(t => t[0] === 'emoji' && t[1] === code && t[2]);
+ return tag ? reactImg(code, tag[2]) : reactNode(x.ev.content === '+' || !x.ev.content ? '❤️' : x.ev.content);
+ }
+ // Where a post's clip is up to, if it has been played: a reply, reaction or
+ // zap made then is about that moment. (No buttons of their own for it: the
+ // usual ones carry the time.)
+ function momentOf(ev) {
+ if (!ev) return null;
+ const cur = pager && pager.cur;
+ const players = cur && cur._ev && cur._ev.id === ev.id
+ ? [cur.querySelector('.mp-media')]
+ : [...document.querySelectorAll('[data-zap-post="' + CSS.escape(ev.id) + '"] :is(video, audio)')];
+ for (const v of players) {
+ if (v && v._momentUrl && v.currentTime > 0 && Number.isFinite(v.duration)) return mediaMoment(v._momentUrl, v.currentTime);
+ }
+ return null;
+ }
+ const withMoment = (ev) => { const m = momentOf(ev); return m ? { ...ev, mediaMoment: m } : ev; };
function attachMediaTimeline(player, url, explicitEvent) {
if (player._timeline) return;
player._timeline = true;
+ player._momentUrl = url;
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);
- }
- },
- }));
+ box.append(mediaTimeline(player, url, { h, event, label: t, moments: momentsOn, glyph: momentGlyph }));
player.dispatchEvent(new Event('timeupdate'));
}
function noteEvent(ev) {
@@ -10615,6 +10624,9 @@ export function messagesFeature(ctx) {
// 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, moment = null) {
+ // the post opened from a clip as it played: a reply to it is about that moment
+ const offered = ui.noteThread && ui.noteThread.momentFor;
+ if (!moment && offered && offered.id === ev.id) moment = offered.moment;
if (ui.noteThread && ui.noteThread.rootId === rootIdOf(ev)) {
ui.noteThread.focusId = ev.id;
ui.noteThread.seed = ev;
@@ -11746,16 +11758,16 @@ export function messagesFeature(ctx) {
const zapFlying = !!zp && zp.flying;
// tap pays the default amount; hold opens the screen that sets it
const zapHold = canZap ? holdable((el) => openZapSettings(pk, ev.id, el.getBoundingClientRect()),
- (e) => { zapNote(pk, ev, e.currentTarget.getBoundingClientRect()); recheckZap(ev.id); }) : null;
+ (e) => { zapNote(pk, ev, e.currentTarget.getBoundingClientRect(), momentOf(ev)); recheckZap(ev.id); }) : null;
const zapMine = !!((z && z.mine) || optimistic);
const zapLabel = zapFlying ? t('zapSending') : zapSats ? t('zapTallyTitle', { n: zapSats.toLocaleString() }) : t('zapTitle');
return h('div', { class: 'row note-acts' },
- btn(I_REPLY, t('msgReply'), replyCount(ev.id), false, () => { if (!signinAsk()) replyToNote(ev); }),
+ btn(I_REPLY, t('msgReply'), replyCount(ev.id), false, () => { if (!signinAsk()) replyToNote(ev, momentOf(ev)); }),
btn(I_BOOST, t('postBoost'), boostN, iBoosted(ev.id), () => boostNote(ev).catch(() => {})),
btn(I_QUOTE, t('postQuote'), 0, false, () => { if (!signinAsk()) quoteNote(ev); }),
// tap to choose how you feel about it; tap again to take it back
btn(mineReact ? postReactNode(mineReact) : I_HEART(false), t('postLike'), likeN, !!mineReact,
- () => { if (signinAsk()) return; if (mineReact) unreact(ev).catch(() => {}); else { ui.reactPick = ev; render(); } }),
+ () => { if (signinAsk()) return; if (mineReact) unreact(ev).catch(() => {}); else { ui.reactPick = withMoment(ev); render(); } }),
// always in the bar — the same row of buttons on every post, so nothing
// shifts when a wallet comes online; without one it is just the number
btn(I_ZAP, zapLabel, zapSats ? fmtSats(zapSats) : 0, zapMine,
@@ -12368,7 +12380,11 @@ 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,
+ // the moment of the clip this reply is about, and a way to make it about the whole post
+ s.mediaMoment ? h('div', { class: 'row small muted', style: 'gap:6px;align-items:center' },
+ t('mediaCommentAt') + ' ' + mediaClock(s.mediaMoment.seconds),
+ h('button', { type: 'button', class: 'btn-ghost btn-sm', 'aria-label': t('mediaMomentDrop'), title: t('mediaMomentDrop'),
+ style: 'padding:0 6px;min-height:0', onClick: () => { s.mediaMoment = null; if (s.momentFor) s.momentFor = null; render(); } }, '×')) : null,
s.preview ? draftPreview(s.draft) : null,
mediaBar(),
// A textarea that grows with the reply, like the chat composer: Enter
@@ -14454,18 +14470,22 @@ export function messagesFeature(ctx) {
h('button', { class: 'mp-like', type: 'button', 'aria-label': t('postLike'), onClick: stop(() => {
if (signinAsk()) { leavePager(); render(); return; }
if (myReactOn(ev.id)) { unreact(ev).catch(() => {}).finally(() => pagerCounts(slide)); setTimeout(() => pagerCounts(slide), 50); return; }
- ui.reactPick = ev; render();
+ ui.reactPick = withMoment(ev); render();
}) }, h('span', { class: 'i', html: I_HEART(false) }), h('span', { class: 'n' })),
// the post itself, as the feed would open it: the whole text, the replies
h('button', { class: 'mp-post', type: 'button', 'aria-label': t('mediaOpenPost'), title: t('mediaOpenPost'), onClick: stop(() => {
+ // a reply written there is about where the clip was
+ const moment = momentOf(ev);
leavePager(); openNoteThread(ev);
+ if (moment && ui.noteThread) ui.noteThread.momentFor = { id: ev.id, moment };
}) }, h('span', { class: 'i', html: I_REPLY }), h('span', { class: 'n' })),
h('button', { class: 'mp-zap', type: 'button', 'aria-label': t('zapTitle'), onClick: stop((e) => {
if (signinAsk()) { leavePager(); render(); return; }
// the first zap ever asks for an amount on a page of its own
const at = e.currentTarget.getBoundingClientRect();
+ const moment = momentOf(ev);
if (!(ctx.zapDefaultSat && ctx.zapDefaultSat())) leavePager();
- zapNote(ev.pubkey, ev, at);
+ zapNote(ev.pubkey, ev, at, moment);
recheckZap(ev.id);
setTimeout(() => pagerCounts(slide), 50);
}) }, h('span', { class: 'i', html: I_ZAP }), h('span', { class: 'n' })))));
@@ -14477,8 +14497,8 @@ export function messagesFeature(ctx) {
guardPicture(media, url);
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));
+ media._momentUrl = url;
+ slide.append(seekBar(media, () => momentsOn(ev.id, url)));
}
return slide;
}
@@ -14493,14 +14513,48 @@ export function messagesFeature(ctx) {
clearTimeout(bar._hide);
bar._hide = setTimeout(() => { if (bar._drag) revealSeek(bar); else bar.classList.remove('reveal'); }, 3500);
}
- function seekBar(v) {
+ // What was said, reacted or zapped at a moment of the clip sits over the
+ // bar at that moment (a tap goes there and says who and what), and floats
+ // up off it as the clip plays past.
+ function seekBar(v, moments = () => []) {
const fill = h('div', { class: 'mp-seek-fill' });
const time = h('span', { class: 'mp-seek-time' });
- const bar = h('div', { class: 'mp-seek' }, h('div', { class: 'mp-seek-track' }, fill), time);
+ const marks = h('div', { class: 'mp-marks' });
+ const bar = h('div', { class: 'mp-seek' }, marks, h('div', { class: 'mp-seek-track' }, fill), time);
+ let items = [], sig = '', checked = 0, last = 0;
+ const say = (x) => {
+ time.textContent = clock(x.moment.seconds) + ' ' + x.text;
+ bar.classList.add('say');
+ clearTimeout(bar._say);
+ bar._say = setTimeout(() => bar.classList.remove('say'), 3000);
+ revealSeek(bar);
+ };
+ const paintMarks = () => {
+ checked = performance.now();
+ items = moments();
+ const next = items.map((x) => x.id).join(',') + '@' + v.duration;
+ if (next === sig) return;
+ sig = next;
+ marks.replaceChildren(...items.map((x) => h('button', {
+ type: 'button', class: 'mp-mark', title: clock(x.moment.seconds) + ' ' + x.text,
+ style: 'left:' + markAt(x.moment.seconds, v.duration) + '%',
+ onPointerdown: (e) => e.stopPropagation(),
+ onClick: (e) => { e.stopPropagation(); try { v.currentTime = x.moment.seconds; } catch {} say(x); },
+ }, momentGlyph(x))));
+ };
+ v.addEventListener('mediaactivity', () => { checked = 0; });
+ // a moment the clip just played through floats up from its place
+ const pop = (x) => {
+ const slide = bar.closest('.mp-slide');
+ if (!slide || !pager || pager.cur !== slide) return;
+ const el = h('div', { class: 'mp-pop', style: 'left:calc(' + bar.offsetLeft + 'px + ' + (markAt(x.moment.seconds, v.duration) / 100) + ' * ' + bar.offsetWidth + 'px)' }, momentGlyph(x));
+ slide.append(el);
+ setTimeout(() => el.remove(), 1700);
+ };
const frac = (e) => { const r = bar.getBoundingClientRect(); return Math.min(1, Math.max(0, (e.clientX - r.left) / (r.width || 1))); };
const show = (f) => {
fill.style.width = (f * 100).toFixed(2) + '%';
- time.textContent = clock(f * v.duration) + ' / ' + clock(v.duration);
+ if (!bar.classList.contains('say')) time.textContent = clock(f * v.duration) + ' / ' + clock(v.duration); // (unless it's saying a moment)
};
const to = (f) => { try { v.currentTime = f * v.duration; } catch {} };
bar.addEventListener('pointerdown', (e) => {
@@ -14509,6 +14563,7 @@ export function messagesFeature(ctx) {
try { bar.setPointerCapture(e.pointerId); } catch {}
bar._drag = { playing: !v.paused, id: e.pointerId };
bar.classList.add('drag');
+ bar.classList.remove('say');
v.pause();
const f = frac(e); show(f); to(f);
});
@@ -14534,7 +14589,12 @@ export function messagesFeature(ctx) {
if (bar._drag) return;
const ok = v.duration > 0 && isFinite(v.duration);
bar.classList.toggle('ready', ok);
- if (ok) show(v.currentTime / v.duration);
+ if (!ok) return;
+ show(v.currentTime / v.duration);
+ if (performance.now() - checked > 1000) paintMarks();
+ const now = v.currentTime;
+ if (!v.paused && now > last && now - last < 1) for (const x of items) if (x.moment.seconds > last && x.moment.seconds <= now) pop(x);
+ last = now;
};
return bar;
}