aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-11-14 22:36:58 -0700
committerAdam Soltys <[email protected]>2026-11-14 22:36:58 -0700
commit1424cc46382266a51123a8425424f3c8cb943e8e (patch)
treee2b21346781566709e7d0e9c6800b6f812661ce7
parent933e85020c2cc56b511ff47a73943c92ea1d044a (diff)
downloadcoinosv3-1424cc46382266a51123a8425424f3c8cb943e8e.tar.gz
coinosv3-1424cc46382266a51123a8425424f3c8cb943e8e.zip
A reply, zap or reaction is about the moment of a clip only when you say so. "i zapped and commented on a post from helen yrmom and didn't intend to tie it to the video timestamp but i guess it did by default. maybe we can revisit the ui/flow there so it doesn't do that by default?" (Adam, 2026-11-14). Anything done to a post whose clip had played took the clip's current time: the reply opened as "Comment at 0:12" (an × to drop it), and a zap's or reaction's sheet had nothing showing it at all. Now the time is offered as a toggle, off until tapped — "⏱ About 0:12 in the clip", ✓ when on — in the reply box, the reaction sheet, and both zap sheets (Ark and Lightning); a one-tap zap, which has no sheet, is never tied. Ticked, it goes out as before (the media tag, "[0:12]" before a comment, its mark on the clip's bar). media-moments-feed-test ticks the toggle where it used to rely on the default and checks it starts off; media-timeline, features-smoke and the zap tests pass
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01VMan6UKGJyuGyZFxddYRxS
-rw-r--r--src/features/ark.js11
-rw-r--r--src/features/messages.js21
-rw-r--r--src/features/zaps.js8
-rw-r--r--src/i18n.js1
-rw-r--r--src/media-timeline.js12
-rw-r--r--src/style.css9
-rw-r--r--tools/media-moments-feed-test.js12
7 files changed, 56 insertions, 18 deletions
diff --git a/src/features/ark.js b/src/features/ark.js
index 1b8c591..0456628 100644
--- a/src/features/ark.js
+++ b/src/features/ark.js
@@ -1,4 +1,4 @@
-import { momentTags } from '../media-timeline.js';
+import { momentTags, momentToggle, mediaClock } 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.
@@ -1892,7 +1892,8 @@ export function arkFeature(ctx) {
// 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, moment = null) {
- const z = { npub, pk, eventId, moment, amount: '', comment: '', status: 'lookup', autoSat };
+ // `moment`: where the clip was — offered in the sheet, tied only when ticked
+ const z = { npub, pk, eventId, momentOffer: moment, moment: null, 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.
@@ -1974,7 +1975,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, z.moment); return; }
+ if (ctx.hook('canLnZap')) { if (ui.arkZap === z) ui.arkZap = null; ctx.hook('lnZapNpub', pk, npub, z.eventId, z.autoSat, z.moment || (z.autoSat ? null : z.momentOffer)); return; }
}
// 3. on-chain fallback (a one-tap zap can't take the send form)
if (onchain && !(z.autoSat && z.eventId)) {
@@ -2227,6 +2228,8 @@ export function arkFeature(ctx) {
h('div', { style: 'display:flex;align-items:center' }, unitTag())),
h('input', { type: 'text', class: 'mono-input', placeholder: t('arkZapCommentPh'), value: z.comment,
onInput: (e) => { z.comment = e.target.value; } }),
+ z.momentOffer ? h('div', { class: 'row' }, momentToggle(h, z.momentOffer, !!z.moment, (on) => { z.moment = on ? z.momentOffer : null; render(); },
+ t('mediaTieTo', { time: mediaClock(z.momentOffer.seconds) }))) : null,
h('div', { class: 'small faint' }, hint));
return h('div', { class: 'card col', style: 'gap:12px' },
h('h3', {}, '⚡ ' + t('zapTitle')),
@@ -2237,7 +2240,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, 0, z.moment); } }, '⚡ ' + t('lnZapFallback'))
+ ? h('button', { class: 'btn-block', disabled: !!ui.busy, onClick: () => { ctx.hook('lnZapNpub', z.pk, z.npub, z.eventId, 0, z.moment || z.momentOffer); } }, '⚡ ' + t('lnZapFallback'))
: null,
payable && spendable < 330
? h('div', { class: 'notice info' }, t(spendable + renewingSat() >= 330 ? 'spendingRenewingPayment' : 'zapNoBalance'))
diff --git a/src/features/messages.js b/src/features/messages.js
index b9ef1e6..318d1cc 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -1,4 +1,4 @@
-import { mediaMoment, momentTags, readMoment, mediaClock, mediaTimeline, markAt } from '../media-timeline.js';
+import { mediaMoment, momentTags, readMoment, mediaClock, mediaTimeline, markAt, momentToggle } from '../media-timeline.js';
import { mediaServers } from '../media-servers.js';
// Messages — Concord community chat (CORD-01..05) + NIP-17 private DMs.
//
@@ -4834,7 +4834,9 @@ export function messagesFeature(ctx) {
} }, '▶ ' + mediaClock(m.seconds));
}
const momentStripped = (ev, text) => (readMoment(ev) ? String(text || '').replace(/^\s*\[\d{1,2}(?::\d{2}){1,2}\]\s*/, '') : text);
- const withMoment = (ev) => { const m = momentOf(ev); return m ? { ...ev, mediaMoment: m } : ev; };
+ // the moment is only offered: a reaction is about it once the toggle says so
+ const withMoment = (ev) => { const m = momentOf(ev); return m ? { ...ev, momentOffer: m } : ev; };
+ const tieLabel = (m) => t('mediaTieTo', { time: mediaClock(m.seconds) });
function attachMediaTimeline(player, url, explicitEvent) {
if (player._timeline) return;
player._timeline = true;
@@ -11279,7 +11281,8 @@ export function messagesFeature(ctx) {
} else ui.noteThread = { rootId: rootIdOf(ev), focusId: ev.id, seed: ev };
const s = ui.noteThread;
ui.profOverThread = false;
- s.mediaMoment = moment;
+ s.momentOffer = moment; // offered, not taken: the toggle in the box ties it
+ s.mediaMoment = null;
s.replying = true; // the box appears under this note
s.scrollPending = false; // focus the composer, without a competing note scroll
s.refocus = true;
@@ -12516,6 +12519,8 @@ export function messagesFeature(ctx) {
},
h('div', { class: 'card col msg-sheet' },
reactRow(null, (e2) => { close(); reactTo(ev, e2).catch(() => {}).finally(after); setTimeout(after, 50); }),
+ ev.momentOffer ? h('div', { class: 'row', style: 'justify-content:center' },
+ momentToggle(h, ev.momentOffer, !!ev.mediaMoment, (on) => { ev.mediaMoment = on ? ev.momentOffer : null; render(); }, tieLabel(ev.momentOffer))) : null,
h('button', { class: 'btn-ghost btn-block', onClick: close }, t('back'))));
}
@@ -13144,10 +13149,8 @@ export function messagesFeature(ctx) {
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'),
},
// 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.momentOffer ? h('div', { class: 'row' },
+ momentToggle(h, s.momentOffer, !!s.mediaMoment, (on) => { s.mediaMoment = on ? s.momentOffer : null; render(); }, tieLabel(s.momentOffer))) : null,
s.preview ? draftPreview(s.draft) : null,
mediaBar(),
// A textarea that grows with the reply, like the chat composer: Enter
@@ -13222,7 +13225,7 @@ export function messagesFeature(ctx) {
// morph leaves a focused field's value alone: clear it by hand.
onClick: () => {
const text = s.draft || '';
- s.draft = ''; s.replying = false; s.preview = false; s.mediaMoment = null;
+ s.draft = ''; s.replying = false; s.preview = false; s.mediaMoment = null; s.momentOffer = null;
ui.postMedia = (ui.postMedia || []).filter((m) => !text.includes(m.url));
const inp = document.querySelector('.thread-reply-input');
if (inp) inp.value = '';
@@ -13243,7 +13246,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; s.mediaMoment = null; // sent: the box folds away
+ s.replying = false; s.preview = false; s.mediaMoment = null; s.momentOffer = null; // sent: the box folds away
toast(t('threadReplied'));
} catch (e) { if (!e.silent) toast(e.message); }
s.sending = false; render();
diff --git a/src/features/zaps.js b/src/features/zaps.js
index 8e29409..49e457f 100644
--- a/src/features/zaps.js
+++ b/src/features/zaps.js
@@ -12,6 +12,7 @@
// land here directly.
import { t } from '../i18n.js';
+import { momentToggle, mediaClock } from '../media-timeline.js';
import { npubOf } from '../nostr.js';
import { parseZapTarget, zapTargetFromProfile, fetchPayParams, buildZapRequest, requestInvoice, parseLnurlAuth, lnurlAuthKey, lnurlAuthLogin, submitWithdraw } from '../lnurl.js';
import { HDKey } from '@scure/bip32';
@@ -213,7 +214,8 @@ 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, moment: target.moment };
+ // a one-tap zap has no sheet to tick: never tied to a moment
+ const t2 = { ...lnTarget, eventId: target.eventId || null, moment: null };
const p = await fetchPayParams(t2.url);
const msat = sats * 1000;
if (msat < p.minSendable || msat > p.maxSendable) {
@@ -286,7 +288,7 @@ export function zapsFeature(ctx) {
const sign = await zapSigner();
zapRequest = await buildZapRequest({
amountMsat: msat, relays: wallet.nostrRelays(), lnurlBech32: z.target.lnurlBech32,
- recipientPk: z.target.pk, eventId: z.target.eventId || null, moment: z.target.moment, comment: z.comment,
+ recipientPk: z.target.pk, eventId: z.target.eventId || null, moment: z.momentOn ? z.target.moment : null, comment: z.comment,
signFn: sign,
});
}
@@ -517,6 +519,8 @@ export function zapsFeature(ctx) {
maxlength: p.allowsNostr ? '280' : String(p.commentAllowed || 280),
onInput: (e) => { z.comment = e.target.value; } })
: null,
+ p.allowsNostr && z.target && z.target.moment ? h('div', { class: 'row' }, momentToggle(h, z.target.moment, !!z.momentOn, (on) => { z.momentOn = on; render(); },
+ t('mediaTieTo', { time: mediaClock(z.target.moment.seconds) }))) : null,
z.social ? h('div', { class: 'small faint' }, p.allowsNostr ? t('lnZapHintNostr') : t('lnZapHintPlain')) : null),
z.error ? h('div', { class: 'notice err' }, z.error) : null,
h('div', { class: 'row gap6' },
diff --git a/src/i18n.js b/src/i18n.js
index efe84f6..3e42daa 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -38,6 +38,7 @@ const STR = {
mediaSeek: 'Seek',
mediaMomentDrop: 'About the whole post, not this moment',
mediaCommentAt: 'Comment at',
+ mediaTieTo: 'About {time} in the clip',
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/media-timeline.js b/src/media-timeline.js
index d8ae008..a70a46c 100644
--- a/src/media-timeline.js
+++ b/src/media-timeline.js
@@ -20,6 +20,18 @@ export function readMoment(event, depth = 0) {
const tag = event?.tags?.find(t => t[0] === 'media');
return tag && /^\d+$/.test(tag[2] || '') ? mediaMoment(tag[1], Number(tag[2])) : null;
}
+// "didn't intend to tie it to the video timestamp but i guess it did by
+// default" (Adam, 2026-11-14, a zap with a comment on a clip he'd played).
+// A reply, zap or reaction made while a clip has played is ABOUT that
+// moment only when asked: this toggle, off until tapped.
+export function momentToggle(h, moment, on, onToggle, label) {
+ if (!moment) return null;
+ return h('button', {
+ type: 'button', class: 'moment-toggle' + (on ? ' on' : ''), 'aria-pressed': on ? 'true' : 'false',
+ onClick: (e) => { e.stopPropagation(); onToggle(!on); },
+ }, h('span', { 'aria-hidden': 'true' }, on ? '\u2713' : '\u23f1'), ' ', label);
+}
+
export function mediaClock(seconds) {
const s = Math.max(0, Math.floor(seconds || 0));
return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
diff --git a/src/style.css b/src/style.css
index 022ae77..a0b1069 100644
--- a/src/style.css
+++ b/src/style.css
@@ -2160,3 +2160,12 @@ html:has(.call-overlay:not(.call-min)) { overflow: hidden; scrollbar-gutter: aut
flex-shrink: 0; font: 600 12px/1 inherit; color: var(--muted); padding: 4px 6px;
border: 1px solid var(--line-strong); border-radius: 6px;
}
+
+/* "About 0:12 in the clip": off until tapped */
+.moment-toggle {
+ display: inline-flex; align-items: center; gap: 2px; min-height: 0;
+ padding: 4px 10px; border-radius: 999px; font-size: 13px; font-weight: 600;
+ background: none; color: var(--muted); border: 1px dashed var(--line-strong); cursor: pointer;
+}
+.moment-toggle:hover { color: var(--ink); }
+.moment-toggle.on { color: var(--accent-ink, #fff); background: var(--accent); border: 1px solid var(--accent); }
diff --git a/tools/media-moments-feed-test.js b/tools/media-moments-feed-test.js
index 5b4c8fb..00baf9c 100644
--- a/tools/media-moments-feed-test.js
+++ b/tools/media-moments-feed-test.js
@@ -140,7 +140,10 @@ try {
check('no "at this time" buttons', await page.evaluate(() => !document.body.innerText.includes('at this time')));
await act('.note-audio', 'Like');
await page.waitForSelector('.msg-sheet');
- await page.evaluate(() => document.querySelector('.msg-sheet button').click());
+ // the moment is offered, not taken (Adam, 2026-11-14): tick it to tie the reaction to 0:05
+ check('the reaction sheet offers the moment, off', await page.evaluate(() => { const b = document.querySelector('.msg-sheet .moment-toggle'); return !!b && b.getAttribute('aria-pressed') === 'false' && b.textContent.includes('0:05'); }));
+ await page.click('.msg-sheet .moment-toggle');
+ await page.evaluate(() => document.querySelector('.msg-sheet button:not(.moment-toggle)').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'));
@@ -149,7 +152,9 @@ try {
await page.screenshot({ path: '/tmp/media-timeline-playing.png' });
await act('.note-audio', 'Reply');
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'));
+ check('the reply composer offers the moment, off', await page.evaluate(() => { const b = document.querySelector('.thread-reply .moment-toggle'); return !!b && b.getAttribute('aria-pressed') === 'false' && b.textContent.includes('0:05'); }));
+ await page.click('.thread-reply .moment-toggle');
+ check('…tapped, it is on', await page.evaluate(() => document.querySelector('.thread-reply .moment-toggle').getAttribute('aria-pressed') === 'true'));
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);
@@ -169,7 +174,8 @@ try {
await sleep(150);
await page.evaluate(() => document.querySelector('.media-pager .mp-cur .mp-like').click());
await page.waitForSelector('.msg-sheet');
- await page.evaluate(() => document.querySelector('.msg-sheet button').click());
+ await page.evaluate(() => document.querySelector('.msg-sheet .moment-toggle').click());
+ await page.evaluate(() => document.querySelector('.msg-sheet button:not(.moment-toggle)').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('...and shows over the pager\'s seek bar', await page.waitForFunction(() => !!document.querySelector('.media-pager .mp-cur .mp-seek .mp-mark'), { timeout: 3000 }).then(() => true, () => false));