Commit Graph
6539 Commits
Author SHA1 Message Date
thePR0M3TH3ANandClaude Opus 4.8 2e7e2016d3 [ui] B2 storage: use pipx for the CORS CLI install + rename secret field to "Application Key"
- CORS setup helper: the Backblaze B2 instructions now say `pipx install b2` instead of
  `pip install b2` (the recommended way to install a CLI tool).
- Storage form: for the Backblaze B2 provider the "Secret Access Key" field is now
  labelled "Application Key" to match B2's own terminology (the secret half of an
  Application Key). Other providers keep "Secret Access Key".

Test updated to assert the pipx instruction. Build + lint clean.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-30 15:19:22 -04:00
thePR0M3TH3ANandClaude Opus 4.8 1144bde881 [ui] #8: one-click "Delete all" for orphaned storage objects in My Videos
The My Videos tab already lists bucket objects no live note references and offers
per-file delete; this adds the bulk action. A "Delete all (N)" button (shown only when
there's more than one orphan) confirms once, then removes every listed key in a single
deleteStorageKeys({ keys, pubkey }) call — which is already array-capable, unlock-gated,
and scoped to the user's prefix. Refreshes the list on success, reports partial results
honestly ("Removed 2 files (1 could not be deleted)"), and surfaces storage-locked as an
unlock prompt instead of a false success.

Tests: tests/my-videos-bulk-orphan-delete.test.mjs (5 — all keys sent in one call,
cancel deletes nothing, storage-locked prompt, partial-count message, empty no-op).
Build + lint clean; My Videos suite 37/37.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-30 11:39:57 -04:00
thePR0M3TH3ANandClaude Opus 4.8 a673ee96aa [storage] #39: fix profile image/banner upload (resolveConnection rejected hex pubkeys)
The Edit Profile image + banner upload was wired (resolve connection -> uploadFile ->
auto-fill URL) but silently broken: ProfileEditController passes a HEX pubkey to
r2Service.resolveConnection(), which only decoded npub1... strings via safeDecodeNpub.
A hex pubkey therefore resolved to null -> "Storage configuration missing" and the
Upload button stayed disabled even with storage configured. (Thumbnails worked because
mediaUploader calls listConnections(pubkey) directly.)

Fix: resolveConnection() now accepts a hex pubkey OR an npub. The rest of the chain is
already provider-agnostic (resolveConnection returns the defaultForUploads connection's
endpoint/baseDomain/forcePathStyle, uploadFile routes R2 vs generic-S3 by provider,
buildPublicUrl builds the URL), so profile/banner upload now works for R2 / B2 /
Custom S3.

Test: tests/r2-resolve-connection-hex.test.mjs (3 — nip19.decode throws on hex to prove
the hex path is used, plus npub still works and empty returns null). Build + lint clean;
r2/storage suites green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-30 11:07:11 -04:00
thePR0M3TH3ANandClaude Opus 4.8 f0beab2713 [ui] store storage credentials per provider so R2 / B2 / S3 don't clash
The Storage pane saved every provider into a single shared "default" connection id,
so configuring Backblaze B2 overwrote your Cloudflare R2 (and vice-versa). The service
layer, upload path, and sync already supported a MAP of connections keyed by id
(listConnections / defaultForUploads / exportAccountRecord) — only the UI collapsed
them into one slot.

- Save each provider under its own slot keyed by provider id (cloudflare_r2 /
  backblaze_b2 / generic_s3), preserving a single active defaultForUploads and lazily
  migrating the legacy "default" slot to its provider id.
- Switching the provider dropdown now loads that provider's saved credentials (or
  clears the fields for a fresh entry), so each provider keeps its own creds visibly.
- Sync is unchanged: it already exports the whole account record, so every provider's
  connection syncs together.

Logic extracted to js/ui/profileModal/storageConnections.js (saveProviderConnection +
pure helpers) so ProfileStorageController stays under the 1000-line cap. Tests:
tests/storage-connections.test.mjs (7 — pure default/dup logic + integration proving
R2+B2 coexist with one default, the re-default switch, legacy-"default" migration, and
that the synced record carries both). Storage + profile-modal suites 69/69; lint clean.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-30 10:58:30 -04:00
thePR0M3TH3ANandClaude Opus 4.8 6006813b7e [ui] #38: make the CORS setup helper provider-aware (R2 / B2 / Custom S3)
The first cut showed for Custom S3 too but always gave B2-only instructions (b2 CLI +
B2-native JSON), and was hidden for R2. Now the helper shows for all bucket providers
and tailors its content:

- Backblaze B2  -> B2-native rules + `b2 update-bucket --corsRules ...`
- Custom S3     -> standard S3 CORSConfiguration + `aws s3api put-bucket-cors
                   --endpoint-url <endpoint> ...`
- Cloudflare R2 -> standard S3 JSON + Cloudflare dashboard guidance (R2 -> bucket ->
                   Settings -> CORS Policy) and the AWS-CLI command vs the derived R2
                   endpoint.

Origins pre-filled from getCorsOrigins(); intro/command-label/notes set per provider;
copy buttons for both blocks. bitvid still auto-applies CORS on Save (R2/S3 via
ensureBucketCors) — this is the manual fallback when the key lacks CORS-write perms.

buildCorsHelpContent() switches on provider; modal text is now data-driven. Tests
extended to 10 (per-provider rules + command + a DOM provider-switch). Build + lint
clean; storage suites 40/40.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-30 10:45:50 -04:00
thePR0M3TH3ANandClaude Opus 4.8 d7ad151698 [ui] #38: add a CORS setup helper modal for B2 / S3-compatible buckets
Confirmed against the real B2 account: the web-console "Share everything..." presets
write DOWNLOAD-only CORS (s3_get/s3_head, b2_download_*) with NO s3_put/s3_post, so
bitvid's browser uploads CORS-fail. The web UI can't set custom rules — it points
users at the B2 CLI.

