Commit Graph
2623 Commits
Author SHA1 Message Date
thePR0M3TH3ANandClaude Opus 4.8 6b251dbda5 [reactions] Fix "Missing signing primitives" — session actor must have a usable key
Root cause of "Failed to send reaction" (after the dispatch fix made the button
reach the publish path): reactions are published under the anonymous SESSION ACTOR,
signed with its private key. But ensureSessionActor adopted a stored session-actor
entry as that key — and a persisted nsec login is passphrase-encrypted with
privateKey "" ("never exposes plaintext"). So a leftover saved-nsec entry hijacked
the anonymous telemetry key, and signEventWithPrivateKey(event, "") threw
"Missing signing primitives". (Repro: logged in with NIP-07 while a saved nsec
account exists on the device.)

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 09:12:14 -04:00
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 b0a2eeaf72 [auth] Storage + NWC inherit the kept-unlocked signer on refresh (#51)
Storage and NWC have no passphrase of their own — the storage master key and the
NWC settings are encrypted WITH the signer (nip44/nip04 self-encryption) and
unlocked by it. So instead of caching a second decrypted secret, both now inherit
the kept-unlocked nsec signer.

_initAutoLogin restores the cached signer (nostrClient.restoreUnlockedSigner)
BEFORE the boot re-login, so:
  - the login flow's NWC hydrate + list decrypt run with the signer present, and
  - the existing storage auto-unlock (ProfileStorageController / UploadModal call
    storageService.unlock when a signer is available) succeeds silently,
all on refresh with no passphrase and no manual "Unlock storage". This is safe for
nsec (the only way the signer returns on reload, since the key is passphrase-
encrypted) and a no-op for NIP-07/46 (nothing cached; their signer already
persists). authService.login never touches the signer registry, so restoring
before login doesn't get clobbered.

Persistence follows the signer cache: storage/NWC stay unlocked exactly as long as
the kept-unlocked key does (the session tier, or until site data is cleared if the
user checked "keep unlocked"). Logout already forgets the cached key, re-locking
all three together.

Lint + build green; unlock/switch/session-actor suites green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 20:01:54 -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 023f37f08f [sync] Unify NWC + Storage sync onto one shared implementation (step 1: backend)
walletSyncService and storageSyncService were near-identical copies (the "two
sync systems"). Extract the shared machinery into createEncryptedSyncItem() —
per-pubkey enable flags, conflict-checked push, cheap remote-exists check,
enable/disable/pull — so each service is now a thin config differing only in
d-tag, kind, availability, and buildPayload/applyPayload. Public surface
(isAvailable/isEnabled/hasRemote/push/pull/enable/disable + the *_DTAG export)
preserved; both dedicated suites pass (8).

Next (step 2): shared confirm dialog + consistent notifications + seamless
auto-sync in the two profile controllers (the user-facing UX unification).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 15:49:42 -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
thePR0M3TH3ANandClaude Opus 4.8 3e57ab2ea9 [moderation] #25: per-video admin block list (hide a single video, not the author)
Admin moderation was author/community-level only. Admins (any editor — same gate as
the author blacklist) can now hide a SINGLE offending video.

- Record: a published kind-30000 NIP-51 list under d-tag bitvid:admin:event-blacklist,
  carrying blocked event ids as `e` tags (NOTE_TYPES.ADMIN_EVENT_BLACKLIST schema).
  Loaded/persisted through the existing admin-list machinery (adminListStore) and cached
  like whitelist/blacklist.
- accessControl: eventBlacklist Set + getEventBlacklist/isEventBlacklisted/
  addToEventBlacklist/removeFromEventBlacklist (editor-gated; normalizes hex or
  nevent/note) + onEventBlacklistChange.
- Render filter already existed (app.blacklistedEventIds, honored by every feed/grid/
  search/playback path) but was fed only by the static ADMIN_INITIAL_EVENT_BLACKLIST
  config. app._rebuildBlacklistedEventIds() now merges that static base with the dynamic
  admin list, and an onEventBlacklistChange listener rebuilds + refreshes grids on change.
- UI: a "Block this video" item in the per-video ⋯ menu (editor-only), handled by
  handleBlacklistEventAction (js/ui/moreMenu/blacklistEventAction.js).

Helpers extracted to js/adminEventBlacklistHelpers.js to keep adminListStore /
accessControl / moreMenuController under their size caps. Tests:
tests/admin-event-blacklist.test.mjs (9). Build + lint clean; admin/access/moderation
suites 56/56.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-30 15:19:34 -04:00
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