diff --git a/components/profile-modal.html b/components/profile-modal.html
index 39d716a6..a4ee0f30 100644
--- a/components/profile-modal.html
+++ b/components/profile-modal.html
@@ -62,6 +62,35 @@
+
+
+
Provider
diff --git a/components/video-modal.html b/components/video-modal.html
index f04c6704..6eaafa51 100644
--- a/components/video-modal.html
+++ b/components/video-modal.html
@@ -211,6 +211,16 @@
–
+
+
+
+
diff --git a/config/instance-config.js b/config/instance-config.js
index 61cb1b96..1959fd2a 100644
--- a/config/instance-config.js
+++ b/config/instance-config.js
@@ -191,7 +191,10 @@ export const PLATFORM_FEE_PERCENT = 30;
* Super Admin profile publishing a `lud16` so bitvid still knows where to route
* fees when they are enabled.
*/
-export const PLATFORM_LUD16_OVERRIDE = "adammalin@strike.me";
+// coinos: Nostr-native, publishes NIP-57 zap receipts, reliable routing,
+// 1-sat minimum. Replaced adammalin@strike.me, whose LNURL was flaky and never
+// published receipts, causing platform-share pay timeouts + missing zap tallies.
+export const PLATFORM_LUD16_OVERRIDE = "a4a6b5849bc917b3befd5c81@coinos.io";
/**
* Optional list of relays to seed new sessions with instead of the defaults.
diff --git a/css/tailwind.source.css b/css/tailwind.source.css
index 015e13c5..4392010a 100644
--- a/css/tailwind.source.css
+++ b/css/tailwind.source.css
@@ -2904,6 +2904,98 @@
transform-origin: top center;
}
+ /* Zap total on video cards (#47 follow-up): orange, pushed to the row's end
+ so views/comments stay left-aligned and sats sit right-aligned. Hidden
+ (via the utility class) until a nonzero total is known. */
+ .video-card__zaps {
+ color: var(--color-zap);
+ margin-inline-start: auto;
+ }
+
+ .video-card__zaps .zap-total-text {
+ font-variant-numeric: tabular-nums;
+ }
+
+ /* Same treatment in the player modal: orange sats on the far side of the
+ meta row (the row is justify-between, views stay left). */
+ .video-modal__zaps {
+ color: var(--color-zap);
+ font-variant-numeric: tabular-nums;
+ }
+
+ /* #43: always-visible active-identity pill. Rendered after the header in
+ flow and pulled up with a negative block-start margin so it sits half-on /
+ half-off the header divider — every pane shows who you're acting as. */
+ .profile-modal__identity {
+ align-self: center;
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-xs);
+ max-inline-size: min(80%, calc(var(--profile-modal-active-min-width) * 2));
+ padding: var(--space-3xs);
+ padding-inline-end: var(--space-md);
+ border-radius: var(--radius-full);
+ border: var(--border-width-hairline) solid var(--color-border-translucent);
+ background-color: var(--color-panel);
+ color: var(--color-text);
+ box-shadow: var(--shadow-md);
+ position: relative;
+ z-index: 2;
+ margin-block-start: calc(-1 * var(--space-sm));
+ margin-block-end: calc(-1 * var(--space-3xs));
+ cursor: pointer;
+ text-align: start;
+ }
+
+ .profile-modal__identity.hidden {
+ display: none;
+ }
+
+ .profile-modal__identity:hover {
+ border-color: var(--color-accent);
+ }
+
+ .profile-modal__identity-avatar {
+ display: flex;
+ inline-size: var(--space-xl);
+ block-size: var(--space-xl);
+ flex-shrink: 0;
+ overflow: hidden;
+ border-radius: var(--radius-full);
+ border: var(--border-width-hairline) solid var(--color-accent);
+ background-color: var(--color-surface);
+ }
+
+ .profile-modal__identity-avatar img {
+ inline-size: 100%;
+ block-size: 100%;
+ object-fit: cover;
+ }
+
+ .profile-modal__identity-copy {
+ display: flex;
+ flex-direction: column;
+ min-inline-size: 0;
+ }
+
+ .profile-modal__identity-kicker {
+ font-size: var(--font-size-3xs);
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: var(--tracking-extra-wide);
+ color: var(--color-accent);
+ line-height: var(--line-height-tight);
+ }
+
+ .profile-modal__identity-name {
+ font-size: var(--font-size-sm);
+ font-weight: 600;
+ line-height: var(--line-height-tight);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
.profile-modal__active-profile {
@apply focus-ring flex flex-wrap items-center gap-4 p-4;
}
diff --git a/css/tokens.css b/css/tokens.css
index 7cc22a09..d874fc9c 100644
--- a/css/tokens.css
+++ b/css/tokens.css
@@ -509,6 +509,8 @@
--color-accent: rgb(99 102 241);
--color-accent-strong: rgb(79 70 229);
--color-accent-pressed: rgb(67 56 202);
+ /* Zap totals (sats) — bitcoin orange, darker for light-bg contrast. */
+ --color-zap: rgb(217 119 6);
--color-accent-override: rgb(84 0 17);
--color-accent-strong-override: rgb(254 0 50);
--color-accent-pressed-override: rgb(169 0 33);
@@ -696,6 +698,8 @@
--color-accent: rgb(129 140 248);
--color-accent-strong: rgb(99 102 241);
--color-accent-pressed: rgb(79 70 229);
+ /* Zap totals (sats) — bitcoin orange. */
+ --color-zap: rgb(247 147 26);
--color-accent-override: rgb(84 0 17);
--color-accent-strong-override: rgb(254 0 50);
--color-accent-pressed-override: rgb(169 0 33);
diff --git a/docs/nostr-event-schemas.md b/docs/nostr-event-schemas.md
index 04e6346c..9c4198a5 100644
--- a/docs/nostr-event-schemas.md
+++ b/docs/nostr-event-schemas.md
@@ -196,6 +196,7 @@ Video posts should treat `videoRootId` as the stable series identifier that rema
| Legacy Direct Message (`NOTE_TYPES.LEGACY_DM`) | `4` | `['p',
]` | NIP-04 encrypted ciphertext |
| Zap request (`NOTE_TYPES.ZAP_REQUEST`) | `9734` | `['p', ]` plus optional `['e', ]`, `['a', ]`, `['amount', ]`, `['lnurl', ]`, and `['relays', ...]` for receipt publishing | Optional plaintext zap note |
| Zap receipt (`NOTE_TYPES.ZAP_RECEIPT`) | `9735` | `['bolt11', ]`, `['description', ]`, `['p', ]` plus optional `['e', ]` and `['a', ]` | Empty content; receipts are published by the recipient's LNURL server |
+| Zap tally (`NOTE_TYPES.ZAP_TALLY`) | `30081` (addressable, `d = payment_hash`) | `['d', ]`, `['p', ]`, optional `['e', ]`/`['a', ]`, `['amount', ]`, `['bolt11', ]`, `['preimage', ]`, `['description', ]`, `['client', 'bitvid']`, `['t', 'zap']` | bitvid-native, payer-signed, **preimage-verified** stand-in for a 9735 when the recipient's wallet doesn't publish one. Verified via `sha256(preimage)==payment_hash` + `description_hash==sha256(zapRequest)`. Counted alongside 9735s (deduped by `payment_hash`). See `docs/zap-tally-plan.md`. |
| HTTP Auth (`NOTE_TYPES.HTTP_AUTH`) | `27235` | `['u', ]`, `['method', ]`, `['payload', ]` | Optional plaintext content (NIP-98 recommends empty, but implementations may vary) |
| Report (`NOTE_TYPES.REPORT`) | `1984` | `['e', , ]`, `['p', , ]`, and optional `['t', ]` | Plaintext report reason |
diff --git a/docs/zap-tally-plan.md b/docs/zap-tally-plan.md
new file mode 100644
index 00000000..068dbb1a
--- /dev/null
+++ b/docs/zap-tally-plan.md
@@ -0,0 +1,515 @@
+# bitvid-native zap tally — dev plan
+
+> Status: **IN PROGRESS** (video-zap tally shipped; profile + chart remain) · Created 2026-07-04
+> Scope: (1) a bitvid-published, preimage-verified zap event so "Most Zapped" and
+> the per-card / per-modal zap totals are global, durable, and independent of
+> whether the recipient's Lightning provider publishes NIP-57 receipts; and (2) a
+> second (orange) zaps-over-time line on the per-video Popularity chart, with a
+> legend (red=Views / orange=Zaps) and visible date labels (§5.9).
+
+This document is the single source of truth for the change so work stays neat
+across sessions. Read it top to bottom before touching code; the **Task
+checklist** at the end is the ordered work queue.
+
+---
+
+## 1. Problem & context
+
+Zap totals today come from **NIP-57 zap receipts (kind 9735)**, which are
+published by the **recipient's LNURL / Lightning-address server** — not by
+bitvid, not by NWC, not by the sender's wallet. Custodial providers (Strike,
+the current `PLATFORM_LUD16_OVERRIDE`) routinely **do not publish** them. So a
+paid zap can leave **no on-relay record at all**, and:
+
+- per-card / modal zap badges show `0 sats` for videos that were really zapped,
+- "Most Zapped" ranks by recency because the metric is empty,
+- there is no global/cross-user tally to display.
+
+### What already exists (as of commit `cbc62306`)
+
+The zap-totals store (`js/zapTotals.js`) already layers three concerns; this
+plan **adds a fourth source** and leaves the rest intact:
+
+1. **Real 9735 receipts** — fetched + summed from relays, deduped by event id
+ (`runBatch`). Gold standard when they exist. **Keep.**
+2. **Session optimistic bump** — `ingestLocalZap`, shown instantly on send,
+ cleared when a real receipt for the pointer later arrives. **Keep.**
+3. **Durable local ledger** — `localStorage: bitvid:sentZaps:v1`, seeds the
+ optimistic layer at construction so the sender's own zaps survive reload.
+ Sender-only, per-device, not global. **Keep** (becomes the offline/instant
+ overlay).
+4. **NEW: bitvid-native zap tally event** — this plan. Global, durable,
+ cross-user, cryptographically verifiable, wallet-independent.
+
+### The insight that makes this sound (not a hack)
+
+A NIP-57 receipt is trusted because the recipient's server signs it — but the
+reason the *server* can attest is that it holds the **preimage** (proof the
+invoice settled). **The payer holds the same preimage** after an NWC
+`pay_invoice`. And the invoice's **description hash commits to the zap request**
+(`description_hash(bolt11) === sha256(zapRequestJSON)`), and the zap request
+contains the **video/profile pointer + amount**. So a payer-signed event
+carrying `{ zapRequest, bolt11, preimage }` is verifiable by *any* client with
+the same cryptographic strength as a 9735 receipt:
+
+1. `sha256(preimage) === payment_hash(bolt11)` → the invoice was actually paid.
+2. `description_hash(bolt11) === sha256(zapRequestJSON)` → that payment was for
+ *this* zap request (binds it to the pointer + amount — replay-proof: a stolen
+ preimage cannot be reused for a different video).
+
+bitvid **already implements both halves** of the bolt11 decode in
+`js/payments/zapReceiptValidator.js` (`extractDescriptionHashFromBolt11`, bech32
+tagged-field walker) — we extend it to also pull `payment_hash` and add one
+sha256 check. No new crypto dependency.
+
+---
+
+## 2. Goals & non-goals
+
+**Goals**
+- A zap the user sends through bitvid produces a **published, verifiable** event
+ so **other bitvid users** see it and it ranks on Most Zapped.
+- Survives reload / new device (it's on relays).
+- Works for **video zaps and profile zaps**.
+- Anti-spam by construction (must actually pay to inflate; misattribution
+ blocked by the description-hash binding).
+- Never double-counts against a real 9735 receipt for the same payment.
+- **Popularity chart** (§5.9): a second **orange zaps-over-time line** beside the
+ red views line, a **legend**, and **visible date labels** so the chart is
+ self-explanatory.
+
+**Non-goals**
+- Interop with other Nostr clients' zap UIs. bitvid **cannot** mint a valid
+ NIP-57 9735 (that must be signed by the recipient's server), so this event is
+ **bitvid-specific**; other clients ignore it. We still *count* real 9735s
+ wherever they exist, so bitvid stays a good NIP-57 citizen on the read side.
+- Replacing the local ledger or the 9735 path — this is an **additional** source.
+- Server-side aggregation. bitvid is a static client; counting stays client-side
+ (same as view counts).
+
+---
+
+## 3. The new event
+
+A **regular, non-replaceable** event (one per successful zap share; they sum),
+signed by the **payer's active signer** (the logged-in user). Modeled on how the
+kind-9735 receipt is shaped so our existing validator logic transfers.
+
+```
+kind: ZAP_TALLY_KIND // see Open decision D1 (proposed 9736)
+pubkey: // the sender's active signer
+created_at:
+content: "" // (optional: mirror the zap comment)
+tags:
+ ["p", ] // zapped party
+ ["e", ] (video zaps) // from the zap request
+ ["a", "30078::"] (video zaps) // stable coordinate
+ ["amount", ""] // echoes the zap request
+ ["bolt11", ""] // carries payment_hash + description_hash
+ ["preimage", ""] // proof of settlement
+ ["description", ] // the signed 9734 (NIP-57 shape)
+ ["client", "bitvid"] // provenance marker
+ ["t", "zap"]
+```
+
+- For **profile-only** zaps there is no `e`/`a`; the `p` tag is the pointer.
+- `description`, `bolt11`, `preimage`, `amount` are **exactly** what a 9735 would
+ carry, so verification reuses the 9735 validator almost verbatim.
+- The event is **self-verifying**: no need to trust the `amount` tag — the
+ authoritative amount is derived from the bolt11 (as the 9735 path already does
+ via `nip57.getSatoshisAmountFromBolt11`).
+
+### Verification algorithm (a valid tally event)
+1. `kind === ZAP_TALLY_KIND` and has a `bolt11` + `preimage` tag.
+2. `sha256(hexToBytes(preimage)) === payment_hash` extracted from the bolt11.
+3. `description_hash` from the bolt11 `=== sha256(utf8(description tag JSON))`.
+ *(NIP-57's own check — already the spine of `zapReceiptValidator`.)*
+4. Sats = `getSatoshisAmountFromBolt11(bolt11)` (authoritative, not the tag).
+5. Pointer keys = the `a`/`e` (and `p`) tags **from the embedded zap request**,
+ not the outer event's tags (defense against a mangled wrapper).
+
+Steps 2–3 are the anti-spam core. A faker must pay a real invoice, and cannot
+retarget a stolen preimage to a different video (the description hash binds it).
+
+---
+
+## 4. Counting, dedup & reconciliation
+
+The store gains a **fourth input** folded into the same `totals` map. The
+critical new concern is **cross-source dedup by `payment_hash`** so a single
+payment that produces *both* a 9735 receipt *and* a bitvid tally is counted once.
+
+Per-pointer entry becomes:
+```
+{
+ sats, // real receipts (9735) + verified tallies, deduped
+ optimisticSats, // durable ledger + session bumps (unchanged)
+ receiptIds: Set, // dedup by EVENT id (unchanged, for 9735)
+ paymentHashes: Set, // NEW: dedup across sources by payment_hash
+ fetchedAt,
+}
+```
+
+Folding rules in `runBatch` (which now queries **both** kinds — see §5):
+- **9735 receipt**: if its `payment_hash` (from its bolt11) is already in
+ `paymentHashes` for the pointer → skip; else add sats, record event id +
+ payment_hash. 9735 is **preferred**: if a tally with the same payment_hash was
+ already counted, the 9735 replaces it (same sats — no delta — but marks the
+ payment as recipient-attested).
+- **bitvid tally**: run the §3 verification. If it fails → ignore. If its
+ `payment_hash` is already counted (by a 9735 or an earlier tally) → skip. Else
+ add the bolt11-derived sats, record payment_hash.
+- **optimistic/ledger prune** (existing behavior, generalized): when a pointer
+ gains *any* real counted zap (9735 **or** verified tally) whose payment_hash
+ matches nothing we optimistically bumped, we still clear the pointer's
+ `optimisticSats` + ledger entry, because the relay is now authoritative for it.
+ (Same "relay wins" rule already shipped; just triggered by tallies too.)
+
+Result: exactly-once counting per real payment, 9735-preferred, tally as the
+fallback, local ledger as the instant/offline overlay.
+
+---
+
+## 5. Code changes (file by file)
+
+### 5.1 `js/nostrEventSchemas.js` — new event type + builder
+- Add `NOTE_TYPES.ZAP_TALLY = "zapTally"` and a schema entry (kind
+ `ZAP_TALLY_KIND`, append-tags, content=text) next to `ZAP_REQUEST`.
+- Add `buildZapTallyEvent({ pubkey, created_at, recipientPubkey, eventId,
+ coordinate, amountMsats, bolt11, preimage, zapRequestJSON, comment })` mirroring
+ `buildZapRequestEvent` (§2270). Returns the unsigned event; caller signs.
+- Export the constant `ZAP_TALLY_KIND` (Open decision D1).
+
+### 5.2 `js/payments/zapReceiptValidator.js` — extract payment_hash + verify
+- Generalize the existing bolt11 tagged-field walker
+ (`extractDescriptionHashFromBolt11`, §164) into a helper that can also return
+ the **payment_hash** field (bolt11 tag `p`, 256-bit) — e.g.
+ `extractBolt11Fields(bolt11) -> { descriptionHash, paymentHash }`. Keep the old
+ function as a thin wrapper (no behavior change to the 9735 path).
+- Add `verifyPaymentPreimage(preimage, paymentHash)` = `sha256(hexToBytes(
+ preimage)) === paymentHash` (use `@noble/hashes/sha256` already pulled via
+ nostr-tools, or WebCrypto `subtle.digest`).
+- Add `verifyBitvidZapTally(event, { tools })` implementing §3 steps 1–5,
+ returning `{ ok, sats, pointerTags, paymentHash }`. Reuses the description-hash
+ check the 9735 validator already performs.
+
+### 5.3 `js/payments/zapSplit.js` — surface bolt11 + preimage on the receipt
+- The per-share `receipt` object (§481) already carries `invoice`, `payment`,
+ `zapRequest`, `amount`. Add the resolved **preimage** explicitly:
+ `receipt.preimage = payment?.result?.preimage || null` (the NWC `pay_invoice`
+ result is `payment.result` per `nwcClient.js` §1155; NIP-47 puts the preimage
+ there). No flow change — just expose it so the controller can publish a tally.
+- (bolt11 is already on `receipt.invoice.invoice`.)
+
+### 5.4 `js/ui/zapController.js` — publish tallies on success
+- The success terminal point already fires `this.callbacks.onZapSuccess({ video,
+ sats })` (added commit `af09505f`). **Extend the payload** to include the
+ per-share proof needed to build tallies:
+ `onZapSuccess({ video, sats, shares: receipts.map(r => ({ recipientType,
+ amountSats: r.amount, bolt11: r.invoice?.invoice, preimage: r.preimage,
+ zapRequest: r.zapRequest })) })`.
+- Only include shares whose `status === "success"` **and** have a `preimage`
+ (payment settled). Unvalidated-on-relay is fine — that's exactly the case this
+ feature exists for.
+- The **retry** path (`executeRetry`, §226) should fire the same hook for the
+ shares it settles (today it doesn't call onZapSuccess at all — wire it).
+
+### 5.5 `js/ui/ModalManager.js` — build, sign, publish, ingest
+- The `onZapSuccess` callback (added `af09505f`, currently just calls
+ `ingestLocalVideoZap`) becomes:
+ 1. Keep the instant `ingestLocalVideoZap(pointer, sats)` (optimistic overlay).
+ 2. For each successful share with a preimage, call a new
+ `app.publishZapTally({ video, share })` (thin app method) that:
+ - derives the pointer via `app.deriveVideoPointerInfo(video)` (same key the
+ badge uses),
+ - builds the tally via `buildZapTallyEvent(...)`,
+ - signs with the active signer (the user is logged in to have zapped),
+ - publishes via the existing relay-publish path
+ (`signAndPublishEvent` / `publishEventToRelays`, using
+ `assertAnyRelayAcceptedOrUnconfirmed` — an all-timeout publish is a soft
+ success like #49),
+ - on success, `store.ingestVerifiedZap(pointer, sats, paymentHash)` so the
+ local view is authoritative immediately (and the durable ledger entry can
+ be upgraded/cleared — see §4).
+- Publishing is **best-effort and non-blocking**: a failed tally publish must not
+ affect the payment UX (the sats already moved). Log + rely on the local ledger.
+
+### 5.6 `js/zapTotals.js` — count tallies as a fourth source
+- `runBatch`: add a second filter set querying `kinds: [ZAP_TALLY_KIND]` by the
+ same `#a`/`#e` pointer batches (and `#p` for profile pointers — see Open
+ decision D2). One `manager.list` call can carry both kinds' filters.
+- Fold results per §4: verify each tally (`verifyBitvidZapTally`), dedup by
+ `payment_hash`, prefer 9735.
+- Add `paymentHashes` to entries; thread it through the existing dedup.
+- Add `ingestVerifiedZap(pointer, sats, paymentHash)` — like `ingestLocalZap`
+ but records the payment_hash and writes to `sats` (authoritative) rather than
+ `optimisticSats`, so the sender's own tally isn't re-counted when it comes back
+ from the relay (dedup by payment_hash handles the echo).
+- Expose `verifyBitvidZapTally` injection point for tests (mirror `getTools`).
+
+### 5.7 `js/constants.js` — feature flag
+- Add `FEATURE_ZAP_TALLY` (default **off** for the first ship; flip on after a
+ soak). Gate both **publishing** (5.5) and **counting** (5.6) behind it so it's
+ a clean rollback and so a half-rolled-out network doesn't miscount.
+
+### 5.8 Profile zaps (optional, same primitives)
+- Channel/profile zap surfaces that call the zap flow pass a `p`-only pointer;
+ the store keys by `p:`. The card/modal badges are video-scoped today,
+ so a profile zap total would surface on channel pages — a follow-up UI task,
+ not required for the video path. Track as D2.
+
+### 5.9 Popularity chart: second (orange) zaps line + legend + date axis
+
+The per-video **Popularity** modal (`js/viewCountChart.js`, opened from the ⋯
+menu — feature #26) currently plots ONE cumulative **views** series and, notably,
+puts the date range only in the SVG `aria-label` — there is **no visible legend
+and no visible date labels**. This sub-feature adds a second series and the
+missing chart furniture. It can ship in two stages: the chart plumbing works
+with today's on-relay zap data (real 9735 receipts) even before the tally event
+(§3) exists; tallies simply make the orange line populated for Strike-style zaps.
+
+**Current shape to build on**
+- `buildViewCountTimeSeries(events)` → `{ series:[{bucketStart, count,
+ cumulative}], total }`, bucketed per day (`VIEW_CHART_WINDOW_SECONDS`).
+- `buildViewCountChartSvg(doc, series, {width,height})` — one `` +
+ area, colored by `svg.classList.add("text-accent")` + `stroke="currentColor"`
+ (this instance's `--color-accent` is red). `pad.bottom:18` already reserves
+ space for an axis strip that today draws nothing visible.
+- The modal (`openPopularityModal`) fetches view events
+ (`listVideoViewEventsWithDefaultClient` + `subscribe…`), dedupes by id, and
+ re-renders on a 250ms debounce.
+
+**Changes**
+
+1. **Zap time-series data.** Add a `buildZapSatsTimeSeries(events, { tools })`
+ next to `buildViewCountTimeSeries`: bucket **cumulative SATS** per day from
+ the union of **9735 receipts** and **verified bitvid tallies** (§3), deduping
+ by **payment_hash** (reuse the store's rule so the chart and the badge agree).
+ Amount per event = `getSatoshisAmountFromBolt11` (authoritative), created_at =
+ the receipt/tally timestamp. Output shape mirrors the view series:
+ `{ series:[{bucketStart, sats, cumulative}], total }`.
+
+2. **Zap event source.** Add `listVideoZapEventsWithDefaultClient(pointer)` +
+ `subscribeVideoZapEvents…` (mirror the view-event facade) that list/subscribe
+ `kinds:[9735, ZAP_TALLY_KIND]` for the pointer's `#a`/`#e`. The modal fetches
+ BOTH streams (views + zaps) and re-renders on the same debounce. Reuse the
+ store's verify + payment_hash dedup helpers (§5.6) so there is one code path
+ for "what counts."
+
+3. **Dual-series renderer.** Generalize `buildViewCountChartSvg` into
+ `buildPopularityChartSvg(doc, { views, zaps }, opts)` that draws BOTH
+ cumulative lines on a shared **time** X axis, each with its **own Y scale**
+ (views in counts, zaps in sats are different units — independent per-series
+ scaling shows the trend/shape honestly without a misleading shared axis; a
+ dual labeled Y-axis is an option — see D5). Colors are token-based:
+ - views line/area → `text-accent` / `currentColor` (red) — unchanged.
+ - zaps line/area → the **`--color-zap`** orange token added for the badges.
+ The token lint forbids raw colors and `var()` in attributes, so add a
+ small utility class (e.g. `.text-zap { color: var(--color-zap); }` in
+ `css/tailwind.source.css`) and wrap the zap series in a ``
+ using `stroke="currentColor"` — same pattern the views line already uses.
+ Draw the zap area at a low opacity like the views area so overlaps stay legible.
+
+4. **Legend.** Below/above the chart, a small two-item legend: a red swatch
+ "Views" and an orange swatch "Zaps (sats)". Plain DOM (`el(doc,…)` helper),
+ token colors via the `.text-accent` / `.text-zap` classes. Include it in the
+ modal so both series are labeled; keep it out of the tiny inline card sparkline
+ if one is ever added.
+
+5. **Visible date axis.** Draw X-axis tick labels (not just `aria-label`): at
+ minimum the first and last bucket dates, ideally 3–4 evenly spaced ticks, using
+ the existing `formatDay(seconds)` formatter, as `` nodes in the reserved
+ `pad.bottom` strip (token text color, `text-2xs`). Add light vertical gridlines
+ at the ticks if it reads cleanly. This answers "users don't know what they're
+ looking at."
+
+6. **Headline + copy.** The modal headline stays the authoritative view count;
+ add a second line for the zap total (from `store.getSnapshot`, so it matches
+ the badge) — e.g. "1.2K views · 4.3K sats zapped". Update the footer note to
+ "Public view + zap data · updates as more load."
+
+**Empty/degenerate states** (mirror the view chart): no zaps → no orange line +
+a greyed "Zaps (sats)" legend item (or omit); a single zap bucket → flat baseline
+segment like the single-view case.
+
+**Independence:** this renders whatever zap events exist on relays. Before the
+tally event ships, the orange line reflects only real 9735s (often empty for
+Strike); after, it reflects tallies too. The local durable ledger (badge-only,
+no per-event timestamps) does **not** feed the time chart — another reason the
+published tally matters for a meaningful zaps-over-time line.
+
+---
+
+## 6. Data flow (end to end)
+
+```
+User clicks Zap (video modal)
+ → zapController.sendZap → runZapAttempt → app.splitAndZap (zapSplit)
+ → per share: build 9734 zap request → LNURL callback → invoice
+ → NWC pay_invoice → payment.result.preimage
+ → receipt { amount, invoice.invoice(bolt11), preimage, zapRequest, status }
+ → success terminal:
+ onZapSuccess({ video, sats, shares:[{amountSats,bolt11,preimage,zapRequest}] })
+ → ingestLocalVideoZap(pointer, sats) // instant + durable ledger
+ → for each settled share: app.publishZapTally // build→sign→publish
+ → store.ingestVerifiedZap(pointer, sats, paymentHash)
+
+Any client later, on a grid/modal:
+ store.request(pointer) → runBatch queries kinds [9735, ZAP_TALLY_KIND] by #a/#e
+ → 9735: sum (dedup by event id + payment_hash)
+ → tally: verifyBitvidZapTally → sum (dedup by payment_hash, 9735-preferred)
+ → badge + Most-Zapped metric reflect the global, verified total
+```
+
+---
+
+## 7. Security & edge cases
+
+- **Fake inflation** — blocked: must pay a real invoice (preimage check), and the
+ sats are bolt11-derived, not tag-claimed. Inflation costs the attacker real
+ sats.
+- **Preimage replay to another video** — blocked: `description_hash(bolt11)`
+ commits to the zap request, which contains the pointer; a retargeted event
+ fails step 3.
+- **Duplicate publish / relay echo** — dedup by `payment_hash` across all
+ sources; the sender's own tally coming back from a relay is a no-op.
+- **Self-zapping** — possible but only adds sats the attacker actually spent;
+ economically pointless. WoT/moderation already available if we later want to
+ weight or hide (mirror the view-count treatment).
+- **Amount mismatch** — ignore the `amount` tag; always use the bolt11 amount.
+- **Missing preimage** (wallet returns none) — skip the tally, fall back to the
+ local ledger; the sender still sees their zap, it just isn't globally attested.
+- **REQ load** — one extra kind in the existing batched receipt fetch (no new
+ round trip); one publish per zap share (small, non-blocking).
+- **Privacy** — a tally publicly links the payer pubkey → video + amount. This is
+ the same disclosure as a NIP-57 zap (zaps are public by design), but note it in
+ the UI copy. The feature flag lets us default-off until we're comfortable.
+
+---
+
+## 8. Testing plan
+
+Unit (node:test, deterministic, no network):
+- `zap-tally-verify.test.mjs` — `verifyBitvidZapTally`: valid passes; bad preimage
+ fails (step 2); mismatched description hash fails (step 3, replay); amount taken
+ from bolt11 not the tag. Use fixed bolt11/preimage vectors.
+- `zap-tally-schema.test.mjs` — `buildZapTallyEvent` emits the exact tag set for
+ video vs profile pointers.
+- Extend `zap-totals.test.mjs` — cross-source dedup: a 9735 and a tally with the
+ same payment_hash count once; a tally with a new payment_hash adds; an invalid
+ tally is ignored; 9735-preferred.
+- `zap-controller` / `ModalManager` wiring — onZapSuccess forwards per-share
+ proof; publishZapTally is best-effort (a publish throw doesn't break the success
+ path); flag-off short-circuits publish + count.
+
+Live (Playwright harness, mock relay — pattern from this session's diagnostics):
+- Seed a verified tally event → it counts + ranks; seed an invalid one → ignored.
+- End-to-end with a stubbed wallet returning a known preimage → publish → re-fetch
+ → badge reflects it after a simulated reload.
+
+Gates: `npm run lint`, `npm run build`, targeted suites. New tests carry
+`test_integrity_note` blocks (Dark Factory rules).
+
+---
+
+## 9. Rollout
+
+1. Land behind `FEATURE_ZAP_TALLY = false` on `unstable`; verify counting logic
+ with seeded events (no publishing yet in the wild).
+2. Flip publishing on for `unstable`, dogfood real zaps, confirm cross-user
+ visibility on a second browser.
+3. Soak on `beta`; watch for miscounts / dedup gaps.
+4. Default-on to `main` once the payment_hash dedup is proven against real 9735s.
+
+Rollback: flip the flag off (stops publishing + counting); already-published
+tallies simply stop being counted. No data migration.
+
+---
+
+## 10. Decisions (RESOLVED 2026-07-04)
+
+- **D1 — event kind → ADDRESSABLE `ZAP_TALLY_KIND = 30081`, `d = payment_hash`.**
+ Chosen: a higher, app-specific kind in bitvid's own family (30078 video,
+ 30079 view, **30081** zap tally). Addressable (30000–39999) with the
+ per-payment `payment_hash` as the `d` tag makes each real payment map to
+ exactly ONE canonical event — relay-level idempotency; a re-publish replaces
+ rather than duplicates. Still queried by its `#a`/`#e`/`#p` tags like a 9735.
+ Reserve the kind in `docs/nostr-event-schemas.md`.
+- **D2 — scope → VIDEO + PROFILE zaps in v1.** Video path drives the badges/Most
+ Zapped; profile path reuses the same primitives with a `p`-only pointer and a
+ channel-page total (new small UI surface). Both ship in v1.
+- **D3 — publish relays → user's WRITE relays** (same path as video publishing).
+ The store queries `client.relays` (a superset incl. write relays); verify at
+ wiring time.
+- **D4 — publishing → ON BY DEFAULT.** `FEATURE_ZAP_TALLY` still exists for
+ rollback but defaults **true**; every successful zap publishes a tally. Keep a
+ short one-line disclosure in the zap UI ("zaps are publicly attributed on
+ bitvid") since it's honest and cheap, but no opt-in gate.
+- **D5 — chart Y-axis → INDEPENDENT PER-SERIES scaling.** Each cumulative line
+ fills the height against its own max; the legend names the two units. No dual
+ labeled Y-axis.
+
+---
+
+## 11. Task checklist (ordered — pick up here across sessions)
+
+- [x] **D1** `ZAP_TALLY_KIND = 30081` (addressable, d=payment_hash) + constant +
+ `docs/nostr-event-schemas.md` row. (commit a6b2996f)
+- [x] `constants.js`: `FEATURE_ZAP_TALLY` (default ON per D4) + runtime plumbing.
+- [x] `nostrEventSchemas.js`: `NOTE_TYPES.ZAP_TALLY` schema + `buildZapTallyEvent`.
+- [x] `zapReceiptValidator.js`: `extractBolt11Fields`, `verifyPaymentPreimage`,
+ `verifyBitvidZapTally`. Tests incl. a real amount-bearing bolt11 e2e.
+- [x] `zapSplit.js`: expose `receipt.preimage`. (commit 41e533e6)
+- [x] `zapController.js`: `onZapSuccess` forwards per-share proof.
+ *(Retry path still TODO — see below.)*
+- [x] `zapTallyPublisher.js` (buildTallyFromShare + publishZapTallies) +
+ `ModalManager.js` wiring: build→sign→publish (best-effort, flag-gated).
+ No `ingestVerifiedZap` needed — the store's existing "relay wins" prune
+ reconciles the optimistic ledger when the published tally is fetched back.
+- [x] `zapTotals.js`: query `[9735, ZAP_TALLY_KIND]`, verify + dedup by
+ payment_hash (`paymentHashes` set), flag-gated counting. (commit 025ab348)
+- [x] Tests: verify, schema, publisher, cross-source dedup, flag-off, e2e verify.
+- [x] `lint` + `build` + suites green; pushed to `unstable`.
+- [ ] **Remaining v1 work:**
+ - [ ] **Profile-zap counting:** the store queries `#a`/`#e` only — add
+ `#p` batches so a profile-only tally (no video pointer) is fetched,
+ and a channel-page total UI surface (D2 chose video+profile).
+ - [x] **Retry path:** `zapController.executeRetry` fires `onZapSuccess`
+ so retried shares publish tallies too. (commit afed041f)
+ - [ ] **Popularity chart §5.9:** orange zaps line + legend + date axis.
+ - [ ] Live end-to-end on unstable (real zap → tally on relay → second
+ browser counts it); rollout per §9.
+
+**Popularity chart (§5.9 — can start once the store's verify + dedup helpers
+exist; the renderer/legend/axis parts have no dependency on publishing tallies):**
+- [ ] `.text-zap` utility in `css/tailwind.source.css`.
+- [ ] `viewCountChart.js`: `buildZapSatsTimeSeries` (bucket cumulative sats,
+ verify + payment_hash dedup).
+- [ ] Zap-event facade: `listVideoZapEventsWithDefaultClient` +
+ `subscribeVideoZapEvents…` (kinds `[9735, ZAP_TALLY_KIND]`).
+- [ ] Generalize `buildViewCountChartSvg` → `buildPopularityChartSvg({views,zaps})`:
+ two token-colored lines, per-series Y (D5), visible X date ticks via
+ `formatDay`, legend (Views=red / Zaps=orange), second headline line.
+- [ ] Wire both fetches + debounced re-render in `openPopularityModal`; empty/
+ single-bucket states for the zap line.
+- [ ] Tests: `zap-time-series.test.mjs` (bucketing + dedup); chart-svg test
+ (two ``s, legend nodes, date-tick `` present).
+
+---
+
+## 12. Relationship to existing work (so nothing regresses)
+
+- `js/zapTotals.js` — extend, don't rewrite. The array/object `pointerKey` fix,
+ the always-visible badge, optimistic bump, durable ledger, and "relay wins"
+ prune all stay; the tally is a new counted source folded into the same map.
+- `js/app/modalCoordinator.js` / `js/ui/views/videoCardZapTotals.js` — **no
+ change**; they render `store.getSnapshot`, which now includes verified tallies.
+- `js/feedEngine/mostZappedFeed.js` — **no change**; its metric is
+ `requestVideoZapTotal`, which now reflects tallies.
+- The NIP-57 `zapReceiptValidator` 9735 path — untouched behavior; we only add
+ helpers and reuse its bolt11 decode.
+- `js/viewCountChart.js` (§5.9) — the single view series stays; we generalize the
+ SVG builder to two series and add the legend + visible date axis the view chart
+ never had. The `--color-zap` token (already added for the badges) is reused for
+ the orange zap line, so cards, modal badge, and chart all share one zap color.
+```
diff --git a/js/app.js b/js/app.js
index 592c0fcb..7a05fc74 100644
--- a/js/app.js
+++ b/js/app.js
@@ -92,6 +92,7 @@ import {
assertAnyRelayAcceptedOrUnconfirmed,
describePublishOutcome,
readRelayPublishSummary,
+ anyRelayAcceptedInSummaries,
} from "./nostrPublish.js";
import {
getActiveSigner,
@@ -118,6 +119,12 @@ import {
formatViewCount,
ingestLocalViewEvent,
} from "./viewCounter.js";
+import {
+ initZapTotals,
+ requestVideoZapTotal,
+ getVideoZapTotalSnapshot,
+ onZapTotalsChanged,
+} from "./zapTotals.js";
import {
formatAbsoluteTimestamp as formatAbsoluteTimestampUtil,
formatAbsoluteDateWithOrdinal as formatAbsoluteDateWithOrdinalUtil,
@@ -697,6 +704,9 @@ class Application {
unsubscribeFromVideoViewCount,
formatViewCount,
ingestLocalViewEvent,
+ requestVideoZapTotal,
+ getVideoZapTotalSnapshot,
+ onZapTotalsChanged,
pointerArrayToKey,
pointerKey: pointerKey,
getCanonicalDesignSystemMode,
@@ -981,6 +991,11 @@ class Application {
} catch (error) {
devLogger.warn("Failed to initialize view counter:", error);
}
+ try {
+ initZapTotals({ nostrClient });
+ } catch (error) {
+ devLogger.warn("Failed to initialize zap totals:", error);
+ }
}
async _initAccessControl() {
@@ -4358,6 +4373,11 @@ class Application {
return this._feed.registerTrendingFeed(...args);
}
+ registerMostZappedFeed(...args) {
+ this._initCoordinators();
+ return this._feed.registerMostZappedFeed(...args);
+ }
+
registerSubscriptionsFeed(...args) {
this._initCoordinators();
return this._feed.registerSubscriptionsFeed(...args);
@@ -4408,6 +4428,11 @@ class Application {
return this._feed.refreshFeed(FEED_TYPES.TRENDING, ...args);
}
+ refreshMostZappedFeed(...args) {
+ this._initCoordinators();
+ return this._feed.refreshFeed(FEED_TYPES.MOST_ZAPPED, ...args);
+ }
+
refreshRecentFeed(...args) {
this._initCoordinators();
return this._feed.refreshFeed("recent", ...args);
@@ -4456,6 +4481,11 @@ class Application {
return this._feed.loadTrendingVideos(...args);
}
+ async loadMostZappedVideos(...args) {
+ this._initCoordinators();
+ return this._feed.loadMostZappedVideos(...args);
+ }
+
async loadOlderVideos(...args) {
this._initCoordinators();
return this._feed.loadOlderVideos(...args);
@@ -5073,7 +5103,7 @@ class Application {
this.showStatus("Deleting. Please wait.", { showSpinner: true });
try {
- await this.nostrService.handleFullDeleteVideo({
+ const deleteDetail = await this.nostrService.handleFullDeleteVideo({
videoRootId: rootId,
video: targetVideo,
pubkey: this.pubkey,
@@ -5110,7 +5140,21 @@ class Application {
}
await this.loadVideos();
- this.showSuccess("All versions deleted successfully!");
+ // #49 conservative delete semantics: only claim "deleted" when at least
+ // one relay ACKed something. An all-timeout run (tombstones + kind-5 sent
+ // but unconfirmed) must not read as a confirmed delete — the video may
+ // linger on relays until they catch up.
+ const deleteSummaries = [
+ ...(deleteDetail?.result?.reverts || []),
+ ...(deleteDetail?.result?.deletes || []),
+ ].map((entry) => entry?.summary);
+ if (anyRelayAcceptedInSummaries(deleteSummaries)) {
+ this.showSuccess("All versions deleted successfully!");
+ } else {
+ this.showSuccess(
+ "Delete request sent, but no relay has confirmed it yet — the video may reappear until relays catch up. Check again in a bit.",
+ );
+ }
if (storageLockedRemnant) {
this.showStatus(
"Heads up: the hosted file is still in your storage bucket because storage is locked. Unlock storage and delete again, or remove it from your bucket manually.",
diff --git a/js/app/feedCoordinator.js b/js/app/feedCoordinator.js
index 2ac93bbc..e46d31ed 100644
--- a/js/app/feedCoordinator.js
+++ b/js/app/feedCoordinator.js
@@ -14,6 +14,10 @@ import {
registerTrendingFeed as registerTrendingFeedPipeline,
buildTrendingFeedRuntime,
} from "../feedEngine/trendingFeed.js";
+import {
+ registerMostZappedFeed as registerMostZappedFeedPipeline,
+ buildMostZappedFeedRuntime,
+} from "../feedEngine/mostZappedFeed.js";
/**
* @param {object} deps - Injected dependencies.
@@ -876,6 +880,11 @@ export function createFeedCoordinator(deps) {
// Preserve the view-count ranking (otherwise re-sorted by date).
metadataModifier = (m) => ({ ...m, preserveOrder: true });
break;
+ case FEED_TYPES.MOST_ZAPPED:
+ runtime = buildMostZappedFeedRuntime(this);
+ // Preserve the zap-total ranking (otherwise re-sorted by date).
+ metadataModifier = (m) => ({ ...m, preserveOrder: true });
+ break;
case "recent":
runtime = this.buildRecentFeedRuntime();
break;
@@ -1041,6 +1050,12 @@ export function createFeedCoordinator(deps) {
refreshMethod = (opts) => this.refreshFeed(FEED_TYPES.TRENDING, opts);
shouldCheckRelayHealth = false;
break;
+ case FEED_TYPES.MOST_ZAPPED:
+ includeTags = false;
+ loadingMessage = "Fetching most-zapped videos\u2026";
+ refreshMethod = (opts) => this.refreshFeed(FEED_TYPES.MOST_ZAPPED, opts);
+ shouldCheckRelayHealth = false;
+ break;
default:
devLogger.warn(
`[Application] Unknown feed type for loadFeedVideos: ${feedType}`,
@@ -1143,6 +1158,14 @@ export function createFeedCoordinator(deps) {
return this.loadFeedVideos(FEED_TYPES.TRENDING, forceFetch);
},
+ registerMostZappedFeed() {
+ return registerMostZappedFeedPipeline(this);
+ },
+
+ async loadMostZappedVideos(forceFetch = false) {
+ return this.loadFeedVideos(FEED_TYPES.MOST_ZAPPED, forceFetch);
+ },
+
async loadOlderVideos(lastTimestamp) {
const olderVideos = await this.nostrService.loadOlderVideos(lastTimestamp, {
blacklistedEventIds: this.blacklistedEventIds,
diff --git a/js/app/modalCoordinator.js b/js/app/modalCoordinator.js
index cf844aa7..ac4e9460 100644
--- a/js/app/modalCoordinator.js
+++ b/js/app/modalCoordinator.js
@@ -25,12 +25,78 @@ export function createModalCoordinator(deps) {
unsubscribeFromVideoViewCount,
formatViewCount,
ingestLocalViewEvent,
+ requestVideoZapTotal,
+ getVideoZapTotalSnapshot,
+ onZapTotalsChanged,
pointerArrayToKey,
pointerKey,
getCanonicalDesignSystemMode,
BITVID_WEBSITE_URL,
} = deps;
+ // Zap-total badge helpers are LOCAL closures, NOT coordinator methods:
+ // coordinator methods run with `this` bound to the Application, so a
+ // cross-method `this.helper()` call only works when app.js also adds an
+ // explicit delegate for it — a silent landmine (a missing delegate here
+ // threw on EVERY modal open and broke video playback entirely).
+ let modalZapTotalUnsub = null;
+
+ function teardownModalZapTotal() {
+ if (typeof modalZapTotalUnsub === "function") {
+ try {
+ modalZapTotalUnsub();
+ } catch (error) {
+ devLogger.warn("[zapTotals] Failed to tear down modal listener:", error);
+ }
+ }
+ modalZapTotalUnsub = null;
+ const el =
+ typeof document !== "undefined"
+ ? document.getElementById("videoZapTotal")
+ : null;
+ if (el) {
+ el.classList.add("hidden");
+ const text = el.querySelector("[data-zap-total-text]");
+ if (text) {
+ text.textContent = "";
+ }
+ }
+ }
+
+ // Orange sats badge on the modal's meta row (right side; views stay left).
+ // Mirrors the card badge: cached total now (also schedules the batched
+ // receipt fetch), re-rendered when a batch lands, hidden while zero.
+ function subscribeModalZapTotal(pointer) {
+ if (
+ typeof requestVideoZapTotal !== "function" ||
+ typeof getVideoZapTotalSnapshot !== "function" ||
+ typeof onZapTotalsChanged !== "function"
+ ) {
+ return;
+ }
+ const el =
+ typeof document !== "undefined"
+ ? document.getElementById("videoZapTotal")
+ : null;
+ if (!el || !pointer) {
+ return;
+ }
+ // Always visible while a video is open (mirrors the view counter); a zero
+ // is a real value, not noise to hide.
+ const render = () => {
+ const sats = getVideoZapTotalSnapshot(pointer);
+ const value = Number.isFinite(sats) && sats > 0 ? sats : 0;
+ const text = el.querySelector("[data-zap-total-text]");
+ if (text) {
+ text.textContent = `${formatViewCount(value)} sats`;
+ }
+ el.classList.remove("hidden");
+ };
+ requestVideoZapTotal(pointer);
+ render();
+ modalZapTotalUnsub = onZapTotalsChanged(render);
+ }
+
return {
normalizeModalTrigger(candidate) {
if (!candidate) {
@@ -117,6 +183,7 @@ export function createModalCoordinator(deps) {
}
}
this.modalViewCountUnsub = null;
+ teardownModalZapTotal();
if (this.videoModal) {
this.videoModal.updateViewCountLabel("\u2013 views");
this.videoModal.setViewCountPointer(null);
@@ -139,6 +206,7 @@ export function createModalCoordinator(deps) {
this.videoModal.updateViewCountLabel("Loading views\u2026");
this.videoModal.setViewCountPointer(pointerKey);
}
+ subscribeModalZapTotal(pointer);
try {
const token = subscribeToVideoViewCount(pointer, ({ total, status, partial }) => {
const latestViewEl = this.videoModal?.getViewCountElement() || null;
diff --git a/js/constants.js b/js/constants.js
index b0bb8663..13e7d0f4 100644
--- a/js/constants.js
+++ b/js/constants.js
@@ -199,6 +199,8 @@ const DEFAULT_FLAGS = Object.freeze({
FEATURE_TRUSTED_HIDE_CONTROLS: true,
FEATURE_IMPROVED_COMMENT_FETCHING: true, // Default true for comment persistence fixes
FEATURE_TRENDING_FEED: true, // "Trending" tab: recently-added ranked by view count. Disable to hide the tab.
+ FEATURE_MOST_ZAPPED_FEED: true, // "Most Zapped" tab: recently-added ranked by zap total. Disable to hide the tab.
+ FEATURE_ZAP_TALLY: true, // bitvid-native preimage-verified zap tally (publish + count). Rollback: set false.
TRUSTED_MUTE_HIDE_THRESHOLD: DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD,
TRUSTED_SPAM_HIDE_THRESHOLD: DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD,
WSS_TRACKERS: DEFAULT_WSS_TRACKERS,
@@ -222,6 +224,8 @@ const runtimeFlags = (() => {
FEATURE_IMPROVED_COMMENT_FETCHING:
DEFAULT_FLAGS.FEATURE_IMPROVED_COMMENT_FETCHING,
FEATURE_TRENDING_FEED: DEFAULT_FLAGS.FEATURE_TRENDING_FEED,
+ FEATURE_MOST_ZAPPED_FEED: DEFAULT_FLAGS.FEATURE_MOST_ZAPPED_FEED,
+ FEATURE_ZAP_TALLY: DEFAULT_FLAGS.FEATURE_ZAP_TALLY,
TRUSTED_MUTE_HIDE_THRESHOLD: DEFAULT_FLAGS.TRUSTED_MUTE_HIDE_THRESHOLD,
TRUSTED_SPAM_HIDE_THRESHOLD: DEFAULT_FLAGS.TRUSTED_SPAM_HIDE_THRESHOLD,
WSS_TRACKERS: [...DEFAULT_FLAGS.WSS_TRACKERS],
@@ -267,6 +271,16 @@ export let FEATURE_TRENDING_FEED = coerceBoolean(
DEFAULT_FLAGS.FEATURE_TRENDING_FEED
);
+export let FEATURE_MOST_ZAPPED_FEED = coerceBoolean(
+ runtimeFlags.FEATURE_MOST_ZAPPED_FEED,
+ DEFAULT_FLAGS.FEATURE_MOST_ZAPPED_FEED
+);
+
+export let FEATURE_ZAP_TALLY = coerceBoolean(
+ runtimeFlags.FEATURE_ZAP_TALLY,
+ DEFAULT_FLAGS.FEATURE_ZAP_TALLY
+);
+
export let FEATURE_TRUST_SEEDS = coerceBoolean(
runtimeFlags.FEATURE_TRUST_SEEDS,
DEFAULT_FLAGS.FEATURE_TRUST_SEEDS
@@ -578,6 +592,7 @@ export const FEED_TYPES = Object.freeze({
KIDS: "kids",
EXPLORE: "explore",
TRENDING: "trending",
+ MOST_ZAPPED: "most-zapped",
SUBSCRIPTIONS: "subscriptions",
HISTORY: "history",
CHANNEL: "channel-profile",
diff --git a/js/feedEngine/mostZappedFeed.js b/js/feedEngine/mostZappedFeed.js
new file mode 100644
index 00000000..7fcde6ec
--- /dev/null
+++ b/js/feedEngine/mostZappedFeed.js
@@ -0,0 +1,126 @@
+// js/feedEngine/mostZappedFeed.js
+//
+// The "Most Zapped" tab (#47): the recently-added (active) source ranked by
+// ZAP TOTAL (sats from kind-9735 receipts). Mirrors trendingFeed.js — lives
+// outside feedCoordinator.js (size cap) and is wired in via thin delegations.
+// The metric getter uses requestVideoZapTotal, which returns the cached total
+// AND schedules a batched receipt fetch for unknown pointers; the Most Zapped
+// view re-runs the feed on zapTotals' change signal so the order settles as
+// totals stream in.
+
+import { FEED_TYPES } from "../constants.js";
+import { requestVideoZapTotal } from "../zapTotals.js";
+import {
+ createActiveNostrSource,
+ createBlacklistFilterStage,
+ createDedupeByRootStage,
+ createModerationStage,
+ createResolvePostedAtStage,
+} from "./index.js";
+import { createMostZappedSorter } from "./sorters.js";
+
+// Resolve a video's zap total via the app's canonical pointer derivation (the
+// same pointer keys the zap flow tags receipts with, so ranking and receipts
+// agree).
+function makeGetZapTotal(app) {
+ return (video) => {
+ if (!video || typeof video !== "object") {
+ return 0;
+ }
+ try {
+ const info =
+ typeof app.deriveVideoPointerInfo === "function"
+ ? app.deriveVideoPointerInfo(video)
+ : null;
+ if (!info?.pointer) {
+ return 0;
+ }
+ const total = requestVideoZapTotal(info.pointer);
+ return Number.isFinite(total) ? Number(total) : 0;
+ } catch (error) {
+ return 0;
+ }
+ };
+}
+
+export function buildMostZappedFeedRuntime(app) {
+ const blacklist =
+ app.blacklistedEventIds instanceof Set
+ ? new Set(app.blacklistedEventIds)
+ : new Set();
+ const moderationThresholds =
+ typeof app.getActiveModerationThresholds === "function"
+ ? app.getActiveModerationThresholds()
+ : null;
+ return {
+ blacklistedEventIds: blacklist,
+ isAuthorBlocked: (pubkey) => app.isAuthorBlocked(pubkey),
+ moderationThresholds: moderationThresholds
+ ? { ...moderationThresholds }
+ : undefined,
+ getZapTotal: makeGetZapTotal(app),
+ };
+}
+
+export function registerMostZappedFeed(app) {
+ if (!app?.feedEngine || typeof app.feedEngine.registerFeed !== "function") {
+ return null;
+ }
+ const existing =
+ typeof app.feedEngine.getFeedDefinition === "function"
+ ? app.feedEngine.getFeedDefinition(FEED_TYPES.MOST_ZAPPED)
+ : null;
+ if (existing) {
+ return existing;
+ }
+
+ const resolveThreshold = (key) => ({ runtimeValue, defaultValue }) => {
+ if (
+ Number.isFinite(runtimeValue) ||
+ runtimeValue === Number.POSITIVE_INFINITY
+ ) {
+ return runtimeValue;
+ }
+ if (typeof app.getActiveModerationThresholds === "function") {
+ const active = app.getActiveModerationThresholds();
+ const candidate =
+ active && typeof active === "object" ? active[key] : undefined;
+ if (Number.isFinite(candidate) || candidate === Number.POSITIVE_INFINITY) {
+ return candidate;
+ }
+ }
+ return defaultValue;
+ };
+
+ try {
+ return app.feedEngine.registerFeed(FEED_TYPES.MOST_ZAPPED, {
+ source: createActiveNostrSource({ service: app.nostrService }),
+ stages: [
+ createBlacklistFilterStage({
+ shouldIncludeVideo: (video, options) =>
+ app.nostrService.shouldIncludeVideo(video, options),
+ }),
+ createDedupeByRootStage({
+ dedupe: (videos) => app.dedupeVideosByRoot(videos),
+ }),
+ createModerationStage({
+ getService: () => app.nostrService.getModerationService(),
+ autoplayThreshold: resolveThreshold("autoplayBlockThreshold"),
+ blurThreshold: resolveThreshold("blurThreshold"),
+ trustedMuteHideThreshold: resolveThreshold("trustedMuteHideThreshold"),
+ trustedReportHideThreshold: resolveThreshold("trustedSpamHideThreshold"),
+ }),
+ createResolvePostedAtStage(),
+ ],
+ sorter: createMostZappedSorter(),
+ hooks: {
+ timestamps: {
+ getKnownVideoPostedAt: (video) => app.getKnownVideoPostedAt(video),
+ resolveVideoPostedAt: (video) => app.resolveVideoPostedAt(video),
+ },
+ },
+ });
+ } catch (error) {
+ return null;
+ }
+}
diff --git a/js/feedEngine/sorters.js b/js/feedEngine/sorters.js
index f23bd52c..042c2dc6 100644
--- a/js/feedEngine/sorters.js
+++ b/js/feedEngine/sorters.js
@@ -3,21 +3,20 @@
import { normalizeHashtag } from "../utils/hashtagNormalization.js";
import { markAsNormalized } from "./utils.js";
-// Ranks the active feed by VIEW COUNT (the "Trending" tab). Counts come from the
-// injected runtime.getViewCount(video) — the shared viewCounter cache populated
-// by the grid cards' per-card subscriptions. Unknown/not-yet-loaded counts are
-// treated as 0, so on a cold cache the feed reads as recency and settles into
-// true trending as counts stream in (the Trending view re-runs the feed on
-// viewCounter's debounced change signal). Trusted-muted always sinks last.
-export function createTrendingSorter() {
- return function trendingSorter(items = [], context = {}) {
+// Shared count-ranked sorter: rank by an injected per-video metric (desc),
+// tie-break by recency, spread authors, sink trusted-muted last. Unknown /
+// not-yet-loaded counts are treated as 0, so on a cold cache the feed reads
+// as recency and settles into the true ranking as counts stream in (the view
+// re-runs the feed on the metric cache's debounced change signal).
+// - Trending (#27): runtime.getViewCount (viewCounter cache)
+// - Most Zapped (#47): runtime.getZapTotal (zapTotals cache)
+function createCountRankedSorter(resolveMetric) {
+ return function countRankedSorter(items = [], context = {}) {
if (!Array.isArray(items)) {
return [];
}
- const getViewCount =
- typeof context?.runtime?.getViewCount === "function"
- ? context.runtime.getViewCount
- : () => 0;
+ const metricFn = resolveMetric(context);
+ const getViewCount = typeof metricFn === "function" ? metricFn : () => 0;
const isMuted = (entry) =>
entry?.metadata?.moderation?.trustedMuted === true ||
@@ -55,12 +54,21 @@ export function createTrendingSorter() {
const live = items.filter((entry) => !isMuted(entry));
const muted = items.filter((entry) => isMuted(entry));
- // Rank by views, then spread authors so the tab isn't walls of one creator.
- // The most-viewed item is still chosen first, so it stays topmost.
+ // Rank by the metric, then spread authors so the tab isn't walls of one
+ // creator. The top-ranked item is still chosen first, so it stays topmost.
return [...spreadAuthors(live.sort(compare)), ...muted.sort(compare)];
};
}
+export function createTrendingSorter() {
+ return createCountRankedSorter((context) => context?.runtime?.getViewCount);
+}
+
+// "Most Zapped" (#47): same ranking mechanics over sats totals.
+export function createMostZappedSorter() {
+ return createCountRankedSorter((context) => context?.runtime?.getZapTotal);
+}
+
// Round-robin interleave by author: one video per creator per pass, each pass in
// the caller's pre-sorted order. Consecutive cards are from different creators,
// so a feed reads as a varied "discovery" mix instead of a chronological wall —
diff --git a/js/mostZappedView.js b/js/mostZappedView.js
new file mode 100644
index 00000000..19dc0ce1
--- /dev/null
+++ b/js/mostZappedView.js
@@ -0,0 +1,65 @@
+// js/mostZappedView.js
+//
+// The "Most Zapped" tab presentation hook (#47). The ranking lives in the feed
+// engine (createMostZappedSorter); zap totals arrive asynchronously as the
+// feed's metric getter schedules batched receipt fetches. While this view is
+// active we listen for zapTotals' change signal and re-run the feed (a cheap
+// re-rank over the in-memory active set, no relay re-fetch) so the order
+// visibly settles as totals stream in — same pattern as trendingView.
+
+import { attachFeedInfoPopover } from "./ui/components/FeedInfoPopover.js";
+import { onZapTotalsChanged } from "./zapTotals.js";
+
+const MOST_ZAPPED_RERANK_DEBOUNCE_MS = 700;
+
+let unsubscribeTotals = null;
+let rerankTimer = null;
+
+export function teardownMostZappedView() {
+ if (typeof unsubscribeTotals === "function") {
+ try {
+ unsubscribeTotals();
+ } catch (error) {
+ // best effort
+ }
+ unsubscribeTotals = null;
+ }
+ if (rerankTimer) {
+ clearTimeout(rerankTimer);
+ rerankTimer = null;
+ }
+}
+
+export function initMostZappedView({ getApp, isActive } = {}) {
+ const infoTrigger = document.getElementById("mostZappedInfoTrigger");
+ if (infoTrigger) {
+ attachFeedInfoPopover(
+ infoTrigger,
+ "Most Zapped ranks recently-added videos by their zap total (sats). The order settles as zap receipts load in."
+ );
+ }
+
+ // Reset any prior subscription (re-entering the tab).
+ teardownMostZappedView();
+
+ const stillActive = typeof isActive === "function" ? isActive : () => true;
+
+ unsubscribeTotals = onZapTotalsChanged(() => {
+ if (rerankTimer) {
+ return;
+ }
+ rerankTimer = setTimeout(() => {
+ rerankTimer = null;
+ if (!stillActive()) {
+ teardownMostZappedView();
+ return;
+ }
+ const app = typeof getApp === "function" ? getApp() : null;
+ if (app && typeof app.refreshMostZappedFeed === "function") {
+ Promise.resolve(
+ app.refreshMostZappedFeed({ reason: "zap-total-update" })
+ ).catch(() => {});
+ }
+ }, MOST_ZAPPED_RERANK_DEBOUNCE_MS);
+ });
+}
diff --git a/js/nostr/client.js b/js/nostr/client.js
index 41f72c4b..e48f4b92 100644
--- a/js/nostr/client.js
+++ b/js/nostr/client.js
@@ -111,7 +111,7 @@ import "./maxListenerDiagnostics.js";
import {
publishEventToRelay,
publishEventToRelays,
- assertAnyRelayAccepted,
+ assertAnyRelayAcceptedOrUnconfirmed,
attachRelayPublishSummary,
} from "../nostrPublish.js";
import {
@@ -2960,13 +2960,17 @@ export class NostrClient {
await this.ensureActiveSignerForPubkey(userPubkeyLower);
try {
- const { signedEvent } = await this.signAndPublishEvent(context.event, {
+ const { signedEvent, summary } = await this.signAndPublishEvent(context.event, {
context: "edited video note",
logName: "Edited video note",
devLogLabel: "edited video note",
resolveActiveSigner: (p) => this.signerManager.resolveActiveSigner(p),
});
+ // Same tally as publishVideo so the edit UI can tell an unconfirmed
+ // (all-timeout, #49) update apart from a confirmed one.
+ attachRelayPublishSummary(signedEvent, summary);
+
await handlePublishNip94(this, signedEvent, context);
await handlePublishNip71(this, signedEvent, context);
@@ -3216,7 +3220,10 @@ export class NostrClient {
let publishSummary;
try {
- publishSummary = assertAnyRelayAccepted(publishResults, {
+ // #49: an all-timeout tombstone publish is an unconfirmed soft success
+ // (summary.unconfirmed=true) so a slow-ack revert/delete doesn't abort
+ // midway; explicit relay rejections still throw below.
+ publishSummary = assertAnyRelayAcceptedOrUnconfirmed(publishResults, {
context: "video revert",
});
} catch (publishError) {
@@ -3237,6 +3244,12 @@ export class NostrClient {
devLogger.log(`Revert event published to ${url}`)
);
+ if (publishSummary.unconfirmed) {
+ userLogger.warn(
+ "[nostr] Video revert was sent but no relay confirmed it within the timeout — proceeding as unconfirmed.",
+ );
+ }
+
if (publishSummary.failed.length) {
publishSummary.failed.forEach(({ url, error: relayError }) => {
const reason =
diff --git a/js/nostr/publishHelpers.js b/js/nostr/publishHelpers.js
index 2b2e05f4..7c62b6e2 100644
--- a/js/nostr/publishHelpers.js
+++ b/js/nostr/publishHelpers.js
@@ -1,7 +1,7 @@
import { ENSURE_PRESENCE_REBROADCAST_COOLDOWN_SECONDS } from "../config.js";
import {
publishEventToRelays,
- assertAnyRelayAccepted,
+ assertAnyRelayAcceptedOrUnconfirmed,
summarizePublishResults,
} from "../nostrPublish.js";
import {
@@ -469,7 +469,10 @@ export async function signAndPublishEvent({
let publishSummary;
try {
- publishSummary = assertAnyRelayAccepted(publishResults, { context });
+ // #49: an all-timeout publish (sent, nothing rejected) is treated as an
+ // unconfirmed soft success — summary.unconfirmed=true — instead of a false
+ // "failed to publish". Explicit relay rejections still throw below.
+ publishSummary = assertAnyRelayAcceptedOrUnconfirmed(publishResults, { context });
} catch (publishError) {
if (publishError?.relayFailures?.length) {
const logLevel = rejectionLogLevel === "warn" ? "warn" : "error";
@@ -485,6 +488,12 @@ export async function signAndPublishEvent({
devLogger.log(`${logName} published to ${url}`);
});
+ if (publishSummary.unconfirmed) {
+ userLogger.warn(
+ `[nostr] ${logName} was sent but no relay confirmed it within the timeout — treating as an unconfirmed publish.`,
+ );
+ }
+
if (publishSummary.failed.length) {
publishSummary.failed.forEach(({ url, error: relayError }) => {
const reason =
@@ -1331,7 +1340,11 @@ export async function rebroadcastEvent({ client, eventId, options = {} }) {
const publishResults = await publishEventToRelays(client?.pool, relays, rawEvent);
try {
- const summary = assertAnyRelayAccepted(publishResults, { context: "rebroadcast" });
+ // #49: rebroadcast re-publishes an existing event, so an all-timeout is a
+ // safe unconfirmed success; only explicit rejections fall to the catch.
+ const summary = assertAnyRelayAcceptedOrUnconfirmed(publishResults, {
+ context: "rebroadcast",
+ });
return {
ok: true,
rebroadcast: true,
diff --git a/js/nostr/reqTelemetry.js b/js/nostr/reqTelemetry.js
new file mode 100644
index 00000000..16ad768f
--- /dev/null
+++ b/js/nostr/reqTelemetry.js
@@ -0,0 +1,184 @@
+// js/nostr/reqTelemetry.js
+//
+// Dev-gated REQ tracer for TODO #9 (cold-login relay-REQ storm). Every
+// subscription that goes through the shimmed pool.sub/pool.list choke point is
+// recorded — kinds, relay fan-out, and the first call site outside the nostr
+// plumbing — so one traced cold login tells us exactly WHICH code paths emit
+// the burst (the storm's remaining sources are unpinned; see the TODO).
+//
+// Zero-cost when off: recordReq is a single boolean check. Enable with
+// localStorage.setItem("bitvid:reqTrace", "1") (then reload)
+// or automatically in dev mode. Read results from the periodic
+// "[req-trace]" logs or on demand via window.__bitvidReqTrace.report().
+
+import { isDevMode } from "../config.js";
+import { userLogger } from "../utils/logger.js";
+
+const STORAGE_FLAG = "bitvid:reqTrace";
+const REPORT_WINDOW_MS = 10000;
+const PLUMBING_FRAME = /reqTelemetry\.js|toolkit\.js|relaySubscriptionService|subscriptionManager|nostrPublish\.js|node_modules|^Error/i;
+
+function readStorageFlag() {
+ try {
+ return (
+ typeof localStorage !== "undefined" &&
+ localStorage.getItem(STORAGE_FLAG) === "1"
+ );
+ } catch (error) {
+ return false;
+ }
+}
+
+// First stack frame that is application code rather than pool plumbing.
+export function extractCallSite(stack) {
+ if (typeof stack !== "string" || !stack) {
+ return "unknown";
+ }
+ for (const rawLine of stack.split("\n")) {
+ const line = rawLine.trim();
+ if (!line || PLUMBING_FRAME.test(line)) {
+ continue;
+ }
+ // "at fn (url:line:col)" / "fn@url:line:col" → keep it short but unique
+ // enough to identify the module (strip origin, keep path:line).
+ const match = line.match(/((?:[\w./-]+\/)?[\w.-]+\.m?js):(\d+)(?::\d+)?/);
+ if (match) {
+ const fnMatch = line.match(/at ([\w.<>$[\]]+) |^([\w.<>$[\]]+)@/);
+ const fn = fnMatch ? fnMatch[1] || fnMatch[2] : "";
+ return `${fn ? `${fn} ` : ""}${match[1]}:${match[2]}`.trim();
+ }
+ return line.slice(0, 120);
+ }
+ return "unknown";
+}
+
+export function createReqTelemetry({
+ now = () => Date.now(),
+ log = (payload) => userLogger.info("[req-trace]", payload),
+ windowMs = REPORT_WINDOW_MS,
+} = {}) {
+ const state = {
+ enabled: false,
+ entries: new Map(), // key: `${kinds}|${callSite}` → { kinds, callSite, count, relayFanout }
+ total: 0,
+ relayFanout: 0,
+ windowStart: 0,
+ timer: null,
+ };
+
+ const reset = () => {
+ state.entries.clear();
+ state.total = 0;
+ state.relayFanout = 0;
+ state.windowStart = now();
+ };
+
+ const record = (relayCount, filters, stack) => {
+ if (!state.enabled) {
+ return;
+ }
+ const list = Array.isArray(filters) ? filters : filters ? [filters] : [];
+ const kinds = Array.from(
+ new Set(
+ list.flatMap((filter) =>
+ Array.isArray(filter?.kinds) ? filter.kinds : [],
+ ),
+ ),
+ )
+ .sort((a, b) => a - b)
+ .join(",");
+ const callSite = extractCallSite(stack);
+ const key = `${kinds || "?"}|${callSite}`;
+ const entry = state.entries.get(key) || {
+ kinds: kinds || "?",
+ callSite,
+ count: 0,
+ relayFanout: 0,
+ };
+ entry.count += 1;
+ entry.relayFanout += Number.isFinite(relayCount) ? relayCount : 0;
+ state.entries.set(key, entry);
+ state.total += 1;
+ state.relayFanout += Number.isFinite(relayCount) ? relayCount : 0;
+ };
+
+ const report = ({ emit = true } = {}) => {
+ const elapsedMs = Math.max(1, now() - state.windowStart);
+ const rows = Array.from(state.entries.values()).sort(
+ (a, b) => b.count - a.count,
+ );
+ const payload = {
+ windowSeconds: Math.round(elapsedMs / 100) / 10,
+ totalSubs: state.total,
+ totalRelayReqs: state.relayFanout,
+ perSecond: Math.round((state.relayFanout / elapsedMs) * 10000) / 10,
+ byKindsAndCaller: rows.slice(0, 15).map((row) => ({
+ kinds: row.kinds,
+ caller: row.callSite,
+ subs: row.count,
+ relayReqs: row.relayFanout,
+ })),
+ };
+ if (emit && state.total > 0) {
+ log(payload);
+ }
+ return payload;
+ };
+
+ const start = () => {
+ if (state.enabled) {
+ return;
+ }
+ state.enabled = true;
+ reset();
+ if (typeof setInterval === "function" && !state.timer) {
+ state.timer = setInterval(() => {
+ report();
+ reset();
+ }, windowMs);
+ if (typeof state.timer?.unref === "function") {
+ state.timer.unref();
+ }
+ }
+ };
+
+ const stop = () => {
+ state.enabled = false;
+ if (state.timer) {
+ clearInterval(state.timer);
+ state.timer = null;
+ }
+ };
+
+ return {
+ get enabled() {
+ return state.enabled;
+ },
+ start,
+ stop,
+ reset,
+ record,
+ report,
+ };
+}
+
+const defaultTelemetry = createReqTelemetry();
+
+// Auto-arm in dev mode or when the localStorage flag is set; expose a console
+// handle either way so it can be started on a production build too.
+if (isDevMode || readStorageFlag()) {
+ defaultTelemetry.start();
+}
+if (typeof window !== "undefined") {
+ window.__bitvidReqTrace = defaultTelemetry;
+}
+
+// Hot-path hook for the pool shim: one boolean check when tracing is off.
+export function recordReq(relayCount, filters) {
+ if (!defaultTelemetry.enabled) {
+ return;
+ }
+ defaultTelemetry.record(relayCount, filters, new Error().stack);
+}
+
+export default defaultTelemetry;
diff --git a/js/nostr/toolkit.js b/js/nostr/toolkit.js
index 05d47e0b..b7e21855 100644
--- a/js/nostr/toolkit.js
+++ b/js/nostr/toolkit.js
@@ -3,6 +3,7 @@
import { isDevMode } from "../config.js";
import { nostrToolsReady } from "../nostrToolsBootstrap.js";
import { devLogger, userLogger } from "../utils/logger.js";
+import { recordReq } from "./reqTelemetry.js";
/**
* The default relay set bitvid bootstraps with before loading a user's
@@ -524,6 +525,10 @@ export function shimLegacySimplePoolMethods(pool) {
const { relayList, filterList, requests } =
createLegacySubscriptionContext(relays, filters);
+ // #9 REQ tracer: every sub/list passes through here — record kinds,
+ // fan-out, and the emitting call site (no-op unless tracing is armed).
+ recordReq(relayList.length, filterList);
+
const listeners = {
event: new Set(),
eose: new Set(),
diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js
index 1fbedef7..ff6f553b 100644
--- a/js/nostrEventSchemas.js
+++ b/js/nostrEventSchemas.js
@@ -47,6 +47,7 @@ export const NOTE_TYPES = Object.freeze({
DM_TYPING: "dmTypingIndicator",
ZAP_REQUEST: "zapRequest",
ZAP_RECEIPT: "zapReceipt",
+ ZAP_TALLY: "zapTally",
WATCH_HISTORY: "watchHistory",
SUBSCRIPTION_LIST: "subscriptionList",
SUBSCRIPTION_BACKUP: "subscriptionBackup",
@@ -92,6 +93,11 @@ export { ADMIN_COMMUNITY_BLACKLIST_PREFIX };
const DEFAULT_APPEND_TAGS = [];
+// bitvid-native zap tally event kind. Addressable (30000–39999) so `d =
+// payment_hash` yields one canonical event per real payment. In bitvid's family
+// (30078 video, 30079 view). See docs/zap-tally-plan.md.
+export const ZAP_TALLY_KIND = 30081;
+
let cachedUtf8Encoder = null;
const ADDITIONAL_POINTER_TAGS = new Set(["p", "e"]);
@@ -527,6 +533,23 @@ const BASE_SCHEMAS = {
"Zap receipts are published by lightning wallets to confirm zap requests.",
},
},
+ [NOTE_TYPES.ZAP_TALLY]: {
+ // bitvid-native, payer-signed, preimage-verified zap tally (docs/zap-tally-plan.md).
+ // Addressable (d = payment_hash) so each real payment maps to one canonical
+ // event: relay-level idempotency, re-publish replaces rather than duplicates.
+ // Counted alongside NIP-57 9735 receipts (deduped by payment_hash), so a
+ // Most-Zapped tally survives when the recipient's wallet never publishes a 9735.
+ type: NOTE_TYPES.ZAP_TALLY,
+ label: "Zap tally",
+ kind: ZAP_TALLY_KIND,
+ identifierTag: { name: "d" },
+ appendTags: DEFAULT_APPEND_TAGS,
+ content: {
+ format: "text",
+ description:
+ "Optional zap message; the authoritative amount + proof live in the tags.",
+ },
+ },
[NOTE_TYPES.WATCH_HISTORY]: {
type: NOTE_TYPES.WATCH_HISTORY,
label: "Watch history month",
@@ -2347,6 +2370,101 @@ export function buildZapRequestEvent(params) {
return event;
}
+/**
+ * Builds a bitvid-native zap tally event (kind ZAP_TALLY_KIND, addressable by
+ * d=payment_hash). Payer-signed, preimage-verified stand-in for a NIP-57 receipt
+ * when the recipient's LNURL server doesn't publish one. Carries the same
+ * {bolt11, preimage, description(zap request)} a 9735 would, so any client can
+ * verify sha256(preimage)==payment_hash and description_hash==sha256(zapRequest).
+ * See docs/zap-tally-plan.md §3. Returns an UNSIGNED event; the caller signs.
+ *
+ * @param {object} params
+ * @param {string} params.pubkey payer pubkey
+ * @param {number} params.created_at
+ * @param {string} params.paymentHash hex; becomes the `d` tag (idempotency)
+ * @param {string} params.recipientPubkey the zapped party (`p`)
+ * @param {string} [params.eventId] zapped video event id (`e`)
+ * @param {string} [params.coordinate] zapped video coordinate kind:pubkey:d (`a`)
+ * @param {number} [params.amountMsats] echoes the zap request amount tag
+ * @param {string} params.bolt11 paid invoice (carries payment_hash + description_hash)
+ * @param {string} params.preimage NWC pay_invoice preimage (proof of settlement)
+ * @param {string} params.zapRequestJson the signed 9734 JSON (NIP-57 `description`)
+ * @param {string} [params.comment]
+ * @returns {object} Unsigned event.
+ */
+export function buildZapTallyEvent(params) {
+ const {
+ pubkey,
+ created_at,
+ paymentHash,
+ recipientPubkey,
+ eventId,
+ coordinate,
+ amountMsats,
+ bolt11,
+ preimage,
+ zapRequestJson,
+ comment = "",
+ } = params || {};
+
+ const schema = getNostrEventSchema(NOTE_TYPES.ZAP_TALLY);
+ const tags = [];
+
+ const normalizedHash = normalizePointerIdentifier(paymentHash);
+ if (normalizedHash) {
+ tags.push(["d", normalizedHash]); // addressable identifier = payment_hash
+ }
+
+ const normalizedRecipient = normalizePointerIdentifier(recipientPubkey);
+ if (normalizedRecipient) {
+ tags.push(["p", normalizedRecipient]);
+ }
+
+ const normalizedEventId = normalizePointerIdentifier(eventId);
+ if (normalizedEventId) {
+ tags.push(["e", normalizedEventId]);
+ }
+
+ if (typeof coordinate === "string" && coordinate.trim()) {
+ tags.push(["a", coordinate.trim()]);
+ }
+
+ if (Number.isFinite(amountMsats)) {
+ tags.push(["amount", String(Math.max(0, Math.round(amountMsats)))]);
+ }
+
+ if (typeof bolt11 === "string" && bolt11.trim()) {
+ tags.push(["bolt11", bolt11.trim()]);
+ }
+
+ if (typeof preimage === "string" && preimage.trim()) {
+ tags.push(["preimage", preimage.trim()]);
+ }
+
+ if (typeof zapRequestJson === "string" && zapRequestJson.trim()) {
+ tags.push(["description", zapRequestJson.trim()]);
+ }
+
+ tags.push(["client", "bitvid"]);
+ tags.push(["t", "zap"]);
+
+ appendSchemaTags(tags, schema);
+
+ const event = {
+ kind: schema?.kind ?? ZAP_TALLY_KIND,
+ pubkey,
+ created_at,
+ tags,
+ content: ensureValidUtf8Content(typeof comment === "string" ? comment : ""),
+ };
+
+ if (isDevMode) {
+ validateEventAgainstSchema(NOTE_TYPES.ZAP_TALLY, event);
+ }
+
+ return event;
+}
+
/**
* Builds a Reaction event (Kind 7).
* E.g., Like ('+'), Dislike ('-'), or emoji.
diff --git a/js/nostrPublish.js b/js/nostrPublish.js
index a9a7a751..c146f464 100644
--- a/js/nostrPublish.js
+++ b/js/nostrPublish.js
@@ -278,9 +278,16 @@ export function attachRelayPublishSummary(event, summary) {
}
const accepted = Array.isArray(summary?.accepted) ? summary.accepted.length : 0;
const failed = Array.isArray(summary?.failed) ? summary.failed.length : 0;
+ // #49: carry the unconfirmed flag (all relays timed out, nothing rejected)
+ // so the outcome describer can say "sent, unconfirmed" instead of lying in
+ // either direction ("published to N relays" / "failed").
+ const tally =
+ summary?.unconfirmed === true
+ ? { accepted, total: accepted + failed, unconfirmed: true }
+ : { accepted, total: accepted + failed };
try {
Object.defineProperty(event, RELAY_PUBLISH_SUMMARY_KEY, {
- value: { accepted, total: accepted + failed },
+ value: tally,
enumerable: false,
configurable: true,
writable: true,
@@ -308,7 +315,11 @@ export function readRelayPublishSummary(event) {
* @param {{ accepted?: number, total?: number }} tally
* @returns {{ tone: "success"|"warning"|"error", message: string }}
*/
-export function describePublishOutcome({ accepted = 0, total = 0 } = {}) {
+export function describePublishOutcome({
+ accepted = 0,
+ total = 0,
+ unconfirmed = false,
+} = {}) {
const a = Number.isFinite(accepted) ? Math.max(0, Math.floor(accepted)) : 0;
const t = Number.isFinite(total) ? Math.max(0, Math.floor(total)) : 0;
@@ -317,6 +328,16 @@ export function describePublishOutcome({ accepted = 0, total = 0 } = {}) {
return { tone: "success", message: "Video shared successfully!" };
}
if (a <= 0) {
+ // #49: every relay timed out without rejecting — the event was sent and
+ // almost always persisted (it shows up after a refresh). Say exactly that:
+ // neither a hard failure nor a fake "published to N relays".
+ if (unconfirmed === true) {
+ return {
+ tone: "warning",
+ message:
+ "Video sent, but no relay has confirmed receiving it yet. It should appear shortly — refresh in a moment to double-check.",
+ };
+ }
return {
tone: "error",
message:
@@ -335,6 +356,24 @@ export function describePublishOutcome({ accepted = 0, total = 0 } = {}) {
};
}
+// #49 delete semantics: a delete flow publishes several events (tombstone
+// reverts + kind-5 chunks) and the UI must NOT claim "deleted" unless at least
+// one relay actually ACKed something — a false "deleted" is worse than a false
+// "failed". An empty list (nothing needed publishing) counts as confirmed.
+export function anyRelayAcceptedInSummaries(summaries = []) {
+ const list = Array.isArray(summaries) ? summaries.filter(Boolean) : [];
+ if (!list.length) {
+ return true;
+ }
+ return list.some((summary) => {
+ const accepted = summary?.accepted;
+ if (Array.isArray(accepted)) {
+ return accepted.length > 0;
+ }
+ return Number.isFinite(accepted) && accepted > 0;
+ });
+}
+
// A RelayPublishError whose per-relay failures are ALL "publish timeout" means the
// event was sent but not ACKed in the window — NOT that any relay rejected it. For
// an idempotent, replaceable event this almost always persisted (users see it
diff --git a/js/payments/lnurl.js b/js/payments/lnurl.js
index 00a9cc5f..b3e6ef0b 100644
--- a/js/payments/lnurl.js
+++ b/js/payments/lnurl.js
@@ -228,10 +228,42 @@ function normalizeNumeric(value, fallback = 0) {
return numeric;
}
-export async function fetchPayServiceData(url, { fetcher } = {}) {
+// LNURL servers (Strike among them) intermittently return transient failures —
+// network errors, 5xx, or {status:"ERROR"} bodies like "Could not get user
+// information" — for addresses that resolve fine seconds later (verified live
+// 2026-07-04 against strike.me: ERROR on one request, OK with a callback on
+// the retry). One short retry keeps a real zap from dying on such a blip; a
+// genuinely bad address still fails after at most one extra request.
+export async function fetchPayServiceData(
+ url,
+ { fetcher, retries = 1, retryDelayMs = 600 } = {},
+) {
const targetUrl = sanitizeUrl(url);
const fetchFn = ensureFetchFunction(fetcher);
+ let lastError = null;
+ for (let attempt = 0; attempt <= retries; attempt += 1) {
+ if (attempt > 0 && retryDelayMs > 0) {
+ await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
+ }
+ try {
+ return await fetchPayServiceDataOnce(targetUrl, fetchFn);
+ } catch (error) {
+ lastError = error;
+ if (error?.transient !== true) {
+ throw error;
+ }
+ }
+ }
+ throw lastError;
+}
+
+function markTransient(error) {
+ error.transient = true;
+ return error;
+}
+
+async function fetchPayServiceDataOnce(targetUrl, fetchFn) {
let response;
try {
response = await fetchFn(targetUrl, {
@@ -247,18 +279,21 @@ export async function fetchPayServiceData(url, { fetcher } = {}) {
);
friendly.cause = error;
friendly.code = "lnurl-unreachable";
- throw friendly;
+ throw markTransient(friendly);
}
if (!response.ok) {
- throw new Error(`Failed to load LNURL metadata (${response.status}).`);
+ throw markTransient(
+ new Error(`Failed to load LNURL metadata (${response.status}).`),
+ );
}
let payload;
try {
payload = await response.json();
} catch (error) {
- throw new Error("LNURL endpoint did not return JSON.");
+ // A non-JSON body is usually an edge/CDN interstitial — treat as a blip.
+ throw markTransient(new Error("LNURL endpoint did not return JSON."));
}
if (!payload || typeof payload !== "object") {
@@ -270,7 +305,7 @@ export async function fetchPayServiceData(url, { fetcher } = {}) {
typeof payload.reason === "string" && payload.reason.trim()
? payload.reason.trim()
: "LNURL endpoint returned an error.";
- throw new Error(reason);
+ throw markTransient(new Error(reason));
}
const callback = sanitizeUrl(payload.callback);
diff --git a/js/payments/nwcClient.js b/js/payments/nwcClient.js
index 8933b7b4..0f0020ca 100644
--- a/js/payments/nwcClient.js
+++ b/js/payments/nwcClient.js
@@ -926,7 +926,7 @@ async function ensureEncryption(context) {
throw new Error("No compatible wallet encryption scheme available.");
}
-function shouldRetryWithFallback(context, error, encryption, { hasRetried }) {
+function shouldRetryWithFallback(context, error, encryption, { hasRetried, method } = {}) {
if (!context || !encryption || hasRetried) {
return false;
}
@@ -934,6 +934,8 @@ function shouldRetryWithFallback(context, error, encryption, { hasRetried }) {
const code = typeof error?.code === "string" ? error.code : "";
const message = typeof error?.message === "string" ? error.message : "";
+ // An explicit "unsupported encryption" rejection means the wallet never
+ // processed the request, so re-sending under nip04 is safe for any method.
if (code === "UNSUPPORTED_ENCRYPTION" || code === "unsupported_encryption") {
return true;
}
@@ -947,6 +949,13 @@ function shouldRetryWithFallback(context, error, encryption, { hasRetried }) {
return false;
}
+ // A TIMEOUT is ambiguous: the wallet may have received the request and be
+ // paying it. NEVER auto-resend a spending method on timeout — the encryption
+ // fallback would double-pay. Read-only methods stay retryable.
+ if (NWC_SPENDING_METHODS.has(method)) {
+ return false;
+ }
+
const state = ensureEncryptionState(context);
if (state.unsupported.has("nip04")) {
return false;
@@ -1358,6 +1367,15 @@ async function dispatchWalletRequest(context, payload, { timeoutMs } = {}) {
return promise;
}
+// NWC methods that MOVE money. A timeout on these is ambiguous — the payment
+// may already be in flight — so we must NOT auto-resend them (double-spend).
+const NWC_SPENDING_METHODS = new Set([
+ "pay_invoice",
+ "pay_keysend",
+ "multi_pay_invoice",
+ "multi_pay_keysend",
+]);
+
async function sendWalletRequest(context, payload, { timeoutMs, __internalRetry = false } = {}) {
try {
return await dispatchWalletRequest(context, payload, { timeoutMs });
@@ -1366,6 +1384,7 @@ async function sendWalletRequest(context, payload, { timeoutMs, __internalRetry
if (
shouldRetryWithFallback(context, error, encryption, {
hasRetried: __internalRetry === true,
+ method: payload?.method,
})
) {
rememberUnsupportedEncryption(context, encryption.scheme);
@@ -1647,4 +1666,6 @@ export const __TESTING__ = Object.freeze({
buildPayInvoiceParams,
getActiveState: () => activeState,
ensureEncryptionForContext: ensureEncryption,
+ shouldRetryWithFallback,
+ NWC_SPENDING_METHODS,
});
diff --git a/js/payments/zapReceiptValidator.js b/js/payments/zapReceiptValidator.js
index fb1d8c30..0ba2945b 100644
--- a/js/payments/zapReceiptValidator.js
+++ b/js/payments/zapReceiptValidator.js
@@ -58,6 +58,97 @@ function computeZapRequestHash(zapRequest) {
return bytesToHex(sha256(data));
}
+function hexToBytes(hex) {
+ const clean = typeof hex === "string" ? hex.trim().toLowerCase() : "";
+ if (!clean || clean.length % 2 !== 0 || /[^0-9a-f]/.test(clean)) {
+ return null;
+ }
+ const bytes = new Uint8Array(clean.length / 2);
+ for (let i = 0; i < bytes.length; i += 1) {
+ bytes[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
+ }
+ return bytes;
+}
+
+// Proof of settlement: a Lightning payment_hash is sha256(preimage). Anyone can
+// check it — this is what makes a payer-signed tally as trustworthy as a
+// recipient-signed 9735 (docs/zap-tally-plan.md §3, step 2).
+export function verifyPaymentPreimage(preimageHex, paymentHashHex) {
+ const preimage = hexToBytes(preimageHex);
+ const expected =
+ typeof paymentHashHex === "string" ? paymentHashHex.trim().toLowerCase() : "";
+ if (!preimage || preimage.length !== 32 || expected.length !== 64) {
+ return false;
+ }
+ return bytesToHex(sha256(preimage)) === expected;
+}
+
+const tagValue = (tags, name) => {
+ const list = Array.isArray(tags) ? tags : [];
+ const tag = list.find((t) => Array.isArray(t) && t[0] === name && t[1] != null);
+ return tag ? String(tag[1]) : "";
+};
+
+// Verify a bitvid zap-tally event (kind ZAP_TALLY_KIND). Returns
+// { ok, sats, paymentHash, pointerTags } — pointerTags come from the EMBEDDED
+// zap request (a/e/p), not the outer wrapper, so a mangled wrapper can't
+// retarget the credit. `getSats` defaults to nostr-tools' bolt11 amount parser;
+// injectable for tests. See docs/zap-tally-plan.md §3.
+export function verifyBitvidZapTally(event, { getSats, extractFields } = {}) {
+ const fail = { ok: false, sats: 0, paymentHash: null, pointerTags: [] };
+ if (!event || typeof event !== "object" || !Array.isArray(event.tags)) {
+ return fail;
+ }
+ const bolt11 = tagValue(event.tags, "bolt11");
+ const preimage = tagValue(event.tags, "preimage");
+ const zapRequestJson = tagValue(event.tags, "description");
+ if (!bolt11 || !preimage) {
+ return fail;
+ }
+
+ const decode = typeof extractFields === "function" ? extractFields : extractBolt11Fields;
+ const { paymentHash, descriptionHash } = decode(bolt11);
+ if (!paymentHash) {
+ return fail;
+ }
+ // Step 2: the invoice was actually paid.
+ if (!verifyPaymentPreimage(preimage, paymentHash)) {
+ return fail;
+ }
+ // Step 3: that payment was for THIS zap request (binds pointer + amount;
+ // blocks reusing a leaked preimage against a different video).
+ if (descriptionHash) {
+ if (!zapRequestJson || computeZapRequestHash(zapRequestJson) !== descriptionHash) {
+ return fail;
+ }
+ }
+
+ // Amount is authoritative from the bolt11, never the tag.
+ let sats = 0;
+ try {
+ const fn =
+ typeof getSats === "function"
+ ? getSats
+ : cachedTools?.nip57?.getSatoshisAmountFromBolt11;
+ const parsed = typeof fn === "function" ? Number(fn(bolt11)) : NaN;
+ sats = Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : 0;
+ } catch (error) {
+ sats = 0;
+ }
+ if (sats <= 0) {
+ return fail;
+ }
+
+ // Pointer tags from the embedded, hash-bound zap request.
+ const req = parseZapRequest(zapRequestJson);
+ const reqTags = Array.isArray(req?.tags) ? req.tags : event.tags;
+ const pointerTags = reqTags.filter(
+ (t) => Array.isArray(t) && (t[0] === "a" || t[0] === "e" || t[0] === "p"),
+ );
+
+ return { ok: true, sats, paymentHash, pointerTags };
+}
+
function normalizeRelayList(relays) {
if (!Array.isArray(relays)) {
return [];
@@ -161,6 +252,74 @@ function normalizePubkey(pubkey) {
return trimmed ? trimmed.toLowerCase() : "";
}
+// Walk a bolt11's bech32 tagged fields once, returning the fields we care about:
+// paymentHash — bolt11 tag 'p' (proves settlement when sha256(preimage)===it)
+// descriptionHash — bolt11 tag 'h' (NIP-57 binds the invoice to the zap request)
+// Both hex, or null if absent/undecodable. Shared by the 9735 path and the
+// bitvid zap-tally verifier (docs/zap-tally-plan.md §3).
+export function extractBolt11Fields(bolt11) {
+ const out = { paymentHash: null, descriptionHash: null };
+ const normalized = normalizeInvoiceValue(bolt11).toLowerCase();
+ if (!normalized) {
+ return out;
+ }
+
+ let decoded;
+ try {
+ decoded = bech32.decode(normalized, 2000);
+ } catch (error) {
+ return out;
+ }
+
+ const words = Array.isArray(decoded?.words) ? decoded.words.slice() : [];
+ if (words.length <= 104) {
+ return out;
+ }
+
+ const dataWords = words.slice(0, -104);
+ if (dataWords.length <= 7) {
+ return out;
+ }
+
+ let index = 7; // skip timestamp words
+ while (index < dataWords.length) {
+ const tagCode = dataWords[index];
+ index += 1;
+ if (typeof tagCode !== "number" || tagCode < 0 || tagCode >= BOLT11_CHARSET.length) {
+ return out;
+ }
+ if (index + 1 >= dataWords.length) {
+ return out;
+ }
+ const length = (dataWords[index] << 5) + dataWords[index + 1];
+ index += 2;
+ if (length < 0 || index + length > dataWords.length) {
+ return out;
+ }
+ const dataSlice = dataWords.slice(index, index + length);
+ index += length;
+ const tag = BOLT11_CHARSET[tagCode];
+ if (tag === "h" && !out.descriptionHash) {
+ try {
+ out.descriptionHash = bytesToHex(Uint8Array.from(bech32.fromWords(dataSlice)));
+ } catch (error) {
+ /* leave null */
+ }
+ } else if (tag === "p" && !out.paymentHash) {
+ try {
+ out.paymentHash = bytesToHex(Uint8Array.from(bech32.fromWords(dataSlice)));
+ } catch (error) {
+ /* leave null */
+ }
+ }
+ if (out.paymentHash && out.descriptionHash) {
+ break;
+ }
+ }
+
+ return out;
+}
+
function extractDescriptionHashFromBolt11(bolt11) {
const normalized = normalizeInvoiceValue(bolt11).toLowerCase();
if (!normalized) {
diff --git a/js/payments/zapSplit.js b/js/payments/zapSplit.js
index d6ce95fb..ee74a449 100644
--- a/js/payments/zapSplit.js
+++ b/js/payments/zapSplit.js
@@ -486,6 +486,15 @@ async function processShare({
metadata,
invoice,
payment,
+ // Proof of settlement from NWC pay_invoice (result.preimage), surfaced so
+ // the caller can publish a preimage-verified bitvid zap tally
+ // (docs/zap-tally-plan.md §5.3). null when the wallet returns none.
+ preimage:
+ (payment && typeof payment === "object" && payment.result &&
+ typeof payment.result.preimage === "string" &&
+ payment.result.preimage.trim())
+ ? payment.result.preimage.trim()
+ : null,
zapRequest,
status: "success",
};
diff --git a/js/payments/zapTallyPublisher.js b/js/payments/zapTallyPublisher.js
new file mode 100644
index 00000000..a996cfcd
--- /dev/null
+++ b/js/payments/zapTallyPublisher.js
@@ -0,0 +1,87 @@
+// js/payments/zapTallyPublisher.js
+//
+// Publishes bitvid-native zap tallies (kind ZAP_TALLY_KIND) after a successful
+// zap, so the zapped video/profile's total is global + durable even when the
+// recipient's LNURL server never publishes a NIP-57 9735 receipt
+// (docs/zap-tally-plan.md §5.4-5.5). Each settled share (creator + platform)
+// carries its own invoice/preimage, so each becomes one payer-signed,
+// preimage-verified tally. Best-effort: a publish failure never affects the
+// payment UX (the sats already moved).
+
+import { buildZapTallyEvent } from "../nostrEventSchemas.js";
+import { extractBolt11Fields } from "./zapReceiptValidator.js";
+import { devLogger, userLogger } from "../utils/logger.js";
+
+const tagFromReq = (req, name) => {
+ const tags = Array.isArray(req?.tags) ? req.tags : [];
+ const tag = tags.find((t) => Array.isArray(t) && t[0] === name && t[1] != null);
+ return tag ? String(tag[1]) : "";
+};
+
+// Pure: build the unsigned tally event for one settled zap share, or null when
+// it can't be made verifiable (no decodable payment_hash / missing proof).
+export function buildTallyFromShare({ share, pubkey, now = () => Math.floor(Date.now() / 1000) } = {}) {
+ if (!share || typeof share !== "object" || !pubkey) {
+ return null;
+ }
+ const bolt11 = typeof share.bolt11 === "string" ? share.bolt11.trim() : "";
+ const preimage = typeof share.preimage === "string" ? share.preimage.trim() : "";
+ const zapRequestJson =
+ typeof share.zapRequest === "string" ? share.zapRequest.trim() : "";
+ if (!bolt11 || !preimage || !zapRequestJson) {
+ return null;
+ }
+
+ const { paymentHash } = extractBolt11Fields(bolt11);
+ if (!paymentHash) {
+ return null; // needs payment_hash for the `d` tag + verification
+ }
+
+ let req = null;
+ try {
+ req = JSON.parse(zapRequestJson);
+ } catch (error) {
+ return null;
+ }
+
+ const amountSats = Math.max(0, Math.round(Number(share.amountSats) || 0));
+
+ return buildZapTallyEvent({
+ pubkey,
+ created_at: now(),
+ paymentHash,
+ recipientPubkey: tagFromReq(req, "p"),
+ eventId: tagFromReq(req, "e"),
+ coordinate: tagFromReq(req, "a"),
+ amountMsats: amountSats > 0 ? amountSats * 1000 : undefined,
+ bolt11,
+ preimage,
+ zapRequestJson,
+ });
+}
+
+// Publish a tally per settled share. `publish(event)` signs + broadcasts (best
+// effort); `enabled` gates the whole thing (FEATURE_ZAP_TALLY). Never throws.
+export async function publishZapTallies({ shares, pubkey, publish, enabled = true, now } = {}) {
+ if (!enabled || typeof publish !== "function" || !pubkey) {
+ return { published: 0 };
+ }
+ const list = Array.isArray(shares) ? shares : [];
+ let published = 0;
+ for (const share of list) {
+ const event = buildTallyFromShare({ share, pubkey, now });
+ if (!event) {
+ continue;
+ }
+ try {
+ await publish(event);
+ published += 1;
+ devLogger.log("[zapTally] Published tally for a settled share.");
+ } catch (error) {
+ // The payment already succeeded; a failed tally publish is non-fatal —
+ // the durable local ledger still shows the zap to the sender.
+ userLogger.warn("[zapTally] Failed to publish zap tally:", error);
+ }
+ }
+ return { published };
+}
diff --git a/js/services/encryptedSyncItem.js b/js/services/encryptedSyncItem.js
index 5307fcb6..50cace7b 100644
--- a/js/services/encryptedSyncItem.js
+++ b/js/services/encryptedSyncItem.js
@@ -16,7 +16,12 @@
// logLabel short label for warnings
import { encryptedSync as defaultEncryptedSync } from "../nostr/encryptedSyncFacade.js";
-import { isSyncEnabled, setSyncEnabled } from "./settingsSyncFlags.js";
+import {
+ isSyncEnabled,
+ setSyncEnabled,
+ getSyncPushedAt,
+ setSyncPushedAt,
+} from "./settingsSyncFlags.js";
import { pushWithConflictCheck } from "./syncConflict.js";
import { userLogger } from "../utils/logger.js";
@@ -100,6 +105,36 @@ export function createEncryptedSyncItem({
return { ...result, imported: true };
}
+ // #15 follow-up ("doesn't sync unless I click Restore"): a device with sync
+ // ENABLED auto-pulls a NEWER remote note at login. Previously enabled devices
+ // only pushed-on-save — edits from another device sat unseen until the manual
+ // Restore click. The per-device freshness marker doubles as "local state
+ // matches note X": it's recorded on push AND after a successful pull, so the
+ // next conflict check stays honest and the same note is never re-pulled.
+ async function autoPullIfNewer(pubkey) {
+ const key = normalizePubkey(pubkey);
+ if (!key || !isAvailable() || !isEnabled(key)) {
+ return { pulled: false, reason: "not-enabled" };
+ }
+ let remoteCreatedAt = 0;
+ try {
+ const peek = await encryptedSync.exists(dtag);
+ remoteCreatedAt = Number(peek?.createdAt) || 0;
+ } catch (error) {
+ return { pulled: false, reason: "peek-failed", cause: error };
+ }
+ const lastSynced = getSyncPushedAt(key, id);
+ if (!remoteCreatedAt || remoteCreatedAt <= lastSynced) {
+ return { pulled: false, reason: "up-to-date" };
+ }
+ const result = await pull(key);
+ if (result?.imported) {
+ setSyncPushedAt(key, id, remoteCreatedAt);
+ return { pulled: true, remoteCreatedAt };
+ }
+ return { pulled: false, reason: result?.error || "pull-failed" };
+ }
+
async function enable(pubkey, options = {}) {
setEnabledFlag(pubkey, true);
return push(pubkey, options);
@@ -118,6 +153,7 @@ export function createEncryptedSyncItem({
hasRemote,
push,
pull,
+ autoPullIfNewer,
enable,
disable,
};
diff --git a/js/services/playbackService.js b/js/services/playbackService.js
index 6abb2d5d..c927ed82 100644
--- a/js/services/playbackService.js
+++ b/js/services/playbackService.js
@@ -988,15 +988,35 @@ class PlaybackSession extends SimpleEventEmitter {
{ once: true }
);
- const probeResult = await this.service.probeHostedUrl({
- url: candidateUrl,
- magnet: this.trimmedMagnet,
- probeUrl,
- });
+ // The probe (fetch HEAD) only helps DECIDE whether to fail over to P2P
+ // fast. When the URL is the only source there's nothing to fail over
+ // to — and the probe's timeout (URL_PROBE_TIMEOUT_MS, 4s) is LONGER than
+ // the playback-start window (PLAYBACK_START_TIMEOUT, 3s), so a slow
+ // no-cors HEAD (e.g. archive.org) would consume the whole budget and the
+ // attempt would time out before `src` is ever set → a false "No playable
+ // source found." So skip the probe when there's no magnet and let the
+ // video element (the real arbiter) load the URL directly.
+ const probeResult =
+ this.magnetForPlayback
+ ? await this.service.probeHostedUrl({
+ url: candidateUrl,
+ magnet: this.trimmedMagnet,
+ probeUrl,
+ })
+ : { outcome: "skipped-url-only" };
const probeOutcome = probeResult?.outcome || "error";
const probeStatus = probeResult?.status || "unknown";
+ // A `