Adds a "CORS setup help" button (shown for B2 / Custom S3, hidden for R2) that opens a
modal with:
- the exact CORS rules JSON in B2-native shape (upload + ranged-playback ops; origins
  pre-filled from getCorsOrigins()), with a Copy button,
- the `b2 update-bucket --corsRules '<json>' <bucket> allPublic` command to apply it,
- step-by-step + the "use a key with writeBucketCors and bitvid sets it for you" tip.

CORS logic extracted to js/ui/profileModal/storageCorsHelp.js (pure builders +
StorageCorsHelp modal controller) so ProfileStorageController stays under the 1000-line
cap. Tests: tests/storage-cors-help.test.mjs (8 — asserts the rules include the upload
ops the presets omit, origin pre-fill, command shape, modal open/close/visibility).
Build + lint clean; profile-modal + storage suites 32/32.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-30 10:37:24 -04:00
thePR0M3TH3ANandClaude Opus 4.8 e79595b902 [storage] #38: add Backblaze B2 as a first-class storage provider
B2 doesn't map onto the generic-S3 "paste an endpoint" model: its S3-compatible
endpoint is region-scoped (s3.<region>.backblazeb2.com) and a public bucket is
addressed virtual-hosted-style, so the public download URL is
https://<bucket>.s3.<region>.backblazeb2.com/<key> — not derivable from a pasted
generic endpoint. So B2 silently failed under "Custom S3".

- s3-url: deriveB2Endpoint(region) -> https://s3.<region>.backblazeb2.com (empty for
  auto/blank/pasted-host so callers can require a real region); re-exported from
  s3Service.
- storageService: PROVIDERS.B2 + resolveB2Endpoint (explicit endpoint wins, else
  derive) + PROVIDER_TESTS[B2] (derived endpoint, forcePathStyle false).
- Public URL = S3-style virtual-hosted, auto-derived; an explicit Public Access URL
  (custom domain/CDN) overrides it. The save path now forwards the user's public URL
  to prepareS3Connection (also fixes generic-S3 ignoring a custom CDN URL when blank
  -> still derives, so no regression).
- UI: "Backblaze B2" provider option + helper; raw Endpoint hidden for B2 (region
  drives it), region shows a us-west-004 example, force-path-style toggle hidden;
  save/test derive endpoint from region with a clear "enter your region" error.
  UploadModal shows B2-specific labels.
- Tests: tests/storage-b2-provider.test.mjs (8). All s3/storage suites green (50);
  build + lint clean.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-30 10:05:53 -04:00
thePR0M3TH3ANandClaude Opus 4.8 35bb1bc576 [ui] #36: auto-open the unlock-saved-key flow from storage when an nsec session is locked
Follow-up to the locked-nsec detection: clicking "Unlock Storage" on a locked
persisted-nsec session (no in-memory signer after a page reload) now auto-opens the
login modal straight to its existing unlock-saved-key (passphrase) flow, so the user
re-unlocks in one step instead of hunting for the Login menu. Once unlocked, the
signer is active app-wide and re-opening the storage pane auto-unlocks.

- applicationBootstrap: add a side-effect-free openLoginModal service to
  profileModalServices (app.loginModalController.openModal — no add-account callback;
  idempotently ensures the modal is initialized first).
- ProfileStorageController: reportLockedNsecSession({ autoOpenLogin }) opens the modal
  only from the user-initiated unlock click, never from the passive pane render (no
  surprise popups).
- Tests extended to 8 (auto-open fires on click; stays closed on passive render).

Build + lint clean.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-30 09:35:40 -04:00
thePR0M3TH3ANandClaude Opus 4.8 a669040740 [ui] #36: clear, actionable storage-unlock error for a locked nsec session after reload
Root cause (user-confirmed repro): a persisted "remember this key" nsec session
restores the logged-in pubkey + UI on reload, but NOT the in-memory signer (the
private key is only passphrase-encrypted). Storage unlock then has no signer, and
the fallback (ensureActiveSignerForPubkey) can only rebuild an EXTENSION signer —
so an nsec user dead-ended on the misleading "No active signer found. Please login."
even though they still look logged in. The crypto layer itself is correct: an
empirical repro shows storageService.unlock round-trips master-key encrypt/decrypt
fine with real nsec cipher closures.

