Commit Graph
1288 Commits
Author SHA1 Message Date
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 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 cf6a830183 [upload] Derive a webseed torrent for external-URL posts (P2P for external links)
Posting an external URL used to skip the torrent/webseed benefit entirely. Now the
upload modal's external tab auto-derives a WebTorrent webseed from the URL at
publish time: fetch the remote file (CORS-permitting), stream it under a hard size
cap, compute the infoHash, and publish a magnet with the URL as the webseed (ws=).
Best-effort — CORS failure / over the cap / any error silently degrades to
URL-only. Only runs when the user didn't paste their own magnet.

- js/utils/externalUrlTorrent.js: fetchBlobWithCap (streams with a 500 MB cap,
  aborts past it, short-circuits on content-length), buildWebseedMagnet, and
  deriveExternalUrlTorrent (reuses createTorrentMetadata). Pure + injectable → unit
  tested without network/WebTorrent.
- mediaUploader.deriveTorrentForExternalUrl: derives the ws= magnet and, when
  storage is unlocked, also hosts the tiny .torrent so the webseed can actually
  bootstrap P2P (xs=) — best-effort, falls back to ws=-only.
- UploadModal.handleExternalFlow calls it; deriveExternalWebseed only hosts the
  .torrent when storage is unlocked, shows progress on the submit button, and
  never throws.

Tests: tests/external-url-torrent.test.mjs (9 — magnet assembly, name parsing,
streamed size-cap enforcement + short-circuit, fetch-failed, derive-with-injected-
hash, failure propagation). Lint + build green.

