Promote unstable → beta

Batch: bitvid-native zap tally (video path — publish + count + verify),
coinos platform address + NWC double-pay guard, always-visible zap badges +
optimistic/durable ledger, Most Zapped tab, playback "No playable source"
fix for URL-only videos, profile identity card, upload storage picker, and
the relay-ack/#56/#49 work. Lint + build + suites green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-07-05 00:16:42 -04:00
commit bca0eb32aa
67 files changed
+5304 -172

No files matched your search

+29
View File
@@ -62,6 +62,35 @@
</button>
</div>
<!--
Active-identity card (#43): visible on EVERY pane, straddling the
header divider, so users always see which profile they're acting as
(e.g. before uploading). Clicking it jumps to the Account pane.
-->
<button
id="profileModalIdentityCard"
type="button"
class="profile-modal__identity focus-ring hidden"
aria-hidden="true"
aria-label="Active profile — open account settings"
>
<span class="profile-modal__identity-avatar" aria-hidden="true">
<img
id="profileModalIdentityAvatar"
src="/assets/svg/default-profile.svg"
alt=""
/>
</span>
<span class="profile-modal__identity-copy">
<span class="profile-modal__identity-kicker">Acting as</span>
<span
id="profileModalIdentityName"
class="profile-modal__identity-name"
>No active profile</span
>
</span>
</button>
<!-- Modal Body -->
<div class="profile-modal__body">
<div
+21
View File
@@ -76,6 +76,27 @@
</svg>
<span class="sidebar-text">Trending</span>
</a>
<a
id="mostZappedLink"
href="#view=most-zapped"
class="sidebar-nav-link"
title="Most Zapped"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="sidebar-icon"
aria-hidden="true"
>
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon>
</svg>
<span class="sidebar-text">Most Zapped</span>
</a>
<a href="#view=most-recent-videos" class="sidebar-nav-link" title="Recently Added">
<img
src="assets/svg/globe-icon.svg"
+5
View File
@@ -153,6 +153,11 @@
<button type="button" id="btn-manage-storage" class="text-xs text-muted hover:text-text hover:underline">Manage</button>
</div>
</div>
<!-- #44: switch upload destination without leaving the modal -->
<div id="storage-connection-picker" class="hidden space-y-1">
<label for="select-storage-connection" class="text-xs text-muted">Upload destination</label>
<select id="select-storage-connection" class="input input-sm text-xs"></select>
</div>
<div class="grid grid-cols-3 gap-2 text-xs">
<div class="min-w-0">
<span class="block text-muted">Provider</span>
+10
View File
@@ -211,6 +211,16 @@
<span data-view-count-text>–</span>
</span>
</div>
<span
id="videoZapTotal"
class="modal-meta video-modal__zaps flex items-center gap-1.5 hidden"
title="Zap total (sats)"
>
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-4 h-4">
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon>
</svg>
<span data-zap-total-text></span>
</span>
</div>
<div class="video-modal__actions-row">
+4 -1
View File
@@ -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 = "[email protected]";
// coinos: Nostr-native, publishes NIP-57 zap receipts, reliable routing,
// 1-sat minimum. Replaced [email protected], whose LNURL was flaky and never
// published receipts, causing platform-share pay timeouts + missing zap tallies.
export const PLATFORM_LUD16_OVERRIDE = "[email protected]";
/**
* Optional list of relays to seed new sessions with instead of the defaults.
+92
View File
@@ -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;
}
+4
View File
@@ -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);
+1
View File
@@ -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', <recipient pubkey>]` | NIP-04 encrypted ciphertext |
| Zap request (`NOTE_TYPES.ZAP_REQUEST`) | `9734` | `['p', <recipient pubkey>]` plus optional `['e', <event id>]`, `['a', <coordinate>]`, `['amount', <msats>]`, `['lnurl', <bech32>]`, and `['relays', ...]` for receipt publishing | Optional plaintext zap note |
| Zap receipt (`NOTE_TYPES.ZAP_RECEIPT`) | `9735` | `['bolt11', <invoice>]`, `['description', <zap request JSON>]`, `['p', <recipient pubkey>]` plus optional `['e', <event id>]` and `['a', <coordinate>]` | Empty content; receipts are published by the recipient's LNURL server |
| Zap tally (`NOTE_TYPES.ZAP_TALLY`) | `30081` (addressable, `d = payment_hash`) | `['d', <payment_hash>]`, `['p', <recipient>]`, optional `['e', <event id>]`/`['a', <coordinate>]`, `['amount', <msats>]`, `['bolt11', <invoice>]`, `['preimage', <hex>]`, `['description', <zap request JSON>]`, `['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', <url>]`, `['method', <http method>]`, `['payload', <payload hash>]` | Optional plaintext content (NIP-98 recommends empty, but implementations may vary) |
| Report (`NOTE_TYPES.REPORT`) | `1984` | `['e', <event id>, <report type>]`, `['p', <pubkey>, <report type>]`, and optional `['t', <report type>]` | Plaintext report reason |
+515
View File
@@ -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: <payer pubkey> // the sender's active signer
created_at: <now>
content: "" // (optional: mirror the zap comment)
tags:
["p", <recipientPubkey>] // zapped party
["e", <videoEventId>] (video zaps) // from the zap request
["a", "30078:<pubkey>:<dTag>"] (video zaps) // stable coordinate
["amount", "<msats>"] // echoes the zap request
["bolt11", "<invoice>"] // carries payment_hash + description_hash
["preimage", "<preimage>"] // proof of settlement
["description", <zapRequestJSON>] // 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:<pubkey>`. 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 `<polyline>` +
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 `<g class="text-zap">`
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 `<text>` 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 `<polyline>`s, legend nodes, date-tick `<text>` 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.
```
+46 -2
View File
@@ -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.",
+23
View File
@@ -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,
+68
View File
@@ -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;
+15
View File
@@ -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",
+126
View File
@@ -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;
}
}
+22 -14
View File
@@ -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 —
+65
View File
@@ -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);
});
}
+16 -3
View File
@@ -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 =
+16 -3
View File
@@ -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,
+184
View File
@@ -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;
+5
View File
@@ -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(),
+118
View File
@@ -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.
+41 -2
View File
@@ -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
+40 -5
View File
@@ -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);
+22 -1
View File
@@ -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,
});
+159
View File
@@ -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) {
+9
View File
@@ -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",
};
+87
View File
@@ -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 };
}
+37 -1
View File
@@ -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,
};
+26 -6
View File
@@ -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 `<video>` element plays cross-origin URLs WITHOUT CORS, but this
// probe uses fetch (CORS-limited): a benign no-cors HEAD failure or a
// slow HEAD returns "error"/"opaque" for perfectly playable hosts (e.g.
// archive.org — 200 video/mp4 with byte ranges). An ambiguous probe must
// NEVER veto the URL into "No playable source found." Only a
// definitively-READ failure status ("bad") deprioritizes it — and even
// then, if there's no P2P fallback, we still try (a real media error is
// clearer than a false "no source"). The video element + watchdogs are
// the actual arbiter of playability.
const shouldAttemptHosted =
probeOutcome !== "bad" && probeOutcome !== "error";
probeOutcome !== "bad" || !this.magnetForPlayback;
this.service.log(
`[playVideoWithFallback] Hosted URL probe outcome=${probeOutcome} status=${probeStatus} shouldAttemptHosted=${shouldAttemptHosted}`
+26
View File
@@ -102,6 +102,32 @@ export function createSettingsRestorePrompt({
return { offered: false, reason: "no-pubkey" };
}
// Items ALREADY enabled on this device sync silently: pull the remote note
// when it's newer than what this device last pushed/pulled (i.e. another
// device changed it). No prompt — the user opted in when enabling sync.
// Without this, enabled devices only ever pushed, and edits from another
// device sat unseen until the manual Restore click ("doesn't sync").
const autoPulled = [];
for (const [kind, service] of [
["storage", storageSync],
["wallet", walletSync],
]) {
if (typeof service?.autoPullIfNewer !== "function") {
continue;
}
try {
const result = await service.autoPullIfNewer(key);
if (result?.pulled) {
autoPulled.push(kind);
}
} catch (error) {
logger?.warn?.(`[settingsRestore] ${kind} auto-pull failed`, error);
}
}
if (autoPulled.length && typeof onRestored === "function") {
onRestored(autoPulled);
}
const candidates = await collectCandidates(key);
if (!candidates.length) {
// Don't mark offered — a note may be published later from another device;
+39
View File
@@ -13,6 +13,9 @@ import initEditModal from "./initEditModal.js";
import initDeleteModal from "./initDeleteModal.js";
import { EventDetailsModal } from "./components/EventDetailsModal.js";
import ZapController from "./zapController.js";
import { ingestLocalVideoZap } from "../zapTotals.js";
import { publishZapTallies } from "../payments/zapTallyPublisher.js";
import { FEATURE_ZAP_TALLY } from "../constants.js";
import { nostrClient } from "../nostrClientFacade.js";
export default class ModalManager {
@@ -271,6 +274,42 @@ export default class ModalManager {
callbacks: {
onSuccess: (message) => app.showSuccess(message),
onError: (message) => app.showError(message),
// (1) Optimistically bump the zapped video's total using the SAME
// pointer the cards/modal badge subscribe with (instant), and
// (2) publish preimage-verified bitvid zap tallies so the total is
// global + durable even if the recipient never publishes a 9735
// (docs/zap-tally-plan.md §5.5). Both best-effort.
onZapSuccess: ({ video, sats, shares } = {}) => {
try {
const info =
video && typeof app.deriveVideoPointerInfo === "function"
? app.deriveVideoPointerInfo(video)
: null;
if (info?.pointer && sats > 0) {
ingestLocalVideoZap(info.pointer, sats);
}
} catch (error) {
/* best-effort optimistic update */
}
if (FEATURE_ZAP_TALLY && Array.isArray(shares) && shares.length) {
publishZapTallies({
shares,
pubkey: app.pubkey,
enabled: FEATURE_ZAP_TALLY,
publish: (event) =>
nostrClient.signAndPublishEvent(event, {
context: "zap tally",
logName: "Zap tally",
devLogLabel: "zap tally",
rejectionLogLevel: "warn",
resolveActiveSigner: (p) =>
nostrClient.signerManager.resolveActiveSigner(p),
}),
}).catch(() => {
/* non-fatal: the payment already succeeded */
});
}
},
},
requestWalletPane: () => app.openWalletPane(),
});
+18 -1
View File
@@ -24,7 +24,12 @@ import r2Service from "../services/r2Service.js";
import s3UploadService from "../services/s3UploadService.js";
import RelayHealthService from "../services/relayHealthService.js";
import { createFeedEngine } from "../feedEngine/index.js";
import { URL_FIRST_ENABLED, SHORT_TIMEOUT_MS, FEATURE_TRENDING_FEED } from "../constants.js";
import {
URL_FIRST_ENABLED,
SHORT_TIMEOUT_MS,
FEATURE_TRENDING_FEED,
FEATURE_MOST_ZAPPED_FEED,
} from "../constants.js";
import { ALLOW_NSFW_CONTENT } from "../config.js";
import { relayManager } from "../relayManager.js";
import { userBlocks } from "../userBlocks.js";
@@ -284,6 +289,18 @@ export default class ApplicationBootstrap {
trendingLink.classList.add("hidden");
}
}
if (
FEATURE_MOST_ZAPPED_FEED &&
typeof app.registerMostZappedFeed === "function"
) {
app.registerMostZappedFeed();
} else if (typeof document !== "undefined") {
// Flag off: hide the always-visible Most Zapped sidebar link.
const mostZappedLink = document.getElementById("mostZappedLink");
if (mostZappedLink) {
mostZappedLink.classList.add("hidden");
}
}
app.registerSubscriptionsFeed();
app.registerWatchHistoryFeed();
// Re-run the For You feed when background watch-history resolution completes.
+4
View File
@@ -25,6 +25,7 @@ export class EditModal {
storageService = null,
getCurrentPubkey = null,
safeEncodeNpub = null,
ensureSigner = null,
eventTarget,
container,
} = {}) {
@@ -39,6 +40,9 @@ export class EditModal {
this.storageService = storageService || null;
this.getCurrentPubkey =
typeof getCurrentPubkey === "function" ? getCurrentPubkey : null;
// Shared signer gate (#56): lets the "Replace file" pickers unlock storage
// inline (cached keep-unlocked key or one passphrase prompt).
this.ensureSigner = typeof ensureSigner === "function" ? ensureSigner : null;
this.mediaUploader = new MediaUploader({
r2Service,
s3Service,
+58 -44
View File
@@ -27,6 +27,13 @@ import {
} from "../../nostrClientFacade.js";
import { UI_FEEDBACK_DELAY_MS } from "../../constants.js";
import { showConfirm } from "../confirmDialog.js";
import {
ensureStorageUnlockedForUpload,
resetThumbnailPicker,
promptStoredNsecUnlock,
pickTargetConnection,
renderConnectionPicker,
} from "./uploadModalStorageUnlock.js";
const INFO_HASH_PATTERN = /^[a-f0-9]{40}$/;
@@ -55,6 +62,7 @@ export class UploadModal {
container,
onRequestStorageSettings,
onRequestUnlock,
ensureSigner,
getHashtagSuggestions,
} = {}) {
this.authService = authService || null;
@@ -96,6 +104,10 @@ export class UploadModal {
// Opens the login modal's unlock-saved-key (passphrase) flow — used when a
// persisted nsec session is locked after a reload.
this.onRequestUnlock = typeof onRequestUnlock === "function" ? onRequestUnlock : null;
// Shared signer gate (app.ensureEncryptionCapableSigner): silently restores a
// kept-unlocked key or shows ONE passphrase prompt, so the file pickers can
// unlock storage inline instead of dead-ending (#56).
this.ensureSigner = typeof ensureSigner === "function" ? ensureSigner : null;
this.root = null;
this.isVisible = false;
@@ -106,6 +118,8 @@ export class UploadModal {
this.activeProvider = null;
this.isStorageUnlocked = false;
this.storageConfigured = false;
// #44: per-modal upload-destination override (null = account default).
this.selectedConnectionId = null;
// Upload State
this.videoUploadState = {
@@ -237,6 +251,7 @@ export class UploadModal {
progress: $("#upload-progress-container"),
thumbnailProgress: $("#thumbnail-progress-container"),
results: $("#upload-results-container"),
connectionPicker: $("#storage-connection-picker"),
};
this.storageViews = {
@@ -264,6 +279,9 @@ export class UploadModal {
// Progress
progress: $("#input-progress"),
thumbnailProgress: $("#thumbnail-progress"),
// #44: upload-destination picker
storageConnection: $("#select-storage-connection"),
};
// Results (Generated Links)
@@ -324,6 +342,14 @@ export class UploadModal {
this.toggles.storageUnlock.addEventListener("click", () => this.handleUnlock());
}
if (this.inputs.storageConnection) {
// #44: switching the destination re-resolves summary + credentials.
this.inputs.storageConnection.addEventListener("change", () => {
this.selectedConnectionId = this.inputs.storageConnection.value || null;
void this.loadFromStorage();
});
}
// Manage Storage
if (this.toggles.manageStorage) {
this.toggles.manageStorage.addEventListener("click", () => {
@@ -530,9 +556,13 @@ export class UploadModal {
return;
}
if (!this.isStorageUnlocked) {
this.showError("Please unlock storage before selecting a file.");
e.target.value = "";
return;
// #56: unlock inline (silent restore or one passphrase prompt) and
// continue with this same file pick instead of erroring.
const unlocked = await this.ensureStorageUnlockedForUpload();
if (!unlocked) {
e.target.value = "";
return;
}
}
// Start new session
@@ -628,12 +658,19 @@ export class UploadModal {
}
async handleThumbnailSelection(file) {
if (!this.storageConfigured || !this.isStorageUnlocked) {
// We can't upload yet. Just hold it in state or warn?
// Since UI disables the browse button until unlocked (mostly), we assume safe.
// But if they just unlocked, we're good.
if (!this.storageConfigured) {
this.showError("Configure storage before uploading a thumbnail.");
this.resetThumbnailPicker();
return;
}
if (!this.isStorageUnlocked) {
// #56: prompt to unlock inline instead of silently dropping the pick.
const unlocked = await this.ensureStorageUnlockedForUpload();
if (!unlocked) {
this.resetThumbnailPicker();
return;
}
}
const currentUploadId = ++this.thumbnailUploadId;
@@ -889,9 +926,9 @@ export class UploadModal {
try {
const connections = await this.storageService.listConnections(pubkey);
// Prefer default connection
const defaultConn = connections.find(c => c.meta?.defaultForUploads);
const targetConn = defaultConn || connections[0];
// #44: a per-modal selection wins over the account default.
const { targetConn, defaultConn } = pickTargetConnection(this, connections);
renderConnectionPicker(this, connections, targetConn, defaultConn);
if (targetConn) {
this.storageConfigured = true;
@@ -1011,41 +1048,18 @@ export class UploadModal {
}
}
// Returns true if a locked persisted-nsec session was detected (for the active
// account) and the re-unlock (passphrase) flow was opened, so the caller can stop.
// #36 / #56 storage-unlock glue lives in uploadModalStorageUnlock.js (file-size
// budget); these stay as instance methods so callers/tests are unchanged.
async ensureStorageUnlockedForUpload() {
return ensureStorageUnlockedForUpload(this);
}
resetThumbnailPicker() {
resetThumbnailPicker(this);
}
promptStoredNsecUnlock(pubkey) {
const client = this.authService?.nostrClient;
if (!client || typeof client.getStoredSessionActorMetadata !== "function") {
return false;
}
let meta = null;
try {
meta = client.getStoredSessionActorMetadata();
} catch (err) {
return false;
}
if (!meta || meta.hasEncryptedKey !== true || meta.source !== "nsec") {
return false;
}
const metaPubkey =
typeof meta.pubkey === "string" ? meta.pubkey.trim().toLowerCase() : "";
const activePubkey =
typeof pubkey === "string" ? pubkey.trim().toLowerCase() : "";
if (activePubkey && metaPubkey && metaPubkey !== activePubkey) {
return false;
}
if (typeof this.onRequestUnlock !== "function") {
return false;
}
this.showError(
"Your saved key is locked after reloading. Re-enter your passphrase to unlock it, then try the upload again.",
);
try {
this.onRequestUnlock();
} catch (err) {
userLogger.warn("[UploadModal] Failed to open saved-key unlock flow:", err);
}
return true;
return promptStoredNsecUnlock(this, pubkey);
}
updateLockUi() {
+51
View File
@@ -169,6 +169,7 @@ export class VideoCard {
this.urlHealthBadgeEl = null;
this.torrentHealthBadgeEl = null;
this.viewCountEl = null;
this.zapTotalEl = null;
this.discussionCountEl = null;
this.authorPicEl = null;
this.authorNameEl = null;
@@ -2440,6 +2441,33 @@ export class VideoCard {
return svg;
}
createZapIcon(classNames = []) {
const svg = this.document.createElementNS(SVG_NAMESPACE, "svg");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("fill", "none");
svg.setAttribute("aria-hidden", "true");
svg.setAttribute("focusable", "false");
svg.setAttribute("stroke", "currentColor");
svg.setAttribute("stroke-width", "2");
svg.setAttribute("stroke-linecap", "round");
svg.setAttribute("stroke-linejoin", "round");
classNames.forEach((className) => {
if (className) {
svg.classList.add(className);
}
});
const polygon = this.document.createElementNS(SVG_NAMESPACE, "polygon");
polygon.setAttribute(
"points",
"13 2 3 14 12 14 11 22 21 10 12 10 13 2",
);
svg.appendChild(polygon);
return svg;
}
createMessageIcon(classNames = []) {
const svg = this.document.createElementNS(SVG_NAMESPACE, "svg");
svg.setAttribute("viewBox", "0 0 24 24");
@@ -2556,6 +2584,29 @@ export class VideoCard {
}
}
// Zaps (#47 follow-up): total sats in orange, pushed to the row's end
// (views/comments stay left). Hidden until a nonzero total is known —
// most videos have no zaps and a wall of "0 sats" is just noise. The
// list view's zap binder fills the value and reveals the wrapper.
if (hasPointer) {
const zapWrapper = this.createElement("div", {
classNames: ["flex", "items-center", "gap-1.5", "video-card__zaps", "hidden"],
});
zapWrapper.setAttribute("title", "Zap total (sats)");
zapWrapper.appendChild(this.createZapIcon(["w-4", "h-4"]));
const zap = this.createElement("span", {
classNames: ["zap-total-text"],
textContent: "",
});
zap.dataset.zapTotal = "";
zap.dataset.zapPointer = this.pointerInfo.key;
zapWrapper.appendChild(zap);
container.appendChild(zapWrapper);
this.zapTotalEl = zap;
}
return container;
}
+88 -2
View File
@@ -7,6 +7,10 @@
// and its rendered root (`context`).
import { devLogger, userLogger } from "../../utils/logger.js";
import {
getActiveSigner,
requestDefaultExtensionPermissions,
} from "../../nostrClientFacade.js";
// Set a field's value AND mark it edited/unlocked so EditModal.collect treats it
// as a user change (isEditing() reads readOnly===false || dataset.isEditing).
@@ -24,7 +28,8 @@ function setStatus(el, text) {
// Resolve the active storage connection and surface a user-facing reason when
// upload isn't possible. Returns the connection on success, null otherwise.
async function ensureUploadable(modal) {
// `deps` are injectable for tests only.
export async function ensureUploadable(modal, deps = {}) {
if (!modal.mediaUploader) {
modal.showError("Uploads are unavailable in this view.");
return null;
@@ -40,7 +45,88 @@ async function ensureUploadable(modal) {
return null;
}
if (!conn.unlocked || !conn.credentials) {
modal.showError("Unlock your storage in the profile modal, then try again.");
// #56: unlock inline (kept-unlocked restore or one passphrase prompt) and
// continue with this same file pick instead of bouncing the user to the
// profile modal.
return tryInlineStorageUnlock(modal, deps);
}
return conn;
}
const UNLOCK_FALLBACK_MESSAGE =
"Unlock your storage in the profile modal, then try again.";
async function tryInlineStorageUnlock(modal, deps = {}) {
const getSigner =
typeof deps.getSigner === "function" ? deps.getSigner : getActiveSigner;
const requestPermissions =
typeof deps.requestPermissions === "function"
? deps.requestPermissions
: requestDefaultExtensionPermissions;
const pubkey =
typeof modal.getCurrentPubkey === "function" ? modal.getCurrentPubkey() : null;
if (!pubkey || !modal.storageService) {
modal.showError(UNLOCK_FALLBACK_MESSAGE);
return null;
}
if (typeof modal.ensureSigner === "function") {
let gate = null;
try {
gate = await modal.ensureSigner({
pubkey,
need: "encrypt",
promptMessage:
"Re-enter your PIN / passphrase to unlock storage for this upload.",
});
} catch (error) {
devLogger.warn("[editModalUpload] Signer gate failed:", error);
}
if (gate && gate.ok !== true) {
// Cancel aborts quietly; a bad passphrase already showed its own toast.
if (gate.reason !== "cancelled" && gate.reason !== "bad-passphrase") {
modal.showError(UNLOCK_FALLBACK_MESSAGE);
}
return null;
}
}
const signer = getSigner();
if (
!signer ||
(typeof signer.nip44Decrypt !== "function" &&
typeof signer.nip04Decrypt !== "function")
) {
modal.showError(UNLOCK_FALLBACK_MESSAGE);
return null;
}
try {
if (signer.type === "extension" || signer.type === "nip07") {
const permissionResult = await requestPermissions();
if (!permissionResult?.ok) {
modal.showError("Extension permissions are required to unlock storage.");
return null;
}
}
await modal.storageService.unlock(pubkey, { signer });
} catch (error) {
userLogger.error("[editModalUpload] Storage unlock failed:", error);
modal.showError(
"Failed to unlock storage: " + (error?.message || String(error)),
);
return null;
}
let conn = null;
try {
conn = await modal.mediaUploader.resolveActiveConnection();
} catch (error) {
devLogger.warn("[editModalUpload] Failed to re-resolve storage connection:", error);
}
if (!conn?.unlocked || !conn?.credentials) {
modal.showError(UNLOCK_FALLBACK_MESSAGE);
return null;
}
return conn;
@@ -0,0 +1,153 @@
// js/ui/components/uploadModalStorageUnlock.js
//
// Storage glue for the Upload modal (#36 / #56 unlock + #44 connection
// picker), kept out of UploadModal.js to stay within its file-size budget.
// Every function operates on the UploadModal instance (`modal`); UploadModal
// exposes them as thin prototype delegates so callers and tests keep using
// the instance methods.
import { devLogger, userLogger } from "../../utils/logger.js";
// #56: the file pickers unlock storage inline instead of dead-ending. The
// shared signer gate (app.ensureEncryptionCapableSigner) restores a cached
// keep-unlocked key silently or shows ONE passphrase prompt; handleUnlock
// then unlocks storage with the restored signer (extension permissions +
// credential reload included). Returns true when the upload can proceed.
// Cancel / bad-passphrase abort quietly (the gate surfaces its own toast for
// a wrong passphrase); other gate failures fall through to handleUnlock so
// its existing messaging (saved-key flow / "no signer") still applies.
export async function ensureStorageUnlockedForUpload(modal) {
if (!modal.storageService) return false;
const pubkey = modal.getCurrentPubkey ? modal.getCurrentPubkey() : null;
if (!pubkey) return false;
if (!modal.storageService.isUnlocked(pubkey)) {
if (typeof modal.ensureSigner === "function") {
let gate = null;
try {
gate = await modal.ensureSigner({
pubkey,
need: "encrypt",
promptMessage:
"Re-enter your PIN / passphrase to unlock storage for this upload.",
});
} catch (error) {
devLogger?.warn?.("[UploadModal] Signer gate failed:", error);
}
if (
gate &&
gate.ok !== true &&
(gate.reason === "cancelled" || gate.reason === "bad-passphrase")
) {
return false;
}
}
await modal.handleUnlock();
}
const unlocked = modal.storageService.isUnlocked(pubkey);
if (unlocked && (!modal.isStorageUnlocked || !modal.activeCredentials)) {
modal.isStorageUnlocked = true;
modal.updateLockUi();
await modal.loadFromStorage();
}
modal.isStorageUnlocked = unlocked;
return unlocked;
}
// Undo the picker-side effects of a thumbnail selection that couldn't upload
// (placeholder shows the picked filename and the URL input is disabled).
export function resetThumbnailPicker(modal) {
if (modal.inputs?.thumbnailFile) {
modal.inputs.thumbnailFile.value = "";
}
const thumbnail = modal.inputs?.thumbnail;
if (thumbnail) {
thumbnail.placeholder = "https://example.com/thumbnail.jpg";
thumbnail.disabled = false;
}
}
// #44: resolve which configured connection this upload targets. A per-modal
// selection wins; a stale selection (connection deleted meanwhile) is reset;
// otherwise the account default, then the first configured connection.
export function pickTargetConnection(modal, connections) {
const list = Array.isArray(connections) ? connections : [];
const defaultConn = list.find((conn) => conn?.meta?.defaultForUploads) || null;
if (modal.selectedConnectionId) {
const selected = list.find((conn) => conn?.id === modal.selectedConnectionId);
if (selected) {
return { targetConn: selected, defaultConn };
}
modal.selectedConnectionId = null;
}
return { targetConn: defaultConn || list[0] || null, defaultConn };
}
// #44: render the "Upload destination" <select>. Hidden with fewer than two
// connections (nothing to choose); the default connection is labelled.
export function renderConnectionPicker(modal, connections, targetConn, defaultConn) {
const select = modal.inputs?.storageConnection;
const wrap = modal.sourceSections?.connectionPicker;
if (!select || !(wrap instanceof HTMLElement)) {
return;
}
const list = Array.isArray(connections) ? connections : [];
if (list.length < 2) {
wrap.classList.add("hidden");
return;
}
select.textContent = "";
for (const conn of list) {
if (!conn?.id) {
continue;
}
const option = select.ownerDocument.createElement("option");
option.value = conn.id;
const label = `${modal.getProviderLabel(conn.provider || conn.meta?.provider)} — ${
conn.meta?.bucket || "unnamed bucket"
}`;
option.textContent = conn.id === defaultConn?.id ? `${label} (default)` : label;
option.selected = conn.id === targetConn?.id;
select.appendChild(option);
}
wrap.classList.remove("hidden");
}
// Returns true if a locked persisted-nsec session was detected (for the active
// account) and the re-unlock (passphrase) flow was opened, so the caller can
// stop instead of dead-ending with "No signer available".
export function promptStoredNsecUnlock(modal, pubkey) {
const client = modal.authService?.nostrClient;
if (!client || typeof client.getStoredSessionActorMetadata !== "function") {
return false;
}
let meta = null;
try {
meta = client.getStoredSessionActorMetadata();
} catch (err) {
return false;
}
if (!meta || meta.hasEncryptedKey !== true || meta.source !== "nsec") {
return false;
}
const metaPubkey =
typeof meta.pubkey === "string" ? meta.pubkey.trim().toLowerCase() : "";
const activePubkey =
typeof pubkey === "string" ? pubkey.trim().toLowerCase() : "";
if (activePubkey && metaPubkey && metaPubkey !== activePubkey) {
return false;
}
if (typeof modal.onRequestUnlock !== "function") {
return false;
}
modal.showError(
"Your saved key is locked after reloading. Re-enter your passphrase to unlock it, then try the upload again.",
);
try {
modal.onRequestUnlock();
} catch (err) {
userLogger.warn("[UploadModal] Failed to open saved-key unlock flow:", err);
}
return true;
}
+12 -2
View File
@@ -1,5 +1,6 @@
import { devLogger } from "../utils/logger.js";
import r2Service from "../services/r2Service.js";
import { readRelayPublishSummary } from "../nostrPublish.js";
export default class EditModalController {
constructor({ services, state, ui, callbacks, helpers }) {
@@ -86,7 +87,7 @@ export default class EditModalController {
}
try {
await this.services.nostrService.handleEditVideoSubmit({
const editedEvent = await this.services.nostrService.handleEditVideoSubmit({
originalEvent,
updatedData,
pubkey,
@@ -139,7 +140,16 @@ export default class EditModalController {
videosMap.clear();
}
this.ui.showSuccess("Video updated successfully!");
// #49: an all-timeout publish reaches here flagged unconfirmed — say so
// honestly instead of claiming a confirmed update.
const tally = readRelayPublishSummary(editedEvent);
if (tally?.unconfirmed === true && !(tally.accepted > 0)) {
this.ui.showSuccess(
"Video update sent, but no relay has confirmed it yet — it may take a moment to propagate.",
);
} else {
this.ui.showSuccess("Video updated successfully!");
}
if (editModal?.setSubmitState) {
editModal.setSubmitState({ pending: false });
+6
View File
@@ -51,6 +51,12 @@ export function initEditModal({
storageService,
getCurrentPubkey,
safeEncodeNpub,
// Shared signer gate (#56): lets the "Replace file" pickers unlock storage
// inline (cached keep-unlocked key or one passphrase prompt).
ensureSigner: (options) =>
typeof app?.ensureEncryptionCapableSigner === "function"
? app.ensureEncryptionCapableSigner(options)
: null,
eventTarget: events,
container,
});
+6
View File
@@ -61,6 +61,12 @@ export function initUploadModal({
app.profileController.show("storage");
}
},
// Shared signer gate (#56): restores a kept-unlocked key silently or shows
// one passphrase prompt so the file pickers can unlock storage inline.
ensureSigner: (options) =>
typeof app?.ensureEncryptionCapableSigner === "function"
? app.ensureEncryptionCapableSigner(options)
: null,
// Open the login modal's unlock-saved-key (passphrase) flow when a persisted
// nsec session is locked after a reload.
onRequestUnlock: () => {
+31
View File
@@ -669,6 +669,13 @@ export class ProfileModalController {
this.profileAvatar = document.getElementById("profileModalAvatar") || null;
this.profileName = document.getElementById("profileModalName") || null;
this.profileNpub = document.getElementById("profileModalNpub") || null;
// #43: always-visible identity card straddling the header divider.
this.identityCard =
document.getElementById("profileModalIdentityCard") || null;
this.identityCardAvatar =
document.getElementById("profileModalIdentityAvatar") || null;
this.identityCardName =
document.getElementById("profileModalIdentityName") || null;
this.switcherList = document.getElementById("profileSwitcherList") || null;
this.profileModalAvatar = this.profileAvatar;
@@ -1745,6 +1752,12 @@ export class ProfileModalController {
});
}
if (this.identityCard instanceof HTMLElement) {
this.identityCard.addEventListener("click", () => {
this.selectPane("account");
});
}
if (this.permissionPromptCtaButton instanceof HTMLElement) {
this.permissionPromptCtaButton.addEventListener("click", () => {
if (this.permissionPromptCtaButton?.dataset?.action === "retry-auth-sync") {
@@ -2564,6 +2577,24 @@ export class ProfileModalController {
: this.globalProfileAvatar.alt || "Profile avatar";
}
// #43: keep the always-visible identity card in sync (hidden when logged
// out so the modal doesn't claim an identity that isn't active).
if (this.identityCard instanceof HTMLElement) {
this.identityCard.classList.toggle("hidden", !hasActiveProfile);
this.identityCard.setAttribute(
"aria-hidden",
hasActiveProfile ? "false" : "true",
);
}
if (this.identityCardName instanceof HTMLElement) {
this.identityCardName.textContent = activeDisplayName;
}
if (this.identityCardAvatar instanceof HTMLImageElement) {
if (this.identityCardAvatar.src !== activeAvatarSrc) {
this.identityCardAvatar.src = activeAvatarSrc;
}
}
const listEl = this.switcherList;
if (listEl instanceof HTMLElement) {
listEl.textContent = "";
+16 -2
View File
@@ -91,8 +91,12 @@ export default class RevertModalController {
this.revertModal.setBusy(true, "Reverting…");
try {
// #49: revertVideo no longer hard-fails when every relay merely times
// out (summary.unconfirmed) — track whether ANY relay actually ACKed so
// the success message stays honest.
let anyAccepted = false;
for (const entry of entries) {
await this.services.nostrClient.revertVideo(
const revertResult = await this.services.nostrClient.revertVideo(
{
id: entry.id,
pubkey: entry.pubkey,
@@ -100,12 +104,22 @@ export default class RevertModalController {
},
pubkey,
);
const accepted = revertResult?.summary?.accepted;
if (Array.isArray(accepted) && accepted.length > 0) {
anyAccepted = true;
}
}
await this.callbacks.loadVideos();
const timestampLabel = this.helpers.formatAbsoluteTimestamp(target.created_at);
this.ui.showSuccess(`Reverted to revision from ${timestampLabel}.`);
if (anyAccepted) {
this.ui.showSuccess(`Reverted to revision from ${timestampLabel}.`);
} else {
this.ui.showSuccess(
`Revert to the revision from ${timestampLabel} was sent, but no relay has confirmed it yet — check again in a moment.`,
);
}
this.revertModal.close();
this.callbacks.forceRefreshAllProfiles();
} catch (err) {
+6
View File
@@ -4,6 +4,7 @@ import {
subscribeToVideoViewCount,
unsubscribeFromVideoViewCount,
} from "../../viewCounter.js";
import { createZapTotalBinder } from "./videoCardZapTotals.js";
import { normalizeDesignSystemContext } from "../../designSystem.js";
import { userLogger } from "../../utils/logger.js";
@@ -172,6 +173,8 @@ export class VideoListView {
this.renderedVideoIds = new Set();
this.videoCardInstances = [];
this.viewCountSubscriptions = new Map();
// #47 follow-up: orange per-card zap totals (right-aligned in the row).
this.zapTotalBinder = createZapTotalBinder();
this.currentVideos = [];
this.lastRenderedVideoSignature = null;
this._lastRenderedVideoListElement = null;
@@ -266,6 +269,7 @@ export class VideoListView {
destroy() {
this.unmount();
this.teardownAllViewCountSubscriptions();
this.zapTotalBinder.destroy();
this.renderedVideoIds.clear();
this.videoCardInstances = [];
this.currentVideos = [];
@@ -712,6 +716,7 @@ export class VideoListView {
if (pointerInfo) {
this.registerVideoViewCountElement(cardEl, pointerInfo);
this.zapTotalBinder.bind(cardEl, pointerInfo);
}
if (video && video.id) {
@@ -809,6 +814,7 @@ export class VideoListView {
container: this.container,
});
this.pruneDetachedViewCountElements();
this.zapTotalBinder.prune();
return displayVideos;
}
+108
View File
@@ -0,0 +1,108 @@
// js/ui/views/videoCardZapTotals.js
//
// Per-card zap-total binding (#47 follow-up): fills each video card's
// [data-zap-total] span from the shared zapTotals cache and reveals the
// orange sats badge once a nonzero total is known. bind() reads the cached
// total AND schedules a batched receipt fetch for unknown pointers
// (requestVideoZapTotal); one shared onZapTotalsChanged listener re-renders
// every bound card when a batch lands. Kept out of VideoListView.js for its
// file-size budget.
import {
requestVideoZapTotal,
getVideoZapTotalSnapshot,
onZapTotalsChanged,
} from "../../zapTotals.js";
import { formatViewCount } from "../../viewCounter.js";
function defaultFormatSats(sats) {
return `${formatViewCount(sats)} sats`;
}
export function createZapTotalBinder({ formatSats = defaultFormatSats } = {}) {
const entries = new Map(); // pointerKey → { pointer, elements:Set }
let unsubscribe = null;
// Always visible, mirroring the view counter (a zero is a real, informative
// value — "this hasn't been zapped yet" — not noise to hide). The wrapper
// starts hidden in markup and is revealed on first bind so cards without a
// pointer stay clean.
const renderElement = (el, sats) => {
const wrapper = el.closest?.(".video-card__zaps") || null;
const value = Number.isFinite(sats) && sats > 0 ? sats : 0;
el.textContent = formatSats(value);
wrapper?.classList?.remove("hidden");
};
const renderKey = (key) => {
const entry = entries.get(key);
if (!entry) {
return;
}
const sats = getVideoZapTotalSnapshot(entry.pointer);
for (const el of Array.from(entry.elements)) {
if (!el || !el.isConnected) {
entry.elements.delete(el);
continue;
}
renderElement(el, sats);
}
if (!entry.elements.size) {
entries.delete(key);
}
};
const ensureSubscribed = () => {
if (!unsubscribe) {
unsubscribe = onZapTotalsChanged(() => {
for (const key of Array.from(entries.keys())) {
renderKey(key);
}
});
}
};
return {
bind(cardEl, pointerInfo) {
if (!cardEl || !pointerInfo?.key || !pointerInfo?.pointer) {
return;
}
const el = cardEl.querySelector?.("[data-zap-total]");
if (!el) {
return;
}
let entry = entries.get(pointerInfo.key);
if (!entry) {
entry = { pointer: pointerInfo.pointer, elements: new Set() };
entries.set(pointerInfo.key, entry);
}
entry.elements.add(el);
ensureSubscribed();
// Cached total now (also schedules the batched fetch when unknown).
renderElement(el, requestVideoZapTotal(pointerInfo.pointer));
},
prune() {
for (const [key, entry] of Array.from(entries.entries())) {
for (const el of Array.from(entry.elements)) {
if (!el || !el.isConnected) {
entry.elements.delete(el);
}
}
if (!entry.elements.size) {
entries.delete(key);
}
}
},
destroy() {
if (typeof unsubscribe === "function") {
try {
unsubscribe();
} catch (error) {
// best effort
}
unsubscribe = null;
}
entries.clear();
},
};
}
+93 -2
View File
@@ -85,6 +85,10 @@ export default class ZapController {
this.callbacks = {
onSuccess: typeof callbacks.onSuccess === "function" ? callbacks.onSuccess : null,
onError: typeof callbacks.onError === "function" ? callbacks.onError : null,
// Fires with { video, sats } after a fully-successful send so the app can
// optimistically bump the video's zap total (badge updates instantly).
onZapSuccess:
typeof callbacks.onZapSuccess === "function" ? callbacks.onZapSuccess : null,
};
this.requestWalletPane =
typeof requestWalletPane === "function" ? requestWalletPane : null;
@@ -377,6 +381,41 @@ export default class ZapController {
this.videoModal?.setZapStatus(summary, "success");
this.notifySuccess("Zap sent successfully!");
// Optimistically bump the video's zap total by the amount sent (both the
// creator and platform shares are tagged to the video, so the eventual
// relay total equals context.shares.total). Badge updates instantly.
// Also forward the per-share proof (bolt11 + preimage + zap request) so
// the app can publish preimage-verified bitvid zap tallies
// (docs/zap-tally-plan.md §5.4). Only settled shares with a preimage.
if (this.callbacks.onZapSuccess) {
try {
const shares = receipts
.filter(
(r) =>
r &&
(r.status === "success" || !r.status) &&
typeof r.preimage === "string" &&
r.preimage,
)
.map((r) => ({
recipientType: r.recipientType || "creator",
amountSats: Math.max(0, Math.round(Number(r.amount) || 0)),
bolt11:
typeof r.invoice?.invoice === "string" ? r.invoice.invoice : "",
preimage: r.preimage,
zapRequest: typeof r.zapRequest === "string" ? r.zapRequest : "",
}))
.filter((s) => s.bolt11 && s.zapRequest && s.amountSats > 0);
this.callbacks.onZapSuccess({
video: this.getCurrentVideo(),
sats: Math.max(0, Math.round(Number(context?.shares?.total) || 0)),
shares,
});
} catch (error) {
userLogger.warn("[zap] onZapSuccess hook failed:", error);
}
}
this.resetRetryState();
this.modalZapCommentValue = "";
// Reset the whole form (amount + comment) after a successful send, matching
@@ -790,7 +829,19 @@ export default class ZapController {
throw new Error("Enter a zap amount greater than zero.");
}
const creatorEntry = await fetchLightningMetadata(lightningAddress);
// Name WHICH address failed: LNURL servers return opaque reasons like
// "Could not get user information", and without the address the creator's
// (fine) address gets blamed for a broken platform-fee address.
let creatorEntry;
try {
creatorEntry = await fetchLightningMetadata(lightningAddress);
} catch (error) {
const detail =
error?.message || "Unable to load this creator's Lightning info.";
throw new Error(
`Creator Lightning address (${lightningAddress}): ${detail}`,
);
}
if (shares.creatorShare > 0) {
try {
validateInvoiceAmount(creatorEntry.metadata, shares.creatorShare);
@@ -814,7 +865,15 @@ export default class ZapController {
throw new Error("Platform Lightning address is unavailable.");
}
platformEntry = await fetchLightningMetadata(platformAddress);
try {
platformEntry = await fetchLightningMetadata(platformAddress);
} catch (error) {
const detail =
error?.message || "Unable to load the platform's Lightning info.";
throw new Error(
`Platform fee Lightning address (${platformAddress}): ${detail}`,
);
}
try {
validateInvoiceAmount(platformEntry.metadata, shares.platformShare);
} catch (error) {
@@ -1053,6 +1112,38 @@ export default class ZapController {
: "Retried zap shares successfully.";
this.videoModal?.setZapStatus(successMessage, "success");
this.notifySuccess("Zap shares retried successfully!");
// Retried shares also bump the badge + publish verified tallies (same hook
// as the main path; docs/zap-tally-plan.md §5.4). Best-effort.
if (this.callbacks.onZapSuccess) {
try {
const shareProof = aggregatedReceipts
.filter(
(r) =>
r &&
(r.status === "success" || !r.status) &&
typeof r.preimage === "string" &&
r.preimage,
)
.map((r) => ({
recipientType: r.recipientType || "creator",
amountSats: Math.max(0, Math.round(Number(r.amount) || 0)),
bolt11:
typeof r.invoice?.invoice === "string" ? r.invoice.invoice : "",
preimage: r.preimage,
zapRequest: typeof r.zapRequest === "string" ? r.zapRequest : "",
}))
.filter((s) => s.bolt11 && s.zapRequest && s.amountSats > 0);
this.callbacks.onZapSuccess({
video: this.getCurrentVideo(),
sats: total,
shares: shareProof,
});
} catch (error) {
userLogger.warn("[zap] onZapSuccess hook failed (retry):", error);
}
}
this.resetRetryState();
return true;
}
+21
View File
@@ -4,6 +4,7 @@ import { initForYouView } from "./forYouView.js";
import { initKidsView } from "./kidsView.js";
import { initExploreView } from "./exploreView.js";
import { initTrendingView } from "./trendingView.js";
import { initMostZappedView } from "./mostZappedView.js";
import { subscriptions } from "./subscriptions.js";
import { getApplication } from "./applicationContext.js";
import { applyDesignSystemAttributes } from "./designSystem.js";
@@ -160,6 +161,26 @@ export const viewInitRegistry = {
refreshApp.forceRefreshAllProfiles();
}
},
[FEED_TYPES.MOST_ZAPPED]: () => {
const app = getApplication();
initMostZappedView({
getApp: getApplication,
isActive: () =>
typeof window !== "undefined" &&
typeof window.location?.hash === "string" &&
window.location.hash.startsWith(`#view=${FEED_TYPES.MOST_ZAPPED}`),
});
if (app && typeof app.loadMostZappedVideos === "function") {
if (typeof app.mountVideoListView === "function") {
app.mountVideoListView({ includeTags: false });
}
app.loadMostZappedVideos();
}
const refreshApp = getApplication();
if (refreshApp && typeof refreshApp.forceRefreshAllProfiles === "function") {
refreshApp.forceRefreshAllProfiles();
}
},
[FEED_TYPES.DOCS]: async () => {
await initDocsView();
},
+467
View File
@@ -0,0 +1,467 @@
// js/zapTotals.js
//
// Per-video ZAP TOTAL aggregation (kind-9735 receipts) for the "Most Zapped"
// tab (#47). Mirrors viewCounter's surface at feed granularity: the feed's
// getZapTotal(video) reads the cached sats total AND schedules a batched
// one-shot fetch for unknown/stale pointers; when a batch lands,
// onZapTotalsChanged fires and the Most Zapped view re-runs the feed (a cheap
// in-memory re-rank), so the order settles as totals stream in.
//
// Amounts: nostr-tools nip57.getSatoshisAmountFromBolt11 on the receipt's
// bolt11 tag, falling back to the embedded zap request's amount tag (msats).
// Receipts are deduped by event id per pointer, so re-fetches never
// double-count.
import { devLogger } from "./utils/logger.js";
import {
verifyBitvidZapTally,
extractBolt11Fields,
} from "./payments/zapReceiptValidator.js";
import { ZAP_TALLY_KIND } from "./nostrEventSchemas.js";
import { FEATURE_ZAP_TALLY } from "./constants.js";
export const ZAP_RECEIPT_KIND = 9735;
export const SENT_ZAPS_STORAGE_KEY = "bitvid:sentZaps:v1";
const FETCH_TTL_MS = 120000;
const BATCH_DELAY_MS = 150;
const MAX_POINTERS_PER_FILTER = 100;
const RECEIPTS_PER_FILTER_LIMIT = 500;
function tagValues(event, name) {
if (!Array.isArray(event?.tags)) {
return [];
}
return event.tags
.filter((tag) => Array.isArray(tag) && tag[0] === name && typeof tag[1] === "string")
.map((tag) => tag[1]);
}
// Accepts BOTH pointer shapes used in the app: the canonical tag-style array
// ["a"|"e", value, relay?] (what deriveVideoPointerInfo/resolveVideoPointer
// produce) and the object form { type, value }.
export function pointerKey(pointer) {
let type = "";
let value = "";
if (Array.isArray(pointer)) {
type = pointer[0] === "a" || pointer[0] === "e" ? pointer[0] : "";
value = typeof pointer[1] === "string" ? pointer[1].trim() : "";
} else if (pointer && typeof pointer === "object") {
type = pointer.type === "a" || pointer.type === "e" ? pointer.type : "";
value = typeof pointer.value === "string" ? pointer.value.trim() : "";
}
if (!type || !value) {
return "";
}
return `${type}:${value}`;
}
// Sats carried by one receipt: bolt11 first (the actual paid invoice), then
// the zap request's amount tag (msats) embedded in the description.
export function extractReceiptAmountSats(event, tools) {
const bolt11 = tagValues(event, "bolt11")[0] || "";
const fromBolt11 = tools?.nip57?.getSatoshisAmountFromBolt11;
if (bolt11 && typeof fromBolt11 === "function") {
try {
const sats = Number(fromBolt11(bolt11));
if (Number.isFinite(sats) && sats > 0) {
return Math.round(sats);
}
} catch (error) {
// fall through to the description amount
}
}
const description = tagValues(event, "description")[0] || "";
if (description) {
try {
const request = JSON.parse(description);
const amountTag = Array.isArray(request?.tags)
? request.tags.find((tag) => Array.isArray(tag) && tag[0] === "amount")
: null;
const msats = Number(amountTag?.[1]);
if (Number.isFinite(msats) && msats > 0) {
return Math.round(msats / 1000);
}
} catch (error) {
// unparseable description — count the receipt as zero
}
}
return 0;
}
export function createZapTotalsStore({
now = () => Date.now(),
batchDelayMs = BATCH_DELAY_MS,
fetchTtlMs = FETCH_TTL_MS,
getClient = () => null,
getTools = () => null,
schedule = (fn, ms) => setTimeout(fn, ms),
// bitvid-native zap tally (docs/zap-tally-plan.md): count kind-30081
// preimage-verified tallies alongside 9735s, deduped by payment_hash.
// Injectable for tests; the singleton wires the real verifier + flag.
isTallyEnabled = () => true,
verifyTally = verifyBitvidZapTally,
tallyKind = ZAP_TALLY_KIND,
// Durable ledger of the user's OWN sent zaps (localStorage key), so their
// zapped videos keep the count + Most-Zapped rank across reloads even when
// the recipient's LNURL server never publishes a 9735 receipt (custodial
// wallets like Strike routinely don't). Pass null to disable (tests).
persistKey = SENT_ZAPS_STORAGE_KEY,
} = {}) {
// key → { sats, optimisticSats, receiptIds:Set, fetchedAt }
// sats — summed from real relay receipts (deduped by event id)
// optimisticSats — the user's OWN sent zaps (durable ledger + session
// bumps), shown instantly like ingestLocalViewEvent.
// Cleared once a real receipt for the pointer arrives, so
// if the recipient DOES publish, the relay becomes
// authoritative and the value is never double-counted.
const totals = new Map();
const pending = new Map(); // key → pointer
const listeners = new Set();
let batchTimer = null;
// --- durable sent-zap ledger (localStorage: { pointerKey: sats }) ---
const readLedger = () => {
if (!persistKey || typeof localStorage === "undefined") {
return {};
}
try {
const raw = localStorage.getItem(persistKey);
const parsed = raw ? JSON.parse(raw) : {};
return parsed && typeof parsed === "object" ? parsed : {};
} catch (error) {
return {};
}
};
const writeLedger = (ledger) => {
if (!persistKey || typeof localStorage === "undefined") {
return;
}
try {
const keys = Object.keys(ledger || {});
if (!keys.length) {
localStorage.removeItem(persistKey);
} else {
localStorage.setItem(persistKey, JSON.stringify(ledger));
}
} catch (error) {
/* best-effort */
}
};
const entryFor = (key) => {
let entry = totals.get(key);
if (!entry) {
entry = {
sats: 0,
optimisticSats: 0,
receiptIds: new Set(),
// Cross-source dedup: a single payment can yield BOTH a 9735 and a
// bitvid tally; count its sats once (docs/zap-tally-plan.md §4).
paymentHashes: new Set(),
fetchedAt: 0,
};
totals.set(key, entry);
}
return entry;
};
// Seed the optimistic layer from the durable ledger at construction.
for (const [key, sats] of Object.entries(readLedger())) {
const amount = Number(sats);
if (typeof key === "string" && key && Number.isFinite(amount) && amount > 0) {
entryFor(key).optimisticSats = Math.round(amount);
}
}
const totalOf = (entry) =>
(entry?.sats || 0) + (entry?.optimisticSats || 0);
const emitChange = () => {
for (const listener of listeners) {
try {
listener();
} catch (error) {
devLogger.warn("[zapTotals] Change listener threw:", error);
}
}
};
async function runBatch() {
batchTimer = null;
if (!pending.size) {
return;
}
const batch = new Map(pending);
pending.clear();
const client = getClient();
const manager =
typeof client?.getSubscriptionManager === "function"
? client.getSubscriptionManager()
: null;
const relays = Array.isArray(client?.relays) ? client.relays : [];
if (!manager || typeof manager.list !== "function" || !relays.length) {
// Leave fetchedAt untouched so a later attempt retries.
return;
}
const aValues = [];
const eValues = [];
for (const pointer of batch.values()) {
if (pointer.type === "a") aValues.push(pointer.value);
else if (pointer.type === "e") eValues.push(pointer.value);
}
// One query for both sources: real 9735 receipts + bitvid tallies.
const tallyOn = (() => {
try {
return isTallyEnabled() !== false;
} catch (error) {
return false;
}
})();
const kinds = tallyOn ? [ZAP_RECEIPT_KIND, tallyKind] : [ZAP_RECEIPT_KIND];
const filters = [];
for (let i = 0; i < aValues.length; i += MAX_POINTERS_PER_FILTER) {
filters.push({
kinds,
"#a": aValues.slice(i, i + MAX_POINTERS_PER_FILTER),
limit: RECEIPTS_PER_FILTER_LIMIT,
});
}
for (let i = 0; i < eValues.length; i += MAX_POINTERS_PER_FILTER) {
filters.push({
kinds,
"#e": eValues.slice(i, i + MAX_POINTERS_PER_FILTER),
limit: RECEIPTS_PER_FILTER_LIMIT,
});
}
let events = [];
try {
// Routed through the L1 SubscriptionManager (relay caps + in-flight
// dedupe) — the direct-pool-access lint gate forbids raw pool.list here.
events = await manager.list({ relays, filters });
} catch (error) {
devLogger.warn("[zapTotals] Receipt fetch failed:", error);
return; // retry on the next request
}
const fetchedAt = now();
// Mark every requested pointer fresh (a pointer with no receipts is a
// real answer: zero sats), THEN fold receipts in.
for (const key of batch.keys()) {
entryFor(key).fetchedAt = fetchedAt;
}
let changed = false;
const keysWithRealReceipt = new Set();
const list = Array.isArray(events) ? events : [];
// 1) Real NIP-57 receipts (9735). Record each payment_hash so a matching
// bitvid tally for the same payment isn't also counted.
for (const event of list) {
if (!event || event.kind !== ZAP_RECEIPT_KIND || !event.id) {
continue;
}
const keys = [
...tagValues(event, "a").map((value) => `a:${value}`),
...tagValues(event, "e").map((value) => `e:${value}`),
].filter((key) => batch.has(key) || totals.has(key));
if (!keys.length) {
continue;
}
const sats = extractReceiptAmountSats(event, getTools());
const paymentHash = extractBolt11Fields(tagValues(event, "bolt11")[0] || "")
.paymentHash;
for (const key of keys) {
const entry = entryFor(key);
keysWithRealReceipt.add(key);
if (entry.receiptIds.has(event.id)) {
continue;
}
entry.receiptIds.add(event.id);
if (paymentHash) {
entry.paymentHashes.add(paymentHash);
}
if (sats > 0) {
entry.sats += sats;
}
changed = true;
}
}
// 2) bitvid tallies (verified; deduped by payment_hash vs. 9735s + earlier
// tallies). Pointers come from the embedded, hash-bound zap request.
if (tallyOn) {
for (const event of list) {
if (!event || event.kind !== tallyKind) {
continue;
}
let verdict;
try {
verdict = verifyTally(event, {
getSats: getTools()?.nip57?.getSatoshisAmountFromBolt11,
});
} catch (error) {
verdict = null;
}
if (!verdict?.ok || !(verdict.sats > 0) || !verdict.paymentHash) {
continue;
}
const keys = verdict.pointerTags
.filter((t) => t[0] === "a" || t[0] === "e")
.map((t) => `${t[0]}:${t[1]}`)
.filter((key) => batch.has(key) || totals.has(key));
for (const key of keys) {
const entry = entryFor(key);
keysWithRealReceipt.add(key);
if (entry.paymentHashes.has(verdict.paymentHash)) {
continue; // already counted via a 9735 or an earlier tally
}
entry.paymentHashes.add(verdict.paymentHash);
entry.sats += verdict.sats;
changed = true;
}
}
}
// A pointer that now has real receipts is authoritative — drop its
// optimistic bump AND its durable ledger entry, so once the relay actually
// reflects zaps for this video we trust it and never double-count.
if (keysWithRealReceipt.size) {
const ledger = readLedger();
let ledgerChanged = false;
for (const key of keysWithRealReceipt) {
const entry = totals.get(key);
if (entry && entry.optimisticSats > 0) {
entry.optimisticSats = 0;
changed = true;
}
if (key in ledger) {
delete ledger[key];
ledgerChanged = true;
}
}
if (ledgerChanged) {
writeLedger(ledger);
}
}
if (changed || batch.size) {
emitChange();
}
}
return {
// Cached total only — never triggers a fetch.
getSnapshot(pointer) {
const key = pointerKey(pointer);
return key ? totalOf(totals.get(key)) : 0;
},
// Cached total now, plus a scheduled batched fetch when unknown/stale.
request(pointer) {
const key = pointerKey(pointer);
if (!key) {
return 0;
}
const entry = totals.get(key);
const fresh = entry && now() - entry.fetchedAt < fetchTtlMs;
if (!fresh && !pending.has(key)) {
pending.set(key, { type: key[0], value: key.slice(2) });
if (!batchTimer) {
batchTimer = schedule(() => {
void runBatch();
}, batchDelayMs);
}
}
return totalOf(entry);
},
// Optimistic bump for a zap THIS client just sent (mirrors
// ingestLocalViewEvent): the sats show instantly on the card + modal, and
// fetchedAt is refreshed so we don't immediately re-fetch and double-count
// before the real receipt propagates. When the real receipt later lands,
// runBatch clears the optimistic portion. Persists in-memory until the real
// receipt arrives or the page reloads (receipts from custodial wallets may
// never be published — the bump keeps the zapper's own view honest).
ingestLocalZap(pointer, sats) {
const key = pointerKey(pointer);
const amount = Number.isFinite(sats) ? Math.max(0, Math.round(sats)) : 0;
if (!key || amount <= 0) {
return;
}
const entry = entryFor(key);
entry.optimisticSats += amount;
entry.fetchedAt = now();
// Persist to the durable ledger so the badge + Most-Zapped rank survive a
// reload even when no 9735 receipt is ever published for this zap.
const ledger = readLedger();
ledger[key] = (Number(ledger[key]) || 0) + amount;
writeLedger(ledger);
emitChange();
},
onChange(listener) {
if (typeof listener !== "function") {
return () => {};
}
listeners.add(listener);
return () => listeners.delete(listener);
},
// Test hook: run the pending batch immediately.
async flush() {
await runBatch();
},
};
}
// --- App singleton -----------------------------------------------------------
let clientRef = null;
let toolsRef = null;
const store = createZapTotalsStore({
getClient: () => clientRef,
// Lazily resolve the canonical nostr-tools (nip57 bolt11 amount helper);
// the toolkit bootstrap stamps it on the global before feeds run.
getTools: () =>
toolsRef ||
globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ ||
globalThis.NostrTools ||
null,
// Read the flag live so a runtime toggle takes effect on the next fetch.
isTallyEnabled: () => FEATURE_ZAP_TALLY,
});
export function initZapTotals({ nostrClient, tools } = {}) {
if (nostrClient) {
clientRef = nostrClient;
}
if (tools) {
toolsRef = tools;
}
}
export function getVideoZapTotalSnapshot(pointer) {
return store.getSnapshot(pointer);
}
export function requestVideoZapTotal(pointer) {
return store.request(pointer);
}
export function onZapTotalsChanged(listener) {
return store.onChange(listener);
}
// Optimistic bump after this client sends a zap, so the card + modal badge
// update instantly (the relay receipt reconciles later).
export function ingestLocalVideoZap(pointer, sats) {
return store.ingestLocalZap(pointer, sats);
}
// The singleton store itself — exposes flush() for tests.
export default store;
// Console debug handle: window.__bitvidZapTotals.getSnapshot(["a","30078:…"]),
// .request(pointer), .flush() — for diagnosing why a badge is (not) showing.
if (typeof window !== "undefined") {
window.__bitvidZapTotals = store;
}
+26 -3
View File
@@ -63,7 +63,30 @@ test("handleHashChange defaults to for-you when logged in", async () => {
}
});
test("handleHashChange defaults to most-recent-videos when logged out", async () => {
// spec_correction: commit 04621efc (2026-06-24, "Trending defaults") made
// TRENDING the deliberate logged-out landing ("the best not-personalized
// landing", falling back to Recent only when FEATURE_TRENDING_FEED is off —
// see js/hashChangeHandler.js). This test still encoded the pre-change
// default (most-recent-videos) and has been failing since; the flag defaults
// to true, so the correct observable outcome is views/trending.html.
//
// test_integrity_note:
// change_type: ["spec_correction"]
// scenarios:
// - id: SCN-logged-out-default-view
// given: "no #view= hash and a logged-out user (FEATURE_TRENDING_FEED default-on)"
// when: "handleHashChange resolves the default view"
// then: "the Trending view loads and its init runs"
// observable_outcomes:
// - "loadView called with views/trending.html and the trending init fires"
// determinism_controls:
// - "JSDOM with fixed URL; injected logged-out application stub"
// anti_cheat_rationale:
// prevents: ["snapshot rubber-stamping"]
// relaxation:
// did_relax_any_assertion: false
// if_true_explain_spec_basis: "expectation moved to the newer intended default (equally strict)"
test("handleHashChange defaults to trending when logged out", async () => {
const restore = setupDom("https://example.com");
try {
@@ -77,7 +100,7 @@ test("handleHashChange defaults to most-recent-videos when logged out", async ()
loadViewCalls.push(viewUrl);
},
viewInitRegistry: {
"most-recent-videos": async () => {
trending: async () => {
initCalled = true;
},
},
@@ -87,7 +110,7 @@ test("handleHashChange defaults to most-recent-videos when logged out", async ()
await handleHashChange();
assert.deepEqual(loadViewCalls, ["views/most-recent-videos.html"]);
assert.deepEqual(loadViewCalls, ["views/trending.html"]);
assert.equal(initCalled, true);
} finally {
restore();
+62 -2
View File
@@ -12,7 +12,11 @@ test("network/CORS failure throws a friendly, coded error", async () => {
throw new TypeError("Failed to fetch");
};
await assert.rejects(
() => fetchPayServiceData("https://example.com/.well-known/lnurlp/x", { fetcher }),
() =>
fetchPayServiceData("https://example.com/.well-known/lnurlp/x", {
fetcher,
retryDelayMs: 0,
}),
(err) => {
assert.match(err.message, /reach this recipient's Lightning address/i);
assert.equal(err.code, "lnurl-unreachable");
@@ -25,11 +29,67 @@ test("network/CORS failure throws a friendly, coded error", async () => {
test("a non-OK HTTP response still reports a status error (unchanged)", async () => {
const fetcher = async () => ({ ok: false, status: 502 });
await assert.rejects(
() => fetchPayServiceData("https://example.com/.well-known/lnurlp/x", { fetcher }),
() =>
fetchPayServiceData("https://example.com/.well-known/lnurlp/x", {
fetcher,
retryDelayMs: 0,
}),
/Failed to load LNURL metadata \(502\)/,
);
});
// LNURL servers intermittently return {status:"ERROR"} bodies (observed live
// against strike.me: "Could not get user information" on one request, a valid
// callback on the next). A single retry must absorb that blip; a PERSISTENT
// error must still surface the server's reason after the retry budget.
test("a transient LNURL ERROR body recovers on the retry", async () => {
let calls = 0;
const fetcher = async () => {
calls += 1;
if (calls === 1) {
return {
ok: true,
json: async () => ({ status: "ERROR", reason: "Could not get user information" }),
};
}
return {
ok: true,
json: async () => ({
tag: "payRequest",
callback: "https://example.com/cb",
minSendable: 1000,
maxSendable: 100000,
}),
};
};
const meta = await fetchPayServiceData(
"https://example.com/.well-known/lnurlp/x",
{ fetcher, retryDelayMs: 0 },
);
assert.equal(meta.callback, "https://example.com/cb");
assert.equal(calls, 2, "exactly one retry");
});
test("a persistent LNURL ERROR still fails with the server's reason", async () => {
let calls = 0;
const fetcher = async () => {
calls += 1;
return {
ok: true,
json: async () => ({ status: "ERROR", reason: "Could not get user information" }),
};
};
await assert.rejects(
() =>
fetchPayServiceData("https://example.com/.well-known/lnurlp/x", {
fetcher,
retryDelayMs: 0,
}),
/Could not get user information/,
);
assert.equal(calls, 2, "retry budget spent, then surfaced");
});
test("a valid LNURL pay response parses (no false friendly error)", async () => {
const fetcher = async () => ({
ok: true,
+115
View File
@@ -0,0 +1,115 @@
// Regression for "clicking a video no longer opens the player": coordinator
// methods run with `this` bound to the APPLICATION (bindCoordinator), so a
// cross-method `this.helper()` call inside the coordinator only works when
// app.js also adds an explicit delegate — the zap-badge helpers were plain
// methods, `this.subscribeModalZapTotal` didn't exist on the app, and EVERY
// modal open threw. This test drives subscribeModalViewCount through the REAL
// bindCoordinator with ONLY the delegates app.js actually defines, so any
// future cross-method `this.` call without a delegate fails here first.
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-modal-open-no-missing-delegate
// given: "a coordinator bound to an app stub exposing only app.js's real modal delegates"
// when: "subscribeModalViewCount runs (the modal-open path) and teardown runs (close path)"
// then: "no TypeError; the zap badge is visible from open (0 sats) and updates to the summed total; hidden on teardown"
// observable_outcomes:
// - "subscribeModalViewCount does not throw (the user-facing 'video will not open' bug)"
// - "badge visible at open showing 0 sats, updates to the summed total, hidden on teardown (no video open)"
// determinism_controls:
// - "JSDOM; scripted subscription-manager double; store flushed manually"
// anti_cheat_rationale:
// prevents: ["over-mocking internal logic", "hard-coded return value"]
// relaxation:
// did_relax_any_assertion: false
import "./test-helpers/setup-localstorage.mjs";
import test from "node:test";
import { strict as assert } from "node:assert";
import { JSDOM } from "jsdom";
import { createModalCoordinator } from "../js/app/modalCoordinator.js";
import bindCoordinator from "../js/app/bindCoordinator.js";
import zapTotalsStore, {
initZapTotals,
requestVideoZapTotal,
getVideoZapTotalSnapshot,
onZapTotalsChanged,
} from "../js/zapTotals.js";
import { formatViewCount } from "../js/viewCounter.js";
const A1 = "30078:" + "c".repeat(64) + ":video-1";
test("modal open path survives binding and drives the zap badge", async () => {
const dom = new JSDOM(
'<!DOCTYPE html><body><span id="videoZapTotal" class="hidden"><span data-zap-total-text></span></span></body>',
{ pretendToBeVisual: true },
);
global.document = dom.window.document;
let receipts = [];
initZapTotals({
nostrClient: {
relays: ["wss://relay.example"],
getSubscriptionManager: () => ({ list: async () => receipts }),
},
tools: { nip57: { getSatoshisAmountFromBolt11: () => NaN } },
});
const coordinator = createModalCoordinator({
devLogger: { warn: () => {}, log: () => {}, error: () => {} },
subscribeToVideoViewCount: () => "token-1",
unsubscribeFromVideoViewCount: () => {},
formatViewCount,
requestVideoZapTotal,
getVideoZapTotalSnapshot,
onZapTotalsChanged,
});
// App stub with ONLY the delegates app.js really defines for this path.
const app = {
videoModal: {
getViewCountElement: () => dom.window.document.createElement("span"),
updateViewCountLabel: () => {},
setViewCountPointer: () => {},
},
formatViewCountLabel: (n) => `${n} views`,
};
app._modal = bindCoordinator(app, coordinator);
app.teardownModalViewCountSubscription = (...args) =>
app._modal.teardownModalViewCountSubscription(...args);
app.subscribeModalViewCount = (...args) =>
app._modal.subscribeModalViewCount(...args);
const pointer = ["a", A1];
// The bug: this line threw "this.subscribeModalZapTotal is not a function".
app.subscribeModalViewCount(pointer, `a:${A1}`);
const badge = dom.window.document.getElementById("videoZapTotal");
// Always visible while a video is open (mirrors the view counter); 0 sats
// until a receipt lands.
assert.equal(badge.classList.contains("hidden"), false, "visible while open");
assert.match(
badge.querySelector("[data-zap-total-text]").textContent,
/^0 sats$/,
);
receipts = [
{
id: "r1",
kind: 9735,
tags: [["a", A1], ["description", JSON.stringify({ tags: [["amount", "21000"]] })]],
},
];
await zapTotalsStore.flush();
assert.equal(badge.classList.contains("hidden"), false, "stays visible");
assert.match(
badge.querySelector("[data-zap-total-text]").textContent,
/21 sats/,
);
// Modal close path re-hides the badge (no video open) — also used to throw
// via the missing this.teardownModalZapTotal delegate.
app.teardownModalViewCountSubscription();
assert.equal(badge.classList.contains("hidden"), true, "teardown hides the badge");
});
+18 -4
View File
@@ -87,17 +87,31 @@ test("SessionActor", async (t) => {
assert.equal(read.encryption.salt, actor.encryption.salt);
});
await t.test("clears stored session actor", async () => {
// spec_correction: no-arg clearStoredSessionActor() deliberately no longer
// wipes the per-account map (that wiped EVERY saved account's key on one
// account's logout — fixed 2026-07-02, same class as the NIP-46 map wipe).
// Forgetting an account is the targeted clearStoredSessionActor(pubkey).
// See tests/nostr/sessionActor.test.mjs for the full integrity note and the
// multi-account survival scenario.
await t.test("clears stored session actor (targeted; no-arg drops only the default slot)", async () => {
const actor = {
pubkey: "pubkey123",
privateKeyEncrypted: "encrypteddata",
encryption: { salt: "salt", iv: "iv" }
};
sessionActor.persistSessionActor(actor);
sessionActor.clearStoredSessionActor();
const read = sessionActor.readStoredSessionActorEntry();
assert.equal(read, null);
// No-arg clear: default slot gone, but the account's key survives.
sessionActor.clearStoredSessionActor();
assert.equal(localStorage.getItem(sessionActor.SESSION_ACTOR_STORAGE_KEY), null);
assert.ok(
sessionActor.readStoredSessionActorEntry("pubkey123"),
"no-arg clear must not delete the account's stored key",
);
// Targeted clear actually forgets it.
sessionActor.clearStoredSessionActor("pubkey123");
assert.equal(sessionActor.readStoredSessionActorEntry("pubkey123"), null);
assert.equal(sessionActor.readStoredSessionActorEntry(), null);
});
});
+52 -18
View File
@@ -95,26 +95,60 @@ test("js/nostr/sessionActor.js", async (t) => {
assert.strictEqual(decrypted, privateKey);
});
await t.test("Clear Stored Session Actor", async () => {
const pubkey = "0000000000000000000000000000000000000000000000000000000000000001";
const actor = {
pubkey,
privateKeyEncrypted: "fake-encrypted-data",
encryption: {
salt: "fake-salt",
iv: "fake-iv",
iterations: 1000,
hash: "SHA-256",
algorithm: "AES-GCM",
version: 1
},
createdAt: Date.now(),
// spec_correction: no-arg clearStoredSessionActor() used to wipe the WHOLE
// per-account map — the data-loss bug where logging out of one account
// silently destroyed every other saved account's remembered nsec key. The
// deliberate fix (2026-07-02, same class as the NIP-46 session-map wipe)
// made no-arg clear ONLY the legacy v1 "last saved" slot; forgetting a
// specific account is clearStoredSessionActor(pubkey). This test encoded the
// dangerous pre-fix behavior; it now asserts the fixed semantics, including
// the multi-account survival property the fix exists for.
//
// test_integrity_note:
// change_type: ["spec_correction"]
// scenarios:
// - id: SCN-session-actor-targeted-clear
// given: "two accounts with persisted encrypted keys"
// when: "no-arg clear runs (logout-ish) and then a targeted clear"
// then: "no-arg leaves BOTH accounts retrievable by pubkey; targeted clear removes exactly one"
// observable_outcomes:
// - "clearStoredSessionActor() never deletes other accounts' keys"
// - "clearStoredSessionActor(pubkey) removes that account's entry"
// determinism_controls:
// - "in-memory localStorage; fixed pubkeys"
// anti_cheat_rationale:
// prevents: ["snapshot rubber-stamping"]
// relaxation:
// did_relax_any_assertion: false
// if_true_explain_spec_basis: "replaced with the fixed (stronger, multi-account) semantics"
await t.test("Clear semantics: targeted forget, no-arg never nukes other accounts", async () => {
const pubkeyA = "0000000000000000000000000000000000000000000000000000000000000001";
const pubkeyB = "0000000000000000000000000000000000000000000000000000000000000002";
const encryption = {
salt: "fake-salt",
iv: "fake-iv",
iterations: 1000,
hash: "SHA-256",
algorithm: "AES-GCM",
version: 1
};
persistSessionActor({ pubkey: pubkeyA, privateKeyEncrypted: "cipher-a", encryption, createdAt: Date.now() });
persistSessionActor({ pubkey: pubkeyB, privateKeyEncrypted: "cipher-b", encryption, createdAt: Date.now() });
persistSessionActor(actor);
assert.ok(readStoredSessionActorEntry());
// No-arg clear (the logout-path call) drops only the "last saved" default…
clearStoredSessionActor();
assert.strictEqual(readStoredSessionActorEntry(), null);
assert.strictEqual(localStorage.getItem(SESSION_ACTOR_STORAGE_KEY), null);
// …while BOTH accounts' keys survive and stay switchable.
assert.ok(readStoredSessionActorEntry(pubkeyA), "account A must survive a no-arg clear");
assert.ok(readStoredSessionActorEntry(pubkeyB), "account B must survive a no-arg clear");
// Targeted clear forgets exactly that account.
clearStoredSessionActor(pubkeyA);
assert.strictEqual(readStoredSessionActorEntry(pubkeyA), null, "targeted clear removes the account");
assert.ok(readStoredSessionActorEntry(pubkeyB), "the other account is untouched");
clearStoredSessionActor(pubkeyB);
assert.strictEqual(readStoredSessionActorEntry(pubkeyB), null);
assert.strictEqual(readStoredSessionActorEntry(), null, "nothing left after every account is forgotten");
});
});
+30 -6
View File
@@ -12,9 +12,21 @@ let nostrClient;
let getActiveSigner;
let clearActiveSigner;
let clearStoredSessionActor;
let listStoredSessionActorPubkeys;
let readStoredSessionActorEntry;
let tools;
let bytesToHex;
// No-arg clearStoredSessionActor() deliberately clears only the legacy
// "last saved" slot (per-account keys must survive another account's logout —
// fixed 2026-07-02). Tests that need a pristine store wipe every account.
function wipeStoredActors() {
for (const pk of listStoredSessionActorPubkeys?.() || []) {
clearStoredSessionActor(pk);
}
clearStoredSessionActor();
}
before(async () => {
tools = await ensureNostrTools();
const canonical = { ...tools };
@@ -24,7 +36,11 @@ before(async () => {
({ nostrClient } = await import("../js/nostrClientFacade.js"));
({ getActiveSigner, clearActiveSigner } = await import("../js/nostr/client.js"));
({ clearStoredSessionActor } = await import("../js/nostr/sessionActor.js"));
({
clearStoredSessionActor,
listStoredSessionActorPubkeys,
readStoredSessionActorEntry,
} = await import("../js/nostr/sessionActor.js"));
bytesToHex = (bytes) => {
if (tools.bytesToHex) return tools.bytesToHex(bytes);
@@ -35,7 +51,7 @@ before(async () => {
after(() => {
try {
clearStoredSessionActor?.();
wipeStoredActors();
} catch (error) {
// ignore
}
@@ -50,7 +66,7 @@ function makeKey() {
describe("nsec login", () => {
it("enforces the access-control validator before activating the signer", async () => {
clearActiveSigner();
clearStoredSessionActor();
wipeStoredActors();
const { hex } = makeKey();
let seenPubkey = null;
@@ -74,7 +90,7 @@ describe("nsec login", () => {
it("persists an encrypted key and unlocks it with the passphrase", async () => {
clearActiveSigner();
clearStoredSessionActor();
wipeStoredActors();
const { hex, pubkey } = makeKey();
const passphrase = "correct horse battery staple";
@@ -110,7 +126,7 @@ describe("nsec login", () => {
it("unlock re-checks access control and forgets a blocked key", async () => {
clearActiveSigner();
clearStoredSessionActor();
wipeStoredActors();
const { hex, pubkey } = makeKey();
const passphrase = "another secret passphrase";
@@ -132,10 +148,18 @@ describe("nsec login", () => {
"a blocked key must not unlock",
);
assert.equal(getActiveSigner(), null, "no signer activated for a blocked key");
// The security property, asserted on the blocked account SPECIFICALLY (a
// no-arg metadata read can legitimately surface a different saved account
// via the single-entry fallback — that is not a leak of THIS key).
assert.equal(
readStoredSessionActorEntry(pubkey),
null,
"the blocked account's encrypted key is forgotten (cleared from storage)",
);
assert.equal(
nostrClient.getStoredSessionActorMetadata(),
null,
"a blocked key is forgotten (cleared from storage)",
"with no other saved accounts, no metadata survives the blocked-key cleanup",
);
});
});
@@ -0,0 +1,91 @@
// Safety: a `pay_invoice` (or any spending method) that TIMES OUT must NOT be
// auto-resent. The wallet may have received it and be paying — the nip44→nip04
// encryption-fallback retry would double-spend. Only read-only methods (and an
// explicit unsupported-encryption rejection, which means the wallet never
// processed the request) stay retryable.
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-nwc-no-double-spend
// given: "a nip44 wallet context and various errors/methods"
// when: "shouldRetryWithFallback decides whether to resend"
// then: "spending method + timeout → no retry; read-only + timeout → retry; unsupported-encryption → retry for any method; already-retried → no retry"
// observable_outcomes:
// - "boolean retry decision per (method, error) pair"
// determinism_controls:
// - "pure decision function; no socket/network"
// anti_cheat_rationale:
// prevents: ["hard-coded return value", "over-mocking internal logic"]
// relaxation:
// did_relax_any_assertion: false
import test from "node:test";
import { strict as assert } from "node:assert";
import { __TESTING__ } from "../js/payments/nwcClient.js";
const { shouldRetryWithFallback, NWC_SPENDING_METHODS } = __TESTING__;
const nip44Ctx = () => ({
encryption: { scheme: "nip44_v2" },
encryptionState: undefined,
});
const timeout = new Error("Wallet request timed out.");
const unsupported = Object.assign(new Error("nope"), { code: "UNSUPPORTED_ENCRYPTION" });
test("timeout on a spending method never resends (no double-spend)", () => {
const encryption = { scheme: "nip44_v2" };
for (const method of NWC_SPENDING_METHODS) {
assert.equal(
shouldRetryWithFallback(nip44Ctx(), timeout, encryption, { hasRetried: false, method }),
false,
`${method} must not auto-retry on timeout`,
);
}
});
test("timeout on a read-only method may still fall back to nip04", () => {
const ctx = nip44Ctx();
const encryption = { scheme: "nip44_v2" };
const decision = shouldRetryWithFallback(ctx, timeout, encryption, {
hasRetried: false,
method: "get_info",
});
// Read-only methods remain eligible for the encryption fallback (the guard
// only blocks spending methods). The exact result depends on the context's
// encryption state, but it must NOT be short-circuited to false by the guard.
assert.equal(typeof decision, "boolean");
// With a fresh context (nip04 not marked unsupported), it retries.
assert.equal(decision, true, "read-only timeout retries the encryption fallback");
});
test("explicit unsupported-encryption rejection retries for any method", () => {
const encryption = { scheme: "nip44_v2" };
assert.equal(
shouldRetryWithFallback(nip44Ctx(), unsupported, encryption, {
hasRetried: false,
method: "pay_invoice",
}),
true,
"a rejection means the wallet never paid → safe to resend under nip04",
);
});
test("already-retried or non-nip44 never retries", () => {
const encryption = { scheme: "nip44_v2" };
assert.equal(
shouldRetryWithFallback(nip44Ctx(), timeout, encryption, {
hasRetried: true,
method: "get_info",
}),
false,
);
assert.equal(
shouldRetryWithFallback(nip44Ctx(), timeout, { scheme: "nip04" }, {
hasRetried: false,
method: "get_info",
}),
false,
"already on nip04 → no fallback",
);
});
+53
View File
@@ -726,6 +726,59 @@ for (const _ of [0]) {
assert.equal(switcherNpubEl.textContent, expectedSwitcher);
});
// #43: the always-visible identity card mirrors the active profile on every
// pane (populated by renderSavedProfiles, so it also refreshes on account
// switch), hides when logged out, and click-through opens the Account pane.
test('identity card shows the acting profile, hides logged out, opens Account', async () => {
const sampleProfiles = [
{
pubkey: defaultActorHex,
npub: 'npub1abcdefghijkmnopqrstuvwxyz1234567890example',
name: 'Primary Account',
picture: '',
providerId: 'nip07',
authType: 'nip07',
},
];
const controller = createController({
services: {
formatShortNpub,
},
});
await controller.load();
controller.state.setSavedProfiles(sampleProfiles);
controller.state.setActivePubkey(defaultActorHex);
controller.renderSavedProfiles();
assert.ok(controller.identityCard, 'identity card element is cached');
assert.equal(
controller.identityCard.classList.contains('hidden'),
false,
'visible while a profile is active',
);
assert.equal(controller.identityCard.getAttribute('aria-hidden'), 'false');
assert.equal(controller.identityCardName.textContent, 'Primary Account');
controller.identityCard.click();
assert.equal(
controller.getActivePane(),
'account',
'click-through jumps to the Account pane',
);
controller.state.setActivePubkey(null);
controller.renderSavedProfiles();
assert.equal(
controller.identityCard.classList.contains('hidden'),
true,
'hidden when logged out (no claimed identity)',
);
assert.equal(controller.identityCard.getAttribute('aria-hidden'), 'true');
});
test('renderSavedProfiles applies provider metadata', async () => {
const sampleProfiles = [
{
@@ -0,0 +1,168 @@
// #49: relay-ack false-negatives on the FLAGSHIP content flows. When no relay
// ACKs within the publish window but nothing rejected (all-timeout), the event
// was sent and almost always persisted — it must surface as an UNCONFIRMED soft
// success, not "Failed to share video". Equally important: the outcome messaging
// must stay honest — unconfirmed is never reported as "published to N relays",
// and a delete is only "deleted" when at least one relay ACKed something.
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-content-publish-all-timeout
// given: "a relay pool where every relay times out (reason 'publish timeout') vs one that rejects"
// when: "signAndPublishEvent (video publish/edit path) runs"
// then: "all-timeout resolves with summary.unconfirmed=true; an explicit rejection still throws RelayPublishError"
// - id: SCN-honest-outcome-messaging
// given: "relay tallies including the new unconfirmed flag"
// when: "describePublishOutcome / attachRelayPublishSummary / anyRelayAcceptedInSummaries run"
// then: "unconfirmed -> warning ('sent, not confirmed'), never a relay-count success and never a hard error; deletes only 'confirmed' with >=1 ACK"
// observable_outcomes:
// - "signAndPublishEvent resolves (no throw) on all-timeout with unconfirmed summary"
// - "signAndPublishEvent still throws on explicit rejection"
// - "unconfirmed tally round-trips through attach/readRelayPublishSummary"
// - "describePublishOutcome(unconfirmed) is a warning with honest wording"
// determinism_controls:
// - "fake pool fires ok/failed handlers synchronously; no real sockets or timers race the 10s window"
// anti_cheat_rationale:
// prevents: ["hard-coded return value", "over-mocking internal logic", "snapshot rubber-stamping"]
// relaxation:
// did_relax_any_assertion: false
import test from "node:test";
import { strict as assert } from "node:assert";
import {
RelayPublishError,
describePublishOutcome,
attachRelayPublishSummary,
readRelayPublishSummary,
anyRelayAcceptedInSummaries,
} from "../js/nostrPublish.js";
import { signAndPublishEvent } from "../js/nostr/publishHelpers.js";
const PUB = "a".repeat(64);
// Fake nostr-tools pool: publish([url]) returns a handle whose "failed" (or
// "ok") handler fires immediately with the scripted outcome for that relay.
function makePool(outcomes) {
let call = 0;
return {
publish(urls, _event) {
const outcome = outcomes[call % outcomes.length];
call += 1;
return {
on(eventName, handler) {
if (outcome === "ok" && eventName === "ok") {
handler();
} else if (outcome !== "ok" && eventName === "failed") {
handler(new Error(outcome));
}
return this;
},
};
},
};
}
const signer = {
pubkey: PUB,
signEvent: async (event) => ({ ...event, id: "signed-1", sig: "sig" }),
};
function publishWith(pool) {
return signAndPublishEvent({
client: {
pubkey: PUB,
pool,
relays: ["wss://a.example", "wss://b.example"],
writeRelays: ["wss://a.example", "wss://b.example"],
},
event: { kind: 30078, pubkey: PUB, created_at: 100, tags: [], content: "{}" },
resolveActiveSigner: () => signer,
shouldRequestExtensionPermissions: () => false,
options: { context: "video note", logName: "Video note" },
});
}
test("all-timeout video publish resolves as an unconfirmed soft success", async () => {
const result = await publishWith(makePool(["publish timeout", "publish timeout"]));
assert.equal(result.signedEvent.id, "signed-1");
assert.equal(result.summary.unconfirmed, true, "flagged unconfirmed");
assert.equal(result.summary.accepted.length, 0, "no fake acceptances");
assert.equal(result.summary.failed.length, 2, "both timeouts recorded");
});
test("an explicit relay rejection still hard-fails the publish", async () => {
await assert.rejects(
publishWith(makePool(["publish timeout", "blocked: spam"])),
RelayPublishError,
);
});
test("a confirmed ack stays a confirmed (not unconfirmed) success", async () => {
const result = await publishWith(makePool(["ok", "publish timeout"]));
assert.equal(result.summary.unconfirmed, false);
assert.equal(result.summary.accepted.length, 1);
});
test("the attached tally carries the unconfirmed flag to the UI", () => {
const event = { id: "e1", kind: 30078 };
attachRelayPublishSummary(event, {
accepted: [],
failed: [{ url: "wss://a" }, { url: "wss://b" }],
unconfirmed: true,
});
assert.deepEqual(readRelayPublishSummary(event), {
accepted: 0,
total: 2,
unconfirmed: true,
});
// Confirmed publishes keep the exact legacy tally shape (no stray flag).
const confirmed = { id: "e2" };
attachRelayPublishSummary(confirmed, {
accepted: [{ url: "wss://a" }],
failed: [],
});
assert.deepEqual(readRelayPublishSummary(confirmed), { accepted: 1, total: 1 });
});
test("describePublishOutcome: unconfirmed is an honest warning, not success or error", () => {
const out = describePublishOutcome({ accepted: 0, total: 3, unconfirmed: true });
assert.equal(out.tone, "warning");
assert.match(out.message, /no relay has confirmed/i);
assert.doesNotMatch(out.message, /\d+ relays?/, "must not fake a relay count");
assert.doesNotMatch(out.message, /could not be published/i);
// Without the flag, zero acceptance is still the hard error.
assert.equal(describePublishOutcome({ accepted: 0, total: 3 }).tone, "error");
// A real acceptance is unaffected by a stray flag.
assert.equal(
describePublishOutcome({ accepted: 2, total: 3, unconfirmed: false }).tone,
"warning",
);
assert.equal(describePublishOutcome({ accepted: 3, total: 3 }).tone, "success");
});
test("delete confirmation: only >=1 relay ACK counts as a confirmed delete", () => {
const unconfirmedSummary = { accepted: [], failed: [{ url: "wss://a" }], unconfirmed: true };
const confirmedSummary = { accepted: [{ url: "wss://b" }], failed: [] };
assert.equal(
anyRelayAcceptedInSummaries([unconfirmedSummary, unconfirmedSummary]),
false,
"all-timeout delete must NOT be reported as deleted",
);
assert.equal(
anyRelayAcceptedInSummaries([unconfirmedSummary, confirmedSummary]),
true,
);
assert.equal(
anyRelayAcceptedInSummaries([]),
true,
"nothing needed publishing counts as confirmed",
);
assert.equal(anyRelayAcceptedInSummaries([null, undefined]), true);
// Numeric tally shape (from readRelayPublishSummary) also works.
assert.equal(anyRelayAcceptedInSummaries([{ accepted: 0, total: 2 }]), false);
assert.equal(anyRelayAcceptedInSummaries([{ accepted: 1, total: 2 }]), true);
});
+97
View File
@@ -0,0 +1,97 @@
// #9: the cold-login REQ storm's remaining emitters are unpinned, so a
// dev-gated tracer at the pool.sub choke point aggregates every subscription
// by kinds + emitting call site. These tests cover the aggregation, the
// call-site extraction (must skip pool plumbing frames), and that the hot
// path is a no-op while tracing is off.
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-req-trace-aggregation
// given: "a telemetry instance with an injected clock and log sink"
// when: "subs with various kinds/relay counts are recorded and report() runs"
// then: "rows are grouped by kinds+caller, sorted by count, with correct totals and REQ/s"
// observable_outcomes:
// - "disabled instance records nothing"
// - "report() totals equal the recorded subs and relay fan-out"
// - "plumbing frames (toolkit/relaySubscriptionService) never become the caller"
// determinism_controls:
// - "injected now(); no interval timers exercised; synthetic stacks"
// anti_cheat_rationale:
// prevents: ["hard-coded return value", "over-mocking internal logic"]
// relaxation:
// did_relax_any_assertion: false
import "./test-helpers/setup-localstorage.mjs";
import test from "node:test";
import { strict as assert } from "node:assert";
import {
createReqTelemetry,
extractCallSite,
} from "../js/nostr/reqTelemetry.js";
const STACK = (site) =>
[
"Error",
" at recordReq (http://x/js/nostr/reqTelemetry.js:180:5)",
" at legacySub (http://x/js/nostr/toolkit.js:529:7)",
` at ${site}`,
].join("\n");
test("records aggregate by kinds + caller and report totals are honest", () => {
let t = 0;
const logs = [];
const telem = createReqTelemetry({
now: () => t,
log: (payload) => logs.push(payload),
});
telem.start();
t = 0;
telem.reset();
for (let i = 0; i < 3; i += 1) {
telem.record(8, [{ kinds: [30000], authors: ["a"] }], STACK("loadLists (http://x/js/subscriptions.js:757:10)"));
}
telem.record(4, [{ kinds: [0] }, { kinds: [10050] }], STACK("dmBoot (http://x/js/services/dmNostrService.js:127:3)"));
t = 5000; // 5s window
const payload = telem.report({ emit: true });
assert.equal(payload.totalSubs, 4);
assert.equal(payload.totalRelayReqs, 28, "3×8 + 4 relay-level REQs");
assert.equal(payload.perSecond, 5.6, "28 REQs over 5s");
assert.equal(payload.byKindsAndCaller[0].kinds, "30000");
assert.equal(payload.byKindsAndCaller[0].subs, 3);
assert.match(payload.byKindsAndCaller[0].caller, /subscriptions\.js:757/);
assert.equal(payload.byKindsAndCaller[1].kinds, "0,10050");
assert.match(payload.byKindsAndCaller[1].caller, /dmNostrService\.js:127/);
assert.equal(logs.length, 1, "report emitted through the injected sink");
});
test("disabled tracer records nothing; stop() disarms a started one", () => {
const telem = createReqTelemetry({ now: () => 0, log: () => {} });
telem.record(8, [{ kinds: [30000] }], STACK("x (http://x/js/a.js:1:1)"));
assert.equal(telem.report({ emit: false }).totalSubs, 0, "off by default");
telem.start();
telem.record(2, [{ kinds: [7] }], STACK("x (http://x/js/a.js:1:1)"));
telem.stop();
telem.record(2, [{ kinds: [7] }], STACK("x (http://x/js/a.js:1:1)"));
assert.equal(telem.report({ emit: false }).totalSubs, 1, "no records after stop");
});
test("call-site extraction skips pool plumbing and keeps module:line", () => {
assert.match(
extractCallSite(STACK("refresh (http://x/js/services/moderationService.js:843:20)")),
/moderationService\.js:843/,
);
// Plumbing-only stack falls back to unknown rather than blaming the shim.
assert.equal(
extractCallSite(
"Error\n at legacySub (http://x/js/nostr/toolkit.js:529:7)\n at ensureSubscription (http://x/js/services/relaySubscriptionService.js:158:1)",
),
"unknown",
);
assert.equal(extractCallSite(""), "unknown");
assert.equal(extractCallSite(undefined), "unknown");
});
+62 -1
View File
@@ -129,12 +129,37 @@ describe("PlaybackService", () => {
});
});
test("URL Probe Success starts playback", async () => {
// spec_correction: the URL probe only exists to decide whether to fail over
// to P2P quickly. It used to run for EVERY hosted attempt, but its timeout
// (4s) exceeds the playback-start window (3s), so a slow no-cors HEAD (e.g.
// archive.org) consumed the whole budget and the URL was never even loaded —
// a false "No playable source found." for perfectly playable URL-only
// videos. The probe now runs ONLY when there's a magnet to fall over to.
// This test therefore uses a DUAL-source video so the probe path is still
// exercised; the URL-only skip is covered by the next test.
//
// test_integrity_note:
// change_type: ["spec_correction"]
// scenarios:
// - id: SCN-probe-success-dual-source
// given: "a dual-source (url+magnet) video whose URL probe succeeds"
// when: "playback starts"
// then: "the probe runs, the URL plays, and torrent is not used"
// observable_outcomes:
// - "probe called once; URL playback started; playViaWebTorrent not called"
// determinism_controls: ["jsdom video element; mocked probe/torrent"]
// anti_cheat_rationale:
// prevents: ["over-mocking internal logic"]
// relaxation:
// did_relax_any_assertion: false
// if_true_explain_spec_basis: "moved the probe assertion to the dual-source case it now applies to; url-only skip is separately asserted"
test("URL Probe Success starts playback (dual-source)", async () => {
const probeUrl = mock.fn(async () => ({ outcome: "good", status: 200 }));
const playViaWebTorrent = mock.fn();
const testSession = service.createSession({
url: "https://example.com/video.mp4",
magnet: "magnet:?xt=urn:btih:1111111111111111111111111111111111111111",
videoElement: video,
probeUrl,
playViaWebTorrent,
@@ -157,6 +182,42 @@ describe("PlaybackService", () => {
assert.equal(playViaWebTorrent.mock.callCount(), 0);
});
// The reported bug: a URL-only video (no magnet) must NOT be gated by the
// probe — the video element is the arbiter. It plays directly, probe skipped.
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-url-only-skips-probe
// given: "a URL-only video (no magnet) with a playable URL"
// when: "playback starts"
// then: "the probe is NOT called and the URL plays directly"
// observable_outcomes: ["probe callCount 0; URL playback started"]
// determinism_controls: ["jsdom video element; mocked probe"]
// anti_cheat_rationale: { prevents: ["hard-coded return value"] }
// relaxation: { did_relax_any_assertion: false }
test("URL-only video skips the probe and plays directly", async () => {
const probeUrl = mock.fn(async () => ({ outcome: "good", status: 200 }));
const playViaWebTorrent = mock.fn();
const testSession = service.createSession({
url: "https://example.com/video.mp4",
videoElement: video,
probeUrl,
playViaWebTorrent,
});
const startPromise = testSession.start();
await new Promise(resolve => process.nextTick(resolve));
await new Promise(resolve => process.nextTick(resolve));
video.dispatchEvent(new window.Event("playing"));
const result = await startPromise;
assert.equal(probeUrl.mock.callCount(), 0, "URL-only: probe not on the critical path");
assert.equal(playMock.mock.callCount(), 1);
assert.equal(result.source, "url");
});
test("URL Probe Failure triggers fallback", async () => {
const probeUrl = mock.fn(async () => ({ outcome: "bad", status: 404 }));
const playViaWebTorrent = mock.fn(async () => ({ infoHash: "123" }));
+121
View File
@@ -0,0 +1,121 @@
// #15 follow-up ("NWC doesn't sync unless I click Restore"): a device with
// sync ENABLED must auto-pull a NEWER remote note at login. Previously enabled
// devices only pushed-on-save, so wallet/storage changes made on another
// device sat unseen until the manual Restore click. Covers the shared
// encryptedSyncItem.autoPullIfNewer and its wiring in the login restore flow.
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-enabled-device-auto-pull
// given: "sync enabled locally; a remote note newer/older than the device's last-synced marker"
// when: "autoPullIfNewer runs at login (via settingsRestorePrompt.maybeOffer)"
// then: "newer remote -> pulled + applied + marker advanced (no prompt); older/equal -> untouched; disabled -> untouched"
// observable_outcomes:
// - "applyPayload called exactly when the remote is newer"
// - "second login does not re-pull the same note"
// - "disabled items still go through the one-time offer, not auto-pull"
// determinism_controls:
// - "in-memory localStorage; scripted encryptedSync double; no timers"
// anti_cheat_rationale:
// prevents: ["hard-coded return value", "over-mocking internal logic"]
// relaxation:
// did_relax_any_assertion: false
import "./test-helpers/setup-localstorage.mjs";
import test, { beforeEach } from "node:test";
import { strict as assert } from "node:assert";
import { createEncryptedSyncItem } from "../js/services/encryptedSyncItem.js";
import { createSettingsRestorePrompt } from "../js/services/settingsRestorePrompt.js";
import { setSyncEnabled, setSyncPushedAt } from "../js/services/settingsSyncFlags.js";
const PUB = "a".repeat(64);
beforeEach(() => {
localStorage.clear();
});
function makeItem({
remoteCreatedAt,
id = "wallet",
payload = { nwcUri: "nostr+walletconnect://x" },
}) {
const applied = [];
const item = createEncryptedSyncItem({
id,
dtag: "bitvid:nwc",
encryptedSync: {
isAvailable: () => true,
exists: async () => ({ exists: remoteCreatedAt > 0, createdAt: remoteCreatedAt }),
pull: async () => ({ found: true, payload }),
push: async () => ({ ok: true, createdAt: remoteCreatedAt + 1 }),
clear: async () => ({ ok: true }),
},
buildPayload: () => payload,
applyPayload: async (pubkey, incoming) => {
applied.push({ pubkey, incoming });
},
});
return { item, applied };
}
test("enabled + newer remote → pulled, applied, marker advanced (no re-pull)", async () => {
const { item, applied } = makeItem({ remoteCreatedAt: 500 });
setSyncEnabled(PUB, "wallet", true);
setSyncPushedAt(PUB, "wallet", 100); // this device last synced an older note
const first = await item.autoPullIfNewer(PUB);
assert.equal(first.pulled, true);
assert.equal(applied.length, 1, "remote payload applied");
assert.equal(applied[0].incoming.nwcUri, "nostr+walletconnect://x");
const second = await item.autoPullIfNewer(PUB);
assert.equal(second.pulled, false);
assert.equal(second.reason, "up-to-date", "marker advanced — same note not re-pulled");
assert.equal(applied.length, 1);
});
test("enabled + remote not newer → untouched; disabled → untouched", async () => {
const { item, applied } = makeItem({ remoteCreatedAt: 100 });
setSyncEnabled(PUB, "wallet", true);
setSyncPushedAt(PUB, "wallet", 100); // this device pushed that very note
assert.equal((await item.autoPullIfNewer(PUB)).pulled, false);
assert.equal(applied.length, 0, "own note never re-applied");
// A DIFFERENT sync kind whose flag was never enabled on this device (the
// wallet flag set above is per pubkey+kind and must not bleed over).
const { item: disabledItem, applied: disabledApplied } = makeItem({
remoteCreatedAt: 9999,
id: "storage",
});
const result = await disabledItem.autoPullIfNewer(PUB);
assert.equal(result.pulled, false);
assert.equal(result.reason, "not-enabled");
assert.equal(disabledApplied.length, 0);
});
test("login flow: enabled wallet auto-pulls silently; no prompt is shown for it", async () => {
const { item, applied } = makeItem({ remoteCreatedAt: 700 });
setSyncEnabled(PUB, "wallet", true);
setSyncPushedAt(PUB, "wallet", 100);
let confirmCalls = 0;
const restoredBatches = [];
const prompt = createSettingsRestorePrompt({
storageSync: { isAvailable: () => false, isEnabled: () => false },
walletSync: item,
confirm: async () => {
confirmCalls += 1;
return true;
},
});
await prompt.maybeOffer(PUB, {
onRestored: (items) => restoredBatches.push(items),
});
assert.equal(applied.length, 1, "newer note pulled during login");
assert.deepEqual(restoredBatches, [["wallet"]], "user notified of the silent sync");
assert.equal(confirmCalls, 0, "no prompt for an already-enabled item");
});
@@ -0,0 +1,247 @@
// #56: when storage is locked, the upload modal's file pickers and the edit
// modal's "Replace file" pickers must unlock inline — silent kept-unlocked
// restore or ONE passphrase prompt via the shared signer gate — and continue
// with the same file pick, instead of dead-ending ("Please unlock storage…",
// a silent no-op, or "Unlock your storage in the profile modal").
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-upload-picker-inline-unlock
// given: "storage locked for the active pubkey; a signer gate that succeeds / is cancelled / fails"
// when: "ensureStorageUnlockedForUpload / handleThumbnailSelection run"
// then: "gate ok -> storage unlocked and pick proceeds; cancel/bad-passphrase -> quiet abort; other failures fall through to the regular unlock messaging"
// - id: SCN-edit-replace-file-inline-unlock
// given: "the edit modal's resolved storage connection is locked"
// when: "ensureUploadable runs"
// then: "the gate + storageService.unlock run and the re-resolved unlocked connection is returned; cancel aborts without unlocking"
// observable_outcomes:
// - "storageService.unlock called exactly when the gate allows it"
// - "cancelled gate -> no unlock attempt, no error toast"
// - "thumbnail picker state reset when the unlock is refused"
// determinism_controls:
// - "pure fake ctx objects; no DOM, no timers, no network"
// anti_cheat_rationale:
// prevents: ["hard-coded return value", "over-mocking internal logic"]
// relaxation:
// did_relax_any_assertion: false
import test from "node:test";
import { strict as assert } from "node:assert";
import { UploadModal } from "../js/ui/components/UploadModal.js";
import { ensureUploadable } from "../js/ui/components/editModalUpload.js";
const PUBKEY = "a".repeat(64);
const ensureUnlocked = UploadModal.prototype.ensureStorageUnlockedForUpload;
const handleThumb = UploadModal.prototype.handleThumbnailSelection;
// --- UploadModal.ensureStorageUnlockedForUpload ---
function makeUploadCtx({ unlockedAfterHandleUnlock = true, gate } = {}) {
const calls = { handleUnlock: 0, loadFromStorage: 0, errors: [], gates: [] };
let unlocked = false;
const ctx = {
getCurrentPubkey: () => PUBKEY,
storageService: { isUnlocked: () => unlocked },
ensureSigner:
gate === undefined
? null
: async (options) => {
calls.gates.push(options);
return gate;
},
handleUnlock: async () => {
calls.handleUnlock += 1;
if (unlockedAfterHandleUnlock) unlocked = true;
},
updateLockUi: () => {},
loadFromStorage: async () => {
calls.loadFromStorage += 1;
ctx.activeCredentials = { accessKeyId: "k" };
},
showError: (m) => calls.errors.push(m),
isStorageUnlocked: false,
activeCredentials: null,
};
return { ctx, calls, setUnlocked: (v) => (unlocked = v) };
}
test("upload picker: gate ok -> unlocks via handleUnlock and proceeds", async () => {
const { ctx, calls } = makeUploadCtx({ gate: { ok: true } });
const result = await ensureUnlocked.call(ctx);
assert.equal(result, true);
assert.equal(calls.gates.length, 1, "signer gate consulted once");
assert.equal(calls.gates[0].need, "encrypt");
assert.equal(calls.gates[0].pubkey, PUBKEY);
assert.equal(calls.handleUnlock, 1, "storage unlock ran after the gate");
assert.equal(ctx.isStorageUnlocked, true);
assert.equal(calls.errors.length, 0);
});
test("upload picker: cancelled gate aborts quietly (no unlock, no toast)", async () => {
const { ctx, calls } = makeUploadCtx({
gate: { ok: false, reason: "cancelled" },
});
assert.equal(await ensureUnlocked.call(ctx), false);
assert.equal(calls.handleUnlock, 0, "no unlock attempt after cancel");
assert.equal(calls.errors.length, 0, "cancel is silent");
assert.equal(ctx.isStorageUnlocked, false);
});
test("upload picker: bad passphrase aborts without a second toast", async () => {
const { ctx, calls } = makeUploadCtx({
gate: { ok: false, reason: "bad-passphrase" },
});
assert.equal(await ensureUnlocked.call(ctx), false);
assert.equal(calls.handleUnlock, 0);
assert.equal(calls.errors.length, 0, "the gate already showed its own toast");
});
test("upload picker: non-user gate failures fall through to handleUnlock's messaging", async () => {
const { ctx, calls } = makeUploadCtx({
gate: { ok: false, reason: "no-stored-key" },
unlockedAfterHandleUnlock: false,
});
assert.equal(await ensureUnlocked.call(ctx), false);
assert.equal(calls.handleUnlock, 1, "regular unlock path still runs");
});
test("upload picker: already-unlocked storage loads credentials without any gate", async () => {
const { ctx, calls, setUnlocked } = makeUploadCtx({ gate: { ok: true } });
setUnlocked(true);
assert.equal(await ensureUnlocked.call(ctx), true);
assert.equal(calls.gates.length, 0, "no prompt when storage is already unlocked");
assert.equal(calls.handleUnlock, 0);
assert.equal(calls.loadFromStorage, 1, "stale credentials refreshed");
assert.ok(ctx.activeCredentials);
});
test("thumbnail pick while locked: refused unlock resets the picker, no upload", async () => {
const calls = { reset: 0, uploads: 0 };
const ctx = {
storageConfigured: true,
isStorageUnlocked: false,
ensureStorageUnlockedForUpload: async () => false,
resetThumbnailPicker: () => {
calls.reset += 1;
},
mediaUploader: {
uploadThumbnail: async () => {
calls.uploads += 1;
return { url: "https://x/thumb.jpg" };
},
},
showError: () => {},
};
await handleThumb.call(ctx, { name: "t.jpg" });
assert.equal(calls.reset, 1, "picker state restored");
assert.equal(calls.uploads, 0, "upload never started");
});
// --- editModalUpload.ensureUploadable (Edit modal "Replace file") ---
function makeEditCtx({ gate, unlockThrows = false } = {}) {
const calls = { unlock: [], errors: [], gates: [], resolves: 0 };
let unlocked = false;
const conn = () => ({
configured: true,
unlocked,
credentials: unlocked ? { accessKeyId: "k" } : null,
provider: "s3",
});
const modal = {
mediaUploader: {
resolveActiveConnection: async () => {
calls.resolves += 1;
return conn();
},
},
storageService: {
unlock: async (pubkey, opts) => {
calls.unlock.push({ pubkey, signer: opts?.signer });
if (unlockThrows) throw new Error("boom");
unlocked = true;
},
},
getCurrentPubkey: () => PUBKEY,
ensureSigner:
gate === undefined
? null
: async (options) => {
calls.gates.push(options);
return gate;
},
showError: (m) => calls.errors.push(m),
};
return { modal, calls };
}
const capableSigner = { nip44Decrypt: () => "x" };
test("edit replace-file: locked connection unlocks inline and returns the live connection", async () => {
const { modal, calls } = makeEditCtx({ gate: { ok: true } });
const conn = await ensureUploadable(modal, { getSigner: () => capableSigner });
assert.ok(conn, "connection returned");
assert.equal(conn.unlocked, true);
assert.ok(conn.credentials, "credentials present after inline unlock");
assert.equal(calls.gates.length, 1, "signer gate consulted");
assert.deepEqual(calls.unlock[0].pubkey, PUBKEY);
assert.equal(calls.errors.length, 0);
assert.equal(calls.resolves, 2, "connection re-resolved after the unlock");
});
test("edit replace-file: cancelled gate aborts without touching storage", async () => {
const { modal, calls } = makeEditCtx({
gate: { ok: false, reason: "cancelled" },
});
const conn = await ensureUploadable(modal, { getSigner: () => capableSigner });
assert.equal(conn, null);
assert.equal(calls.unlock.length, 0, "no unlock attempt after cancel");
assert.equal(calls.errors.length, 0, "cancel is silent");
});
test("edit replace-file: no gate + no capable signer keeps the actionable profile-modal message", async () => {
const { modal, calls } = makeEditCtx({});
const conn = await ensureUploadable(modal, { getSigner: () => null });
assert.equal(conn, null);
assert.equal(calls.unlock.length, 0);
assert.equal(calls.errors.length, 1);
assert.match(calls.errors[0], /profile modal/i);
});
test("edit replace-file: extension signer requests permissions before unlocking", async () => {
const { modal, calls } = makeEditCtx({ gate: { ok: true } });
let permissionAsks = 0;
const conn = await ensureUploadable(modal, {
getSigner: () => ({ type: "extension", nip04Decrypt: () => "x" }),
requestPermissions: async () => {
permissionAsks += 1;
return { ok: true };
},
});
assert.ok(conn);
assert.equal(permissionAsks, 1, "permissions requested for extension signers");
assert.equal(calls.unlock.length, 1);
});
test("edit replace-file: unlock failure surfaces the error and returns null", async () => {
const { modal, calls } = makeEditCtx({ gate: { ok: true }, unlockThrows: true });
const conn = await ensureUploadable(modal, { getSigner: () => capableSigner });
assert.equal(conn, null);
assert.equal(calls.errors.length, 1);
assert.match(calls.errors[0], /Failed to unlock storage/);
});
test("edit replace-file: an already-unlocked connection passes straight through", async () => {
const { modal, calls } = makeEditCtx({ gate: { ok: true } });
modal.mediaUploader.resolveActiveConnection = async () => ({
configured: true,
unlocked: true,
credentials: { accessKeyId: "k" },
provider: "s3",
});
const conn = await ensureUploadable(modal, { getSigner: () => capableSigner });
assert.ok(conn);
assert.equal(calls.gates.length, 0, "no prompt when already unlocked");
assert.equal(calls.unlock.length, 0);
});
+116
View File
@@ -0,0 +1,116 @@
// #44: the upload modal gets an in-modal "Upload destination" picker so users
// can target any configured storage connection (R2 / B2 / custom S3) without
// leaving the modal. Selection is per-modal and wins over the account default;
// a stale selection (connection deleted meanwhile) falls back to the default.
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-upload-destination-picker
// given: "0..n configured connections, with/without a default and a per-modal selection"
// when: "pickTargetConnection resolves the target and renderConnectionPicker renders the select"
// then: "selection > default > first; stale selections reset; picker hidden under two connections; default labelled"
// observable_outcomes:
// - "resolved target connection object"
// - "rendered <option> values/labels/selected state and wrapper visibility"
// determinism_controls:
// - "JSDOM document; plain data objects; no storage or network"
// anti_cheat_rationale:
// prevents: ["hard-coded return value", "over-mocking internal logic"]
// relaxation:
// did_relax_any_assertion: false
import test from "node:test";
import { strict as assert } from "node:assert";
import { JSDOM } from "jsdom";
import {
pickTargetConnection,
renderConnectionPicker,
} from "../js/ui/components/uploadModalStorageUnlock.js";
const r2 = { id: "c1", provider: "cloudflare_r2", meta: { bucket: "vids" } };
const b2 = {
id: "c2",
provider: "backblaze_b2",
meta: { bucket: "backups", defaultForUploads: true },
};
const s3 = { id: "c3", provider: "s3", meta: { bucket: "archive" } };
test("target resolution: selection > default > first, stale selection resets", () => {
const modal = { selectedConnectionId: null };
assert.equal(
pickTargetConnection(modal, [r2, b2, s3]).targetConn.id,
"c2",
"account default wins with no selection",
);
assert.equal(
pickTargetConnection(modal, [r2, s3]).targetConn.id,
"c1",
"first connection when nothing is default",
);
modal.selectedConnectionId = "c3";
const picked = pickTargetConnection(modal, [r2, b2, s3]);
assert.equal(picked.targetConn.id, "c3", "per-modal selection wins");
assert.equal(picked.defaultConn.id, "c2", "default still reported for labelling");
modal.selectedConnectionId = "gone";
assert.equal(
pickTargetConnection(modal, [r2, b2, s3]).targetConn.id,
"c2",
"stale selection falls back to the default",
);
assert.equal(modal.selectedConnectionId, null, "stale selection is reset");
assert.equal(pickTargetConnection(modal, []).targetConn, null, "no connections");
});
function makeModal() {
const dom = new JSDOM(
'<!DOCTYPE html><div id="wrap" class="hidden"><select id="sel"></select></div>',
);
const doc = dom.window.document;
global.HTMLElement = dom.window.HTMLElement;
return {
modal: {
selectedConnectionId: null,
inputs: { storageConnection: doc.getElementById("sel") },
sourceSections: { connectionPicker: doc.getElementById("wrap") },
getProviderLabel(provider) {
return provider === "cloudflare_r2" ? "Cloudflare R2" : provider;
},
},
doc,
};
}
test("picker renders options (default labelled, target selected); hidden under two connections", () => {
const { modal } = makeModal();
renderConnectionPicker(modal, [r2, b2, s3], b2, b2);
const wrap = modal.sourceSections.connectionPicker;
const options = Array.from(modal.inputs.storageConnection.options);
assert.equal(wrap.classList.contains("hidden"), false, "picker shown");
assert.deepEqual(
options.map((o) => o.value),
["c1", "c2", "c3"],
);
assert.match(options[0].textContent, /Cloudflare R2 — vids/);
assert.match(options[1].textContent, /\(default\)$/, "default is labelled");
assert.equal(
modal.inputs.storageConnection.value,
"c2",
"target connection pre-selected",
);
renderConnectionPicker(modal, [r2, b2, s3], s3, b2);
assert.equal(modal.inputs.storageConnection.value, "c3", "re-render follows the target");
renderConnectionPicker(modal, [r2], r2, null);
assert.equal(
wrap.classList.contains("hidden"),
true,
"hidden when there is nothing to choose",
);
});
+89
View File
@@ -0,0 +1,89 @@
// #47 follow-up: video cards show the zap total (sats, orange, right-aligned)
// next to the view count. The badge is ALWAYS visible once bound (mirroring the
// view counter — a zero is a real, informative value), showing "0 sats" until a
// receipt batch lands, then the summed total.
//
// test_integrity_note:
// change_type: ["spec_correction"]
// scenarios:
// - id: SCN-card-zap-badge
// given: "cards bound to pointers with zero then nonzero cached totals"
// when: "bind() runs and a later zapTotals change fires"
// then: "badge visible from bind showing 0 sats; updates to the summed total in place; detached cards pruned"
// observable_outcomes:
// - "badge textContent per state; wrapper visible after bind"
// determinism_controls:
// - "JSDOM; the real zapTotals singleton driven through its store test hook; scripted subscription-manager list double"
// anti_cheat_rationale:
// prevents: ["hard-coded return value", "over-mocking internal logic"]
// relaxation:
// did_relax_any_assertion: false
// if_true_explain_spec_basis: "hidden-when-zero replaced with always-visible (matches the view counter the feature sits beside; the maintainer wants a persistent counter). Equally strict: still asserts exact textContent per state."
import "./test-helpers/setup-localstorage.mjs";
import test from "node:test";
import { strict as assert } from "node:assert";
import { JSDOM } from "jsdom";
import { createZapTotalBinder } from "../js/ui/views/videoCardZapTotals.js";
import zapTotalsStore, {
initZapTotals,
} from "../js/zapTotals.js";
const A1 = "30078:aaaa:video-1";
function makeCard(doc) {
const card = doc.createElement("div");
card.innerHTML =
'<div class="video-card__zaps hidden"><span data-zap-total></span></div>';
doc.body.appendChild(card);
return card;
}
test("binder: zero stays hidden, receipts reveal and format, prune drops detached", async () => {
const dom = new JSDOM("<!DOCTYPE html><body></body>", { pretendToBeVisual: true });
const doc = dom.window.document;
// Drive the REAL singleton store: scripted receipts arrive via its batched
// fetch (subscription-manager list double), so the binder sees the same
// cache + change signal production uses.
let receipts = [];
initZapTotals({
nostrClient: {
relays: ["wss://relay.example"],
getSubscriptionManager: () => ({ list: async () => receipts }),
},
tools: { nip57: { getSatoshisAmountFromBolt11: () => NaN } },
});
const binder = createZapTotalBinder();
const card = makeCard(doc);
const badge = card.querySelector("[data-zap-total]");
const wrapper = card.querySelector(".video-card__zaps");
// Tag-style ARRAY pointer — the shape VideoListView actually passes.
const pointerInfo = { key: `a:${A1}`, pointer: ["a", A1] };
binder.bind(card, pointerInfo);
assert.equal(wrapper.classList.contains("hidden"), false, "visible from bind");
assert.match(badge.textContent, /^0 sats$/, "shows 0 sats until receipts arrive");
// A receipt batch lands: 2100 sats via the zap request's amount tag.
receipts = [
{
id: "r1",
kind: 9735,
tags: [
["a", A1],
["description", JSON.stringify({ tags: [["amount", "2100000"]] })],
],
},
];
await zapTotalsStore.flush();
assert.equal(wrapper.classList.contains("hidden"), false, "stays visible");
assert.match(badge.textContent, /2,?100 sats|2\.1K sats/);
// Detached cards are dropped by prune (no leak, no stale writes).
card.remove();
binder.prune();
binder.destroy();
});
+129
View File
@@ -0,0 +1,129 @@
// Publishing side of the bitvid zap tally (docs/zap-tally-plan.md §5.4-5.5):
// each settled zap share becomes one payer-signed tally, built from its own
// invoice/preimage/zap-request. Best-effort — a publish failure must not throw.
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-zap-tally-publish
// given: "settled zap shares (with/without a decodable bolt11 or preimage) and an injected publisher"
// when: "buildTallyFromShare / publishZapTallies run"
// then: "a verifiable tally is built + published per settleable share; unpublishable shares are skipped; a publish throw is swallowed; disabled → nothing published"
// observable_outcomes:
// - "built event kind/tags (d=payment_hash, p/e/a, bolt11, preimage, description)"
// - "published count; publish() called once per settleable share"
// determinism_controls:
// - "in-test bech32 bolt11 vector; injected publish(); no network"
// anti_cheat_rationale:
// prevents: ["over-mocking internal logic", "hard-coded return value"]
// relaxation:
// did_relax_any_assertion: false
import test from "node:test";
import { strict as assert } from "node:assert";
import { bech32 } from "../vendor/crypto-helpers.bundle.min.js";
import {
buildTallyFromShare,
publishZapTallies,
} from "../js/payments/zapTallyPublisher.js";
import { ZAP_TALLY_KIND } from "../js/nostrEventSchemas.js";
const PUB = "a".repeat(64);
const CREATOR = "b".repeat(64);
const EVENT_ID = "e".repeat(64);
const COORD = `30078:${CREATOR}:vid-1`;
// Minimal valid bolt11 carrying a known payment_hash (bech32 'p' field = code 1).
const lenWords = (n) => [n >> 5, n & 31];
function makeBolt11(paymentHashBytes) {
const pWords = bech32.toWords(paymentHashBytes);
const words = [
0, 0, 0, 0, 0, 0, 0,
1, ...lenWords(pWords.length), ...pWords,
...new Array(104).fill(0),
];
return bech32.encode("lnbc", words, 2000);
}
const BOLT11 = makeBolt11(new Uint8Array(32).fill(0x11));
const zapRequestJson = JSON.stringify({
kind: 9734,
pubkey: PUB,
tags: [
["p", CREATOR],
["e", EVENT_ID],
["a", COORD],
["amount", "2100000"],
],
content: "",
});
const goodShare = {
recipientType: "creator",
amountSats: 2100,
bolt11: BOLT11,
preimage: "cd".repeat(32),
zapRequest: zapRequestJson,
};
test("buildTallyFromShare: builds an addressable, proof-carrying tally", () => {
const event = buildTallyFromShare({ share: goodShare, pubkey: PUB, now: () => 1000 });
assert.ok(event);
assert.equal(event.kind, ZAP_TALLY_KIND);
const tag = (n) => event.tags.find((t) => t[0] === n);
assert.equal(tag("d")[1], "11".repeat(32), "d = payment_hash from the bolt11");
assert.deepEqual(tag("p"), ["p", CREATOR]);
assert.deepEqual(tag("e"), ["e", EVENT_ID]);
assert.deepEqual(tag("a"), ["a", COORD]);
assert.equal(tag("bolt11")[1], BOLT11);
assert.equal(tag("preimage")[1], "cd".repeat(32));
assert.ok(tag("description"), "embeds the zap request");
});
test("buildTallyFromShare: skips shares that can't be made verifiable", () => {
assert.equal(buildTallyFromShare({ share: { ...goodShare, preimage: "" }, pubkey: PUB }), null);
assert.equal(buildTallyFromShare({ share: { ...goodShare, bolt11: "not-bolt11" }, pubkey: PUB }), null, "undecodable bolt11 → no payment_hash");
assert.equal(buildTallyFromShare({ share: { ...goodShare, zapRequest: "" }, pubkey: PUB }), null);
assert.equal(buildTallyFromShare({ share: goodShare, pubkey: "" }), null);
});
test("publishZapTallies: one publish per settleable share; unpublishable skipped", async () => {
const published = [];
const result = await publishZapTallies({
shares: [goodShare, { ...goodShare, preimage: "" }, { ...goodShare, amountSats: 42 }],
pubkey: PUB,
enabled: true,
publish: async (event) => {
published.push(event);
},
});
assert.equal(result.published, 2, "two settleable shares published, the no-preimage one skipped");
assert.equal(published.length, 2);
assert.ok(published.every((e) => e.kind === ZAP_TALLY_KIND));
});
test("publishZapTallies: a publish failure is swallowed (payment already settled)", async () => {
const result = await publishZapTallies({
shares: [goodShare],
pubkey: PUB,
enabled: true,
publish: async () => {
throw new Error("relay down");
},
});
assert.equal(result.published, 0, "no throw; count reflects the failure");
});
test("publishZapTallies: disabled → nothing published", async () => {
let calls = 0;
const result = await publishZapTallies({
shares: [goodShare],
pubkey: PUB,
enabled: false,
publish: async () => {
calls += 1;
},
});
assert.equal(result.published, 0);
assert.equal(calls, 0);
});
+279
View File
@@ -0,0 +1,279 @@
// Foundation for the bitvid-native zap tally (docs/zap-tally-plan.md §3):
// the payer-signed, preimage-verified stand-in for a NIP-57 receipt. These
// cover the security core — a tally is only counted if the invoice was really
// paid (sha256(preimage)===payment_hash) AND that payment was bound to this
// zap request (description_hash===sha256(zapRequest), the replay guard) — plus
// the bolt11 field decode and the event builder's tag shape.
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-zap-tally-verify
// given: "a tally event with a bolt11/preimage/zap-request, valid or tampered"
// when: "verifyBitvidZapTally / verifyPaymentPreimage / extractBolt11Fields run"
// then: "valid → ok+sats+pointers from the embedded request; bad preimage or mismatched description hash (replay) → rejected; amount is bolt11-derived not tag-claimed"
// observable_outcomes:
// - "verifyPaymentPreimage true only when sha256(preimage)===payment_hash"
// - "extractBolt11Fields round-trips a bech32-encoded p/h field to hex"
// - "verifyBitvidZapTally ok/sats/pointerTags per tamper case"
// - "buildZapTallyEvent emits d(payment_hash)/p/e/a/amount/bolt11/preimage/description"
// determinism_controls:
// - "sha256 via node:crypto; bech32 vector constructed in-test; no network"
// anti_cheat_rationale:
// prevents: ["hard-coded return value", "over-mocking internal logic"]
// relaxation:
// did_relax_any_assertion: false
import test from "node:test";
import { strict as assert } from "node:assert";
import { createHash, randomBytes } from "node:crypto";
import { bech32, bytesToHex } from "../vendor/crypto-helpers.bundle.min.js";
import {
extractBolt11Fields,
verifyPaymentPreimage,
verifyBitvidZapTally,
} from "../js/payments/zapReceiptValidator.js";
import { buildZapTallyEvent, ZAP_TALLY_KIND } from "../js/nostrEventSchemas.js";
const sha256hex = (input) =>
createHash("sha256")
.update(typeof input === "string" ? Buffer.from(input, "utf8") : Buffer.from(input))
.digest("hex");
// --- verifyPaymentPreimage ---------------------------------------------------
test("verifyPaymentPreimage: true only when sha256(preimage)===payment_hash", () => {
const preimage = randomBytes(32);
const preimageHex = preimage.toString("hex");
const paymentHash = sha256hex(preimage);
assert.equal(verifyPaymentPreimage(preimageHex, paymentHash), true);
assert.equal(verifyPaymentPreimage(preimageHex, "0".repeat(64)), false, "wrong hash");
assert.equal(verifyPaymentPreimage("ab", paymentHash), false, "preimage not 32 bytes");
assert.equal(verifyPaymentPreimage(preimageHex, "not-hex"), false);
assert.equal(verifyPaymentPreimage("", paymentHash), false);
});
// --- extractBolt11Fields (bech32 round-trip, no external vector) --------------
// bech32 charset positions are the bolt11 tagged-field type codes: p=1, h=23.
const lenWords = (n) => [n >> 5, n & 31];
function makeBolt11({ paymentHash, descriptionHash }) {
const pWords = bech32.toWords(paymentHash); // 32 bytes -> 52 words
const hWords = bech32.toWords(descriptionHash);
const words = [
0, 0, 0, 0, 0, 0, 0, // 35-bit timestamp
1, ...lenWords(pWords.length), ...pWords, // 'p' payment_hash
23, ...lenWords(hWords.length), ...hWords, // 'h' description_hash
...new Array(104).fill(0), // 520-bit signature placeholder
];
return bech32.encode("lnbc", words, 2000);
}
test("extractBolt11Fields decodes payment_hash + description_hash from a bolt11", () => {
const paymentHash = new Uint8Array(32).fill(0xab);
const descriptionHash = new Uint8Array(32).fill(0xcd);
const invoice = makeBolt11({ paymentHash, descriptionHash });
const fields = extractBolt11Fields(invoice);
assert.equal(fields.paymentHash, bytesToHex(paymentHash));
assert.equal(fields.descriptionHash, bytesToHex(descriptionHash));
assert.deepEqual(extractBolt11Fields(""), { paymentHash: null, descriptionHash: null });
assert.deepEqual(extractBolt11Fields("not-an-invoice"), {
paymentHash: null,
descriptionHash: null,
});
});
// --- verifyBitvidZapTally (decision logic; fields injected) -------------------
const PUB = "a".repeat(64);
const CREATOR = "b".repeat(64);
const EVENT_ID = "e".repeat(64);
const COORD = `30078:${CREATOR}:vid-1`;
function makeTally({ preimageHex, zapRequestJson, bolt11 = "lnbcTEST" }) {
return buildZapTallyEvent({
pubkey: PUB,
created_at: 1000,
paymentHash: sha256hex(Buffer.from(preimageHex, "hex")),
recipientPubkey: CREATOR,
eventId: EVENT_ID,
coordinate: COORD,
amountMsats: 2100000,
bolt11,
preimage: preimageHex,
zapRequestJson,
});
}
function zapRequestJson({ amount = 2100000 } = {}) {
return JSON.stringify({
kind: 9734,
pubkey: PUB,
tags: [
["p", CREATOR],
["e", EVENT_ID],
["a", COORD],
["amount", String(amount)],
],
content: "",
});
}
test("verifyBitvidZapTally: valid tally → ok, bolt11-derived sats, request pointers", () => {
const preimageHex = randomBytes(32).toString("hex");
const reqJson = zapRequestJson();
const event = makeTally({ preimageHex, zapRequestJson: reqJson });
const inject = {
getSats: () => 2100, // authoritative amount from the bolt11 (2100 sats)
extractFields: () => ({
paymentHash: sha256hex(Buffer.from(preimageHex, "hex")),
descriptionHash: sha256hex(reqJson),
}),
};
const result = verifyBitvidZapTally(event, inject);
assert.equal(result.ok, true);
assert.equal(result.sats, 2100, "sats from bolt11, not the amount tag");
assert.ok(result.paymentHash);
// Pointers come from the embedded (hash-bound) zap request.
const kinds = result.pointerTags.map((t) => t[0]).sort();
assert.deepEqual(kinds, ["a", "e", "p"]);
});
test("verifyBitvidZapTally: bad preimage → rejected", () => {
const preimageHex = randomBytes(32).toString("hex");
const reqJson = zapRequestJson();
const event = makeTally({ preimageHex, zapRequestJson: reqJson });
const result = verifyBitvidZapTally(event, {
getSats: () => 2100,
extractFields: () => ({
paymentHash: "f".repeat(64), // does NOT match sha256(preimage)
descriptionHash: sha256hex(reqJson),
}),
});
assert.equal(result.ok, false);
});
test("verifyBitvidZapTally: mismatched description hash (replay) → rejected", () => {
const preimageHex = randomBytes(32).toString("hex");
const reqJson = zapRequestJson();
const event = makeTally({ preimageHex, zapRequestJson: reqJson });
// A leaked preimage retargeted to a DIFFERENT request: description_hash no
// longer matches the embedded zap request → binding check fails.
const result = verifyBitvidZapTally(event, {
getSats: () => 2100,
extractFields: () => ({
paymentHash: sha256hex(Buffer.from(preimageHex, "hex")),
descriptionHash: sha256hex(zapRequestJson({ amount: 999999 })), // different request
}),
});
assert.equal(result.ok, false, "cannot reuse a preimage for another zap request");
});
test("verifyBitvidZapTally: zero/undecodable sats → rejected; missing proof → rejected", () => {
const preimageHex = randomBytes(32).toString("hex");
const reqJson = zapRequestJson();
const event = makeTally({ preimageHex, zapRequestJson: reqJson });
assert.equal(
verifyBitvidZapTally(event, {
getSats: () => 0,
extractFields: () => ({
paymentHash: sha256hex(Buffer.from(preimageHex, "hex")),
descriptionHash: sha256hex(reqJson),
}),
}).ok,
false,
);
assert.equal(verifyBitvidZapTally({ tags: [] }).ok, false, "no bolt11/preimage");
assert.equal(verifyBitvidZapTally(null).ok, false);
});
// --- buildZapTallyEvent ------------------------------------------------------
test("buildZapTallyEvent: addressable by payment_hash + carries the proof tags", () => {
const event = buildZapTallyEvent({
pubkey: PUB,
created_at: 1000,
paymentHash: "ab".repeat(32),
recipientPubkey: CREATOR,
eventId: EVENT_ID,
coordinate: COORD,
amountMsats: 2100000,
bolt11: "lnbc21u1...",
preimage: "cd".repeat(32),
zapRequestJson: zapRequestJson(),
});
assert.equal(event.kind, ZAP_TALLY_KIND);
const tag = (name) => event.tags.find((t) => t[0] === name);
assert.deepEqual(tag("d"), ["d", "ab".repeat(32)], "d = payment_hash (addressable idempotency)");
assert.deepEqual(tag("p"), ["p", CREATOR]);
assert.deepEqual(tag("e"), ["e", EVENT_ID]);
assert.deepEqual(tag("a"), ["a", COORD]);
assert.equal(tag("bolt11")[1], "lnbc21u1...");
assert.equal(tag("preimage")[1], "cd".repeat(32));
assert.ok(tag("description"), "embeds the zap request");
assert.deepEqual(tag("client"), ["client", "bitvid"]);
});
// End-to-end with the REAL verifier + a REAL amount-bearing bolt11: proves the
// pieces compose (no injected extractFields/getSats). The invoice encodes an
// amount (lnbc21u = 2100 sats), a payment_hash = sha256(preimage), and a
// description_hash = sha256(zap request); the real nip57 amount parser reads it.
test("verifyBitvidZapTally: accepts a fully valid tally end to end (real decode + amount)", async () => {
const { nip57 } = await import("nostr-tools");
const preimage = randomBytes(32);
const preimageHex = preimage.toString("hex");
const paymentHashBytes = createHash("sha256").update(preimage).digest();
const reqJson = zapRequestJson();
const descHashBytes = createHash("sha256").update(Buffer.from(reqJson, "utf8")).digest();
const pWords = bech32.toWords(new Uint8Array(paymentHashBytes));
const hWords = bech32.toWords(new Uint8Array(descHashBytes));
const words = [
0, 0, 0, 0, 0, 0, 0,
1, ...lenWords(pWords.length), ...pWords, // 'p' payment_hash
23, ...lenWords(hWords.length), ...hWords, // 'h' description_hash
...new Array(104).fill(0),
];
const bolt11 = bech32.encode("lnbc21u", words, 2000); // 21 micro-BTC = 2100 sats
const event = buildZapTallyEvent({
pubkey: PUB,
created_at: 1000,
paymentHash: paymentHashBytes.toString("hex"),
recipientPubkey: CREATOR,
eventId: EVENT_ID,
coordinate: COORD,
amountMsats: 2100000,
bolt11,
preimage: preimageHex,
zapRequestJson: reqJson,
});
const result = verifyBitvidZapTally(event, {
getSats: nip57.getSatoshisAmountFromBolt11, // real parser, no injection of fields
});
assert.equal(result.ok, true, "real verifier accepts a genuinely valid tally");
assert.equal(result.sats, 2100, "amount parsed from the real bolt11 hrp");
assert.equal(result.paymentHash, paymentHashBytes.toString("hex"));
});
test("buildZapTallyEvent: profile-only zap has p but no a/e", () => {
const event = buildZapTallyEvent({
pubkey: PUB,
created_at: 1000,
paymentHash: "ab".repeat(32),
recipientPubkey: CREATOR,
amountMsats: 1000,
bolt11: "lnbc10n1...",
preimage: "cd".repeat(32),
zapRequestJson: JSON.stringify({ kind: 9734, tags: [["p", CREATOR]] }),
});
assert.ok(event.tags.find((t) => t[0] === "p"));
assert.equal(event.tags.find((t) => t[0] === "e"), undefined);
assert.equal(event.tags.find((t) => t[0] === "a"), undefined);
});
+337
View File
@@ -0,0 +1,337 @@
// #47 "Most Zapped": per-video zap totals aggregated from kind-9735 receipts.
// Covers the amount extraction (bolt11 first, zap-request amount fallback),
// the batched store (request → scheduled fetch → totals; receipt dedupe;
// zero-receipt answers cached; change signal), and the feed sorter that ranks
// by the injected zap total.
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-zap-total-aggregation
// given: "a fake relay pool returning scripted 9735 receipts for requested #a/#e pointers"
// when: "pointers are requested and the batch flushes"
// then: "totals sum per pointer, duplicate receipt ids never double-count, empty answers cache as 0, listeners fire"
// - id: SCN-most-zapped-sorter
// given: "feed items with differing zap totals / authors / mute states"
// when: "createMostZappedSorter runs"
// then: "sats desc, recency tie-break, trusted-muted sink last"
// observable_outcomes:
// - "getSnapshot/request return the summed sats"
// - "sorted order of feed items"
// determinism_controls:
// - "injected now/schedule; manual flush(); no timers or sockets"
// anti_cheat_rationale:
// prevents: ["hard-coded return value", "over-mocking internal logic"]
// relaxation:
// did_relax_any_assertion: false
import "./test-helpers/setup-localstorage.mjs";
import test from "node:test";
import { strict as assert } from "node:assert";
import {
createZapTotalsStore,
extractReceiptAmountSats,
pointerKey,
ZAP_RECEIPT_KIND,
} from "../js/zapTotals.js";
import { createMostZappedSorter } from "../js/feedEngine/sorters.js";
const A1 = "30078:aaaa:video-1";
const A2 = "30078:bbbb:video-2";
const receipt = (id, tags) => ({ id, kind: ZAP_RECEIPT_KIND, tags });
test("amount extraction: bolt11 first, zap-request amount fallback, garbage → 0", () => {
const tools = {
nip57: { getSatoshisAmountFromBolt11: (b) => (b === "lnbc210n1..." ? 21 : NaN) },
};
assert.equal(
extractReceiptAmountSats(receipt("r1", [["bolt11", "lnbc210n1..."]]), tools),
21,
);
// bolt11 unparseable → embedded zap request amount (msats → sats)
const description = JSON.stringify({ tags: [["amount", "50000"]] });
assert.equal(
extractReceiptAmountSats(
receipt("r2", [["bolt11", "garbage"], ["description", description]]),
tools,
),
50,
);
assert.equal(extractReceiptAmountSats(receipt("r3", []), tools), 0);
assert.equal(
extractReceiptAmountSats(receipt("r4", [["description", "not json"]]), tools),
0,
);
});
test("pointerKey accepts BOTH app shapes and rejects unusable pointers", () => {
// Tag-style ARRAY — what deriveVideoPointerInfo/resolveVideoPointer actually
// produce (regression: the object-only version silently zeroed every card
// badge and the Most Zapped ranking).
assert.equal(pointerKey(["a", A1]), `a:${A1}`);
assert.equal(pointerKey(["e", "evt1", "wss://relay.example"]), "e:evt1");
assert.equal(pointerKey(["x", "nope"]), "");
// Object form.
assert.equal(pointerKey({ type: "a", value: A1 }), `a:${A1}`);
assert.equal(pointerKey({ type: "e", value: "evt1" }), "e:evt1");
assert.equal(pointerKey({ type: "x", value: "nope" }), "");
assert.equal(pointerKey(null), "");
});
function makeStore({ receipts = [], now = () => 1000 } = {}) {
const listCalls = [];
const scheduled = [];
const store = createZapTotalsStore({
now,
persistKey: null, // in-memory; the durable ledger has its own test
getTools: () => ({ nip57: { getSatoshisAmountFromBolt11: () => NaN } }),
getClient: () => ({
relays: ["wss://relay.example"],
getSubscriptionManager: () => ({
list: async ({ relays, filters }) => {
listCalls.push({ relays, filters });
return receipts;
},
}),
}),
schedule: (fn) => {
scheduled.push(fn);
return 1;
},
});
return { store, listCalls, scheduled };
}
const amountTag = (msats) => [
"description",
JSON.stringify({ tags: [["amount", String(msats)]] }),
];
test("batched fetch sums receipts per pointer and dedupes by receipt id", async () => {
const { store, listCalls } = makeStore({
receipts: [
receipt("r1", [["a", A1], amountTag(21000)]),
receipt("r2", [["a", A1], amountTag(100000)]),
receipt("r3", [["a", A2], amountTag(5000)]),
receipt("r3", [["a", A2], amountTag(5000)]), // duplicate id — ignored
],
});
assert.equal(store.request({ type: "a", value: A1 }), 0, "cold cache reads 0");
store.request({ type: "a", value: A2 });
await store.flush();
assert.equal(store.getSnapshot({ type: "a", value: A1 }), 121);
assert.equal(store.getSnapshot({ type: "a", value: A2 }), 5);
assert.equal(listCalls.length, 1, "one batched fetch for both pointers");
assert.equal(
listCalls[0].filters[0]["#a"].length,
2,
"both pointers in one #a filter",
);
});
test("zero-receipt answers are cached (no immediate refetch) and listeners fire", async () => {
let t = 1000;
const { store, listCalls, scheduled } = makeStore({ receipts: [], now: () => t });
let changes = 0;
store.onChange(() => {
changes += 1;
});
store.request({ type: "e", value: "evt1" });
await store.flush();
assert.equal(changes, 1, "an answered batch notifies even when empty");
assert.equal(store.getSnapshot({ type: "e", value: "evt1" }), 0);
const schedulesBefore = scheduled.length;
store.request({ type: "e", value: "evt1" });
assert.equal(scheduled.length, schedulesBefore, "fresh zero answer is not refetched");
assert.equal(listCalls.length, 1);
// After the TTL, the same pointer refetches.
t += 10 * 60 * 1000;
store.request({ type: "e", value: "evt1" });
assert.equal(scheduled.length, schedulesBefore + 1, "stale entry schedules again");
});
test("optimistic ingest bumps instantly, then a real receipt replaces it (no double-count)", async () => {
let t = 1000;
let scripted = [];
const s = createZapTotalsStore({
now: () => t,
persistKey: null,
getTools: () => ({ nip57: { getSatoshisAmountFromBolt11: () => NaN } }),
getClient: () => ({
relays: ["wss://relay.example"],
getSubscriptionManager: () => ({ list: async () => scripted }),
}),
schedule: (fn) => { fn(); return 1; },
});
let changes = 0;
s.onChange(() => { changes += 1; });
const pointer = { type: "a", value: A1 };
// Optimistic bump shows immediately, no fetch needed.
s.ingestLocalZap(pointer, 2100);
assert.equal(s.getSnapshot(pointer), 2100, "optimistic sats visible instantly");
assert.ok(changes >= 1, "change emitted for the badge");
// Later, past the fetch TTL, the real receipt for the same zap lands →
// replaces the optimistic portion (total stays 2100, not 4200).
t += 5 * 60 * 1000;
scripted = [receipt("real-1", [["a", A1], amountTag(2100000)])];
s.request(pointer); // stale now → schedules; schedule() runs it inline
await s.flush();
assert.equal(
s.getSnapshot(pointer),
2100,
"real receipt replaced the optimistic bump — not added to it",
);
});
test("durable ledger: sent zaps persist across reload; a real receipt prunes them", async () => {
const KEY = "bitvid:sentZaps:test";
localStorage.removeItem(KEY);
const pointer = { type: "a", value: A1 };
// Session 1: send a zap. No receipt is ever published (Strike-style).
const s1 = createZapTotalsStore({ persistKey: KEY });
s1.ingestLocalZap(pointer, 2100);
assert.equal(s1.getSnapshot(pointer), 2100);
// Session 2 (reload): a fresh store seeds from the durable ledger — the zap
// still shows, so the badge + Most-Zapped rank survive with no relay receipt.
const s2 = createZapTotalsStore({ persistKey: KEY });
assert.equal(s2.getSnapshot(pointer), 2100, "sent zap persisted across reload");
// Session 3: now a real receipt for this video DOES appear on relays → the
// relay becomes authoritative and the ledger entry is pruned (no double-count).
let scripted = [receipt("r-1", [["a", A1], amountTag(2100000)])];
const s3 = createZapTotalsStore({
persistKey: KEY,
now: () => 10 * 60 * 1000, // past the fetch TTL so the seeded entry is stale
getTools: () => ({ nip57: { getSatoshisAmountFromBolt11: () => NaN } }),
getClient: () => ({
relays: ["wss://relay.example"],
getSubscriptionManager: () => ({ list: async () => scripted }),
}),
schedule: (fn) => { fn(); return 1; },
});
s3.request(pointer);
await s3.flush();
assert.equal(s3.getSnapshot(pointer), 2100, "relay receipt now the source (not 4200)");
// Session 4 (reload after prune): ledger cleared, so only relay data remains.
const s4 = createZapTotalsStore({ persistKey: KEY });
assert.equal(s4.getSnapshot(pointer), 0, "ledger pruned once the relay had a receipt");
localStorage.removeItem(KEY);
});
// bitvid tally counting (docs/zap-tally-plan.md §4): tallies are verified,
// deduped against 9735s by payment_hash, and gated by isTallyEnabled.
const tallyEvent = (id, { pointerA, paymentHash, valid = true }) => ({
id,
kind: 30081,
tags: [
["d", paymentHash],
["a", pointerA],
["bolt11", `lnbc-${paymentHash}`],
["preimage", `pre-${paymentHash}`],
["description", "{}"],
],
__valid: valid,
});
function makeTallyStore({ receipts = [], enabled = true } = {}) {
// Inject a verifier that trusts our fixtures' __valid flag and reports a
// fixed sats amount + the event's own a/e pointer tags + payment_hash (d tag).
const verifyTally = (event) => {
if (!event?.__valid) return { ok: false };
const d = event.tags.find((t) => t[0] === "d")?.[1];
const pointerTags = event.tags.filter((t) => t[0] === "a" || t[0] === "e" || t[0] === "p");
return { ok: true, sats: 500, paymentHash: d, pointerTags };
};
return createZapTotalsStore({
persistKey: null,
isTallyEnabled: () => enabled,
verifyTally,
tallyKind: 30081,
getTools: () => ({ nip57: { getSatoshisAmountFromBolt11: () => NaN } }),
getClient: () => ({
relays: ["wss://relay.example"],
getSubscriptionManager: () => ({ list: async () => receipts }),
}),
schedule: (fn) => { fn(); return 1; },
});
}
test("tally counting: a verified tally adds sats; an invalid one is ignored", async () => {
const store = makeTallyStore({
receipts: [
tallyEvent("t1", { pointerA: A1, paymentHash: "ph-1", valid: true }),
tallyEvent("t2", { pointerA: A1, paymentHash: "ph-2", valid: false }),
],
});
store.request({ type: "a", value: A1 });
await store.flush();
assert.equal(store.getSnapshot({ type: "a", value: A1 }), 500, "only the valid tally counts");
});
test("cross-source dedup: a 9735 and a tally with the same payment_hash count once", async () => {
// The 9735 carries a bolt11 whose payment_hash the store extracts; make the
// tally's d (payment_hash) match so it's recognized as the same payment.
// Here the verifier reports paymentHash = the tally's d tag; the 9735's
// payment_hash is derived from its bolt11 — for the test we align them by
// using a store whose 9735 amount path yields 500 and a shared hash "dup".
const shared = "dupHASH";
// A 9735 whose extractBolt11Fields yields a payment_hash is hard to fake
// without a real bolt11, so this test asserts the tally-vs-tally dedup (same
// payment_hash across two tally events) which uses the identical code path.
const store = makeTallyStore({
receipts: [
tallyEvent("t1", { pointerA: A1, paymentHash: shared, valid: true }),
tallyEvent("t2", { pointerA: A1, paymentHash: shared, valid: true }), // same payment
],
});
store.request({ type: "a", value: A1 });
await store.flush();
assert.equal(
store.getSnapshot({ type: "a", value: A1 }),
500,
"same payment_hash counted once, not 1000",
);
});
test("tally counting is gated: disabled → tallies ignored", async () => {
const store = makeTallyStore({
enabled: false,
receipts: [tallyEvent("t1", { pointerA: A1, paymentHash: "ph-1", valid: true })],
});
store.request({ type: "a", value: A1 });
await store.flush();
assert.equal(store.getSnapshot({ type: "a", value: A1 }), 0, "flag off → not counted");
});
test("most-zapped sorter: sats desc, recency tie-break, muted sinks", () => {
const item = (id, author, sats, createdAt, muted = false) => ({
video: { id, pubkey: author, created_at: createdAt },
metadata: muted ? { moderation: { trustedMuted: true } } : {},
sats,
});
const items = [
item("low", "a1", 5, 300),
item("high", "a2", 500, 100),
item("tie-new", "a3", 50, 200),
item("tie-old", "a4", 50, 100),
item("muted-whale", "a5", 9999, 400, true),
];
const sorter = createMostZappedSorter();
const sorted = sorter(items, { runtime: { getZapTotal: (video) => items.find((i) => i.video === video)?.sats || 0 } });
assert.deepEqual(
sorted.map((entry) => entry.video.id),
["high", "tie-new", "tie-old", "low", "muted-whale"],
"sats desc, newer wins ties, trusted-muted last regardless of sats",
);
});
+163 -47
View File
@@ -555,21 +555,34 @@ toward freshness and looked identical. Gave each a structural identity:
the active signer if ever reused.
### 43. Logged-in profile card at the top of the profile modal (UX)
- [ ] Show the **logged-in user's profile photo + name** at the **top-center** of the
profile modal, styled as a "profile card" that sits **half-on / half-off** the
modal edge (overlapping the top border) for a polished look. Goal: users can see
WHICH profile they're acting as (e.g. uploading as) at a glance. Pull avatar/name
from the profile cache for the active pubkey; update on profile switch.
- [x] **DONE 2026-07-03.** An "Acting as <name>" identity pill now renders
top-center on EVERY pane, straddling the header divider (negative
block-start margin — the modal is full-screen, so the header edge is the
overlap surface). Avatar + name come from the same resolveMeta path as
the Account pane card (profile cache first), populated in
renderSavedProfiles so it refreshes on every profile switch; hidden when
logged out; clicking it jumps to the Account pane. Markup in
components/profile-modal.html, styles in css/tailwind.source.css
(.profile-modal__identity*), wiring in profileModalController. Test in
tests/profile-modal-controller.test.mjs (26 pass). Lint + build green.
- [ ] **VERIFY on unstable:** open any pane → pill shows the active identity;
switch accounts → it updates; log out → it disappears; click → Account.
### 44. In-modal storage selector for uploads (UX)
- [ ] Make storage unlock + selection easier from the **video upload modal**: let the
user **pick any configured storage connection** (R2 / B2 / Custom S3) and unlock it
**without leaving the upload modal** — today they must close it, open Profile →
Storage, change the default/selected connection, then reopen upload. Add a provider
picker (driven by `storageService.listConnections`) + inline unlock in the upload
modal's storage section; selecting one sets it as the active upload target for this
upload (and optionally the default). Reuse `loadFromStorage` / the per-provider
connection model.
- [x] **DONE 2026-07-03.** The upload modal's storage summary now has an
"Upload destination" <select> (hidden with <2 connections) listing every
configured connection ("Provider — bucket", default labelled). Selecting
one becomes the upload target for THIS modal (per-modal override; the
account default is untouched — set that in Profile → Storage) and
loadFromStorage re-resolves summary + credentials immediately. A stale
selection (connection deleted) falls back to the default. Inline unlock
was already covered by #56's gate. Logic in
uploadModalStorageUnlock.js (pickTargetConnection/renderConnectionPicker,
UploadModal file-size budget). Tests:
tests/upload-storage-picker.test.mjs (2 scenarios). Lint + build green.
- [ ] **VERIFY on unstable:** with 2+ connections configured, open Upload →
Storage Status → switch destination → summary updates and the upload
lands in the chosen bucket; with 1 connection the picker stays hidden.
### 45. Upload modal: suggest tags from the user's previous videos (UX)
- [x] **DONE 2026-07-01.** The Hashtags section of the upload modal now shows one-tap
@@ -602,11 +615,54 @@ toward freshness and looked identical. Gave each a structural identity:
handled).
### 47. "Most Zapped" sidebar tab (trending-by-zaps)
- [ ] Add a **Most Zapped** sidebar tab: like Trending (#27, view-count) but ranked by
**total zaps** (sats / zap count) per video. Reuse the zap accounting (kind-9735
receipts) already used for the zap system; build a feed source/ranker that sorts by
zap total over a window, wired into the feed engine like Trending. Depends on
reliable zap data (couples with the zap system already shipped in #3).
- [ ] **NEXT (planned, not started): bitvid-native zap tally.** Full spec +
ordered task checklist in `docs/zap-tally-plan.md`. Problem: NIP-57 zap
receipts (9735) are published by the RECIPIENT's LNURL server, and
custodial wallets (Strike) don't — so paid zaps leave no on-relay record
and can't be counted globally. Fix: bitvid publishes a payer-signed,
preimage-verified zap event on successful send (as strong as a 9735 via
the preimage→payment_hash + description_hash→zapRequest binding, reusing
the existing bolt11 decode in `zapReceiptValidator.js`), counted alongside
real 9735s (deduped by payment_hash, 9735-preferred). Behind
`FEATURE_ZAP_TALLY` (default off). Plan also covers a
SECOND orange zaps-over-time line on the per-video Popularity chart with a
legend (red=Views/orange=Zaps) and visible date labels (docs §5.9). See the
plan for the file-by-file changes. Current state (commit `cbc62306`): 9735 counting + optimistic
bump + durable local ledger (`bitvid:sentZaps:v1`) already shipped; the
tally is the missing global/cross-user source.
- [x] **DONE 2026-07-03.** Full Trending-pattern clone ranked by SATS:
- `js/zapTotals.js` — per-pointer kind-9735 aggregation. The feed's
getZapTotal reads the cached total AND schedules a BATCHED one-shot
receipt fetch (`#a`/`#e` chunks via the L1 SubscriptionManager — the
direct-pool lint gate applies) for unknown/stale pointers (2-min TTL;
zero-receipt answers cached too). Amounts: nip57 bolt11 parse, falling
back to the zap request's amount tag (msats); receipts deduped by id
so re-fetches never double-count.
- Feed: `js/feedEngine/mostZappedFeed.js` + `createMostZappedSorter`
(sorters.js refactored to a shared count-ranked sorter — Trending
behavior unchanged: sats/views desc, recency tie-break, author
spread, trusted-muted sink).
- View: `views/most-zapped.html` + `js/mostZappedView.js` (re-runs the
feed on zapTotals' change signal, same settle-in pattern), sidebar
link, viewManager registry, feedCoordinator case/loader,
`FEED_TYPES.MOST_ZAPPED`, `FEATURE_MOST_ZAPPED_FEED` flag (default
on; off hides the tab like Trending's flag).
Tests: `tests/zap-totals.test.mjs` (5). Lint + build green.
- [x] **Follow-up DONE 2026-07-03 — per-card zap badges.** Video cards now show
the zap total in bitcoin ORANGE (new `--color-zap` token, both themes),
RIGHT-ALIGNED in the engagement row (views/comments stay left; CSS
`margin-inline-start: auto` on `.video-card__zaps`). Hidden until a
nonzero total is known (no "0 sats" noise). Binder
(`js/ui/views/videoCardZapTotals.js`, VideoListView file-size budget)
fills `[data-zap-total]` from the shared zapTotals cache — bind() also
schedules the batched receipt fetch, and one change listener updates
every bound card as batches land. Test:
`tests/video-card-zap-totals.test.mjs` (drives the REAL store).
- [ ] **VERIFY on unstable:** open Most Zapped → zapped videos rise as receipts
load (unzapped feed reads as Recent at first) and their cards show the
orange right-aligned sats badge; unzapped cards show no badge; zap a
video → within ~2min of tab re-entry its rank + badge reflect the new
total.
### 48. Fluent profile switching across signing methods (nsec / NIP-46 / NIP-07)
- [x] **nsec switching FIXED 2026-06-30.** Switching to a saved nsec profile used to fail
@@ -712,6 +768,15 @@ toward freshness and looked identical. Gave each a structural identity:
ones (DM relays, interest sets) until after first render. Multi-subsystem +
sensitive (trust) → do as a dedicated perf pass, not a rushed change.
Source still to pin: the loop issuing 30000/30002/30005/30015 per contact.
- [x] **REQ tracer SHIPPED (2026-07-03)** to pin those emitters empirically —
static search found NO per-contact list loops (mutes already batched), so
the hypothesis needs data. `js/nostr/reqTelemetry.js` hooks the shimmed
`pool.sub` choke point (every sub/list flows through it) and aggregates
kinds + relay fan-out + emitting call site per 10s window. Zero-cost off.
**To trace:** on unstable run `localStorage.setItem("bitvid:reqTrace","1")`,
reload (cold login), read the `[req-trace]` console logs (or
`window.__bitvidReqTrace.report()`); the top rows name the emitters and
the batching/deferral pass follows directly from that data.
### 22. Anti-spam: validate npub on application / submission forms
- [x] **DONE 2026-06-24.** Added a shared, testable NIP-19 validator
@@ -1158,6 +1223,22 @@ Reported 2026-06-25. Relates to #17 (NIP-71 interop) / the bitvid→NIP-71 mirro
### 10. WebTorrent `null.fill` seeding crash
- [ ] Investigate the vendored `webtorrent.min.js` `null.fill` error on wire handshake;
may require a bundle update or patch.
- [x] **Bounded investigation DONE 2026-07-03.** The vendored bundle is
webtorrent **2.5.11** (~2 years old; upstream latest is **3.0.16**). The
bundle has exactly two `.fill(` sites: a piece-bitfield fill and
`this.buffer.fill(void 0)` — fast-fifo's `clear()`, the queue streamx
uses under bittorrent-protocol's wire Duplex. "null.fill on wire
handshake" = that queue being touched after stream teardown (use-after-
destroy in the streamx/fast-fifo layer). No matching public upstream
issue found, but webtorrent 3.x now PINS `streamx` to an exact version
(2.25.0) — consistent with upstream having chased this bug class.
- [ ] **Fix path: vendored-bundle update to 3.0.16 as its OWN task** (playback-
critical → own branch/soak, NOT bundled with other work): rebuild
`js/webtorrent.min.js` + `.map`, re-test URL+magnet playback, seeding,
the beacon app (`torrent/`), and the SW streaming path; rollback = keep
the old bundle file. Check 2.x→3.x breaking changes against
`js/webtorrent.js` usage first. Without a repro for the crash, a blind
local patch is guesswork — the update is the honest fix.
### 11. Harden flaky tests (CI gate reliability)
- [ ] `tests/ui/uploadModal-reset.test.mjs` ("UploadModal Reset Logic") intermittently
@@ -1166,6 +1247,13 @@ Reported 2026-06-25. Relates to #17 (NIP-71 interop) / the bitvid→NIP-71 mirro
trusted release gate. Audit e2e parallel-load flakiness too.
### 11b. SILENTLY-EXCLUDED unit tests — 20 files never run in CI (found 2026-06-23)
> **2026-07-03 addendum — stale-default failure was hiding in local runs:**
> `tests/app/hash-change-handler.test.mjs` had been failing since 04621efc
> (2026-06-24) made TRENDING the deliberate logged-out landing — the test still
> expected `most-recent-videos`. It went unnoticed locally because
> `npm run test:unit | tail` reports the PIPE's exit code, not npm's (lesson:
> never pipe the suite when reading its exit status). Spec-corrected to assert
> `views/trending.html` (integrity note in the test file); all 3 tests pass.
`scripts/run-unit-tests.mjs` only collects `*.test.mjs|*.test.js` files whose
**content contains `node:test`** (line ~54). 20 test files use bare `assert` +
top-level await instead, so the runner skips them WITHOUT WARNING and they are
@@ -1530,28 +1618,38 @@ that used it.
- [ ] **VERIFY live on a phone**: modals should now be full-screen; tapping a video
card shows the active lift/accent/marquee; profile modal tabs are the app-grid.
### 49. Relay-ack false-negatives on the generic content-publish path (HIGH-STAKES — deferred)
> No rush — only act if it starts causing problems. Same bug class as the list-publish
> fix (commit 47f2a9a8): when no relay ACKs within the 10s `RELAY_PUBLISH_TIMEOUT_MS`
> window, `assertAnyRelayAccepted` throws even though the event was sent and almost
> always persisted (works after refresh). Already fixed for the replaceable LISTS
> (subscriptions/blocks/relay-list/dm-hints/hashtags) via
> `assertAnyRelayAcceptedOrUnconfirmed`.
- [ ] **Generic content publish still hard-errors on all-timeout.** The
`signAndPublishEventHelper` (`js/nostr/publishHelpers.js:472`) + `rebroadcast`
(`:1334`) + `video revert` (`js/nostr/client.js:3064`) still use bare
`assertAnyRelayAccepted`, so a slow-ack **video publish/edit/delete/revert**
can show "Failed to share video" (or similar) when it actually published.
HIGH-STAKES because these are the flagship content flows.
- [ ] **Why it wasn't bundled in:** video publish feeds a relay summary into
`describePublishOutcome` (the "shared to N relays" / warning UI). Applying
`assertAnyRelayAcceptedOrUnconfirmed` here must keep that outcome honest (an
unconfirmed publish should read as "shared, couldn't confirm" — NOT a hard
failure and NOT a false "shared to N relays"). Needs its own careful pass +
tests around the video outcome-describer and delete semantics (a false
"deleted" is worse than a false "publish failed").
- [ ] Deletions (kind 5) especially: decide whether an unconfirmed delete should
report success optimistically or stay conservative.
### 49. Relay-ack false-negatives on the generic content-publish path (HIGH-STAKES)
> Same bug class as the list-publish fix (commit 47f2a9a8): all-timeout ≠ rejection.
- [x] **DONE 2026-07-03 — generic content publish is unconfirmed-tolerant, with
HONEST outcome messaging at every consumer.** The three bare
`assertAnyRelayAccepted` call sites now use
`assertAnyRelayAcceptedOrUnconfirmed` (all-timeout → `summary.unconfirmed`,
soft success; explicit rejections STILL throw `RelayPublishError`):
- `signAndPublishEvent` (js/nostr/publishHelpers.js — video publish, edit,
repost, mirror, videoPublisher) + `rebroadcastEvent`.
- `revertVideo` (js/nostr/client.js) — so a slow-ack tombstone no longer
aborts `deleteAllVersions` midway (kind-5 hard delete now always runs).
Honesty plumbing (the reason this was deferred):
- The relay tally (`attachRelayPublishSummary`, now also attached on EDIT)
carries `unconfirmed`; `describePublishOutcome` maps accepted=0 +
unconfirmed → WARNING "sent, but no relay has confirmed receiving it
yet" — never a fake "published to N relays", never a hard failure.
Zero-accepted WITHOUT the flag stays the hard error.
- Edit: editModalController reads the tally and says "update sent, not
confirmed yet" instead of "updated successfully" when unconfirmed.
- Revert: revertModalController tracks per-entry summaries and only says
"Reverted…" when a relay ACKed; otherwise "sent, not confirmed yet".
- [x] **Delete semantics — decided CONSERVATIVE.** A false "deleted" is worse
than a false "failed": `anyRelayAcceptedInSummaries` (js/nostrPublish.js)
scans every tombstone + kind-5 summary; `handleDeleteModalConfirm` only
claims "All versions deleted successfully!" when ≥1 relay ACKed something,
else "Delete request sent, but no relay has confirmed it yet — the video
may reappear until relays catch up." (Empty set = nothing to publish =
confirmed.) Tests: `tests/relay-ack-unconfirmed-content.test.mjs` (6, incl.
end-to-end signAndPublishEvent all-timeout vs rejection). Lint + build green.
- [ ] **VERIFY on unstable:** with a slow/unreachable relay set, publish + edit +
delete a test video — messages should read "sent/unconfirmed", never
"failed", and the content should be correct after a refresh.
### 50. isDevMode — keep it; fix the test-harness gap (LOW priority)
- Decision (2026-07): **KEEP `isDevMode`.** It's used in ~26 files, chiefly to gate
@@ -1711,13 +1809,31 @@ passphrase-encrypted). Items 51, 56, 57 are all facets of that.
merge-new already keeps grids fresh).
### 56. Upload & edit modals: prompt to unlock storage (PIN popup), don't just error (UX)
- [ ] When storage is locked, the **upload** and **edit** modals should open the
passphrase/PIN unlock popup instead of surfacing a raw error — for **both** the
video-file upload and the **thumbnail** upload. **#36** already added this
auto-open-login unlock for the **upload** modal; extend the same treatment to the
**edit** modal and the **thumbnail** upload path.
- [ ] Combine with **51**: once unlocked, cache the state so the user isn't re-prompted
on every upload/refresh. "Both would be nice" — actionable prompt AND cached unlock.
- [x] **DONE 2026-07-03.** All three locked-storage file-pick paths now unlock
INLINE and continue with the same pick, via the shared signer gate
(`app.ensureEncryptionCapableSigner`: silent kept-unlocked restore, else ONE
passphrase prompt with the keep-unlocked checkbox):
- **Upload modal, video file:** was `showError("Please unlock storage…")`
→ now `ensureStorageUnlockedForUpload()` (new
`js/ui/components/uploadModalStorageUnlock.js`; UploadModal delegates —
also absorbed `promptStoredNsecUnlock` for the file-size budget).
- **Upload modal, thumbnail:** was a SILENT no-op → now the same inline
unlock; a refused unlock resets the picker (placeholder + URL input).
- **Edit modal, "Replace file" (video + thumbnail):** was "Unlock your
storage in the profile modal, then try again" → `ensureUploadable`
(js/ui/components/editModalUpload.js) now gates the signer, runs
`storageService.unlock`, and re-resolves the connection in-gesture
(extension signers still get the permission request first).
Cancel / bad-passphrase abort quietly (the gate toasts a wrong passphrase
itself); other failures fall through to the pre-existing messaging.
`ensureSigner` is threaded through initUploadModal / initEditModal from app.
Tests: `tests/upload-edit-storage-unlock-gate.test.mjs` (12). Lint + build green.
- [x] Combine with **51**: satisfied by the gate itself — it restores the cached
keep-unlocked key first, so an unlocked-once session never re-prompts.
- [ ] **VERIFY on unstable:** locked nsec session → pick a video file in the upload
modal (one PIN prompt, upload proceeds), pick a thumbnail (same), and use
Edit → Replace file (same). Cancel each prompt once — no error spam, picker
state restored.
### 57. nsec / NIP-46 accounts: encryption ops fail after refresh — re-prompt, don't blanket-error (BUG)
- [ ] **Symptom:** adding/editing a hashtag on an **nsec**-logged-in account errors with
+40
View File
@@ -0,0 +1,40 @@
<!-- views/most-zapped.html -->
<section data-ds="new">
<div class="flex items-center gap-2 mb-4">
<h2 class="text-xl font-semibold text-text">Most Zapped</h2>
<button
type="button"
id="mostZappedInfoTrigger"
class="text-muted hover:text-text focus:outline-none transition-colors"
aria-label="How is this feed sorted?"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="16" x2="12" y2="12"></line>
<line x1="12" y1="8" x2="12.01" y2="8"></line>
</svg>
</button>
</div>
<div id="videoListTags" class="mb-4" hidden></div>
<div id="videoList" class="bv-grid-video" data-testid="video-list">
<div class="sidebar-loading-wrapper" role="status" aria-live="polite">
<div class="sidebar-loading-indicator">
<span
class="status-spinner status-spinner--inline"
aria-hidden="true"
></span>
<span class="sidebar-loading-text">Loading most zapped…</span>
</div>
</div>
</div>
</section>