From 1424cc46382266a51123a8425424f3c8cb943e8e Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 14 Nov 2026 22:36:58 -0700 Subject: 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 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VMan6UKGJyuGyZFxddYRxS --- src/features/ark.js | 11 +++++++---- src/features/messages.js | 21 ++++++++++++--------- src/features/zaps.js | 8 ++++++-- 3 files changed, 25 insertions(+), 15 deletions(-) (limited to 'src/features') 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' }, -- cgit v1.2.3