Next: the same for editing a URL in the Edit modal.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 09:08:32 -04:00
thePR0M3TH3ANandClaude Opus 4.8 a20388c5d6 [video-modal] Fix dead channel/creator link — event-name mismatch (#53)
Clicking the creator avatar/name in the video player modal did nothing. VideoModal
dispatched "navigate:profile", but ModalManager only listens for "creator:navigate"
(→ app.openCreatorChannel), so the event went into the void — same class as the old
action:embed bug noted just above the handler.

Fix:
- VideoModal.handleCreatorNavigation dispatches "creator:navigate" (the listened name)
  with { pubkey }.
- ModalManager's handler forwards event.detail.pubkey to app.openCreatorChannel.
- routerCoordinator.openCreatorChannel(pubkey) accepts an optional pubkey (prefers it,
  falls back to the active video's creator), so it navigates to the modal's own
  creator rather than whatever currentVideo happens to be.

Tests: tests/video-modal-controllers.test.mjs (+2 — dispatches creator:navigate with
the pubkey and never the dead navigate:profile; no-op without a pubkey). Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 08:51:10 -04:00
thePR0M3TH3ANandClaude Opus 4.8 298e456ff9 [dm] Clear the previous account's DMs on account switch (not just the modal view)
Switching accounts (e.g. NIP-07 → nsec) still showed the OLD account's direct
messages. The earlier switch fix reset only the profile modal's DM view, but the
DM data lives in the app-wide nostrService store (this.dmMessages), which the
messages pane reads via listDirectMessageConversationSummaries /
getDirectMessagesForConversation. Logout clears that store
(stopDirectMessageSubscription + clearDirectMessages); the switch path never did,
so the old messages persisted.

Fix: on an identity change, authSessionCoordinator.handleAuthLogin now stops the
old DM subscription and clears the in-memory DM store BEFORE the new account's
subscription starts — so the pane shows the new account's DMs (or empty→new),
never the old.

Uses a new clearDirectMessages({ keepSnapshot: true }) option: it wipes the
in-memory store but preserves each account's PERSISTED per-actor DM snapshot, so
switching back to an account stays fast (per-account DM cache). Logout still fully
clears (keepSnapshot defaults false).

Tests: tests/services/nostr-service.test.mjs — clearDirectMessages empties the
in-memory store + resets the active actor and emits cleared/updated, in both
keepSnapshot modes. Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 21:45:23 -04:00
thePR0M3TH3ANandClaude Opus 4.8 6bf2cf26fd [nip46] Stop connection teardown from wiping ALL saved remote-signer sessions
Switching from a NIP-46 account to another account (e.g. an nsec account) failed to
switch BACK with "No remote signer session is stored on this device." Root cause:
disconnectRemoteSigner() defaults to keepStored:false and calls the no-argument
clearStoredNip46Session(), which removed the entire per-account v2 map
(bitvid:nip46:sessions:v2) — deleting EVERY saved account's session, not just a
teardown of the live connection. Four nip46Connector connect/handshake-error paths
call the same no-arg clear, so any connect hiccup also nuked all sessions.

Fix: the no-argument clearStoredNip46Session() now clears ONLY the legacy v1
"last-connected" default slot and leaves the per-account v2 map intact. A specific
account is still forgotten via clearStoredNip46Session(pubkey) (unchanged), so
removing one account is unaffected while switching between saved NIP-46 accounts
survives connection teardown.

Spec correction: the prior test asserted the buggy "no-arg clear wipes everything";
replaced with the corrected behavior + a switch-away/switch-back regression.
Test: tests/nip46-multi-account.test.mjs (7). Lint + build green.

NOTE: this prevents future wipes; a session already deleted by the bug must be
reconnected once (Login → remote signer) — after which switching persists.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 21:16:55 -04:00
thePR0M3TH3ANandClaude Opus 4.8 3df398d7ad [upload] Suggest one-tap hashtag chips from the user's past videos (#45)
The upload modal's Hashtags section now shows chips of the user's most-used past
hashtags ("Reuse a tag from your videos:"). Clicking one adds it to the NIP-71 't'
repeater; already-added chips dim + disable; the row is hidden for users with no
past tags.

- js/utils/hashtagSuggestions.js: pure rankHashtagsByFrequency(videos) — count =
  number of the user's videos using each tag, normalized (case + '#') and deduped
  per video, ranked by count then alphabetically. Reads video.nip71.hashtags, falls
  back to raw event 't' tags minus bitvid's fixed t=video marker.
- app.getUserHashtagSuggestions() sources the user's own videos via
  nostrService.getActiveVideosByAuthors([pubkey]) and ranks them (top 12), injected
  into the modal as getHashtagSuggestions.
- UploadModal renders the chips on open and wires clicks through the existing
  Nip71FormManager (addRepeaterEntry + setFieldValue, dedup via collectRepeaterValues).

Tests: tests/hashtag-suggestions.test.mjs (5 — per-video counting, normalization,
marker exclusion, tie-break + limit, empty input). Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 20:59:56 -04:00
thePR0M3TH3ANandClaude Opus 4.8 6bdf28fb30 [upload] Auto-unlock storage in the upload modal for a kept-unlocked session (#51)
The upload modal only READ storage's unlock state (isUnlocked) on load/open/refresh
— it never auto-unlocked the way the profile Storage pane does. So after a refresh
with "keep unlocked" ticked, the upload modal still opened with storage Locked even
though the signer had been restored.

Add UploadModal.maybeAutoUnlockStorage(pubkey), called on load(), open(), and
refreshState(): if storage is locked but a decrypt-capable signer is already present
(e.g. a kept-unlocked nsec restored on refresh), unlock silently. Guarded two ways:
  - only when the account ALREADY exists (new storageService.hasStoredAccount) so it
    never auto-creates storage for magnet/URL-only uploaders, and
  - only when the signer is already available, so it never triggers a fresh
    permission prompt on modal open.

Tests: storageService.hasStoredAccount (false before setup, true after unlock,
never creates an account, survives lock). Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 20:20:57 -04:00
thePR0M3TH3ANandClaude Opus 4.8 cebd3dc1e7 [auth] Add "Lock this device" control to re-lock a kept-unlocked session (#51)
The opt-in "keep unlocked" cache had no user-facing way to undo it mid-session
(it cleared only on logout / clear-site-data). Add a "Lock this device" button to
the profile modal footer (desktop + mobile), shown only when the active account
has a cached key to forget (app.isSessionKeptUnlocked -> client.hasCachedUnlockedKey).

Clicking it runs app.lockKeptUnlockedSession(pubkey), which re-locks in one action
everything the signer unlocks:
  - forgets the cached key (both tiers) so future reloads re-prompt for the PIN,
  - drops the in-memory signer (logoutSigner) so the CURRENT session re-locks now
    (the next signing action prompts again via ensureEncryptionCapableSigner),
  - storageService.lock(pubkey), and
  - nwcSettingsService.clearCache() (re-hydrates from the signer next time).

Visibility refreshes on renderSavedProfiles + selectPane. Tests: 2 in
tests/profile-modal-controller.test.mjs (shown only when kept-unlocked; click locks
the active pubkey). Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 20:16:00 -04:00
thePR0M3TH3ANandClaude Opus 4.8 1b17bb8fcc [auth] Opt-in "keep unlocked" so a reload doesn't re-prompt for the PIN (#51)
After unlocking a saved nsec key the user had to re-enter the PIN on every reload,
because the key is only passphrase-encrypted and the in-memory signer is lost on
refresh. Add an opt-in unlocked-key cache with two tiers (maintainer's choice):

- session (default, always): sessionStorage — survives refresh/navigation, cleared
  when the tab/browser closes. Same rough exposure as the in-memory signer.
- persistent (opt-in): the unlock prompt now shows a "Keep me unlocked on this
  device" checkbox with an inline unencrypted-at-rest warning; when checked the key
  is also written to localStorage and survives until site data is cleared.

client.restoreUnlockedSigner(pubkey) re-registers the signer from the cache WITHOUT
a passphrase — it verifies the cached key derives to that pubkey and forgets a
mismatch, so it never adopts another account. It runs proactively from
ensureActiveSignerForPubkey (auto-login/refresh) and lazily from
ensureEncryptionCapableSigner before any prompt, so after a reload the signer is
restored silently and storage/hashtags/DMs/reactions/blocks/subs all just work.
Logout and remove-saved-profile forget the cached key (forgetUnlockedSigner).

SECURITY: while cached, the key is stored decrypted — the passphrase no longer
protects it at rest. Session tier is the default; persistence is opt-in + warned.

Tests: tests/unlocked-key-cache.test.mjs (8 — session vs persistent tiers, promote
persistent->session on read, reject non-hex, forget one, clear all, missing-storage
degrade). Related unlock/switch/session-actor suites green. Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 19:51:41 -04:00
thePR0M3TH3ANandClaude Opus 4.8 2dc7397535 [hashtags] Monotonic created_at so a rapid second edit doesn't false-error (#57)
Adding a second hashtag interest/disinterest quickly showed "didn't work" yet the
change persisted after a refresh. Cause: the replaceable preferences event used
created_at = floor(now/1000), so two edits in the same wall-clock second produced
the SAME created_at. Relays reject the second as "not newer" (client shows an
error) while relays that break created_at ties by id still store it (so it appears
after a refresh) — an inconsistent false-negative.

Fix: nextReplaceableCreatedAt(lastCreatedAt) (js/nostr/replaceableCreatedAt.js) —
always advance to max(now, lastCreatedAt + 1), so each publish is strictly newer
than the last and every relay accepts it unambiguously. Reusable for the other
replaceable lists (subscriptions/blocks) that have the same latent collision.

Tests: tests/replaceable-created-at.test.mjs (5 — same-second bump, clock-ahead,
no-prior, rapid successive strictly-increasing, floors fractional). Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 19:41:00 -04:00
thePR0M3TH3ANandClaude Opus 4.8 1622e85d10 [auth] Route reactions, blocks, subscriptions & DM send through the signer gate (#54/#57)
Fast-follows to the ensureEncryptionCapableSigner gate: every remaining
signing/encryption flow now re-unlocks a reloaded nsec signer with one passphrase
prompt instead of dead-ending. Each call site short-circuits ONLY on the
user-driven outcomes (cancel / bad-passphrase); every other case falls through to
the flow's own existing error handling, so non-nsec signers (e.g. an unresponsive
NIP-07 extension) behave exactly as before.

- Reactions (#54): ReactionController.handleReaction gates before the optimistic
  update with need:"sign" (kind-7 reactions don't need encryption) — fixes the
  "like/dislike does nothing" that was a rolled-back publish on a lost signer.
- Blocks: handleProfileBlocklistMutation (need:"encrypt", NIP-04 mute/block lists).
- Subscriptions: the channel Subscribe/Unsubscribe toggle (need:"encrypt", kind 30000).
- DM send: both composers — handleSendProfileMessage and handleDmAppShellSendMessage
  (need:"encrypt", NIP-17/NIP-04).

Reactions get the gate via a new ensureSigner callback on ReactionController; the
modal flows use the services.ensureEncryptionCapableSigner already exposed.

Tests: tests/reaction-signer-gate.test.mjs (3 — cancelled/bad-passphrase block
publish, ok proceeds, asserts need:"sign"). Related suites (decision, DM, profile
modal, switch delegators) green. Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 19:23:04 -04:00
thePR0M3TH3ANandClaude Opus 4.8 1b79ab9d09 [auth] Re-unlock the signer on demand instead of blanket-erroring encryption ops (#57)
A persisted nsec session restores the pubkey + UI on reload but NOT the in-memory
signer (the private key is only passphrase-encrypted — TODO #36). So after a refresh,
adding/editing a hashtag on an nsec account dead-ended with "Connect a Nostr signer
that supports encryption before managing hashtag preferences" — even though the user
looks logged in. Same class blocks subscriptions/blocks/DMs/reactions.

New reusable gate app.ensureEncryptionCapableSigner({ pubkey, need, promptMessage }):
checks the active signer's capabilities; if it can't encrypt/sign AND a MATCHING
locked nsec key exists for the account, prompts once for the passphrase and re-unlocks
the signer app-wide via unlockStoredSessionActor. One unlock re-enables every signing
flow until the next reload. Never unlocks a different account's key (pubkey-mismatch
guard); "no-stored-key" leaves non-nsec cases (e.g. an unresponsive NIP-07 extension)
to the caller's own error. Branching lives in js/nostr/ensureSignerDecision.js (pure,
unit-testable without the app shell).

Wired hashtag add/edit through it (ProfileHashtagController.persistHashtagPreferences):
a cancelled prompt aborts silently, a bad passphrase shows its own toast, and publish
never runs until the signer is confirmed. Exposed to the modal as
services.ensureEncryptionCapableSigner.

This is the shared machinery for the whole reloaded-nsec cluster (#51/#54/#56/#57);
subscriptions/blocks/DM-send/reactions are mechanical fast-follows against the same
gate (tracked in TODO #57).

Tests: tests/ensure-signer-decision.test.mjs (9 — capability + recoverable/fail
branching, never-wrong-key), + 2 wiring tests (gate blocks publish until ok) in
tests/profile-modal-controller.test.mjs. Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 19:11:19 -04:00
thePR0M3TH3ANandClaude Opus 4.8 87af650b38 [profile] Refresh the profile modal on account switch (no manual reload)
Switching accounts with the profile modal open (e.g. NIP-07 -> a saved nsec) left
the PREVIOUS account's DMs, subscriptions, hashtags and lists on screen until the
user refreshed. Two problems:

1. handleAuthLogin ran its identity-reset steps unguarded, so if any one threw
   (renderSavedProfiles, a warm-list call, etc.) the steps after it were skipped —
   including handleActiveDmIdentityChanged, which is what clears the previous
   account's conversation. Result: stale DMs after a switch. Each reset step now
   runs in isolation via runIdentityResetStep, and the DM reset runs first.

2. The switch-with-modal-open refresh called populateOpenProfilePanels(), which
   only paints from current service state and omits lazily-loaded panes (messages,
   watch history). It also fired before the new identity's lists had loaded, with
   no reliable refresh afterward. Replaced with refreshOpenPanesForActiveIdentity(),
   which re-runs the ACTIVE pane through selectPane() — the same load+populate path
   as opening it fresh — so DMs reload, subscriptions/hashtags refetch, etc. It runs
   immediately, again once profile/wallet data settles, and once more from the auth
   coordinator when the new identity's blocks/subscriptions/hashtags finish loading
   (backfill for slow sync). Non-active panes stay lazy and repopulate on select.

Per-user list caches (blocks/subscriptions/hashtags are already keyed per pubkey)
mean the active pane shows cached data instantly on switch, then updates when the
new identity's fetch settles — no manual refresh.

Tests (tests/profile-modal-controller.test.mjs +3): a throwing reset step is
isolated; a throwing DM reset no longer skips the saved-profiles reset; and
refreshOpenPanesForActiveIdentity re-runs the active pane only when the modal is
open. Existing profile-modal / switch-coordinator / stored-nsec-switch suites green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 16:31:01 -04:00
thePR0M3TH3ANandClaude Opus 4.8 8ee39510ac [sync] Accepting the restore offer now enables ongoing sync (step 3)
Accepting the one-time login restore offer used to pull the encrypted copy once
but never set the per-device sync flag — so users "restored once and stopped
syncing", and the settings toggle showed OFF despite having just restored. That
is exactly the "not syncing / has to keep confirming" behavior we're removing.

Accepting now means "keep this device in sync": after a successful pull we call
setSyncEnabled(pubkey, kind, true), so future saves auto-push (conflict-checked)
with no further prompts. Declining still enables nothing. No immediate push is
needed — we just pulled, so local == remote.

This completes the seamless model: enable once (or accept the offer once) →
saves auto-push on conflict-only confirm, restores pull with a small toast.

Tests: settings-restore-prompt.test.mjs +2 — accept enables the restored item
(and only that item); decline enables nothing.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 16:11:41 -04:00
thePR0M3TH3ANandClaude Opus 4.8 a59a74c976 [sync] Unify wallet + storage sync UI: one confirm, consistent notifications (step 2)
ProfileWalletController and ProfileStorageController each had their own copy of
the sync toggle/restore/overwrite-confirm logic, producing different notifications
and duplicate confirm dialogs. Extract js/ui/profileModal/syncSection.js — one
overwrite-confirm dialog, one status-class mapping, and shared runSyncToggle /
runSyncRestore handlers. Both controllers now delegate, so every syncable item
gets identical toasts + one confirm (conflict-only, via the shared syncConflict).

Intentional divergence preserved: the NWC spending-secret warning stays as the
wallet's preEnableConfirm (a wallet-connect URI can spend), since publishing it
even encrypted warrants a one-time heads-up.

Tests: tests/sync-section.test.mjs (8) — enable/conflict/nothing-to-sync/rollback/
preEnableConfirm/disable/restore, plus wallet-vs-storage produce the same message
shapes. wallet-sync + storage-sync + profile-modal suites green (34).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 16:04:33 -04:00
thePR0M3TH3ANandClaude Opus 4.8 a2c9f44307 [auth] Fix "prepareStoredNsecSwitch is not a function" when switching to a saved nsec account
Switching (from NIP-07) to an added nsec account threw
"this.prepareStoredNsecSwitch is not a function".

Root cause: bindCoordinator binds every coordinator method to the Application
instance, so inside handleProfileSwitchRequest `this` === app. Its call to the
sibling `this.prepareStoredNsecSwitch(pubkey)` therefore resolves against the
Application and needs an app-level delegator (like handleProfileSwitchRequest,
handleModerationSettingsChange, and waitForIdentityRefresh all have). The #48 work
added prepareStoredNsecSwitch as a coordinator sibling but not its delegator, and
its tests only exercised authService.switchProfile — not the full app wiring — so
it slipped through.

Fix: add the app.prepareStoredNsecSwitch delegator (-> this._auth.prepareStoredNsecSwitch).

Tests: tests/profile-switch-coordinator-delegators.test.mjs (4) — asserts every
sibling handleProfileSwitchRequest calls via this.X exists as an app method.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 15:34:20 -04:00
thePR0M3TH3ANandClaude Opus 4.8 47f2a9a865 [nostr] Fix spurious "failed" errors on unconfirmed list publishes across the board
Same class of bug as the subscription fix, applied everywhere an idempotent,
replaceable list is published with an optimistic UI: when no relay ACKs within the
10s window (slow/flaky relays), the event was still sent and almost always
persists (it shows up after a refresh), but assertAnyRelayAccepted threw -> the UI
showed a scary error / reverted, even though it worked.

- nostrPublish.js: add shared helpers relayPublishFailuresAreAllTimeouts() and
  assertAnyRelayAcceptedOrUnconfirmed() — the latter returns a summary flagged
  { unconfirmed: true } on all-timeout instead of throwing; explicit relay
  rejections (and pre-publish signer/encryption errors) still throw.
- Applied to the replaceable-list publishes: subscriptions (kind 30000, refactored
  to the shared helper), user mute/block list (10000), relay list (10002), DM
  relay hints (10050), hashtag preferences (30015).

Left as-is for now (higher-stakes, separate follow-up): the generic content
publish path (video/edit/delete/revert/rebroadcast) in publishHelpers/client.js —
same latent issue but the video outcome-describer needs careful handling.

Tests: tests/subscription-publish-timeout.test.mjs (8) now cover both the
classifier and assertAnyRelayAcceptedOrUnconfirmed (confirmed->summary,
all-timeout->soft, rejection->throws). hashtag-preferences suite green (14).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 15:20:58 -04:00
thePR0M3TH3ANandClaude Opus 4.8 a9271fd0e6 [subscriptions] Stop spurious "Failed to update subscriptions" on unconfirmed publish
Subscribe/unsubscribe intermittently showed an error but "worked after refresh":
the kind-30000 list event WAS sent and persisted, but when no relay ACKed it
within the 10s window, assertAnyRelayAccepted threw -> toggleChannel threw -> the
button handler reverted the optimistic UI and showed "Failed to update your
subscriptions." The event was on the relays the whole time (hence the refresh).

Fix: in publishSubscriptionList, distinguish an all-timeout (unconfirmed) result
from an explicit rejection. The list is an idempotent, replaceable event that was
already sent + locally cached, so an all-timeout is treated as a SOFT SUCCESS
(record the event id, no throw, reconciles on next load) instead of a hard error.
Only an explicit relay rejection (or a pre-publish signer/encryption error, thrown
earlier) still fails loudly. New exported classifier
subscriptionPublishFailuresAreAllTimeouts().

Tests: tests/subscription-publish-timeout.test.mjs (5 — all-timeout=soft,
any-rejection=hard, empty=hard, case-insensitive).

Note: assertAnyRelayAccepted is used the same way by userBlocks/app list publishes
— they likely have the same false-negative and could get the same treatment later.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 14:08:33 -04:00
thePR0M3TH3ANandClaude Opus 4.8 cf4ce5c3b8 [admin] Add sub-tab toolbar + Blocked-videos tool to the admin pane (#25 follow-up)
The admin pane stacked every list (Moderators / Whitelist / Blacklist) in one
scroll, and the per-event block list had no admin UI (block-only via the ⋯ menu,
no view/unblock). This reorganizes the pane into a sub-tab toolbar and adds the
missing Blocked-videos tool.

- Sub-tabs: a role="tablist" toolbar in #profilePaneAdmin switches between
  Whitelist (default), Blacklist, Blocked videos, and Moderators. Moderators is
  super-admin-only — its tab button is hidden otherwise. selectAdminSubtab()
  shows one section at a time (ARIA + data-state), falling back to the default
  when a hidden tab is requested.
- Blocked videos: view the per-event admin block list, unblock per row (confirm
  → removeFromEventBlacklist), and add by nevent/hex id. Rows show the video
  title+author when it's in the local cache, else the shortened id. Reuses the
  existing accessControl event-blacklist API + onEventBlacklistChange (live
  re-render) and refreshes the grids on change. No backend changes.
- Row-building/render logic in js/ui/profileModal/blockedVideosSection.js
  (keeps ProfileAdminController under its size cap; unit-testable without a DOM).

Spec correction: profile-modal-controller.test's admin assertion updated for the
sub-tab layout (Moderators section now hidden until its tab is active; its tab
button is shown to a super admin; selecting it reveals the section).

Tests: tests/profile-blocked-videos.test.mjs (7 — rows dedupe/enrich/fallback,
render, unblock wiring); profile-modal-controller admin test updated.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 10:56:38 -04:00
thePR0M3TH3ANandClaude Opus 4.8 e3139e8712 [feed] Break up per-creator clustering in For You / Trending grids
The For You (personalized) and Trending tabs ranked strictly by tier/score and
view count, which stacked a creator's whole back-catalogue into one block.

Add spreadAuthors() to the feed sorters: a gentle diversity re-rank that keeps
the caller's ranked order as the priority but avoids runs of the same author —
at each step it takes the highest-ranked remaining item whose author wasn't
among the last `window` picks (default 1), falling back to the plain top item
when every remaining candidate is a recent author. The #1 ranked item is always
chosen first, so "best-fit"/"most-viewed" stays topmost, and each author's items
keep their relative ranked order.

Applied to createForYouScoreSorter (personalized branch) and createTrendingSorter
(live items). Unlike interleaveByAuthor (full round-robin, used only for For
You's no-signal fallback + Explore), this preserves ranking rather than
flattening it, so each tab keeps its unique ordering process — just de-clustered.
Recent (chronological) and Explore (already diversified) are unchanged.

Tests: tests/feed-engine/spread-authors.test.mjs (7 — top preserved, no
avoidable adjacent dupes, per-author order kept, single-author/short/anon edge
cases, window:2 spread).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 10:14:03 -04:00
thePR0M3TH3ANandClaude Opus 4.8 a1ed04b22c [dm] Fix NIP-17 send failing with nip17-keygen-failed (missing bytesToHex import)
Sending a NIP-17 DM builds a kind-1059 gift wrap, which needs a fresh ephemeral
keypair. generateEphemeralKeypair() hex-encodes the generated secret with
bytesToHex — but bytesToHex was never imported in client.js, so the call threw
ReferenceError. The try/catch swallowed it into privateKey="", keygen returned
null, and every NIP-17 send failed with "We couldn't create secure wrapper keys
for NIP-17 delivery." (nip17-keygen-failed). The seal (kind 13) signed fine
because it uses the active signer; only the ephemeral keygen was broken. Build
and lint don't parse JS, so the missing import wasn't caught.

- Add bytesToHex to the canonical js/utils/hex.js and import it in client.js.
- Add tests/nip17-send-gift-wrap.test.mjs: forces the NIP-17 path (useNip17 +
  relay hints, nip44-capable signer) and asserts a kind-1059 gift wrap is
  published. Reverting the import reproduces the exact nip17-keygen-failed error;
  the existing DM tests only covered the legacy kind-4/NIP-04 path (they delete
  nip44Encrypt), so this path had no coverage.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 09:29:43 -04:00
thePR0M3TH3ANandClaude Opus 4.8 a4549f967f [channel] Fix slow/incomplete channel video grid: coalesce loads, bound relays, parity source
Three fixes for "the channel grid loads slowly and not all videos come in even
when they show on other grids":

1. Race condition (primary "won't load" cause). loadUserVideos() bumps a shared
   currentVideoLoadToken and only renders if its token is still current. A burst
   of refresh triggers — e.g. the NIP-71 ingest flushing repeatedly right after
   a channel opens (RELAY STORM in the logs) — supersedes every in-flight load
   before it paints, so the grid stays on "Loading videos…". Add
   requestChannelVideoLoad(): while a load for the same channel is in flight, a
   refresh flags a single trailing rerun instead of starting a competing load;
   navigation still starts immediately. All three call sites routed through it.

2. Slowness from hung relays. The per-relay pool.list() fan-out had no timeout,
   so one relay that never sends EOSE stalled the merged render. Add
   raceListWithTimeout() (exported, pure) bounding each list() to 8s, resolving
   to [] on timeout/rejection.

3. Completeness/parity. The channel grid sourced only nostrClient.allEvents + a
   scoped relay fetch, which can diverge from the shared feed cache the other
   grids render from. getCachedChannelVideoEvents() now also unions the author's
   videos from nostrService.getActiveVideosByAuthors() (same videosMap-backed
   author index), so a video shown on another grid is present here by construction.

Tests: tests/channel-relay-timeout.test.mjs (5).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 08:47:54 -04:00
thePR0M3TH3ANandClaude Opus 4.8 61e4b25de7 [feed] Auto-refresh + optimistic insert after publishing a video (#46)
After a successful publish, the new video now appears in the feed instantly
without a manual page reload.

- Add nostrClient.ingestLocalVideoEvent(rawEvent) + a thin nostrService wrapper:
  optimistically insert the just-published event into the shared active-video
  cache. Mirrors live ingestion (convert -> root created_at -> tombstone guard
  -> allEvents/activeMap), so it obeys the same dedupe/active-key rules — a
  later relay echo of the same event won't duplicate it and an older revision
  can't overwrite a newer active entry (#20/#21).
- app.publishVideoNote: inject publishResult.legacy, then refresh via the
  canonical refreshAllVideoGrids({ reason: "video-published", forceMainReload:
  true }) instead of a bare loadVideos() — refreshes whichever feed is active
  (For You / Explore / Recent) plus subscription/channel grids and forces a
  render (bypassing the load cooldown) so the injected video shows.

Tests: tests/optimistic-feed-insert.test.mjs (6).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-30 22:49:02 -04:00
thePR0M3TH3ANandClaude Opus 4.8 95fe88784a [nostr-core] Per-account NIP-46 session storage for multi-account switching (#48)
Mirror the per-account nsec key store for remote-signer sessions: move from a
single slot (bitvid:nip46:session:v1) to a per-user-pubkey map
(bitvid:nip46:sessions:v2) so several saved NIP-46 accounts each keep their own
reconnectable session and the profile switcher can move between them.
Previously only the last-connected session survived, so switching among saved
NIP-46 accounts reconnected the wrong one (or failed).

- nip46Client.js: new per-pubkey session map keyed by userPubkey.
  readStoredNip46Session takes an optional target pubkey; writeStoredNip46-
  SessionSync writes both the map and the v1 last-connected default;
  clearStoredNip46Session(pubkey) forgets one account (no-arg wipes all). Legacy
  v1 slot migrated into the map on first read. Adds listStoredNip46SessionPubkeys().
- SignerManager: useStoredRemoteSigner({ pubkey }) selects the target account's
  session and, on access-denial, forgets only that account. getStoredNip46-
  Metadata(pubkey) is per-account.
- nip46 provider threads expectPubkey into the reuse call so a switch reconnects
  the requested account, with the expectPubkey guard as a backstop.

nip46Connector.js is dead code; the live path is entirely in SignerManager.
Tests: tests/nip46-multi-account.test.mjs (7).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-30 22:40:22 -04:00
thePR0M3TH3ANandClaude Opus 4.8 8692dac671 [nostr-core] Per-account nsec key storage for multi-account switching (#48)
Move the encrypted-key store from a single slot to a per-pubkey map so
several saved nsec accounts each keep their own key on the device and the
profile switcher can move between them. Previously bitvid:sessionActor:v1
held one key (last-saved wins), so switching among saved nsec accounts
silently failed.

- sessionActor.js: new bitvid:sessionActors:v2 map keyed by pubkey (follows
  the existing bitvid:<thing>:<npub> convention). readStoredSessionActorEntry,
  persistSessionActor, clearStoredSessionActor take an optional target pubkey;
  legacy v1 slot is kept in sync as the last-saved default and migrated into
  the map on first read. IndexedDB mirror keyed per-pubkey. Adds
  listStoredSessionActorPubkeys().
- client.js: getStoredSessionActorMetadata / unlockStoredSessionActor accept a
  target pubkey; access-denial cleanup forgets only the blocked account.
- nsec provider threads expectPubkey into the unlock so a switch unlocks the
  requested account, with the expectPubkey guard as a backstop.
- authSessionCoordinator: look up the target account's stored-key metadata.

NIP-46 multi-account (single slot bitvid:nip46:session:v1) remains as a
follow-up. Tests: tests/session-actor-multi-account.test.mjs (7).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-30 22:33:03 -04:00
thePR0M3TH3ANandClaude Opus 4.8 abdd11b18c [auth] #48: NIP-46 switch reconnects stored session + reject wrong-account switches
Completes fluent switching across signing methods (NIP-07 / nsec / NIP-46):

- NIP-46 switching: it had the same bug — switchProfile called the provider with no
  reuseStored, so it started a fresh handshake (needs a new connect URI/QR) instead of
  reconnecting. Now handleProfileSwitchRequest sends { providerId: "nip46",
  reuseStored: true } for NIP-46 targets; authService.switchProfile forwards reuseStored;
  the nip46 provider's useStoredRemoteSigner reconnects the stored session and activates
  the account. On failure -> a clear "reconnect your remote signer" toast.

- Wrong-account guard (all methods): requestLogin now enforces expectPubkey (only set by
  switchProfile). A single-slot stored nsec/NIP-46 session resolving to a DIFFERENT
  account than requested is rejected with `pubkey-mismatch` instead of silently switching
  to the wrong profile. Also protects NIP-07 (switching to an account the extension isn't
  currently on fails clearly instead of grabbing the extension's active account).

Tests: tests/authService.test.mjs (+3 — unlockStored/passphrase + reuseStored forwarding,
expectPubkey rejection). Build + lint clean; auth suites green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-30 22:19:13 -04:00
thePR0M3TH3ANandClaude Opus 4.8 fb2f12b49d [auth] #48: fluent switch to a saved nsec account (prompt for PIN + unlock stored key)
Switching to a saved nsec profile silently failed: switchProfile -> requestLogin -> the
nsec provider was called with no passphrase, so it threw "secret-required" and the switch
was caught+logged with no user feedback. It never prompted for the PIN.

Now handleProfileSwitchRequest detects an nsec target and, before switching, prepares the
stored-key unlock:
- evaluateStoredNsecSwitch(meta, target) (pure) gates on a persisted nsec key for THIS
  account (via getStoredSessionActorMetadata); errors clearly otherwise ("log in with its
  nsec", "different account's key saved").
- showPasswordPrompt (js/ui/promptDialog.js) collects the PIN/passphrase in a styled
  dialog (Promise<string|null>, stacks above modals).
- switchProfile(pubkey, { unlockStored: true, passphrase }) — authService.switchProfile
  now forwards unlockStored/passphrase to requestLogin, which routes to the nsec
  provider's unlockStoredSessionActor to restore the signer and activate the account.
- Wrong PIN -> "Incorrect PIN/passphrase" toast (decrypt-failed).

So NIP-07 -> nsec switching works fluently. NIP-46 switching is unchanged (follow-up to
verify/extend); multiple-nsec accounts still share one stored slot (follow-up).

Tests: tests/stored-nsec-switch.test.mjs (6), tests/password-prompt.test.mjs (6). Build +
lint clean; auth suites 25/25.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-30 22:11:43 -04:00
thePR0M3TH3ANandClaude Opus 4.8 c48083dfcc [admin] fix whitelist/blacklist edits with nsec & NIP-46; loosen file-size limits
Admin list edits worked with a NIP-07 extension but silently failed with nsec and
NIP-46 (Amber). persistNostrState signed with window.nostr.signEvent directly (and
required requestDefaultExtensionPermissions), so non-extension logins threw
"nostr-extension-missing".

Fix: sign with the ACTIVE signer (getActiveSigner() from the registry — extension/nsec/
NIP-46 all expose signEvent), and only run the extension-permission prompt when the
signer is an extension (type extension/nip07). Regression test in
tests/admin-list-store.test.mjs (nsec signer used while window.nostr.signEvent throws).

Also (per maintainer request): raise the file-size lint margins to stop churning on an
arbitrary cap — THRESHOLD 1000→1200 (new files), GROWTH_MARGIN 50→200 (grandfathered).

TODOs added: #42 follow-up audit of every signing path (must use the active signer, not
window.nostr); #43 logged-in profile card at the top of the profile modal (half-on/half-
off); #44 in-modal storage selector + unlock for the upload modal.

Build + lint clean; admin-list suite green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-30 17:00:57 -04:00
thePR0M3TH3ANandClaude Opus 4.8 ad83c9f8db [ui] route all native alert()/confirm() through the site notification system
Every browser alert()/confirm() now uses the app's UI instead of native dialogs
(no prompt() existed). Two new pieces:

- js/ui/confirmDialog.js — showConfirm(message, opts) -> Promise<boolean>, a styled
  `bv-modal modal-always-on-top` dialog (Confirm/Cancel/backdrop/Escape; danger variant
  focuses Cancel). Importable anywhere, so future confirmations use it too. It stacks
  above other modals and is typable thanks to the recent stacked-modal focus fix.
- js/ui/appNotify.js — notifyError/notifyStatus/notifySuccess routing to the app's
  NotificationController, for modules without a local showError; re-exports showConfirm.

Migrated: UploadModal (10 alert -> this.showError, 1 confirm), subscriptionHistory
(3 alert -> notify*, 1 confirm), MyVideosController (2), ProfileStorage/Wallet/Relay
controllers, profileModalController (2), settingsRestorePrompt (default confirm now
showConfirm), nostr/client deleteAllVersions. confirmSyncOverwrite helpers made async
(syncConflict already awaits confirmOverwrite).

Tests: tests/confirm-dialog.test.mjs (6 — confirm/cancel/backdrop/Escape resolution,
DOM cleanup, danger focus, no-DOM fallback). Build + lint clean; affected suites green.
(Note: build/lint don't parse JS — caught a multi-line-import split + a wrong import
path via module-import checks.)

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-30 16:49:42 -04:00
thePR0M3TH3ANandClaude Opus 4.8 6e85c2a32b [storage] fix B2 / generic-S3 uploads ("S3 endpoint is required") — prepareUpload contract
Uploading to a Backblaze B2 (or any generic-S3) bucket failed with "S3 endpoint is
required." even though the connection tested fine.

Root cause: the two upload services have different prepareUpload contracts —
r2Service.prepareUpload(npub, { credentials }) vs s3UploadService.prepareUpload(settings).
MediaUploader calls both the r2 way, so for S3/B2 the npub STRING became the settings →
validateS3Connection saw no endpoint. Compounding it, a storageService.getConnection()
result keeps bucket/endpoint/region under `.meta`, which validateS3Connection reads at
the top level. (R2 worked because its service already uses the (npub, {credentials})
contract.)

Fix:
- s3UploadService.prepareUpload now accepts (npub, { credentials }) AND a direct settings
  object (back-compat), flattening meta → top-level via flattenS3Settings.
- The storage Test now derives/passes the B2 public URL (else the prefix), so it stops
  falsely warning "Public Bucket URL is missing. Uploads will fail."

Tests: tests/services/s3-upload-prepare-contract.test.mjs (3 — meta flatten, npub ignored
when credentials given, direct-settings back-compat). Build + lint clean; upload/storage
suites 46/46.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-30 16:02:37 -04:00
thePR0M3TH3ANandClaude Opus 4.8 ffeafa90d5 [ui] fix: nsec PIN/passphrase field unclickable when login opens over the profile modal
Opening the login modal's unlock-saved-key (nsec passphrase/PIN) field on top of the
profile modal left it unclickable/untypable. The profile modal's document-level focusin
trap yanks focus back to itself whenever focus leaves the modal — but it didn't
recognize a modal STACKED on top (the login modal is also a .bv-modal), so it stole
focus away from the PIN field the moment it was clicked.

Fix: the focusin trap now yields when the focus target is inside a different OPEN
.bv-modal (isInStackedModal, extracted to js/ui/focusTrapStacking.js so it's unit-
testable without constructing the whole controller). The stacked login modal keeps focus
and the field is typable again. This also makes the trap robust for any future stacked
modal, not just the #36 re-unlock flow.

Test: tests/focus-trap-stacking.test.mjs (5 — yields to an open stacked modal's field;
does not yield within the trapping modal, to a hidden modal, to a bare target, or on a
null/non-element). Build + lint clean; profile-modal suite green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-30 15:50:27 -04:00
thePR0M3TH3ANandClaude Opus 4.8 5afd602d52 [ui] upload: route by the credentials' own provider (fix "S3 endpoint is required")
With both Backblaze B2 and Cloudflare R2 configured, an upload could fail with "S3
endpoint is required." when the upload modal's tracked provider drifted out of sync
with the active credentials (user: storage selector on Backblaze but the modal set for
Cloudflare). A Cloudflare R2 connection stores an accountId and derives its endpoint, so
routing it through the S3 service — which requires an explicit endpoint — threw.

Fix: MediaUploader.uploadVideo/uploadThumbnail now choose the service + provider from the
CREDENTIALS' own provider (credentials.provider || credentials.meta.provider || provider),
so a stale modal provider can no longer send an R2 connection through the S3 path (or
vice-versa). The R2 service derives the endpoint from accountId as before.

Test: tests/media-uploader-provider-routing.test.mjs (4 — R2 creds route to r2Service
even with a stale Backblaze arg; B2 creds route to s3Service; meta.provider + passed-arg
fallbacks). Build + lint clean; upload/storage suites green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-30 15:35:18 -04:00
thePR0M3TH3ANandClaude Opus 4.8 f00d2b5f49 [ui] upload modal: prompt for the nsec passphrase instead of dead-ending after reload
With a persisted ("remember this key") nsec login, uploading a video after a page
reload reported "storage is locked" and then "No signer available to unlock storage."
The reload restores the logged-in pubkey but NOT the in-memory signer (the key is
passphrase-encrypted), and UploadModal.handleUnlock dead-ended on the alert — it never
asked for the passphrase.

Fix (mirrors the storage pane's #36 behavior): handleUnlock now calls
promptStoredNsecUnlock(pubkey) when there's no usable signer. If the active account has
a locked saved nsec key (getStoredSessionActorMetadata), it shows an actionable message
and opens the login modal's existing unlock-saved-key (passphrase) flow via a new
onRequestUnlock hook (wired in initUploadModal -> app.loginModalController.openModal),
instead of the dead-end alert. After the user re-enters their passphrase the signer is
active and the upload proceeds.

Tests: tests/upload-modal-nsec-unlock.test.mjs (5 — detects locked nsec for the active
account + opens unlock; ignores missing/non-nsec/other-account; falls through with no
hook). Build + lint clean.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-30 15:24:29 -04:00