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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
- 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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
nostr-count-fallback: STALE TEST after a refactor — countUnsupportedRelays moved
from NostrClient to its ConnectionManager, and the test read it off the client
(undefined.has). Repointed the assertion at client.connectionManager
.countUnsupportedRelays. No behavior change, no assertion weakened.
Also recorded the precise multi-precondition diagnosis for the still-quarantined
nostr-publish-rejection (dev-mode override before the first dynamic config import;
then a NIP-71 invocation sub-test) in the QUARANTINE note + TODO, so it's a quick
finish next time.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Triaged two more silently-excluded test files back into CI.
- nostr-boost-actions: STALE SPEC. Test required buildRepostEvent to throw
/missing-event-relay/ with no relay hint, but a relay hint is a NIP-18 SHOULD
(not MUST) — the builder degrades to a two-element `e` tag so a user can always
repost. Replaced with an exact assertion of the valid output (kind 16 + e/p/k).
- admin-list-store: STALE HARNESS (no production change, no assertion weakened).
Community curator lists are fetched via the batched SubscriptionManager
(cold-start relay-storm fix) the harness never mocked; the mock "hex" curator
values started with "npub" so parseCommunityBlacklistReferences decoded them
twice; and createListEvent omitted the `d` tag selectNewestEventsForReferences
matches on. Fixed all three to be faithful to production.
Removed both from the QUARANTINE map; TEST_INTEGRITY.md + TODO updated.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
A video from a genuinely whitelisted author refused to play with "not from a
whitelisted author" (the source URL was confirmed healthy — HTTP 200, video/mp4,
range, CORS *). Root cause: accessControl keeps the whitelist in two sets —
whitelistPubkeys (hex) and whitelist (npub) — and canAccess consulted only one
based on the caller's input format without cross-deriving. playbackCoordinator
passes the author's npub, so it checked only the npub set; an entry present in
only the hex representation read as "not allowed."
- canAccess now derives BOTH hex and npub from the candidate and checks both sets
(only fixes false-negatives; a non-whitelisted author is still denied).
- Dev-gated diagnostic at the playback rejection logs hex/npub, whitelistMode,
set sizes, and inNpubSet/inHexSet to pinpoint any remaining denial.
- Tests: tests/access-control-canaccess.test.mjs.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Audited the generic-S3 path: upload/thumbnail/.torrent, CORS guidance, and the
delete/edit cleanup path-style (incl. the documented vhost bug) were already
tested. The real gap was public-URL resolution — the upload tests mock
buildS3ObjectUrl, so the actual path-style/vhost logic in js/storage/s3-url.js
was unverified.
Added tests/storage/s3-url.test.mjs (11 pure-function scenarios): path-style puts
the bucket in the PATH; vhost puts it in the host subdomain (no double-prefix);
endpoint path-prefix preserved; bare-endpoint https upgrade; key/slash
normalization; explicit publicBaseUrl wins; missing inputs → "". Also covers
s3Service.derivePublicBaseUrl / buildS3ObjectUrl. Closes#6.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
A WebTorrent-only card's sole liveness signal is the swarm probe (20s timeout,
5min cache), and P2P liveness is slow + intermittent — a live-but-slow video is
indistinguishable from a dead one until that probe finishes. Hiding such a card
until the swarm verifies could bury perfectly good P2P content for minutes.
hide-until-verified now only suppresses a card while the FAST, reliable CDN probe
is still running. Once the CDN resolves dead (or is absent, i.e. P2P-only), it
falls back to show-pending for the WebTorrent part: shown while the swarm is
checked, hidden only once it too is confirmed dead. Burying live content is worse
than a brief flash of a dead one, and only the slow P2P probe is at issue.
cardNeedsEagerLivenessProbe likewise skips P2P-only cards (they start visible).
Tests: hide-foreign does-not-bury-WebTorrent-only coverage in
tests/video-card-source-visibility.test.mjs.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Setting CARD_LIVENESS_POLICY=hide-foreign made all foreign/NIP-71 content
disappear: a hide-until-verified card starts display:none, which has no layout
box, so the IntersectionObserver never fires for it — its liveness probe never
runs, it never gets a healthy verdict, and it stays hidden forever (deadlock).
Fix: cardNeedsEagerLivenessProbe() identifies cards governed by a
hide-until-verified policy (foreign under hide-foreign; all non-owner under
hide-all). Both health observers now kick the probe for those cards from
onCardRegister — which fires for every card regardless of visibility — instead of
waiting for an intersection that a hidden card can never produce. The URL probe is
deferred one microtask so attachUrlHealthBadges has wired state.onCheck after
observe(). Probes stay concurrency-capped + priority-ordered; show-pending is
unchanged (no eager probing).
Tests: cardNeedsEagerLivenessProbe predicate coverage in
tests/video-card-source-visibility.test.mjs.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Makes the card liveness-visibility behavior switchable so it can be A/B
feel-tested, and probes ahead of the scroll for a snappier result.
- CARD_LIVENESS_POLICY (config/instance-config.js): "show-pending" (default,
unchanged) | "hide-foreign" (foreign/ingested cards hidden until a source
verifies; native cards stay show-pending) | "hide-all". Owner's own cards are
always visible. Flip live for testing without a rebuild:
window.__BITVID_CARD_LIVENESS_POLICY__ = "hide-foreign" then refresh/scroll.
Implemented via a data-foreign provenance flag on the card +
getCardLivenessPolicy() in cardSourceVisibility.js.
- LIVENESS_PROBE_PREFETCH_MARGIN (default 600px): both liveness probes were
already viewport-gated (IntersectionObserver, on-screen-first, WebTorrent
priority-queued + concurrency-capped); this expands their rootMargin so cards
just below the fold verify before the user reaches them.
- Tests: three policy scenarios in tests/video-card-source-visibility.test.mjs;
also un-broke 3 pre-existing VideoCard tests by adding the missing
IntersectionObserver JSDOM stub.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Finishes the remaining #3 zap audit items.
- zapSplit: when the platform fee would pay the SAME Lightning address as the
creator (e.g. the creator is the platform operator), collapse to a single
full-amount payment instead of sending two invoices to one wallet — avoids an
extra routing fee and a confusing duplicate receipt for the same total.
- Audited split correctness: floor(amount*fee/100) with the creator taking the
remainder loses no sats; a sub-1-sat fee rounds to 0 and the whole zap goes to
the creator (no zero-amount platform send); fee is clamped 0–100 and a junk
override falls back to the default. The earlier "fee landed in my own wallet"
report is expected behavior (sender is the platform operator, or a self-zap),
not a bug.
- Cheat-resistant tests: testCreatorEqualsPlatformCollapsesToOnePayment and
testSplitRoundingPreservesEverySat in tests/zap-split.test.mjs.
- Receipt validation (9735) and NWC budget/retry UX confirmed already solid;
audit plan + TODO #3 closed.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The embed submission forms accepted any free-text identifier and published it,
so they were an easy spam vector. Add a shared, testable NIP-19 validator and
require a well-formed npub before a submission is sent.
- js/utils/validateNpub.js: validateNpubHex() decodes via nostr-tools nip19 and
returns the 64-char hex pubkey, or null for anything that isn't a real npub
(free text, wrong prefix, bad checksum, wrong NIP-19 entity). Also exposed as
window.validateNpubHex for the classic iframe form scripts.
- Application form (required npub): submit is blocked unless valid; the decoded
hex is written into the application body for canonical storage/matching.
- Feature-request / bug-fix / feedback (optional npub): stays optional, but a
non-empty value must be a valid npub or submit is blocked with an inline error.
- Tests: tests/validate-npub.test.mjs (real nostr-tools, no implementation mirror).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The torrent beacon's processing overlay was gated on WebTorrent's metadata-ready
callback (client.add/seed), which only fires once metadata arrives from peers. A
magnet with no live seeders never resolved it, so the spinner span forever, and a
torrent that emitted its own 'error' didn't always reach the client-level handler.
- beginProcessing() arms a 30s watchdog that drops the overlay and warns ("still
searching for peers — it'll keep trying in the background") instead of hanging;
the torrent stays in the table and keeps announcing.
- handleTorrentReady, the client error handler, and destroy() clear the watchdog.
- addMagnet/seedFiles wrap add/seed in try/catch and attach a per-torrent 'error'
listener so bad/duplicate magnets fail visibly.
- Tests: tests/torrent/beacon-watchdog.test.mjs (JSDOM).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The video modal's ⋯ trigger declares its context via `data-more-dropdown="modal"`,
but attachMoreMenuHandlers read only `data-context`, so it defaulted to "card",
skipped the modal video resolution, and opened the popover with a null video. The
panel is built from that video, so every menu item got an empty data-event-id and
both Event Details and Popularity reported "No video selected."
- Read context from `data-more-dropdown` as well as `data-context`.
- Modal branch falls back to the modal's `activeVideo` when getCurrentVideo() is
null (deep-link opens don't set the playback service's currentVideo).
- Defense-in-depth: handleEventDetailsAction resolves the video from
app.videosMap.get(eventId); view-stats resolves via a new getVideoByEventId
callback instead of a risky `|| currentVideo` that could show the wrong stats.
- Regression tests in tests/more-menu-controller.test.mjs.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The watch-page/chart "exact" count relied on the NIP-45 COUNT, but
countEventsAcrossRelays returns bestEstimate = the single relay with the MOST
events — it can't union across relays. So when views are distributed across
relays it under-counted (a video's card showed 19 while the chart's uncapped
list union showed ~63).
exactCountForPointer now lists view events across ALL relays (one on-demand
list, not a storm) and folds the UNION into the deduped dedupeBuckets (one entry
per viewer-window). total = the set size, so it is accurate AND monotonic (never
spuriously decreases on partial relay responses). The per-relay COUNT is kept
only as a lower-bound floor when the list truncates (>2000 events), flagged
partial. The popularity modal passes { exact: true }; since the count flows
through shared pointer state, opening a video corrects its count everywhere.
Grid cards (no exact) still use the §17-capped batched backfill (approximate,
storm-safe) until opened — an accepted accuracy/cost tradeoff.
Also note the ⋯ menu "Event Details" button does nothing (new TODO #31).
Tests: testExactCountUnionsAcrossRelays. Grandfathered viewCounter.js size.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
A "Popularity" item in the video ⋯ menu opens a public views-over-time chart
(view data is public). Builds on the now-trustworthy view counts (#4).
- buildViewCountTimeSeries buckets raw kind-30079 view events by the counter's
dedupe window (one view per viewer per day) into a cumulative series.
- buildViewCountChartSvg renders a token-colored (currentColor / text-accent)
SVG area+line chart — no charting library, no raw colors.
- The modal fetches via listVideoViewEventsWithDefaultClient, shows the total,
and updates LIVE by subscribing to view events (debounced re-render); cleans
up on close (Esc / backdrop / ✕). Lazy-loaded from the menu handler so it
only ships when opened.
New size tokens for the modal; reuses .ds-overlay-backdrop for the scrim.
Tests: tests/view-count-chart.test.mjs (cumulative dedupe, junk filtering,
SVG line/area + empty/single-point).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The liveness probe already tries all of a video's sources, but actual playback
only ever tried the single primary URL and then fell straight to WebTorrent. Now
playback fails over across hosted mirrors first.
- PlaybackSession builds an ordered, de-duped hostedSourceCandidates (primary URL
first, then the video's imeta `sources`; accepts string or {url} entries).
- attemptHostedPlayback takes a candidateUrl; the url-first execution loop tries
each candidate and advances to the next mirror on probe-fail/stall/timeout,
cleaning up watchdogs + resetting the <video> between candidates, only calling
WebTorrent after every hosted mirror fails.
- `sources` plumbed coordinator -> strategy -> session. Single-source videos
(no `sources`) behave exactly as before.
Tests: tests/services/playbackService_multisource.test.mjs (candidate
construction; the full async fail-over flow is covered by the existing
playbackService_order suite in CI — that mock-timers harness doesn't run in this
sandbox). ROLLBACK: revert the loop to the single attemptHostedPlayback() call
and drop the sources plumbing.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
New FEED_TYPES.TRENDING tab: the active (recently-added) source ranked by view
count (recency tiebreak, trusted-muted last), gated by FEATURE_TRENDING_FEED
(default on). Builds on the now-trustworthy view counts (#4).
- createTrendingSorter reads counts from runtime.getViewCount, which resolves
each video's pointer via the app's canonical deriveVideoPointerInfo against the
shared viewCounter cache (new getVideoViewCountSnapshot export) — the same
counts the grid cards already load.
- Live re-rank: viewCounter now emits a coalesced onViewCountsChanged signal;
while the Trending view is active it debounce-re-runs the feed (cheap re-rank
over the in-memory active set, no relay re-fetch), so the order settles into
true trending as counts stream in. Cold cache reads as recency, then improves.
- Pipeline extracted to js/feedEngine/trendingFeed.js (feedCoordinator is at its
size cap; bumped the cap for the thin load/refresh delegations).
- Wiring: FEED_TYPES + flag, feedCoordinator cases, app.js wrappers,
applicationBootstrap registration (hides the sidebar link when flag off),
viewManager init + js/trendingView.js, views/trending.html, sidebar link.
Tests: tests/trending-sorter.test.mjs.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Triaged the monolithic tests/view-counter.test.mjs and got the whole file green,
then removed it from the QUARANTINE map:
- Pinned nostrClient.getSubscriptionManager() to null so the test exercises the
legacy per-pointer hydrate/subscribe path it mocks. The batched
SubscriptionManager path (added with the relay-storm work) was silently
shadowing the mocked legacy APIs.
- Made the dedupe-window test bucket-aligned (it used Date.now(), so the
+window/2 offset could straddle a fixed-bucket boundary and flake by time of
day).
- Corrected the hydrateVideoHistory root-timestamp assertions to the refactored
unit boundary: hydrateVideoHistory populates the rootCreatedAtByRoot MAP; the
video object's rootCreatedAt FIELD is applied by syncActiveVideoRootTimestamp.
reactionCounter.js audit (kind-7 likes): accurate, no fix needed. It does not use
NIP-45 COUNT; it lists kind-7 events and dedupes via applyReactionToState keyed
by pubkey (one reaction per user, latest-wins) with per-content totals — no
raw-count inflation. Kind-7 isn't replaceable, so listing+dedupe is the only
accurate model.
Spec corrections documented in TEST_INTEGRITY.md (SCN-view-counter-legacy-path,
SCN-hydrate-root-timestamp).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
View counts inflated. viewCounter.js dedupes locally by (viewer, time-window)
correctly, but the published events couldn't be deduped by relays:
generateViewEventDedupeTag built the kind-30079 `d` tag with random entropy +
exact timestamp, so every view was a UNIQUE event. And exactCountForPointer runs
a NIP-45 COUNT (raw event count, no dedupe) and bumped the displayed total up to
it (max(deduped, raw)). Net: reloads / multi-device / re-watches each added a
view.
Make the `d` tag deterministic and window-bucketed (scope:viewer:bucket, no
entropy) so kind-30079 view events are genuinely parameterized-replaceable:
relays keep one event per (viewer, video, window), so the cheap NIP-45 COUNT
becomes accurate AND scalable and the raw count stops inflating. buildViewEvent
no longer invents a random `d` tag (the only production caller, publishViewEvent,
always supplies the deterministic one). Pre-existing entropy events age out over
the backfill horizon.
Tests: tests/view-event-dedupe.test.mjs.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Explore shared the full active set with Recent/For You and leaned on freshness,
so it read like a reshuffled Recently-added. Give it a discovery identity:
- Weights: drop freshness (0.25 -> 0.12) and bump novelty (0.30 -> 0.35) so
recency stops dominating and topic-novelty leads.
- Add a soft followed-author penalty (weight 0.3): the scorer now reads
subscriptionAuthors from the Explore runtime and deprioritizes creators you
already follow, so non-followed creators surface ("new to you"). A penalty,
not a filter, so the feed stays full if you follow lots of people.
- More variety: lower the diversity sorter's MMR lambda (0.7 -> 0.5).
Also compacted For You's subscriptionAuthors reader to a one-liner to stay
under feedCoordinator's size cap while adding the same reader to Explore.
Tests: explore follows-penalty scoring; existing explore-scorer/diversity +
feedCoordinator suites green.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
For You shared the full active set with Recent/Explore and only re-ranked by a
blended score, so when a user's signals were thin it collapsed toward freshness
and looked identical to Recently-added.
Give it a structural identity:
- The scorer tags each item forYouTier: 2 = followed author, 1 = interest/
watch-topic match, 0 = other.
- The sorter leads with tier (your people first) regardless of raw score, then
ranks within tier; trusted-muted still sinks last.
- When NOTHING is tiered (logged-out / no follows / no interests) it falls back
to an author-interleaved discovery order (new interleaveByAuthor) so the feed
never reads as a chronological clone of Recently-added.
Tests: tests/for-you-tiering.test.mjs (tier beats score, tiers stack, muted
last, no-signal interleave, scorer tier tagging). Existing for-you-scorer suite
still green.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
loadUserVideos rendered only the live relay fetch, and the render REPLACES the
grid container. So when a relay holding a creator's video transiently failed or
EOSE'd late, that video disappeared from the wall even though it was already
known in memory/cache — the "channel doesn't pull in all of a creator's videos"
bug (#20, symptom 1).
Union the live events with the already-known author events
(mergeChannelVideoSources, live wins on id collision) before building the
renderable list, so the rendered set is a superset and can't shrink on a
transient relay gap. Existing dedupe-by-root + access/delete filtering still
decide precedence downstream, so newer versions and deletions still win.
Helper + tests live in channelProfileVideos.js (channelProfile.js is at its
size cap); the call site stays net-zero. Streaming/first-relay-wins render is
still the separate 17d UX follow-up.
Regression: tests/channel-video-merge.test.mjs.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The cold-login REQ storm came from list subsystems subscribing/fetching across
the user's full NIP-65 relay set (often ~20) instead of the documented §17 read
cap. The fetchListIncrementally paths already route through capReadRelays, but
the live subscriptions and direct pool.list calls bypassed it:
- userBlocks.ensureBlockListSubscription: cap the live block-list subscription
- subscriptions.ensureSubscriptionListSubscription: cap the live follows-list
subscription
- hashtagPreferencesService: cap the relay set used by pool.list + fetch
capReadRelays keeps the user's own relays first and guarantees the reserved
defaults, bounded to MAX_SUBSCRIBE_RELAYS (8). Trade-off accepted: a list event
living only on a user's 9th+ relay may sync on a later pass rather than at login.
Regression: tests/user-blocks-relay-cap.test.mjs.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
orderDecryptors compared candidate `priority` before the `preferLegacy` flag.
The signer registers its nip44 candidate at an earlier priority (-20) than
nip04 (-10), so priority always decided the order and preferLegacy was dead
code — every kind-4 DM tried nip44 first, which fails on the `?iv=` NIP-04
ciphertext ("invalid base64: Unknown letter ?") before falling back to nip04.
That doubled the serialized extension round-trips per message on cold inbox
load and spammed the console.
Move the legacy preference (and it already had the explicit-hint check) above
the priority comparison so kind-4 messages attempt nip04 first. Gift-wrap
(preferGiftWrap) and same-scheme priority ordering are unchanged.
Regression: tests/dm-decryptor-order.test.mjs.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The "Message" button opened the profile modal's messages tab even with no user
logged in, leaving an unusable composer (DMs need a signing identity). Gate
openDirectMessageComposer behind isUserLoggedIn(): surface a login message and
open the login modal (falling back to authService.requestLogin) instead of
opening the messages pane.
Regression: tests/app/dm-composer-login-gate.test.mjs.
Co-Authored-By: Claude Opus 4.8 <[email protected]>