diff options
| author | Adam Soltys <[email protected]> | 2026-11-14 22:36:58 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-11-14 22:36:58 -0700 |
| commit | 1424cc46382266a51123a8425424f3c8cb943e8e (patch) | |
| tree | e2b21346781566709e7d0e9c6800b6f812661ce7 /src | |
| parent | 933e85020c2cc56b511ff47a73943c92ea1d044a (diff) | |
| download | coinosv3-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
Diffstat (limited to 'src')
| -rw-r--r-- | src/features/ark.js | 11 | ||||
| -rw-r--r-- | src/features/messages.js | 21 | ||||
| -rw-r--r-- | src/features/zaps.js | 8 | ||||
| -rw-r--r-- | src/i18n.js | 1 | ||||
| -rw-r--r-- | src/media-timeline.js | 12 | ||||
| -rw-r--r-- | src/style.css | 9 |
6 files changed, 47 insertions, 15 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); } |