Fix: detect the locked persisted-nsec case (getLockedStoredNsecSession via
getStoredSessionActorMetadata, scoped to the account being unlocked) and surface an
actionable error/status ("re-enter your passphrase from the Login menu to unlock
your saved key, then unlock storage") in both the Unlock-button flow and the passive
Storage-pane status text, instead of the generic no-signer / no-decryptor messages.
New error code storage-unlock-locked-nsec-session.

Tests: tests/storage-unlock-locked-nsec.test.mjs (6 behavioral). Build + lint clean;
existing storage-service tests still 14/14.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-29 22:19:45 -04:00
thePR0M3TH3ANandClaude Opus 4.8 eb4d071203 [ui] #34(b): My Videos mirror toggle reflects relay truth, not just the local flag
The per-row "Share to apps" toggle read only the device-local opt-in flag
(nip71MirrorFlags), so a fresh device / cleared cache / other device reported a
video as un-shared even when its NIP-71 mirror events (34235/34236) exist on the
user's relays.

- nip71MirrorService: add batched findMirrors(videos) -> Map<videoRootId,
  {mirrored, kinds, duplicate}>, one lookup per root via the SubscriptionManager
  chokepoint (no direct pool.list).
- MyVideosController: tag each mirror button with data-mirror-root; after the
  synchronous (local-flag) render, refreshMirrorStates() looks up relay truth,
  reconciles the local flag to match (flag stays a cache hint), and corrects each
  button label. Fire-and-forget; detection failures fall back to the local flag.
- tests/my-videos-mirror-relay-truth.test.mjs: 4 jsdom behavioral tests
  (promote unmirrored->shared, clear stale shared, per-row batch reconcile,
  button tagging). Build + lint clean; mirror+my-videos suites 48/48.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-29 21:45:23 -04:00
thePR0M3TH3ANandClaude Opus 4.8 26d67d59b2 fix(mirror): idempotent NIP-71 re-mirror + self-heal cross-kind duplicates (#34)
The mirror kind (34235 normal / 34236 short) is inferred from the video's
dimensions, but (kind,pubkey,d) is the addressable identity — so re-mirroring with
different dims published the OTHER kind and showed the video TWICE in NIP-71
clients.

nip71MirrorService.publish() now looks up the author's existing mirror (both kinds,
via the SubscriptionManager chokepoint) and REUSES the existing kind so a re-publish
replaces the same coordinate. If both kinds already exist (the bug's aftermath), it
self-heals by NIP-09-deleting the stale-kind coordinate. Explicit options.short still
wins. Added findMirror(video) for relay-truth detection ({mirrored,kinds,duplicate}).

Dependency-injected; 4 new scenario tests (12/12 green); lint clean. Follow-up (b):
wire findMirror into MyVideosController so the UI toggle reflects relay truth instead
of the device-local flag.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-29 20:57:04 -04:00
thePR0M3TH3ANandClaude Opus 4.8 432e5d0149 docs(todo): #34 NIP-71 mirror audit — device-local detection + unstable kind (34235/34236) cause stale state + cross-kind duplicates
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-29 20:28:01 -04:00
thePR0M3TH3ANandClaude Opus 4.8 74a6bbbd76 docs(todo): add explicit VERIFY task for #33 NIP-46 diagnostics
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-29 20:25:21 -04:00
thePR0M3TH3ANandClaude Opus 4.8 088a5c797d diag(nip46): add temporary lifecycle diagnostics for #33
Code-level audit of the NIP-46/Amber login+profile+persistence bug points at a
single shared SignerManager.nip46Client + per-profile restore/switch not being
multi-account-aware. Added TEMP [nip46-diag] userLogger traces (visible on
unstable; devLogger is gated off there) at:
- scheduleStoredRemoteSignerRestore (restore-on-load → "unknown user")
- disconnectRemoteSigner (the shared-client teardown → "logs out everyone")
- SignerManager.logout (scope)
- authService.switchProfile (does the switch resolve the expected pubkey)

These let a single Amber reproduction pinpoint the exact failure before the fix.
Findings + plan recorded under TODO #33. Remove diagnostics with the fix.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-29 20:22:40 -04:00
thePR0M3TH3ANandClaude Opus 4.8 adf0488df8 fix(css): replace var() in @media conditions with literal (#40)
CSS custom properties are invalid inside @media conditions — Chromium/Brave drop
the whole block — so every `@media (max-width: calc(theme("screens.lg") -
var(--breakpoint-edge-offset)))` was DEAD. The offset token was added to satisfy the
design-token lint, but `0.02px` is already exempt there, so the token was never
needed and silently broke the queries.

Swapped var(--breakpoint-edge-offset) → 0.02px in all 4 @media conditions (valid,
lint-clean, exclusive breakpoint preserved). Verified valid via matchMedia. This
re-activates 3 previously-dead mobile blocks that were intended but never worked:
full-screen modals on mobile, profile-modal menu/pane switching, and the video-card
tap-active state (plus the profile-nav app-grid).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-29 20:09:52 -04:00
thePR0M3TH3ANandClaude Opus 4.8 5ea5e266e4 fix(mobile): profile-modal grid was in a dead @media block (var() in condition)
The profile-modal nav app-grid never applied because its @media condition used
`calc(theme("screens.lg") - var(--breakpoint-edge-offset))` — CSS custom
properties are invalid inside @media conditions, so Chromium/Brave drop the whole
block (confirmed via matchMedia). My earlier in-browser preview used !important,
which bypassed the media query and masked it.

Moved the grid rules into a valid `@media (max-width: theme("screens.lg"))` block
(theme() resolves to a literal at build time). Cascade verified without !important:
display:grid, 3 columns, flex-direction:column. Lint clean.

Filed #40: the same var()-in-@media pattern silently breaks many other responsive
rules (it was added to satisfy the token lint) — needs a broader audit.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-29 19:52:16 -04:00
thePR0M3TH3ANandClaude Opus 4.8 39eeee9ccc docs(todo): record profile-modal app-grid under #7 (sidebar reverted)
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-29 14:49:56 -04:00
thePR0M3TH3ANandClaude Opus 4.8 e38866c372 feat(mobile): profile modal tabs become an app-grid (#7)
On mobile (<1024px, the full-width menu view), the profile modal's tab nav
(Account, Relays, Wallet, Storage, …) renders as a 3-column grid of icon tiles
with the title centered below each icon, instead of the vertical list. Desktop
(>=lg) keeps its vertical nav, unchanged.

Scoped to `.profile-modal__nav` inside the existing mobile media query; token-only;
labels wrap at spaces. Validated live in-browser.

(Note: the earlier sidebar app-grid was a misread of the request and was reverted;
this is the intended component.)

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-29 14:49:27 -04:00
thePR0M3TH3AN ab20640b9b Revert "feat(mobile): sidebar nav becomes an app-grid on mobile (#7)"
This reverts commit d0f8859f8b.
2026-06-29 14:42:47 -04:00
thePR0M3TH3ANandClaude Opus 4.8 d0f8859f8b feat(mobile): sidebar nav becomes an app-grid on mobile (#7)
Below the desktop breakpoint (<1024px), the open/expanded sidebar's primary nav
renders as a 3-column grid of icon tiles with the title centered below each icon
(app-grid feel), instead of the desktop vertical list. Desktop and the collapsed
icon rail are unchanged.

- Scoped to `.sidebar-nav` so the footer "More" links are untouched.
- Token-only (no raw measurements), high specificity via the sidebar-expanded
  prefix (no !important). Labels wrap at spaces, not mid-word.
- Validated live in-browser; lint clean.

Option A (grid shows on the open mobile menu). An always-on variant was deferred —
it would require widening/overlaying the mobile sidebar.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-29 12:52:17 -04:00
thePR0M3TH3ANandClaude Opus 4.8 e73b627ddd docs(todo): add reported items (#33-#39) + mobile-UI note to #7
From 2026-06-25 user report:
- #33 (HIGH): NIP-46/Amber login+profile+persistence broken — "unknown user" /
  no videos until logout+hard refresh, profile switching broken, logout logs out
  all accounts; audit the login/profile/session-persistence system.
- #34: NIP-71 mirror shows mirrored videos as "not mirrored"; re-mirror creates
  duplicates in NIP-71 clients; make detection reliable + the publish idempotent.
- #35: admin whitelisting tool slow/cumbersome — improve alongside #22/#23.
- #36: storage unlock fails with nsec login.
- #37: channel playlists (creator-curated note lists).
- #38: generic S3 doesn't work with Backblaze B2 (endpoint/public-URL mismatch) —
  add B2 as its own provider.
- #39: image uploads (profile image/banner first) should prefer uploading to the
  user's configured storage and auto-grab the URL.
- #7: broadened to "improve mobile UI" across the app, not just the card.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-29 11:02:11 -04:00
thePR0M3TH3ANandClaude Opus 4.8 ce4f006e9d test(11b): un-quarantine nostr-count-fallback; document publish-rejection setup
nostr-count-fallback: STALE TEST after a refactor — countUnsupportedRelays moved
from NostrClient to its ConnectionManager, and the test read it off the client
(undefined.has). Repointed the assertion at client.connectionManager
.countUnsupportedRelays. No behavior change, no assertion weakened.

Also recorded the precise multi-precondition diagnosis for the still-quarantined
nostr-publish-rejection (dev-mode override before the first dynamic config import;
then a NIP-71 invocation sub-test) in the QUARANTINE note + TODO, so it's a quick
finish next time.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-29 10:55:27 -04:00
thePR0M3TH3ANandClaude Opus 4.8 fe653706a1 test(11b): un-quarantine nostr-boost-actions + admin-list-store
Triaged two more silently-excluded test files back into CI.

- nostr-boost-actions: STALE SPEC. Test required buildRepostEvent to throw
  /missing-event-relay/ with no relay hint, but a relay hint is a NIP-18 SHOULD
  (not MUST) — the builder degrades to a two-element `e` tag so a user can always
  repost. Replaced with an exact assertion of the valid output (kind 16 + e/p/k).

- admin-list-store: STALE HARNESS (no production change, no assertion weakened).
  Community curator lists are fetched via the batched SubscriptionManager
  (cold-start relay-storm fix) the harness never mocked; the mock "hex" curator
  values started with "npub" so parseCommunityBlacklistReferences decoded them
  twice; and createListEvent omitted the `d` tag selectNewestEventsForReferences
  matches on. Fixed all three to be faithful to production.

Removed both from the QUARANTINE map; TEST_INTEGRITY.md + TODO updated.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-29 10:11:52 -04:00
thePR0M3TH3ANandClaude Opus 4.8 8ab531b241 fix(access): canAccess cross-derives hex+npub so whitelisted authors aren't blocked (#32)
A video from a genuinely whitelisted author refused to play with "not from a
whitelisted author" (the source URL was confirmed healthy — HTTP 200, video/mp4,
range, CORS *). Root cause: accessControl keeps the whitelist in two sets —
whitelistPubkeys (hex) and whitelist (npub) — and canAccess consulted only one
based on the caller's input format without cross-deriving. playbackCoordinator
passes the author's npub, so it checked only the npub set; an entry present in
only the hex representation read as "not allowed."

- canAccess now derives BOTH hex and npub from the candidate and checks both sets
  (only fixes false-negatives; a non-whitelisted author is still denied).
- Dev-gated diagnostic at the playback rejection logs hex/npub, whitelistMode,
  set sizes, and inNpubSet/inHexSet to pinpoint any remaining denial.
- Tests: tests/access-control-canaccess.test.mjs.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-25 10:39:11 -04:00
thePR0M3TH3ANandClaude Opus 4.8 f5bf3d115c test(storage): cover generic-S3 public-URL resolution / forcePathStyle (#6)
Audited the generic-S3 path: upload/thumbnail/.torrent, CORS guidance, and the
delete/edit cleanup path-style (incl. the documented vhost bug) were already
tested. The real gap was public-URL resolution — the upload tests mock
buildS3ObjectUrl, so the actual path-style/vhost logic in js/storage/s3-url.js
was unverified.

Added tests/storage/s3-url.test.mjs (11 pure-function scenarios): path-style puts
the bucket in the PATH; vhost puts it in the host subdomain (no double-prefix);
endpoint path-prefix preserved; bare-endpoint https upgrade; key/slash
normalization; explicit publicBaseUrl wins; missing inputs → "". Also covers
s3Service.derivePublicBaseUrl / buildS3ObjectUrl. Closes #6.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-25 10:21:53 -04:00
thePR0M3TH3ANandClaude Opus 4.8 fea5af34cf docs(todo): defer #29 (admin-whitelist WoT bypass) as YAGNI
Decision: defer until a real flag-brigading incident exists to scope against.
The premise is sound but speculative; low risk at small scale (WoT mutes come
from trusted muters, so brigading needs in-graph coordination). Recorded the
analysis + exact implementation pointer (stages.js adminWhitelistBypass
placeholder + hideBypass framework) and the scoping refinements (bypass the WoT
hide layer only; never override a viewer's own block/mute; lean toward bypassing
soft report categories while still hiding hard/illegal ones) so it's a small,
well-scoped change when needed.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-25 10:11:27 -04:00
thePR0M3TH3ANandClaude Opus 4.8 2e510b2330 fix(liveness): don't bury WebTorrent-only content under hide-until-verified
A WebTorrent-only card's sole liveness signal is the swarm probe (20s timeout,
5min cache), and P2P liveness is slow + intermittent — a live-but-slow video is
indistinguishable from a dead one until that probe finishes. Hiding such a card
until the swarm verifies could bury perfectly good P2P content for minutes.

hide-until-verified now only suppresses a card while the FAST, reliable CDN probe
is still running. Once the CDN resolves dead (or is absent, i.e. P2P-only), it
falls back to show-pending for the WebTorrent part: shown while the swarm is
checked, hidden only once it too is confirmed dead. Burying live content is worse
than a brief flash of a dead one, and only the slow P2P probe is at issue.
cardNeedsEagerLivenessProbe likewise skips P2P-only cards (they start visible).

Tests: hide-foreign does-not-bury-WebTorrent-only coverage in
tests/video-card-source-visibility.test.mjs.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-25 09:48:55 -04:00
thePR0M3TH3ANandClaude Opus 4.8 129d877d38 fix(liveness): eager-probe hide-until-verified cards (fixes vanished foreign feed)
Setting CARD_LIVENESS_POLICY=hide-foreign made all foreign/NIP-71 content
disappear: a hide-until-verified card starts display:none, which has no layout
box, so the IntersectionObserver never fires for it — its liveness probe never
runs, it never gets a healthy verdict, and it stays hidden forever (deadlock).

Fix: cardNeedsEagerLivenessProbe() identifies cards governed by a
hide-until-verified policy (foreign under hide-foreign; all non-owner under
hide-all). Both health observers now kick the probe for those cards from
onCardRegister — which fires for every card regardless of visibility — instead of
waiting for an intersection that a hidden card can never produce. The URL probe is
deferred one microtask so attachUrlHealthBadges has wired state.onCheck after
observe(). Probes stay concurrency-capped + priority-ordered; show-pending is
unchanged (no eager probing).

Tests: cardNeedsEagerLivenessProbe predicate coverage in
tests/video-card-source-visibility.test.mjs.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-25 09:33:41 -04:00
thePR0M3TH3ANandClaude Opus 4.8 a3b66f8841 chore(liveness): default CARD_LIVENESS_POLICY to hide-foreign (#5)
Set the default policy to hide-foreign for live feel-testing: foreign/ingested
cards stay hidden until a source verifies (no flash of dead strangers), while
native/own cards keep showing instantly. Revert to "show-pending" if it feels
worse.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-25 09:21:00 -04:00
thePR0M3TH3ANandClaude Opus 4.8 ccbe8d9bb2 feat(liveness): config-driven card visibility policy + probe prefetch (#5)
Makes the card liveness-visibility behavior switchable so it can be A/B
feel-tested, and probes ahead of the scroll for a snappier result.

- CARD_LIVENESS_POLICY (config/instance-config.js): "show-pending" (default,
  unchanged) | "hide-foreign" (foreign/ingested cards hidden until a source
  verifies; native cards stay show-pending) | "hide-all". Owner's own cards are
  always visible. Flip live for testing without a rebuild:
  window.__BITVID_CARD_LIVENESS_POLICY__ = "hide-foreign" then refresh/scroll.
  Implemented via a data-foreign provenance flag on the card +
  getCardLivenessPolicy() in cardSourceVisibility.js.
- LIVENESS_PROBE_PREFETCH_MARGIN (default 600px): both liveness probes were
  already viewport-gated (IntersectionObserver, on-screen-first, WebTorrent
  priority-queued + concurrency-capped); this expands their rootMargin so cards
  just below the fold verify before the user reaches them.
- Tests: three policy scenarios in tests/video-card-source-visibility.test.mjs;
  also un-broke 3 pre-existing VideoCard tests by adding the missing
  IntersectionObserver JSDOM stub.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-25 09:13:52 -04:00
thePR0M3TH3ANandClaude Opus 4.8 df99bdb65f fix(zap): collapse split when creator == platform; close #3 audit
Finishes the remaining #3 zap audit items.

- zapSplit: when the platform fee would pay the SAME Lightning address as the
  creator (e.g. the creator is the platform operator), collapse to a single
  full-amount payment instead of sending two invoices to one wallet — avoids an
  extra routing fee and a confusing duplicate receipt for the same total.
- Audited split correctness: floor(amount*fee/100) with the creator taking the
  remainder loses no sats; a sub-1-sat fee rounds to 0 and the whole zap goes to
  the creator (no zero-amount platform send); fee is clamped 0–100 and a junk
  override falls back to the default. The earlier "fee landed in my own wallet"
  report is expected behavior (sender is the platform operator, or a self-zap),
  not a bug.
- Cheat-resistant tests: testCreatorEqualsPlatformCollapsesToOnePayment and
  testSplitRoundingPreservesEverySat in tests/zap-split.test.mjs.
- Receipt validation (9735) and NWC budget/retry UX confirmed already solid;
  audit plan + TODO #3 closed.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-25 08:56:00 -04:00
thePR0M3TH3ANandClaude Opus 4.8 b52a1b3fea docs(zap): close #3 CORS/LNURL — accept graceful degradation, no proxy
Decision: keep the visual zappability flag as the honest fallback for
CORS-less LNURL hosts. A proxy breaks the static/no-custody rule and adds an
invoice-swap security surface; the platform-fee split is inherently incompatible
with a CORS-blocked recipient in a static client; and WebLN's only CORS-bypass
(webln.lnurl()) pays directly with no NIP-57 receipt, no split, and no
deterministic amount. Current behavior is the right answer.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-25 08:41:55 -04:00
thePR0M3TH3ANandClaude Opus 4.8 e4e5c552c7 feat(forms): validate submitter npub on submission forms (#22)
The embed submission forms accepted any free-text identifier and published it,
so they were an easy spam vector. Add a shared, testable NIP-19 validator and
require a well-formed npub before a submission is sent.

- js/utils/validateNpub.js: validateNpubHex() decodes via nostr-tools nip19 and
  returns the 64-char hex pubkey, or null for anything that isn't a real npub
  (free text, wrong prefix, bad checksum, wrong NIP-19 entity). Also exposed as
  window.validateNpubHex for the classic iframe form scripts.
- Application form (required npub): submit is blocked unless valid; the decoded
  hex is written into the application body for canonical storage/matching.
- Feature-request / bug-fix / feedback (optional npub): stays optional, but a
  non-empty value must be a valid npub or submit is blocked with an inline error.
- Tests: tests/validate-npub.test.mjs (real nostr-tools, no implementation mirror).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-25 08:31:45 -04:00
thePR0M3TH3ANandClaude Opus 4.8 fdcf034464 fix(beacon): stop the processing spinner hanging forever (#28)
The torrent beacon's processing overlay was gated on WebTorrent's metadata-ready
callback (client.add/seed), which only fires once metadata arrives from peers. A
magnet with no live seeders never resolved it, so the spinner span forever, and a
torrent that emitted its own 'error' didn't always reach the client-level handler.

- beginProcessing() arms a 30s watchdog that drops the overlay and warns ("still
  searching for peers — it'll keep trying in the background") instead of hanging;
  the torrent stays in the table and keeps announcing.
- handleTorrentReady, the client error handler, and destroy() clear the watchdog.
- addMagnet/seedFiles wrap add/seed in try/catch and attach a per-torrent 'error'
  listener so bad/duplicate magnets fail visibly.
- Tests: tests/torrent/beacon-watchdog.test.mjs (JSDOM).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-24 21:57:11 -04:00
thePR0M3TH3ANandClaude Opus 4.8 1c256808ff fix(more-menu): resolve modal ⋯ video so Event Details + Popularity work (#31)
The video modal's ⋯ trigger declares its context via `data-more-dropdown="modal"`,
but attachMoreMenuHandlers read only `data-context`, so it defaulted to "card",
skipped the modal video resolution, and opened the popover with a null video. The
panel is built from that video, so every menu item got an empty data-event-id and
both Event Details and Popularity reported "No video selected."

- Read context from `data-more-dropdown` as well as `data-context`.
- Modal branch falls back to the modal's `activeVideo` when getCurrentVideo() is
  null (deep-link opens don't set the playback service's currentVideo).
- Defense-in-depth: handleEventDetailsAction resolves the video from
  app.videosMap.get(eventId); view-stats resolves via a new getVideoByEventId
  callback instead of a risky `|| currentVideo` that could show the wrong stats.
- Regression tests in tests/more-menu-controller.test.mjs.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-24 18:48:49 -04:00
thePR0M3TH3ANandClaude Opus 4.8 63dcb5f6ed fix(more-menu): make ⋯ "Event Details" robust + non-silent (#31)
The wiring was structurally intact (menu item event-details → handler case →
handleEventDetailsAction → EventDetailsModal.open), so the failure was silent:
- handleEventDetailsAction only opened the modal `if (… && payload.video)` and
  never caught the async open() rejection — any miss = nothing happens.
- EventDetailsModal.open ran the (throwable) renderVersion BEFORE openStaticModal,
  so a render hiccup left the click looking dead.

Hardened:
- handleEventDetailsAction surfaces "No video selected" / "unavailable" instead of
  failing silently, falls back to app.eventDetailsModal, and .catch()es open().
- open() now shows the modal (openStaticModal) FIRST, then renders in a try/catch.
- The ⋯ popover is dismissed before opening the modal.

Couldn't repro by inspection, so this both fixes the likely causes and makes any
remaining failure visible (a toast / console warn naming the exact reason).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-24 17:53:32 -04:00
thePR0M3TH3ANandClaude Opus 4.8 36f3c6e127 views: exact count unions across relays (fixes cross-relay under-count) (#4)
The watch-page/chart "exact" count relied on the NIP-45 COUNT, but
countEventsAcrossRelays returns bestEstimate = the single relay with the MOST
events — it can't union across relays. So when views are distributed across
relays it under-counted (a video's card showed 19 while the chart's uncapped
list union showed ~63).

exactCountForPointer now lists view events across ALL relays (one on-demand
list, not a storm) and folds the UNION into the deduped dedupeBuckets (one entry
per viewer-window). total = the set size, so it is accurate AND monotonic (never
spuriously decreases on partial relay responses). The per-relay COUNT is kept
only as a lower-bound floor when the list truncates (>2000 events), flagged
partial. The popularity modal passes { exact: true }; since the count flows
through shared pointer state, opening a video corrects its count everywhere.

Grid cards (no exact) still use the §17-capped batched backfill (approximate,
storm-safe) until opened — an accepted accuracy/cost tradeoff.

Also note the ⋯ menu "Event Details" button does nothing (new TODO #31).

Tests: testExactCountUnionsAcrossRelays. Grandfathered viewCounter.js size.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-24 17:29:53 -04:00
thePR0M3TH3ANandClaude Opus 4.8 1bc2b06395 fix(popularity): headline reads the authoritative view counter, not a re-count
The chart showed a different (higher, fluctuating) total than the card's view
count because it re-derived its own total from a one-off, unwindowed relay
listVideoViewEvents — which varies by relay response and counts a wider set than
the counter.

Now the modal headline subscribes to the same viewCounter the card uses (the
deduped, cached, authoritative total), so it always matches the number shown
everywhere and is stable. The chart below is purely the time DISTRIBUTION of
recorded views, listed within the counter's backfill window
(VIEW_COUNT_BACKFILL_MAX_DAYS) so it lines up with the headline's window. The
count subscription is cleaned up on close.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-24 14:07:20 -04:00
thePR0M3TH3ANandClaude Opus 4.8 6911df075a fix(popularity): correct lazy-import path + forward video; single-instance modal
Two bugs in the #26 wiring surfaced live:
- The ⋯ menu lazy-import used "../../viewCountChart.js" which resolves one level
  too high (dist/viewCountChart.js → 404 "Failed to fetch dynamically imported
  module"). Corrected to "../viewCountChart.js" (js/ui → js/viewCountChart.js).
- The video-list context path called handleMoreMenuAction without the video, so
  view-stats hit "No video selected". Forward detail.video.

Also make the popularity modal single-instance: close any open one (proper
subscription/listener cleanup) before opening a new one, so the two menu paths
firing for one click can't stack duplicates or leak a live subscription.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-24 13:59:09 -04:00
thePR0M3TH3ANandClaude Opus 4.8 575befb83c feed: add public popularity (views-over-time) chart in the ⋯ menu (#26)
A "Popularity" item in the video ⋯ menu opens a public views-over-time chart
(view data is public). Builds on the now-trustworthy view counts (#4).

- buildViewCountTimeSeries buckets raw kind-30079 view events by the counter's
  dedupe window (one view per viewer per day) into a cumulative series.
- buildViewCountChartSvg renders a token-colored (currentColor / text-accent)
  SVG area+line chart — no charting library, no raw colors.
- The modal fetches via listVideoViewEventsWithDefaultClient, shows the total,
  and updates LIVE by subscribing to view events (debounced re-render); cleans
  up on close (Esc / backdrop / ✕). Lazy-loaded from the menu handler so it
  only ships when opened.

New size tokens for the modal; reuses .ds-overlay-backdrop for the scrim.

Tests: tests/view-count-chart.test.mjs (cumulative dedupe, junk filtering,
SVG line/area + empty/single-point).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-24 13:51:41 -04:00
thePR0M3TH3ANandClaude Opus 4.8 0a37e625bb playback: fail over across hosted mirrors before dropping to P2P (#5)
The liveness probe already tries all of a video's sources, but actual playback
only ever tried the single primary URL and then fell straight to WebTorrent. Now
playback fails over across hosted mirrors first.

- PlaybackSession builds an ordered, de-duped hostedSourceCandidates (primary URL
  first, then the video's imeta `sources`; accepts string or {url} entries).
- attemptHostedPlayback takes a candidateUrl; the url-first execution loop tries
  each candidate and advances to the next mirror on probe-fail/stall/timeout,
  cleaning up watchdogs + resetting the <video> between candidates, only calling
  WebTorrent after every hosted mirror fails.
- `sources` plumbed coordinator -> strategy -> session. Single-source videos
  (no `sources`) behave exactly as before.

Tests: tests/services/playbackService_multisource.test.mjs (candidate
construction; the full async fail-over flow is covered by the existing
playbackService_order suite in CI — that mock-timers harness doesn't run in this
sandbox). ROLLBACK: revert the loop to the single attemptHostedPlayback() call
and drop the sources plumbing.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-24 13:30:30 -04:00
thePR0M3TH3ANandClaude Opus 4.8 c967789257 todo: note the preserveOrder render bug that masked the feed identities
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-24 13:14:16 -04:00
thePR0M3TH3ANandClaude Opus 4.8 04621efc52 feed: stop the grid re-sorting ranked feeds chronologically; Trending defaults
The real reason every tab looked identical: VideoListView.render re-sorts
displayVideos by date UNLESS metadata.preserveOrder is set, and only Explore set
it. So For You's tiering, Trending's view-count ranking, and Kids' scoring were
all discarded at render and shown in plain chronological order — i.e. the #21
feed-identity work was invisible. Set preserveOrder for For You, Trending, and
Kids so their feed-engine order survives to the grid.

Also per request:
- Sidebar: Trending now sits above Recently Added.
- Default homepage: logged-out users land on Trending (gated by
  FEATURE_TRENDING_FEED, falls back to Recent); logged-in stays For You. The
  logout-from-For-You redirect follows the same rule.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-24 13:13:45 -04:00
thePR0M3TH3ANandClaude Opus 4.8 02854d4fb5 feed: add Trending tab — recently-added ranked by view count (#27)
New FEED_TYPES.TRENDING tab: the active (recently-added) source ranked by view
count (recency tiebreak, trusted-muted last), gated by FEATURE_TRENDING_FEED
(default on). Builds on the now-trustworthy view counts (#4).

- createTrendingSorter reads counts from runtime.getViewCount, which resolves
  each video's pointer via the app's canonical deriveVideoPointerInfo against the
  shared viewCounter cache (new getVideoViewCountSnapshot export) — the same
  counts the grid cards already load.
- Live re-rank: viewCounter now emits a coalesced onViewCountsChanged signal;
  while the Trending view is active it debounce-re-runs the feed (cheap re-rank
  over the in-memory active set, no relay re-fetch), so the order settles into
  true trending as counts stream in. Cold cache reads as recency, then improves.
- Pipeline extracted to js/feedEngine/trendingFeed.js (feedCoordinator is at its
  size cap; bumped the cap for the thin load/refresh delegations).
- Wiring: FEED_TYPES + flag, feedCoordinator cases, app.js wrappers,
  applicationBootstrap registration (hides the sidebar link when flag off),
  viewManager init + js/trendingView.js, views/trending.html, sidebar link.

Tests: tests/trending-sorter.test.mjs.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-24 13:06:34 -04:00
thePR0M3TH3ANandClaude Opus 4.8 8deba55cdf views: un-quarantine view-counter test; audit reactionCounter (#4)
Triaged the monolithic tests/view-counter.test.mjs and got the whole file green,
then removed it from the QUARANTINE map:
- Pinned nostrClient.getSubscriptionManager() to null so the test exercises the
  legacy per-pointer hydrate/subscribe path it mocks. The batched
  SubscriptionManager path (added with the relay-storm work) was silently
  shadowing the mocked legacy APIs.
- Made the dedupe-window test bucket-aligned (it used Date.now(), so the
  +window/2 offset could straddle a fixed-bucket boundary and flake by time of
  day).
- Corrected the hydrateVideoHistory root-timestamp assertions to the refactored
  unit boundary: hydrateVideoHistory populates the rootCreatedAtByRoot MAP; the
  video object's rootCreatedAt FIELD is applied by syncActiveVideoRootTimestamp.

reactionCounter.js audit (kind-7 likes): accurate, no fix needed. It does not use
NIP-45 COUNT; it lists kind-7 events and dedupes via applyReactionToState keyed
by pubkey (one reaction per user, latest-wins) with per-content totals — no
raw-count inflation. Kind-7 isn't replaceable, so listing+dedupe is the only
accurate model.

Spec corrections documented in TEST_INTEGRITY.md (SCN-view-counter-legacy-path,
SCN-hydrate-root-timestamp).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-24 12:22:02 -04:00
thePR0M3TH3ANandClaude Opus 4.8 a9cb8a94b4 views: deterministic d-tag so relays dedupe a viewer's repeat views (#4)
View counts inflated. viewCounter.js dedupes locally by (viewer, time-window)
correctly, but the published events couldn't be deduped by relays:
generateViewEventDedupeTag built the kind-30079 `d` tag with random entropy +
exact timestamp, so every view was a UNIQUE event. And exactCountForPointer runs
a NIP-45 COUNT (raw event count, no dedupe) and bumped the displayed total up to
it (max(deduped, raw)). Net: reloads / multi-device / re-watches each added a
view.

Make the `d` tag deterministic and window-bucketed (scope:viewer:bucket, no
entropy) so kind-30079 view events are genuinely parameterized-replaceable:
relays keep one event per (viewer, video, window), so the cheap NIP-45 COUNT
becomes accurate AND scalable and the raw count stops inflating. buildViewEvent
no longer invents a random `d` tag (the only production caller, publishViewEvent,
always supplies the deterministic one). Pre-existing entropy events age out over
the backfill horizon.

Tests: tests/view-event-dedupe.test.mjs.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-24 12:01:25 -04:00
thePR0M3TH3ANandClaude Opus 4.8 926652156d todo: record feed-identity work (#21 done, #20 tab-switching resolved)
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-24 08:51:26 -04:00
thePR0M3TH3ANandClaude Opus 4.8 087cf410cb feed: make Explore "new-to-you + diverse, less recency"
Explore shared the full active set with Recent/For You and leaned on freshness,
so it read like a reshuffled Recently-added. Give it a discovery identity:

- Weights: drop freshness (0.25 -> 0.12) and bump novelty (0.30 -> 0.35) so
  recency stops dominating and topic-novelty leads.
- Add a soft followed-author penalty (weight 0.3): the scorer now reads
  subscriptionAuthors from the Explore runtime and deprioritizes creators you
  already follow, so non-followed creators surface ("new to you"). A penalty,
  not a filter, so the feed stays full if you follow lots of people.
- More variety: lower the diversity sorter's MMR lambda (0.7 -> 0.5).

Also compacted For You's subscriptionAuthors reader to a one-liner to stay
under feedCoordinator's size cap while adding the same reader to Explore.

Tests: explore follows-penalty scoring; existing explore-scorer/diversity +
feedCoordinator suites green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-24 08:50:50 -04:00
thePR0M3TH3ANandClaude Opus 4.8 4873a43135 feed: make For You lead with "your people first" (tiering + discovery fallback)
For You shared the full active set with Recent/Explore and only re-ranked by a
blended score, so when a user's signals were thin it collapsed toward freshness
and looked identical to Recently-added.

Give it a structural identity:
- The scorer tags each item forYouTier: 2 = followed author, 1 = interest/
  watch-topic match, 0 = other.
- The sorter leads with tier (your people first) regardless of raw score, then
  ranks within tier; trusted-muted still sinks last.
- When NOTHING is tiered (logged-out / no follows / no interests) it falls back
  to an author-interleaved discovery order (new interleaveByAuthor) so the feed
  never reads as a chronological clone of Recently-added.

Tests: tests/for-you-tiering.test.mjs (tier beats score, tiers stack, muted
last, no-signal interleave, scorer tier tagging). Existing for-you-scorer suite
still green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-24 08:46:30 -04:00
thePR0M3TH3ANandClaude Opus 4.8 f05ba5edf9 channel: union live fetch with known events so the grid never shrinks
loadUserVideos rendered only the live relay fetch, and the render REPLACES the
grid container. So when a relay holding a creator's video transiently failed or
EOSE'd late, that video disappeared from the wall even though it was already
known in memory/cache — the "channel doesn't pull in all of a creator's videos"
bug (#20, symptom 1).

Union the live events with the already-known author events
(mergeChannelVideoSources, live wins on id collision) before building the
renderable list, so the rendered set is a superset and can't shrink on a
transient relay gap. Existing dedupe-by-root + access/delete filtering still
decide precedence downstream, so newer versions and deletions still win.

Helper + tests live in channelProfileVideos.js (channelProfile.js is at its
size cap); the call site stays net-zero. Streaming/first-relay-wins render is
still the separate 17d UX follow-up.

Regression: tests/channel-video-merge.test.mjs.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-24 08:19:37 -04:00