Commit Graph
6646 Commits
Author SHA1 Message Date
thePR0M3TH3ANandClaude Opus 4.8 567a12c758 tests(11): make uploadModal-reset zombie-callback test deterministic (correct mock boundary)
The "guard against zombie callbacks" subtest passed its asserts but leaked async
activity after the test ended (node:test flags that as failure). Root cause: it
mocked modal.generateTorrentMetadata / resolveUploadIdentifier, but
handleVideoSelection drives the upload through this.mediaUploader.uploadVideo —
a DIFFERENT object with its own helpers — so the mediaUploader still ran real
createTorrentMetadata()/hashing on a synthetic {name:"video.mp4"} file ("invalid
input type" async) and captureVideoMetadata() ran URL.createObjectURL(file)
("must be an instance of Blob"). Not intermittent — a consistent wrong-boundary mock.

Fix: mock at the real DI boundary — modal.mediaUploader.uploadVideo = () =>
delayedUpload (controllable pending promise) — and stub captureVideoMetadata.
Hermetic, deterministic (3/3 exit 0, # pass 3). Mutation-verified: breaking the
videoUploadId zombie guard makes the test fail. Production code untouched. See
TEST_INTEGRITY.md.

NOTE: the runner is fail-fast, so the full suite surfaces pre-existing failures one
at a time. tests/unit/hashChangeHandler.test.mjs is next (consistent AssertionError
on "loads default view if hash is empty" — likely the same stale-default bug the
sibling tests/app/hash-change-handler.test.mjs already had when TRENDING became the
logged-out default). Enumerating full scope next.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-05 11:29:36 -04:00
thePR0M3TH3ANandClaude Opus 4.8 a326d04330 tests(11b): un-quarantine dm-block-filter — hermetic block-list subscription; QUARANTINE now empty
Fully closes #11b's un-quarantine work: the QUARANTINE map is now EMPTY.

dm-block-filter.test.mjs (node:test) PASSED all subtests then hung, so the
node:test runner never exited. Diagnosed via process.getActiveResourcesInfo():
the "integration with loaded user block list" test's manager.loadBlocks() opens a
LIVE block-list subscription through nostrClient's real SimplePool aimed at the
test's fake relay URLs, leaking 4 TCP sockets + reconnect timers. It is NOT the
decrypt-retry timer (verified absent). Nulling nostrClient.pool did not help —
loadBlocks calls ensurePool() (userBlocks.js:967) to re-create+connect a real
pool when pool is falsy.

Fix (node:test-SAFE, no force-exit): make the test hermetic — (1) a TRUTHY stub
pool so ensurePool() is skipped, (2) a no-op getSubscriptionManager so
startBlockListSubscription opens nothing. The block-merge logic under test still
runs fully through the already-stubbed fetchListIncrementally. Restored in finally.
Verified BOTH directions: a clean run exits 0 (# pass 12), AND a deliberately
broken assertion still exits 1 (# fail 1) — failures are NOT masked. A force
process.exit in an after() hook was rejected precisely because it masked failures
(process.exitCode isn't set when the hook fires). See TEST_INTEGRITY.md.

NOTE: the full suite still has one SEPARATE, pre-existing red unrelated to #11b —
tests/ui/uploadModal-reset.test.mjs (TODO #11): its "zombie callbacks" subtest
feeds a plain object where webtorrent expects a Blob ("must be an instance of
Blob") and leaks async activity after the test ends. Untouched here; tracked
under #11 (harden flaky tests), not the #11b quarantine work.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-05 11:03:03 -04:00
thePR0M3TH3ANandClaude Opus 4.8 d6ac9429c1 tests(11b): un-quarantine user-blocks + nwc-client + nostr-publish-rejection; quarantine dm-block-filter
Closes the #11b un-quarantine work: the three target files now pass and run in
CI. A full-suite run with a per-file timeout then surfaced a FOURTH, pre-existing
hang that had never been in the map (dm-block-filter) — quarantined with a triage
note rather than left to hang CI.

user-blocks.test.mjs — post-completion HANG. The decrypt-timeout section installs
a never-resolving nip44Decrypt whose background decrypt-retry timer outlives the
run (manager.reset() races it). Added the repo's established bare-assert
`setTimeout(() => process.exit(0), 50)` exit after the final assertion.

nostr-publish-rejection.test.mjs — post-completion HANG (was NOT in the map, so it
had been silently hanging local `npm run test:unit`, which has no per-file timeout
unless UNIT_TEST_TIMEOUT_MS is set). All assertions pass, then the imported app
stack (app.js + subscriptions + userBlocks) keeps the loop alive. Same
`process.exit(0)`-after-cleanup fix. (The dev-mode + NIP-71 spec fixes were
already in the file.)

nwc-client.test.mjs — (1) mock shadow: readToolkitFromScope prefers the bootstrap's
frozen canonical toolkit over window.NostrTools, so per-section mocks never took
effect and real @noble rejected the fake keys. Remove the scope canonical so the
resolver falls through to each mock. (2) spec correction: one section expected a
rejection when the toolkit "lacks nip44", but mergeWithCanonical ALWAYS backfills
the canonical nip44 (even explicit `nip44:null`) — nip44_v2 is never absent. The
wallet now advertises a genuinely-unsupported scheme (nip44_v3) to exercise the
same UNSUPPORTED_ENCRYPTION path, and asserts error.code too. See TEST_INTEGRITY.md.

dm-block-filter.test.mjs — QUARANTINED (pre-existing, node:test). All subtests pass,
then it hangs: manager.loadBlocks() opens a live block-list subscription through
nostrClient's real SimplePool pointed at the test's fake relay URLs, leaking TCP
sockets + reconnect timers that node:test can't drain. Needs a node:test-safe fix
(inject a mock pool so no real socket opens) — a force process.exit in after() is
UNSAFE (verified: process.exitCode isn't set yet, so it masks failures).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-05 10:45:55 -04:00
thePR0M3TH3ANandClaude Opus 4.8 96228a451b zaps: fix zap totals counting zero — coordinate used kind 0 instead of 30078
The zap write path (zapSplit.js derivePointerTag) built the `a` coordinate
tag straight from `videoEvent.kind`. bitvid's internal video objects don't
always carry the Nostr kind — it can be 0 or undefined — and the inline
builder only rejected non-finite kinds, so a kind-0 object produced a
`0:pubkey:d` coordinate (Number.isFinite(0) === true). This flowed into both
the kind-9734 zap request and the kind-30081 bitvid tally.

The read side (zapTotals / mostZappedFeed) fetches totals by querying
`30078:pubkey:d` via buildVideoAddressPointer, which defaults invalid/zero
kinds to 30078. So the writer emitted `0:...` while the reader queried
`30078:...` — they never matched and every zap counted as zero, even after
a cache clear + refresh. Confirmed against live relays: 4 published bitvid
tallies all carried `0:...` coordinates while the real video events are
kind 30078.

Fix: derivePointerTag now delegates to the same canonical
buildVideoAddressPointer the reader uses, keeping writer and reader
coordinates in lockstep. Adds a regression test driving splitAndZap with
kind 0 / undefined / non-numeric video objects, asserting the zap request's
`a` tag defaults to `30078:pubkey:d`.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-05 09:18:38 -04:00
thePR0M3TH3ANandClaude Opus 4.8 cf82978060 zaps: profile-zap counting + channel "sats zapped" total (#47 — closes the feature)
The store now supports p: (profile/channel) pointers — every zap tagged to a
creator pubkey (direct profile zaps AND zaps to their videos, both carry a `p`
tag), from 9735 receipts + verified tallies:

- pointerKey accepts "p"; new profilePointer(pubkey) helper.
- runBatch batches p: pointers into a #p filter; both folds count #p-tagged
  events (deduped by payment_hash like a:/e:).
- Channel page: a "⚡ N sats zapped" total under the lightning address, wired
  to the store via js/channelZapTotal.js (own module for channelProfile.js's
  size budget), hidden until nonzero.

Completes the D2 video + profile scope; the bitvid-native zap tally feature is
now feature-complete (publish + count + chart + video + profile). Tests:
profilePointer/pointerKey + #p counting in tests/zap-totals.test.mjs. Lint
(size cap ok) + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-05 00:36:44 -04:00
thePR0M3TH3ANandClaude Opus 4.8 974fdc836d charts: dual-line popularity chart — views (red) + zaps (orange) over time (#47 §5.9)
The per-video Popularity modal now plots a second, orange zaps-over-time line
beside the red views line, with a legend and — new — VISIBLE x-axis date ticks
(the old chart only put the range in the aria-label).

- buildZapSatsTimeSeries: buckets cumulative SATS from 9735 receipts + verified
  bitvid tallies, deduped by payment_hash (same rule as the badge store) so the
  chart and badge agree.
- buildPopularityChartSvg: two token-colored cumulative lines on a shared time
  x-axis, each with its OWN y scale (independent per-series — D5), plus date-tick
  <text> labels. Views = .text-accent, zaps = .text-zap (new --color-zap
  utility). Legend is plain DOM in the modal; headline gains "· N sats zapped".
- js/zapEventsFacade.js: lists a pointer's [9735, ZAP_TALLY_KIND] events via the
  L1 SubscriptionManager for the chart; modal re-renders on onZapTotalsChanged.
- All flag-gated (FEATURE_ZAP_TALLY); the existing views-only chart + its test
  are untouched (buildViewCountChartSvg kept).

Verified live earlier that imports resolve (app builds/runs). Tests:
tests/zap-time-series-chart.test.mjs (5) + existing view-count-chart green.
Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-05 00:27:00 -04:00
thePR0M3TH3ANandClaude Fable 5 68bdee8da5 playback: fix false "No playable source found." on URL-only videos
A bitvid (kind-30078) video with a perfectly playable hosted URL (verified:
archive.org 200 video/mp4 with byte ranges) failed with "No playable source
found." — the video element's src was never even set.

Root cause: the hosted-URL probe (fetch HEAD, mode:no-cors) runs BEFORE src is
assigned, inside the playback-start window — but URL_PROBE_TIMEOUT_MS (4s) is
LONGER than PLAYBACK_START_TIMEOUT (3s). A slow no-cors HEAD (archive.org, and
any host that doesn't answer HEAD fast) consumed the whole start budget, the
withTimeout wrapper aborted the attempt, and the URL was never loaded. A
`<video>` element plays cross-origin URLs WITHOUT CORS anyway, so the fetch
probe was both unnecessary and actively harmful here.

Fix:
- Skip the probe entirely when there's no magnet (URL-only) — nothing to fail
  over to, so the video element loads the URL directly. This is the reported
  case; verified live: the video now plays (readyState 4, currentTime advancing).
- An ambiguous/error probe no longer VETOES the URL (only a definitively-read
  "bad" status deprioritizes it, and even then only when a magnet fallback
  exists) — the video element + watchdogs are the real arbiter.

Tests: spec-corrected the always-probe assertion to a dual-source case + added
a URL-only skip-probe test (tests/services/playbackService.test.mjs). 17 pass.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-05 00:13:14 -04:00
thePR0M3TH3ANandClaude Fable 5 5a7481e30e zaps: reliable coinos platform address + fix NWC double-pay on timeout
Two things:

1. Platform fee address → [email protected] (verified live:
   allowsNostr + nostrPubkey, 1-sat min, working callback). coinos is
   Nostr-native and PUBLISHES zap receipts, unlike strike.me whose flaky LNURL
   + never-published receipts caused the platform-share pay timeouts and the
   missing zap tallies. The 30% platform fee is unchanged — only the rail.

2. Double-pay guard (bug found while diagnosing): on a `pay_invoice` timeout,
   nwcClient's nip44->nip04 encryption-fallback resent the request — but a
   timeout is ambiguous (the wallet may already be paying), so the resend
   could DOUBLE-SPEND. shouldRetryWithFallback now refuses to auto-resend any
   spending method (pay_invoice/pay_keysend/multi_pay_*) on timeout; read-only
   methods and explicit unsupported-encryption rejections (wallet never paid)
   stay retryable. The manual "Send again" retry is the safe path for a real
   payment timeout.

Tests: tests/nwc-pay-timeout-no-double-spend.test.mjs (4). Lint + build green.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-04 23:54:16 -04:00
thePR0M3TH3ANandClaude Fable 5 69a8ecaa3e docs: mark zap-tally retry item done + status IN PROGRESS (#47)
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-04 23:41:55 -04:00
thePR0M3TH3ANandClaude Fable 5 afed041f9a zaps: retried shares also publish tallies + bump the badge (#47)
executeRetry now fires the same onZapSuccess hook as the main send path with
the retried shares' proof, so a zap completed via the retry flow gets its
optimistic bump and preimage-verified tally publish too (was previously only
on the first-attempt path). Closes the retry-path item in the plan checklist.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-04 23:41:25 -04:00
thePR0M3TH3ANandClaude Fable 5 1d732479ed zaps: end-to-end verify test + plan checklist status (#47)
Adds an integration test exercising the REAL verifier against a genuinely
valid, amount-bearing bolt11 (real bech32 decode + nip57 amount parse + real
preimage/description-hash math, no injected fields) — proves the tally pieces
compose. Marks the shipped slices in docs/zap-tally-plan.md §11 and lists the
remaining v1 work: profile-zap #p counting + channel-page UI, the retry-path
publish hook, and the popularity chart (§5.9).

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-04 23:40:26 -04:00
thePR0M3TH3ANandClaude Fable 5 41e533e675 zaps: publish preimage-verified tallies on successful zap (#47)
Third slice of docs/zap-tally-plan.md §5.3-5.5 — the publish side:

- zapSplit.js: surface receipt.preimage (NWC pay_invoice result.preimage).
- zapController.js: onZapSuccess now forwards per-share proof
  {recipientType, amountSats, bolt11, preimage, zapRequest} for every settled
  share with a preimage.
- zapTallyPublisher.js (new): buildTallyFromShare (pure — builds the addressable
  tally from a share's bolt11/preimage/zap-request, d=payment_hash, pointers
  from the zap request) + publishZapTallies (best-effort, flag-gated, never
  throws).
- ModalManager.js: onZapSuccess keeps the instant optimistic bump AND publishes
  the tallies via nostrClient.signAndPublishEvent to write relays (an
  all-timeout publish is a soft success per #49). A publish failure is
  non-fatal — the durable local ledger still shows the sender their zap.

With the counting slice (025ab348), a sent zap now: shows instantly (ledger),
publishes a verified tally, and every bitvid client counts it — surviving the
recipient never publishing a 9735. Tests: tests/zap-tally-publisher.test.mjs
(5). Lint + build green.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-04 23:38:44 -04:00
thePR0M3TH3ANandClaude Fable 5 025ab3487d zaps: count bitvid tallies in the zap-totals store (#47)
Second slice of docs/zap-tally-plan.md §4. runBatch now queries kinds
[9735, ZAP_TALLY_KIND] in one call and folds both sources:

- 9735 receipts: unchanged sum, but now also records each receipt's
  payment_hash (from its bolt11) for cross-source dedup.
- bitvid tallies: verifyBitvidZapTally (preimage + description-hash bound),
  amount from the bolt11, pointers from the embedded zap request; counted
  once per payment_hash so a payment that yields BOTH a 9735 and a tally is
  never double-counted.

Entries gain a paymentHashes set. Counting is gated by isTallyEnabled
(singleton reads FEATURE_ZAP_TALLY live). The existing "relay wins" prune
clears the optimistic/ledger overlay once a real tally or receipt lands, so
the sender's own published tally reconciles the instant bump with no extra
plumbing.

Tests: cross-source/same-payment dedup, invalid-tally rejection, flag gating
(tests/zap-totals.test.mjs). Lint + build green.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-04 23:35:51 -04:00
thePR0M3TH3ANandClaude Fable 5 a6b2996f4e zaps: foundation for the bitvid-native zap tally (#47) — schema + verify
First slice of docs/zap-tally-plan.md (decisions D1-D5 resolved in the doc):

- ZAP_TALLY_KIND = 30081 (addressable, d=payment_hash → one canonical event
  per real payment) + NOTE_TYPES.ZAP_TALLY schema + buildZapTallyEvent
  (nostrEventSchemas.js). Video + profile pointers.
- FEATURE_ZAP_TALLY flag (constants.js), default ON per D4.
- zapReceiptValidator.js: extractBolt11Fields (payment_hash + description_hash
  from the existing bech32 walker), verifyPaymentPreimage
  (sha256(preimage)==payment_hash), verifyBitvidZapTally — the security core:
  proves the invoice was paid AND bound to this zap request (description-hash
  binding blocks reusing a leaked preimage for another video). Amount is
  bolt11-derived, never the tag; pointers come from the embedded zap request.
- Schema-doc row for kind 30081.

Tests: tests/zap-tally-verify.test.mjs (8) — preimage check, bech32 field
round-trip, valid/bad-preimage/replay/zero-sats decisions, builder tag shape
(video + profile). Lint + build green.

No publishing or counting wired yet — next slices per the plan checklist.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-04 23:32:12 -04:00
thePR0M3TH3ANandClaude Fable 5 ce4904f1a4 docs: add dual-line popularity chart (views + zaps over time) to the zap plan
Extends docs/zap-tally-plan.md §5.9: a second orange zaps-over-time line on the
per-video Popularity chart beside the existing red views line, a legend
(red=Views / orange=Zaps sats), and VISIBLE date labels (the current chart only
puts the range in the SVG aria-label). Reuses the --color-zap token from the
badges, buckets cumulative sats from 9735 + verified tally events (deduped by
payment_hash, same rule as the store), and can partially ship on 9735-only data
before the tally event lands. Adds checklist items, a chart Y-axis open
decision (D5), test targets, and updates goals/scope + TODO #47.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-04 15:27:47 -04:00
thePR0M3TH3ANandClaude Fable 5 ae7cbf22ba docs: dev plan for bitvid-native, preimage-verified zap tally (#47)
Full spec for making Most Zapped + zap badges global/durable/cross-user
without depending on external wallets to publish NIP-57 receipts (which
custodial providers like Strike don't). Key insight: the payer holds the
preimage after NWC pay_invoice, and the invoice's description hash commits
to the zap request's pointer+amount — so a payer-signed event carrying
{zapRequest, bolt11, preimage} is verifiable with the same cryptographic
strength as a recipient-signed 9735, reusing bitvid's existing bolt11 decode
in zapReceiptValidator.js.

docs/zap-tally-plan.md covers: the event schema, the verify algorithm,
cross-source dedup by payment_hash (9735-preferred), file-by-file code
changes, security/edge cases, a feature-flagged rollout, a test plan, open
decisions, and an ordered task checklist so the build stays neat across
sessions. TODO #47 points at it. No code changes yet.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-04 15:13:34 -04:00
thePR0M3TH3ANandClaude Fable 5 cbc62306a4 zaps: durably persist sent zaps so they survive reload + rank on Most Zapped
Root cause the user hit: NWC doesn't publish zap receipts, and neither does
bitvid — per NIP-57 the recipient's LNURL server publishes the kind-9735
receipt. Custodial providers (Strike) routinely don't, so a paid zap leaves
NO on-relay record to count, and the in-memory optimistic bump vanished on
reload → zapped videos showed 0 and never rose on Most Zapped.

Add a durable local ledger (localStorage bitvid:sentZaps:v1) of the user's
OWN sent zaps, seeded into the store's optimistic layer at construction so
the badge + Most-Zapped rank survive reloads with no receipt. Reconciliation:
when a real 9735 receipt for a pointer later appears on relays, the relay
becomes authoritative and that pointer's ledger entry is pruned, so a
receipt-publishing recipient never double-counts. ingestLocalZap writes the
ledger; runBatch prunes it.

Live-verified: card badge 0 -> 4.3K sats on send, still 4.3K after a full
page reload, ledger present in localStorage. Tests cover persist-across-
reload and prune-on-real-receipt.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-04 15:05:01 -04:00
thePR0M3TH3ANandClaude Fable 5 af09505fb3 zaps: optimistic zap-total bump so the badge updates instantly on send
Mirrors ingestLocalViewEvent for view counts: sending a zap through the
video modal now bumps that video's zap total immediately on the card + modal
badge, instead of waiting for the relay receipt (delayed by the 2-min cache
TTL, and dependent on the recipient's wallet actually publishing a 9735).

- zapTotals store tracks optimisticSats separately from real receipt sats;
  getSnapshot/request return the sum. When the real receipt for the pointer
  later arrives, runBatch clears the optimistic portion so it's never
  double-counted. If the receipt never lands (custodial wallets often don't
  publish), the bump persists in-memory until reload — keeping the zapper's
  own view honest.
- ZapController fires a new onZapSuccess({video, sats}) at the fully-success
  terminal point (sats = context.shares.total; both creator + platform shares
  tag the video, so the eventual relay total matches).
- ModalManager derives the pointer via app.deriveVideoPointerInfo (the SAME
  key the cards/modal subscribe with) and calls ingestLocalVideoZap.

Live-verified: a rendered card's badge went 0 sats -> 5K sats instantly on
ingest, no relay round-trip. Test covers the bump + replace-on-real-receipt.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-04 14:55:14 -04:00
thePR0M3TH3ANandClaude Fable 5 8d45f25962 zaps: make the card + modal zap counter always visible (0 sats when none)
Diagnosis (live, verified): the zap-totals pipeline is correct end to end —
a seeded receipt queries and sums to the right sats — but of 310 kind-9735
receipts reachable on the relays, ZERO reference any bitvid video, so every
badge was legitimately hidden. That reads as "broken" and profile zaps to a
Lightning address never attach to a video, so hidden-when-zero left the
feature invisible in practice.

Show the counter always, like the view counter it sits beside: "0 sats"
until a receipt lands, then the summed total. Card binder and modal render
no longer gate on sats>0; the badge is revealed on bind/open and re-hidden
on modal close (no video open). Live-checked: 158/158 cards render the
badge. Tests spec-corrected with integrity notes.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-04 14:43:22 -04:00
thePR0M3TH3ANandClaude Fable 5 213cb97ee8 modal: fix video player failing to open — missing-delegate TypeError on every open
Root cause of "clicking a video card no longer opens the player":
coordinator methods run with `this` bound to the Application
(bindCoordinator), and the zap-badge helpers were added as coordinator
methods called via `this.subscribeModalZapTotal(...)` — but app.js only
exposes coordinator methods through explicit delegates, which were never
added. Every modal open threw "this.subscribeModalZapTotal is not a
function" inside subscribeModalViewCount (and every close threw in
teardown). Same delegate landmine that broke Most Zapped's loader.

The helpers are now plain closures inside createModalCoordinator — immune
to the delegate pattern by construction. Regression test drives the REAL
bindCoordinator with only app.js's actual delegates, so any future
cross-method `this.` call without a delegate fails in CI first.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-04 10:45:50 -04:00
thePR0M3TH3ANandClaude Fable 5 941580f227 feeds: fix Most Zapped eternal spinner — missing app-level delegates
app.js delegates each feedCoordinator method explicitly (the coordinator is
lazy), and registerMostZappedFeed / loadMostZappedVideos were never added.
Bootstrap's `typeof app.registerMostZappedFeed === "function"` guard was
silently false (feed never registered) and the view registry's
loadMostZappedVideos call no-oped, so the view's spinner was never replaced.
Reproduced in a Playwright harness (spinner before, 158 videos rendered
after adding the delegates).

Also exposes window.__bitvidZapTotals (snapshot/request/flush) for
diagnosing zap-badge visibility in live sessions.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-04 09:34:44 -04:00
thePR0M3TH3ANandClaude Fable 5 cbe894969d zaps: fix badge never showing (array pointers) + zap total in the player modal
The zap-totals store's pointerKey only understood {type,value} objects, but
deriveVideoPointerInfo produces tag-style ARRAYS (["a", value, relay?]) — so
every card-badge lookup keyed to nothing, no receipt fetch was ever
scheduled, and the Most Zapped ranking metric silently read 0 for every
video. pointerKey now normalizes both shapes; tests use the array shape the
app actually passes.

Also adds the orange sats badge to the video player modal's meta row
(right side via the row's existing justify-between; views stay left),
driven by the same cache/change-signal as the cards and torn down with the
view-count subscription.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-04 09:20:11 -04:00
thePR0M3TH3ANandClaude Fable 5 ad62205afd zaps: retry transient LNURL failures once before failing the zap
Verified live against strike.me: the SAME lnurlp endpoint returned
{status:"ERROR","reason":"Could not get user information"} on one request
and a valid callback moments later — the address was fine, the server
blipped. fetchPayServiceData now retries once (600ms) on the transient
classes (fetch rejection, non-OK HTTP, non-JSON body, ERROR-status body);
shape-invalid payloads still fail immediately, and a persistent error
surfaces the server's reason after the single retry.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-04 09:02:27 -04:00
thePR0M3TH3ANandClaude Fable 5 e2d4251035 sync/zaps: enabled devices auto-pull newer wallet/storage notes at login; zap errors name the failing address (#15 follow-up)
Two fixes from live testing:

- "NWC doesn't sync unless I click Restore": devices with sync ENABLED only
  ever pushed-on-save — edits from another device sat unseen until the
  manual Restore click. encryptedSyncItem.autoPullIfNewer pulls the remote
  note at login when its created_at is newer than this device's last-synced
  marker (set on push AND now after a successful pull, so the same note is
  never re-pulled and the next conflict check stays honest). Wired into the
  login restore flow ahead of the one-time offer; disabled items are never
  touched.

- "Could not get user information" on a known-good creator address: that
  reason string comes from the LNURL SERVER, and the failing fetch was the
  PLATFORM-FEE address, not the creator's. prepareLightningContext now
  wraps both metadata fetches so the toast names which address failed
  (e.g. "Platform fee Lightning address (…): Could not get user
  information") instead of blaming the creator.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-04 08:56:05 -04:00
thePR0M3TH3ANandClaude Fable 5 86d7db4b1a cards: orange right-aligned zap-total badge next to view counts (#47 follow-up)
Video cards show the total sats from the shared zapTotals cache: new
--color-zap token (bitcoin orange, both themes), badge pushed to the
engagement row's end so views/comments stay left-aligned, hidden until a
nonzero total is known. The list view's zap binder fills [data-zap-total]
on bind (also scheduling the batched receipt fetch) and one shared change
listener updates every bound card as receipt batches land.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 21:49:35 -04:00
thePR0M3TH3ANandClaude Fable 5 8dfd2034b0 feeds: "Most Zapped" sidebar tab ranked by zap totals (#47)
Trending-pattern clone ranked by sats from kind-9735 receipts:

- js/zapTotals.js: per-pointer receipt aggregation. The feed's metric getter
  returns the cached total and schedules a batched fetch (#a/#e chunks via
  the L1 SubscriptionManager) for unknown/stale pointers; amounts parse via
  nip57 bolt11 with the zap request's amount tag as fallback; receipts are
  deduped by id so re-fetches never double-count; zero-receipt answers are
  cached (2-min TTL).
- sorters.js: shared count-ranked sorter (metric desc, recency tie-break,
  author spread, trusted-muted sink); Trending delegates unchanged, Most
  Zapped ranks on runtime.getZapTotal.
- Full wiring: feed definition, view + info popover, change-signal re-rank
  (order settles as receipts stream in), sidebar link, viewManager registry,
  feedCoordinator case/loader, FEED_TYPES.MOST_ZAPPED, and a
  FEATURE_MOST_ZAPPED_FEED flag (default on; off hides the tab).

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 21:38:53 -04:00
thePR0M3TH3ANandClaude Fable 5 35a50611fe upload: in-modal "Upload destination" storage picker (#44)
The storage summary gains a connection <select> (hidden with fewer than two
connections) listing every configured connection with the default labelled.
Selection is a per-modal override — loadFromStorage re-resolves the summary
and credentials immediately, stale selections fall back to the default, and
the account default stays untouched. Picker logic lives in
uploadModalStorageUnlock.js for UploadModal's file-size budget.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 21:24:13 -04:00
thePR0M3TH3ANandClaude Fable 5 d1a578f3a2 profile modal: always-visible "Acting as" identity card (#43)
Identity pill top-center on every pane, straddling the header divider, so
users always see which profile they're acting as (e.g. before uploading).
Populated in renderSavedProfiles (same resolveMeta path as the Account pane
card, so it refreshes on profile switch), hidden when logged out, and
click-through jumps to the Account pane.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 21:24:13 -04:00
thePR0M3TH3ANandClaude Fable 5 27be0301c5 todo: #10 bounded investigation — null.fill is a streamx/fast-fifo use-after-destroy; fix is a vendored 3.0.16 update as its own soaked task
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 21:04:29 -04:00
thePR0M3TH3ANandClaude Fable 5 096fe12c19 nostr: dev-gated REQ tracer at the pool.sub choke point (#9)
The cold-login REQ storm's remaining emitters are unpinned (the suspected
per-contact list loops don't exist; mutes are already batched). Every
sub/list flows through the shimmed pool.sub, so recordReq() there aggregates
kinds + relay fan-out + emitting call site per 10s window. Zero-cost when
off; arm with localStorage bitvid:reqTrace=1 (auto in dev mode), read
[req-trace] logs or window.__bitvidReqTrace.report().

One traced cold login on unstable will name the storm's sources; the
batching/deferral pass follows from that data.

Tests: tests/req-telemetry.test.mjs (3).

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 21:02:27 -04:00
thePR0M3TH3ANandClaude Fable 5 99d1b1dd36 tests: spec-correct four stale tests hidden by piped exit codes
A per-file sweep (local suite runs were reading the PIPE's exit code, not
npm's) surfaced four tests failing on HEAD:

- hash-change-handler: logged-out default is deliberately TRENDING since
  04621efc; test still expected most-recent-videos
- session-actor / sessionActor / nsec-login: encoded the pre-fix "no-arg
  clearStoredSessionActor wipes EVERY account" behavior that 2026-07-02
  deliberately removed (one account's logout must not destroy other saved
  accounts' keys). Replaced with stronger assertions: multi-account survival
  on no-arg clear, targeted forget, and the blocked-key security property
  asserted on the blocked account specifically.

Integrity notes in each file; TODO #11b addendum documents the pipe lesson.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 21:01:45 -04:00
thePR0M3TH3ANandClaude Fable 5 1934f9386a publish: all-timeout content publishes are unconfirmed soft successes (#49)
signAndPublishEvent (video publish/edit/repost/mirror), rebroadcastEvent, and
revertVideo now use assertAnyRelayAcceptedOrUnconfirmed: when every relay
times out without rejecting, the event was sent and almost always persisted,
so it no longer surfaces as "Failed to share video". Explicit rejections
still throw.

Messaging stays honest at every consumer:
- relay tally carries `unconfirmed` (now attached on edits too);
  describePublishOutcome maps 0-accepted+unconfirmed to a WARNING ("sent,
  no relay confirmed yet") — never a fake relay count, never a hard error
- edit/revert controllers say "sent, not confirmed yet" when unconfirmed
- deletes stay CONSERVATIVE: "deleted successfully" only when >=1 relay
  ACKed a tombstone or kind-5 (anyRelayAcceptedInSummaries); an unconfirmed
  revert no longer aborts deleteAllVersions midway, so the kind-5 always runs

Tests: tests/relay-ack-unconfirmed-content.test.mjs (6).

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 21:01:31 -04:00
thePR0M3TH3ANandClaude Fable 5 e32298cc89 uploads: locked-storage file picks unlock inline instead of dead-ending (#56)
All three paths (upload video pick, upload thumbnail pick, edit "Replace
file") now route through the shared signer gate — silent kept-unlocked
restore, else ONE passphrase prompt — then unlock storage and continue the
same pick. Cancel/bad-passphrase abort quietly; a refused thumbnail pick
restores the picker. Unlock glue extracted to uploadModalStorageUnlock.js
(file-size budget); ensureSigner threaded via initUploadModal/initEditModal.

Tests: tests/upload-edit-storage-unlock-gate.test.mjs (12).

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 21:01:20 -04:00
thePR0M3TH3ANandClaude Opus 4.8 7ead34378e [lint] Reword todo refs in test comments — #11b parses as a hex color
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 23:23:08 -04:00
thePR0M3TH3ANandClaude Opus 4.8 50d9d51a40 [tests] Un-quarantine nostr-publish-rejection (#11b, closes #50's harness gap)
Two fixes bring the file fully green in CI:
1. Force dev mode via globalThis.__BITVID_DEV_MODE_OVERRIDE__ BEFORE any
   config-importing module loads — the dev-default assertions depended on the
   environment (#50's documented test-harness gap).
2. spec_correction: the NIP-71 sub-test asserted publishVideo auto-invokes
   publishNip71Video whenever nip71 metadata is present. Stale on two counts:
   legacy 21/22 auto-publish is deliberately OFF (FEATURE_PUBLISH_NIP71:false;
   the opt-in 34235/36 MIRROR is the interop story, and #58 removed the form UI
   feeding this path), and the internal call uses the module-level function so
   the client-method stub never intercepted anyway. Corrected to assert the
   video note publishes with NO NIP-71 auto-publish while the flag is off.

Removed from the runner QUARANTINE map — remaining quarantined: user-blocks
(hang, needs deterministic rewrite) and nwc-client (mock-injection rework).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 23:22:43 -04:00
thePR0M3TH3ANandClaude Opus 4.8 3bc79f8e48 [edit] Grids refresh immediately after editing a video (#52)
Editing left grids showing the pre-edit version until a manual reload. The edit
flow already re-ran loadVideos(), but the cache still held the OLD version: unlike
new publishes (#46's optimistic insert), the edit never ingested its signed
replaceable event locally, so the cache-first render stayed stale until relays
echoed the update back.

handleEditVideoSubmit now ingests the edited signed event via
ingestLocalVideoEvent — newest-wins replace in the active store + videos:updated —
so the grid shows the new title/thumbnail/description immediately, with the relay
echo confirming behind it.

Test: nostr-service suite +1 (edited event ingested, videos:updated + videos:edited
emitted, result passed through). Lint green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 23:19:15 -04:00
thePR0M3TH3ANandClaude Opus 4.8 9afbc0cbbb [search] Decode npub authors to hex — the Author filter matched nothing (npubs)
The Author field (and the author: token) passed values through as-typed, but the
matcher compares against video.pubkey — HEX. So entering an npub (the only format
users actually have) silently returned zero results; only raw hex worked.

New normalizeAuthorValues (searchFilters.js, injectable decoder for tests):
npub1* decodes via safeDecodeNpub to hex, hex passes through lowercased, and
undecodable values are dropped — surfaced as parse errors on the author: token
path. Applied in BOTH the parser and the filter modal's applyFilters, so the
Author panel field and the token syntax now genuinely filter by creator.

Tests: +2 in tests/search-filters.test.mjs (decode/passthrough/drop with an
injected decoder; parser hex passthrough + garbage error). Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 23:12:20 -04:00
thePR0M3TH3ANandClaude Opus 4.8 a6a6d4b0f7 [search] Wire sort/textScope/relay:, fix dual-type duration, drop dead flag, first tests
Audit found the search pipeline largely real (token parser, local + NIP-50 relay
search, enforced author/tag/date/has/nsfw/kind filters, wired modal) with dead
spots, now fixed:

- sort: offered in the modal but never applied. New pure sortSearchResults —
  recent (default), views (shared view-count cache), trending (views weighted by
  recency), longest — applied in searchView.
- duration filter only read nip71.duration, so bitvid's OWN kind-30078 videos
  (top-level duration) never matched. Both video types now satisfy duration
  filters; same shapes cover the planned live (30311) / shorts kinds.
- textScope now enforced in the local title/description match (all|title|description).
- relay: now scopes the video relay-search to that relay (was parsed-but-unused).
- FEATURE_SEARCH_FILTERS removed: dev-only flag consumed by NOTHING while the
  filter UI ships and works everywhere (plumbing + setter deleted, no consumers).

Tests (tests/search-filters.test.mjs, 4 — surface previously had zero): parser
tokens; matcher enforces tags/date/has:magnet/duration for BOTH duration shapes;
views/longest/recent sorts (no input mutation); trending ranks fresh-riser above
stale-hit. Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 23:07:22 -04:00
thePR0M3TH3ANandClaude Opus 4.8 4591d4a260 [onboarding] Fix popover pushed off-screen by full-height targets (sidebar step)
Step 2 (sidebar) left the user "stuck looking at a glow with nothing to do": the
sidebar is a full-height column, so the below/above-only placement computed a
popover position past the bottom of the viewport — the card existed but was
invisible off-screen.

Placement is now a pure, exported decision (computeTourPlacement): try below →
above → beside-right → beside-left, whichever actually fits the popover, with a
centered-over-the-dim fallback when nothing does (viewport-filling targets) — and
coordinates are clamped so the card can NEVER leave the viewport. The sidebar
step also hints placement: "right". A preferred placement that doesn't fit falls
back to one that does.

Tests: +1 in tests/onboarding-tour.test.mjs — full-height column → beside-right
inside the viewport; button → bottom; near-bottom target → top; viewport-filling
target → centered; unfittable preference falls back and never goes below the
fold. Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 22:50:46 -04:00
thePR0M3TH3ANandClaude Opus 4.8 8e7c6bc325 [onboarding] First-run guided tour with spotlight + accent glow (#59, phase 1)
New-user onboarding per docs/onboarding-plan.md: on the first login of a pubkey
on a device, a skippable guided tour escorts the user around bitvid — a scrim
dims the page with a spotlight CUTOUT over each step's anchor, ringed by a
pulsing glow driven by the theme's accent tokens, plus an anchored popover with
Back/Next/Skip, progress dots, and arrow/Esc keyboard nav.

- js/ui/onboarding/tourEngine.js: dependency-free engine. Spotlight = one
  element whose 200vmax box-shadow (--surface-overlay-darker) dims everything
  except the cutout; an ::after ring animates between --color-accent and
  --color-accent-strong. Positioning via CSS custom properties set from JS (the
  sanctioned dynamic-var pattern; visual styling lives in an injected token-only
  stylesheet). Steps with missing anchors auto-skip, so one script serves all
  layouts. Repositions on resize/scroll.
- js/ui/onboarding/bitvidTour.js: the step script (welcome → feeds →
  subscriptions → upload → profile → setup CTAs that deep-link into the storage
  and wallet panes via profileController.show).
- js/services/onboardingService.js: once-per-pubkey-per-device flags
  (bitvid:onboarding:v1, settingsRestorePrompt-style).
- app.maybeOfferOnboardingTour fires 3.5s after login (feed already rendered
  thanks to the warm cold-boot work; never blocks login);
  app.startOnboardingTour({force}) + a "Take the tour" button in the profile
  modal footer make it re-launchable.
- Lint allowlists: tourEngine.js added to the inline-style allowlist (CSS-var
  positioning, VideoCard precedent) and the design-token ALLOWED_FILES
  (injected stylesheet geometry, RevertModal precedent) — all colors are tokens.

Tests: tests/onboarding-tour.test.mjs (5 — flag lifecycle, tour render/advance/
finish, skip teardown, missing-anchor exclusion, once-vs-force offering, final
CTA deep-link + completion). Lint + build green.

Phases 2-4 (getting-started checklist, empty-state upgrades, fresh-npub create-
account + key backup + kind-0 bootstrap) tracked in the plan + TODO #59.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 22:44:45 -04:00
thePR0M3TH3ANandClaude Opus 4.8 4795d5edbd [auth] Bug pass on login/signer/session surface: 4 fixes before promotion
Requested pre-promotion sweep of login methods, active signers, and anon session
actors. Four real bugs found and fixed:

1. Logging out could wipe EVERY saved account's remembered nsec key. The no-arg
   clearStoredSessionActor() removed the whole per-account v2 map, and it was
   reachable from SignerManager.logout() (non-persisted nsec cleanup) and from
   the blocked-key cleanup in registerPrivateKeySigner — same bug class as the
   NIP-46 session-map wipe fixed earlier. Now: the no-arg form clears only the
   legacy v1 slot; logout targets the departing account (and skips entirely when
   it can't name one); the blocked-key cleanup targets the blocked pubkey
   (mirroring the already-targeted twin in unlockStoredSessionActor).
   Spec-corrected the test that had encoded the wipe-all as intended behavior.

2. Removing a saved profile left its keys on the device. The switcher's
   remove-account flow cleared NWC + the keep-unlocked cache but kept the
   remembered (encrypted) nsec key and the NIP-46 session. New
   client.forgetStoredAccount(pubkey) (SignerManager.forgetStoredSessionActor +
   clearStoredNip46Session) forgets both WITHOUT touching the active session's
   in-memory actors; wired into handleProfileLogoutRequest.

3. Logout never actually re-locked a kept-unlocked device. The forget read
   detail.pubkey / this.pubkey — both already nulled by authService.logout()
   (the detail only carries previousPubkey) — so the cached decrypted key
   silently survived logout. Now reads previousPubkey.

4. restoreUnlockedSigner bypassed access control: a since-blacklisted account's
   cached key would silently restore its signer. It now accepts a validator
   (mirroring unlockStoredSessionActor), forgets the cached key on denial, and
   both app-level call sites (_initAutoLogin, ensureEncryptionCapableSigner)
   pass an accessControl.canAccess gate.

Also verified-safe during the pass (no action): logout's persisted-nsec keep
logic under the ephemeral session-actor overwrite; NIP-46 logout keepStored;
the ephemeral actor as a harmless extra DM-decrypt candidate; corrupt-map
recovery clears; anon blacklist-seeding now always has a signing-capable actor.

Tests: session-actor multi-account suite spec-corrected + re-verified (no-arg
clear keeps per-account keys); 57 tests across session-actor / nip46 /
unlocked-key / switch / signer-gate / feed-gate suites green. Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 21:59:52 -04:00
thePR0M3TH3ANandClaude Opus 4.8 b8e6921ac0 [perf] Stale-while-revalidate cold boot: don't serialize behind admin lists + list sync
Cold refresh stalled on "Fetching moderation filters…" and then (logged in)
"Fetching subscriptions…" before the grid could render. Boot was SERIAL behind
relay fetches even though every stage already had a local cache:

1. _initAccessControl awaited the full admin-list relay refresh (up to 15s)
   before auto-login — but accessControl hydrates whitelist/blacklist from
   localStorage at construction, and isHydrated() existed for exactly this case
   (unused). Now: when cached lists are present (every visit after the first),
   boot proceeds immediately and the refresh continues in the background — the
   existing whitelist/blacklist change listeners re-filter the grids when it
   lands. First-ever visits still wait (a whitelist-mode instance must never
   render unfiltered). The admin-pane refresh no longer blocks boot at all.

2. bootstrapTrustedSeeds awaited the same relay refresh (up to 3.5s more).
   waitForAccessControl now fast-paths on isHydrated(): cached editors seed
   trust immediately, and its onEditors/Whitelist/BlacklistChange listeners
   already re-apply seeds when the real refresh lands.

3. The post-login feed gate held the grid for the FULL list sync + NIP-07
   decryption (up to 8s). New js/app/feedListCacheGate.js: a probe that checks
   all three list services (blocks/subscriptions/hashtags) hold a valid cache
   for the active pubkey, raced into the gate — warm boots open as soon as the
   caches hydrate (first ticks) and the background sync re-filters on arrival.
   Cold boots (no caches) behave exactly as before.

Net effect: warm cold-refresh renders from IndexedDB-cached events with cached
filters/lists near-instantly; relay truth streams in behind it. No moderation
semantics changed — every stage that lacks a cache still waits.

Tests: tests/feed-list-cache-gate.test.mjs (5 — all-three-caches probe incl.
stale-pubkey/missing-service, late-hydration early release, cold-boot timeout,
throwing probe). Access-control + switch suites green. Lint + build green
(coordinator compacted to stay under its size cap).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 15:32:34 -04:00
thePR0M3TH3ANandClaude Opus 4.8 5466d97544 [ui] Remove NIP-71 authoring sections from upload/edit modals (#58)
bitvid authors kind-30078 and MIRRORS to NIP-71; it never authors NIP-71 directly.
Verified before removing: the My Videos mirror (buildNip71MirrorEvent) derives
everything from the bitvid note itself (duration/dims are auto-captured at upload,
contentWarning comes from the NSFW toggle, imeta is built from the video), and the
only consumer of the form's NIP-71 metadata (publishNip71Video) is gated behind
FEATURE_PUBLISH_NIP71: false — the fields fed a dead path and confused uploaders.

Removed from the upload modal: "NIP-71 Technical" (content warning, duration,
summary + Customize toggle) and the IMETA variant editor. Removed from the edit
modal: kind (21/22) selector, published_at, alt, duration, content warning,
summary, IMETA, text tracks, segments, participants, references. KEPT in both:
hashtags (they feed hashtag feeds, the suggestion chips, and the mirror's t tags)
and the web seeds / torrent (xs) inputs (bitvid-note fields, not NIP-71). All the
JS plumbing is missing-DOM tolerant, so no wiring changes were needed to collect.

Compatibility: existing videos' NIP-71 extras survive a save. EditModal keeps the
video's full stored metadata (storedNip71Metadata) and merges the edited hashtags
into it on submit instead of replacing it with the reduced form's empty output
(t mirror key kept in sync).

Also fixes a latent pre-existing bug the reduction exposed: nip71Edited compared
the submit-side copy AFTER publishedAt normalization (string→timestamp,
delete-empty) against an UN-normalized original, so any video with publishedAt
made every save look "edited". Both sides now share normalizeCollectedNip71.

Tests: +2 in edit-modal-submit-state (stored extras survive a hashtag edit;
untouched save keeps nip71Edited false). Modal/payload/mirror suites green
(39 + 7). Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 15:18:10 -04:00
thePR0M3TH3ANandClaude Opus 4.8 84cec15483 [perf] Fix SW image cache slowing cold thumbnail bursts (grid load regression)
"Recently Added" grids loaded visibly slower after the image cache shipped. The
feed's data path was untouched — the regression was the thumbnails: the SW handler
had three compounding costs on a cold cache:

1. The network fetch waited behind caches.open + cache.match, delaying every
   thumbnail's download start.
2. caches.open ran per request instead of once.
3. trimImageCache ran a full keys() enumeration after EVERY put — a grid burst of
   dozens of thumbnails did dozens of O(n) enumerations, all contending with the
   cache.match calls of later images.

Fix: start the network fetch immediately (cold path is now as fast as no SW; a
warm cache still serves the cached copy for first paint), memoize the cache open,
and amortize trims (every 20 puts). SW_VERSION bumped so testers pick it up.

Tests: sw-image-cache (7 — adds a regression test that gates cache.match behind a
promise and asserts the download starts anyway; all prior behaviors re-verified
against the real sw.min.js). Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 14:59:21 -04:00
thePR0M3TH3ANandClaude Opus 4.8 c20938b4aa [perf] Media caching: immutable upload headers + SW image cache (#55)
Verified first that event caching needs nothing: allEvents (incl. ingested NIP-71)
already persists to IndexedDB and every grid renders cache-first + merge-new. The
real gap was MEDIA: thumbnails/avatars/banners re-downloaded across visits.

Part 1 — Cache-Control on uploads (js/storage/s3-multipart.js):
computeCacheControl already stamped most media immutable but missed webp/avif/ico
and .torrent files (which fell to the 1h default). Added them — safe because
buildR2Key namespaces keys by infohash (content-addressed, a key's bytes never
change). Playlists (m3u8/mpd) stay short-lived (live manifests mutate in place).
Exported for tests.

Part 2 — service-worker image cache (sw.min.js):
Cross-origin images (the hosts bitvid does NOT control headers for) are now served
stale-while-revalidate from a versioned Cache-API cache (bitvid-images-v1):
- instant on revisit; background refresh self-heals changed images (and where
  part 1's immutable headers apply, that refresh is served by the HTTP cache — free)
- opaque (no-cors) responses cached — exactly the external-thumbnail case
- LRU-capped (300); quota failures drop the cache and fail open; every handler
  error falls back to plain fetch so a caching bug can never break images
- the activate handler's wipe-all now PRESERVES the current image cache (old
  versions + other caches still wiped); same-origin assets untouched (they ride
  the host HTTP cache + dist cache-purge pipeline)
- app._initServiceWorker registers the SW at boot when none exists, same
  path/scope/options as webtorrent.js's setupServiceWorker so playback's later
  register() resolves to the same registration (playback path untouched)
- SW_VERSION bumped

Tests: storage-cache-control (3, exported policy) + sw-image-cache (6 — evaluates
the REAL sw.min.js in a VM sandbox and drives its handlers: cold/warm/offline/
opaque flows, same-origin + non-image non-interception, activate preserves the
image cache). Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 14:48:18 -04:00
thePR0M3TH3ANandClaude Opus 4.8 9e5cb9f57b [channel] Show view counts on channel-profile cards (were blank)
Channel-profile video cards rendered blank view counts. The main feed subscribes
each card's [data-view-count] element to the shared view counter
(VideoListView.ensureViewCountSubscription / registerVideoViewCountElement), but the
channel-profile grid renders its own VideoCards and never did — so nothing ever
populated the element.

Add js/channelViewCounts.js (subscribeChannelCardViewCount /
clearChannelCardViewCounts) and call it from channelProfile.js after each card's
getRoot(); teardown runs in clearChannelVideoCardRegistry (already invoked on grid
re-render / channel close). Logic lives in a new module so channelProfile.js stays
under its size cap.

On "are they counted correctly?": yes — this subscribes to the SAME batched
kind-30079 view-count transport the feed uses (one shared subscription + the deduped
cache), so the channel now shows the exact same counts the feed does. The counting
itself was never the issue; the channel grid just wasn't displaying it.

Tests: tests/channel-view-counts.test.mjs (3 — non-blank label, safe no-ops,
idempotent teardown). Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 10:44:09 -04:00
thePR0M3TH3ANandClaude Opus 4.8 6b251dbda5 [reactions] Fix "Missing signing primitives" — session actor must have a usable key
Root cause of "Failed to send reaction" (after the dispatch fix made the button
reach the publish path): reactions are published under the anonymous SESSION ACTOR,
signed with its private key. But ensureSessionActor adopted a stored session-actor
entry as that key — and a persisted nsec login is passphrase-encrypted with
privateKey "" ("never exposes plaintext"). So a leftover saved-nsec entry hijacked
the anonymous telemetry key, and signEventWithPrivateKey(event, "") threw
"Missing signing primitives". (Repro: logged in with NIP-07 while a saved nsec
account exists on the device.)

Every ensureSessionActor caller uses it to SIGN telemetry (reactions / view events /
watch-history), so the returned actor must carry a usable plaintext key. Now it only
reuses a current/stored actor that has one; otherwise it tracks the locked entry (for
other flows) and generates a fresh ephemeral signing key — instead of returning a
keyless actor that can sign nothing.

Existing session-actor suites green; lint + build green.

Follow-up: the fresh ephemeral key isn't persisted, so a like isn't remembered across
reloads (different anonymous key) — separate from this crash fix.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 10:02:43 -04:00
thePR0M3TH3ANandClaude Opus 4.8 6e2894e3d8 [video-modal] Fix dead like/dislike buttons — dispatch video:reaction (#54)
The real #54 bug (the signer gate was correct but unreachable): the reactions
controller forwards the raw click event to VideoModal.handleReactionClick, which
called this.reactionsController.handleReaction(event) — a method that does NOT
exist on the video-modal reactions controller (it only binds buttons + does
optimistic UI), passing the DOM event as the "reaction". So clicking like/dislike
threw / silently did nothing and never reached the publish path.

Fix: handleReactionClick now derives +/- from the clicked button
(#modalLikeBtn / #modalDislikeBtn, or a bare "+"/"-" for programmatic callers) and
dispatches "video:reaction" — the event ModalManager already routes to
app.handleVideoReaction -> reactionController.handleReaction (signs + publishes the
kind-7, and runs the ensureEncryptionCapableSigner gate from the earlier #54 pass).

Note: #53 (channel avatar/name link) is already fixed in a20388c5
(handleCreatorNavigation now dispatches "creator:navigate"); if it still looks dead
it's a stale build — rebuild dist / redeploy the branch under test.

Tests: tests/video-modal-controllers.test.mjs (+2). Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 09:40:46 -04:00
thePR0M3TH3ANandClaude Opus 4.8 757fca1059 [edit] Host the .torrent (xs=) for edited external URLs too — close the gap
The edit-modal external-URL webseed was ws=-only because the Edit modal had no
storage credentials wired in. Reuse mediaUploader.resolveActiveConnection() (the
same resolver the Edit modal's file-replace uploads use) to get the active
provider + credentials when storage is unlocked, and pass them to
deriveTorrentForExternalUrl so it hosts the tiny .torrent and emits ws=+xs= — full
P2P bootstrap, matching the upload modal. Storage locked / no connection → ws=-only
(unchanged, best-effort).

Edit-modal submit-state + upload-field + external-url-torrent suites green.
Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 09:17:32 -04:00
thePR0M3TH3ANandClaude Opus 4.8 11c7884366 [edit] Derive a webseed torrent when editing a video's URL to an external link
Mirror the upload-modal external-URL webseed for the Edit modal: when the user
changes a video's URL to an external link with no magnet, auto-derive the webseed
torrent (fetch + hash the remote file → magnet ws=url) so an edited external URL
keeps the P2P benefit. Best-effort + size-capped; on CORS / too-large / any error
it stays URL-only. Runs before the existing magnet normalization so the derived
magnet's ws/xs are extracted the same way a pasted magnet's are.

submit() is now async (it was fire-and-forget from the form handler; the
pendingSubmit guard already prevents re-entry, so it just stays "pending" during
the derivation).

Known gap: hosting the tiny .torrent (xs=) on the edit path needs storage-
credential plumbing the Edit modal doesn't currently have — so the edit path is
ws=-only for now (upload path hosts xs= when storage is unlocked). Tracked as a
follow-up.

Edit-modal submit-state + upload-field tests green. Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 09:12:14 -04:00