mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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:
commit
bca0eb32aa
67 files changed
+5304
-172
No files matched your search
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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.
|
||||
```
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 —
|
||||
|
||||
@@ -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
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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" }));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user