### Motivation
- Prevent a browser-policy muted-autoplay retry from overwriting a viewer's explicit mute/unmute preference and ensure fallback mutes are transient.
- Ensure that when a user logs in while a video comment modal is open, the open thread is reloaded (not just refreshed) so comments continue to display correctly after relay-backed services are reconfigured.
### Description
- Add `js/utils/autoplayPreference.js` with `muteForAutoplayFallback` and `consumeAutoplayFallbackMute` helpers to mark and consume transient fallback mutes.
- Update `js/services/playbackService.js` to treat sound as default unless the viewer explicitly chose mute, persist explicit choices via `localStorage`, ignore fallback-generated volume changes using `consumeAutoplayFallbackMute`, and expose `muteForAutoplayFallback` delegating to the new util.
- Replace direct `videoElement.muted = true` calls with `playbackService.muteForAutoplayFallback` in `js/app/playbackCoordinator.js` and `js/webtorrent.js` so fallback mutes are marked as transient.
- Change `js/app/authSessionCoordinator.js` to reload the currently open video comment thread with `commentController.load(activeModalVideo)` after login if a modal video is active, instead of only calling `refreshAuthState`.
- Update tests in `tests/services/playbackService.test.mjs` and add a test in `tests/app/login-defers-dms.test.mjs` to cover the login behavior that reloads an open comment thread.
### Testing
- Ran the `PlaybackService` unit tests (`tests/services/playbackService.test.mjs`) which assert `prepareVideoElement` behavior, explicit mute persistence, and the transient nature of fallback mute, and they passed.
- Ran the auth login unit tests including `tests/app/login-defers-dms.test.mjs` which verifies DM deferral and reload of an open comment thread, and they passed.
(cherry picked from commit ce14004725)
devLogger.warn is a no-op outside dev mode (IS_DEV_MODE = false on
this deployment), so when AuthService.requestLogin() rejected a
BitLogin sign-in -- e.g. the invite-only access-control check
("Access restricted to admins and moderators users only.") -- nothing
ever told the user why. The widget itself doesn't know bitvid rejected
it, so it kept showing its own "Signed in" screen, and the login modal
never closed, looking exactly like a hung/broken close button.
On rejection: route the error through app.handleLoginModalError (the
same visible-banner path every other sign-in method already uses) and
reset the widget via widget.logout() so it doesn't keep claiming a
session bitvid never actually granted.
Also added a WeakSet guard so wireBitloginLogin() can never attach two
"bitlogin-login" listeners to the same element, regardless of how
attachWidget() ends up getting invoked -- cheap insurance, no observed
bug currently triggers it.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DY9UMTuF43Ekc2U4E1eDwX
bitlogin's widget now implements NIP-04 (nip04Encrypt/nip04Decrypt),
closing the one gap left relative to a real NIP-07 extension. bitvid's
own nip07Adapter/nsecAdapter/nip46Adapter all already wire this up and
report it via signerCapabilities.nip04 -- BitLogin's adapter was the
one exception, hardcoded to nip04: false because the widget genuinely
didn't support it before. js/nostr/client.js's DM encrypt/decrypt path
only offers NIP-04 to a caller when that capability flag is true, so a
BitLogin-signed-in user previously had no working legacy-DM path at
all. Re-vendored the widget bundle (bitlogin's CHANGELOG.md, 2026-07-21
entry, third one) and updated the one test that hardcoded the old
(now-incorrect) capabilities shape.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DY9UMTuF43Ekc2U4E1eDwX
The login modal now shows <bitlogin-auth> -- a portable Nostr identity
behind a login name and password -- as the default sign-in path,
styled with bitvid's own brand tokens (accent-strong, surfaces, radii).
The existing NIP-07 extension, nsec, and NIP-46 remote-signer options
still work exactly as before, just collapsed under a new "Other login
options" fold instead of a flat "Available methods" grid; nip07 no
longer carries the stale "Recommended" eyebrow now that BitLogin has
that role.
BitLogin isn't a single button-click-then-await-a-promise flow like
the other providers -- it's a whole multi-screen sign-in/create/recover
UI -- so it gets a permanent, statically-declared widget in
components/login-modal.html (js/ui/bitloginModalIntegration.js relocates
it, never destroys and recreates it, since removal tears down its crypto
Web Worker) instead of a grid button. It still goes through the exact
same session-establishment path as every other signer: a new "bitlogin"
provider (js/services/authProviders/bitlogin.js) lets AuthService
resolve and activate it via the normal requestLogin -> handleAuthLogin
path, while providersForModal excludes it from the grid so it isn't
rendered twice.
Two things this needed beyond the wiring itself:
- js/app.js is one line from its lint/file-size ceiling, so the only
change there is renaming an import binding (providers -> providersForModal),
a net-zero line change. The actual widget/event wiring lives in a new,
unconstrained module instead.
- The login modal's HTML (including the permanent widget element) loads
asynchronously after bootstrap runs, so wireBitloginLogin() waits via
MutationObserver rather than assuming a specific call order.
Vendored via scripts/build-bitlogin-widget.mjs (mirrors the existing
Bitcoin Connect/Blossom vendor steps): BitLogin isn't published to npm,
so the widget build is copied verbatim from the sibling ../bitlogin
checkout into committed vendor/bitlogin/, then lazy-imported (like
Bitcoin Connect) so its ~350KB never weighs on visitors who never open
the login modal.
Gated behind FEATURE_BITLOGIN (config/instance-config.js), default on
for this unstable-branch experiment per AGENTS.md §1 -- reset to false
before this reaches main until verified end-to-end against production
relays.
Verified: npm run lint (clean), the full auth/login test suite (43/43,
including new coverage for the provider + adapter), and a manual
Chrome pass against the built dist/ -- widget renders themed correctly,
"Other login options" still shows the original four methods unchanged.
- deleteByUrl maps a Blossom URL back to its content-addressed blob (blobSha256FromUrl) and deletes it via BUD-12, defaulting to the URL's own origin. Wired into the edit-modal orphan cleanup so a superseded Blossom video/thumbnail is reclaimed (best-effort, never blocks the edit).
- BUD-03 kind-10063 server-list discovery: read (serversFromServerListEvent) + build (buildBlossomServerListEvent) + an extracted, testable import/publish helper (blossomServerList.js), plus 'Import from my server list' / 'Publish my server list' controls in the Storage pane's Blossom section.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
importAccountRecord hard-required encryptedMasterKey, but a Blossom-only account is keyless (no encrypted payloads) and legitimately has none — so its synced record failed to import. Require the envelope ONLY when the record carries an encrypted connection that actually needs it to unlock.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Two torrent-path robustness fixes surfaced while testing WebTorrent peering:
- streamVideo now sets announce: [...WSS_TRACKERS]. A .torrent-buffer add (Blossom companion piece-map) has an EMPTY embedded announce list, so without this it reached no tracker, found no WebRTC peers, and streamed webseed-only (looked like a stuck '1 peer'). WebTorrent merges these with any trackers already on the source.
- A forced-torrent playback now falls back to the hosted URL when the torrent DEFINITIVELY fails (e.g. a transient companion-lookup failure under relay congestion, or a dead swarm) instead of hard-erroring 'No playable source found'. Only triggers on real failure, not a slow swarm. Rollback noted inline.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
storageService now emits onConnectionsChanged (from importAccountRecord, the sync-import path); the upload modal subscribes and, when open + active user, re-derives its storage destination in place (re-attempt auto-unlock, recompute unlocked state, reload, repaint) — so credentials synced from Nostr are reflected without reopening. Bumped UploadModal's file-size baseline for the growth.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Blossom servers reject a .torrent (415), so a Blossom video's magnet is webseed-only and can't bootstrap WebTorrent. This restores P2P without hosting a .torrent:
- Upload hardening: correct Content-Type (overrides empty/generic application/octet-stream), HTTP status surfaced in errors, and best-effort .torrent sidecar so a rejected sidecar no longer sinks the upload (URL-first fallback).
- Publish: when the .torrent can't be hosted, publish the piece-map as an infohash-keyed companion event (kind 30078, d=bitvid:torrent:<infohash>) and ship a ws= magnet. Gated by FEATURE_BLOSSOM_TORRENT_METADATA (default off = no trace); size-capped.
- Playback: torrentMetadataService lazily fetches the companion, VERIFIES the reconstructed infohash === the magnet's btih (untrusted content), sources the webseed from the video event, and hands the .torrent buffer to WebTorrent so it streams with zero peers. Relay reads go through the subscription manager.
Inherited by any upload-based media (shorts, audio-publish); excludes ingest/live. See docs/blossom-torrent-metadata-plan.md. Includes a Prettier reflow in css/tailwind.source.css.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
fillStorageForm read the provider only from conn.provider (top-level), but getConnection returns { ...payload, meta } with the provider in meta (and no top-level provider for keyless Blossom) — so the selector always fell back to cloudflare_r2. Mirror connectionProvider()'s conn.provider || conn.meta?.provider fallback so the pane reflects the loaded default connection.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The keyless Blossom save returned early without triggering storageSyncService.push
(the S3 path does it after saving), so changing Blossom servers never updated the
synced copy — it looked like the settings didn't sync. Blossom servers already
live in the account record's plaintext meta (which exportAccountRecord ships), so
the same push the S3 branch uses now runs after a keyless save: the record is
re-encrypted to self and published, and it roams like S3.
NOTE: sync export/import needs the account's encryptedMasterKey, which exists once
any secret-bearing (S3) connection has been unlocked/saved. A Blossom-ONLY account
(no S3 ever) has no master key envelope yet — encrypted sync for that case is a
follow-up.
Lint + build clean. Local only.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Real-device failure: multiServerUpload uploads to one server then has the OTHERS
fetch it server-to-server via PUT /mirror — those /mirror requests fail from a
browser (net::ERR_FAILED / CORS), so the whole upload returned no blob URL
("Blossom upload did not return a usable blob URL").
Switch blossomService.uploadFile to upload DIRECTLY to each server (BUD-02
PUT /upload) in parallel via uploadBlob, resilient: first success wins, per-server
failures are collected, and if ALL fail the error names each server + the likely
cause (CORS / size / payment) and suggests blossom.band. A fresh signed upload
auth (BUD-11) is created per request.
Tests: blossom-service 11/11 (added partial-failure fallback + all-fail error).
Lint + build clean. Local only.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Two root causes for "Blossom set as default but not showing / not usable":
1. SAVE required unlock. saveConnection throws "Storage is locked" without a
master key — but Blossom has no secret. Added
storageService.saveKeylessConnection() (writes plaintext meta, no encryption,
no unlock) and the Blossom save branch now uses it. So a Blossom connection
saves even if the user never unlocked storage.
2. UploadModal treated Blossom like a locked S3 connection: it only set
activeCredentials when isStorageUnlocked, showed "Locked 🔒", blocked the
file-pick behind an unlock prompt, and mislabeled it "S3 Compatible". Now:
getProviderLabel → "Blossom"; loadFromStorage resolves servers from the
plaintext meta (no unlock); the summary shows "N servers / Nostr-native"; the
two upload gates and updateLockUi bypass unlock for Blossom
(activeProviderIsBlossom). Uploads authorize with the Nostr signer instead.
Bumped UploadModal size baseline (+gating code). Lint + build clean. Local only.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The management primitives, self-contained + unit-tested. Not yet wired into the
S3-shaped orphan-GC / delete-flow (that reconcile system assumes bucket/key
semantics — its own browser-tested increment, same pattern as the upload).
- Vendor entry: export createDeleteAuth (+ rebuilt bundle, still ~27KB).
- blossomService.deleteFile({ server, sha256, signer }) — BUD-12 DELETE /<sha256>
with a signed kind-24242 delete auth (BUD-11).
- blossomService.listFiles({ server, pubkey, signer }) — BUD-12 GET /list/<pubkey>,
optional list auth.
Tests: blossom-service 9/9 (added delete/list guards + happy paths). Lint + build
clean.
REMAINING Phase 2: wire deleteFile into video-delete cleanup (resolve
server+sha256 from the blossom storagePointer), orphan-GC parity, and the
kind-10063 discovery UI (import/publish server list — needs relay fetch/publish
plumbing).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Makes Blossom configurable + uploadable end-to-end (local testing).
- Storage pane: a "Blossom (Nostr-native)" provider option (revealed only when the
flag is on) that swaps the S3 field group (#storageS3Fields) for a keyless
#storageBlossomSection — a multi-server textarea (one URL per line). No
access-key/secret/bucket/CORS.
- ProfileStorageController: parseBlossomServers(); save branch persists the server
list in the PLAINTEXT connection meta ({servers}, no encrypted secret) via the
per-provider slot; provider-change loads servers from meta WITHOUT an unlock;
Test does a best-effort reachability ping; visibility branch hides S3 fields +
CORS helper for Blossom.
- mediaUploader.resolveActiveConnection: Blossom connections resolve from plaintext
meta with no unlock (uploads authorize via the Nostr signer, not a stored key),
so credentials = { provider, servers, meta } flow straight into the Phase-1a
engine.
- instance-config: FEATURE_BLOSSOM_STORAGE = true for LOCAL testing (reset to false
before pushing).
Tests: blossom-service 7/7 (isAvailable now flag-relative). Lint + build clean.
Blossom UI present in dist.
VERIFY locally: Storage → Provider → Blossom → add a server → Save → upload a
video → it plays from <server>/<sha256>. Watch the UploadModal's own storage
gating (may need a tweak for the no-secret/no-PIN Blossom case).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The Blossom upload orchestration, isolated + unit-tested. Only routes when a
connection's provider is "blossom", so the S3/R2 path is completely unaffected
(that branch can't trigger until the Phase-1b config UI can create a Blossom
connection).
- blossomService now owns the orchestration (WebTorrent-free, so it's Node-
testable): uploadFile (multiServerUpload + BUD-11 auth) and uploadVideo (blob →
injected torrent generator → upload .torrent as a 2nd blob → magnet with ws=the
Blossom video url + xs=the Blossom .torrent url → blossom storagePointer). Same
result shape as the S3 path, so publish is untouched. Exports isBlossomProvider
+ resolveBlossomServers.
- mediaUploader: thin Blossom branch at the top of uploadThumbnail/uploadVideo
(delegates to blossomService, injecting generateTorrentMetadata so the service
never imports WebTorrent); new getSigner dep. UploadModal + EditModal pass
getSigner: () => getActiveSigner().
Tests: tests/blossom-service.test.mjs (7 — guards, mirror, torrent/magnet
orchestration, storagePointer). Lint + build clean. Flag still off.
NEXT (Phase 1b): the keyless multi-server Storage-pane config UI + connection
model (servers in plaintext connection meta — no secret) + resolveActiveConnection
Blossom branch, so a server can be configured and uploads run end-to-end.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Phase 0 of docs/blossom-plan.md. No UX; off-by-default ("off = no trace").
- Vendored: pinned [email protected] bundled by scripts/build-blossom-sdk.mjs
→ vendor/blossom-client-sdk.bundle.min.js (esbuild, browser ESM, ~27KB). The
optional @cashu/cashu-ts peer dep (BUD-07 payments, only reached on a lazy 402
branch bitvid never hits) is aliased to a throwing stub. `npm run build:blossom-sdk`.
- Flag: FEATURE_BLOSSOM_STORAGE wired like FEATURE_AUDIO_INGEST / _BITCOIN_CONNECT
(instance-config → config → constants), default FALSE.
- Service: js/services/blossomService.js implements the same
uploadFile({…}) → {url,key} contract as s3UploadService (so a later
isBlossomProvider branch drops into mediaUploader). Lazy-loads the SDK; core
path = createUploadAuth(signer, sha256) + multiServerUpload (BUD-11 auth via
bitvid's existing signer, BUD-04 mirror). Torrent parity + result-shape mapping +
mediaUploader wiring + Storage-pane config land in Phase 1.
Tests: tests/blossom-service.test.mjs (5 — availability gate + input guards).
Lint + build clean. Nothing wired into the app yet.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
When FEATURE_BITCOIN_CONNECT is on, "Connect wallet" is the primary path and the
raw Wallet Connect URI field collapses under an "Advanced — paste a connection
manually" <details> disclosure (backup, de-emphasized). When the flag is off, the
controller forces the disclosure open and hides its summary, so the URI field
shows plainly exactly as before (no behavior change). Default-zap field stays
always visible. Adds a small token-based rotating-caret style for the summary.
Still LOCAL (flag ON for testing); not pushed, not deployed.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Phase 0+1 of docs/bitcoin-connect-plan.md (#61). Adds Alby's Bitcoin Connect as a
one-tap NWC connect front-end that feeds bitvid's EXISTING wallet pipeline; manual
URI entry stays as the backup.
- Vendored: pinned @getalby/[email protected].3 bundled by
scripts/build-bitcoin-connect.mjs → vendor/bitcoin-connect.bundle.min.js (esbuild,
browser ESM). crypto-shim aliases the L402-only `import('crypto')` bitvid never
calls. `npm run build:bitcoin-connect` rebuilds it.
- Flag: FEATURE_BITCOIN_CONNECT wired like FEATURE_AUDIO_INGEST
(instance-config → config → constants).
- UI: a "Connect wallet" button in the Wallet Connect pane (hidden unless the flag
is on); the manual URI field stays below as the backup.
- Controller: ProfileWalletController.handleBitcoinConnect() lazy-imports the bundle
(only on click — the ~700KB never touches the main bundle), inits NWC-only with
least-privilege requestMethods and persistConnection:false, then on onConnected
extracts provider.client.nostrWalletConnectUrl → the SAME validate→save path as
the manual field → disconnect() so bitvid solely holds the spending secret. The
URI is never logged.
NOTE: config/instance-config.js FEATURE_BITCOIN_CONNECT is TRUE for local testing
only. Reset to false (shipping default) before pushing/deploying. Local commit;
not pushed, not deployed.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The share popover's menu items (Copy URL, Copy Magnet, Copy CDN Link, Share on
Nostr) only set `data-action` — like the ⋯ menu — but unlike the ⋯ menu (which
moreMenuController delegates) nothing wired their clicks, so they did nothing.
Only the bottom "forced-source" test links self-wire. Same dead-button class as
the old embed/creator-link bugs.
Add a delegated click handler on the share panel (bindShareMenuActions) that
routes each action to the event videoModalController already handles:
copy-magnet → video:copy-magnet, copy-cdn → video:copy-cdn, share-nostr →
video:share-nostr, and share (Copy URL) → a new video:copy-url (buildShareUrl
makes the plain `?v=<nevent>` deep link; controller copies it with a toast).
Forced-source links are skipped (they self-wire).
Tests: tests/video-modal-controllers.test.mjs (+5 routing scenarios, 24/24).
Lint + build clean.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Adds the Audio/Music/Podcast feature flag to the instance config so a deployer
enables/disables it in one place like the other flags. Follows the exact
FEATURE_NIP71_INGEST wiring: export in config/instance-config.js → re-export in
js/config.js → DEFAULT_FLAGS + runtimeFlags snapshot + `export let` in
js/constants.js. Defaults FALSE ("off = no trace") — only an explicit `true`
lights it up; with it off nothing audio renders and the shipped audio guard keeps
audio-only notes out of the video feeds. No consumers yet (build only).
Also locks docs/audio-integration-plan.md decisions D1–D10 to the agreed
recommendations (status → DECISIONS LOCKED).
Verified: constants + config re-export FEATURE_AUDIO_INGEST === false by default.
Lint + build clean. docs/audio-integration-plan.md, TODO #60.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Follow-up to the audio-only feed guard. Real case: a Nodesignal-Talk podcast
published with `imeta m audio/mp4` + a `.f4a` URL. It was already filtered via
its imeta hint (audio/mp4 → hasAudio), but unlike the .ogg/.mp3 ones it actually
PLAYS in a <video> element (AAC-in-MP4 = audio + poster), which is why it looked
like it "worked." It's still a podcast, so it stays out of the video grid.
Closes the fallback gap for audio/mp4 notes that lack an imeta audio hint: added
.f4a/.m4b/.mka to AUDIO_ONLY_URL_PATTERN (AAC-in-MP4 audio / audiobooks /
Matroska audio). .mp4/.m4v remain video and are explicitly asserted not to match.
Tests: video-schema-and-conversion (+2, 13/13). Lint + build clean. TODO #60.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The convert-time guard (17b48cbf) only blocks FRESH events. Audio notes cached
before it existed are rehydrated straight from IndexedDB/localStorage as
already-converted video objects (`sig: "unavailable (cached)"`), bypassing
convertEventToVideo — so they kept showing in the main feed and channel grids.
Extract the audio check into a reusable object-level predicate
`isAudioOnlyVideoObject(video)` (url + magnet + imeta tags) and apply it at every
boundary a cached object can slip through:
- convertEventToVideo now delegates to it (fresh ingest, unchanged behavior)
- PersistenceManager.applyCachedPayload drops audio-only on hydration (purges
the cache going forward)
- NostrClient.getActiveVideos filters it (main feed, immediate effect)
- nostrService.shouldIncludeVideo filters it above the viewer-is-author
short-circuit (channel/author grids, incl. the creator's own channel)
Audio-only = no magnet AND no imeta `m video/*` AND (imeta `m audio/*` OR an
unambiguous audio url); `.ogg` alone stays video. Bumped client.js size baseline
(+6 lines). Tests: video-schema-and-conversion (+3 predicate scenarios, 11/11);
nostr-service + ingest-adapter suites green. Lint + build clean. TODO #60.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
A podcast/music note published as a native kind-30078 (content url = an .ogg/
.mp3 audio file, imeta `m audio/*`) was accepted by convertEventToVideo (it has
a url) and rendered as a broken <video> — black frame + audio on Chrome/FF,
MEDIA_ERR_SRC_NOT_SUPPORTED on Safari/iOS — since bitvid has no <audio> player.
The existing audio guard only covered the NIP-71 ingest path, not native events.
convertEventToVideo now marks such notes invalid ("audio-only source (no video)")
so every ingestion path that already drops invalid filters them: main feed buffer,
author/channel batch fetch, and local ingest. Audio-only = no magnet AND no
`imeta m video/*` AND (an `imeta m audio/*` variant OR an unambiguous audio URL
.mp3/.m4a/.aac/.wav/.flac/.opus/.oga/.weba). `.ogg` alone is NOT filtered
(ambiguous → video/ogg); only when imeta says audio. A video imeta variant wins.
Direct open-by-nevent is intentionally unaffected — an explicit link still loads.
Near-term half of TODO #60 (the future dedicated Audio tab remains open).
Tests: tests/video-schema-and-conversion.test.mjs (+5, 8/8). Lint + build green.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Adds a second entry point (alongside the Safety-tab npub input) for the
per-author moderation override. On blurred/warned video surfaces, an
"Always show creator" button now sits beside "Show anyway". Clicking it
dispatches a `video:trust-author` document CustomEvent; the single app-level
listener in applicationBootstrap.js applies the account-level override and
refreshes affected videos, clearing the web-of-trust warning for every video
from that creator until reset.
Wired into VideoCard, VideoModal (persistent reused button + teardown
cleanup), and SimilarContentCard.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Add "trusted creator" overrides that suppress the web-of-trust warning/blur
for ALL of a creator's videos until reset — the account-level companion to the
existing per-video "Show anyway".
- cache.js: per-author override store (bitvid:moderationAuthorOverrides:v1),
keyed by author hex, with get/set/has/clear/list + persist/load, mirroring
the per-video store.
- moderationDecorator: overrideActive now also honors an account-level override
for the video's author, so the whole class of WoT warnings clears for every
video by that creator.
- app.js: load the author overrides on startup.
- Services + contract: getAuthorModerationOverrides / setAuthorModerationOverride
/ clearAuthorModerationOverride; set/clear refresh the feed + fire the shared
moderation-override event so the Safety tab list updates.
- Safety tab: a "Trusted creators" section — add by npub, list each with a
Reset, mirroring the Active-overrides section.
- tests: storage set/get/has/clear/list, hex normalization, persist→load,
invalid-input guard.
Next: the "Always show this creator" button on the video warning overlay
(second entry point) — a larger multi-component UI change, done separately.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Root cause of "a known video never shows on the channel": the video was a
whitelisted creator's NIP-71 note that lives ONLY on nos.lol, but the channel
fetch queried nostrClient.relays — the user's CAPPED NIP-65 read set — which
dropped nos.lol, so we never queried the one relay that had it. Confirmed by
per-relay probe: snort/damus/primal returned 0, nos.lol returned the 2 videos.
loadUserVideos now unions the user's relays with DEFAULT_RELAY_URLS (nos.lol,
damus, primal, snort) for the scoped channel fetch, so a creator's videos on
any bundled default relay are found regardless of the read cap. Channel pages
are a deliberate request for one creator, so the slightly wider fan-out is fine.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Temporary dev-mode logging to pinpoint why a known video (e.g. a whitelisted
creator's NIP-71 video) never renders on their channel wall:
- loadUserVideos logs the relay set size, relay event count + kind breakdown,
and the cache event count.
- buildRenderableChannelVideos logs per-stage counts: input → converted →
uniqueById → byRoot → afterDeleted, plus drop counts for blacklist/canAccess
and the final render count.
devLogger.info → visible in dev mode only (localhost), no prod noise.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The channel video grid felt slow and often flashed "No videos" because, when
the warm cache was cold (direct URL, a new tab, or an author not in your feed),
it fell back to a bespoke one-shot pool.list per relay with an 8s timeout,
AWAITED ALL relays before rendering once, and showed "No videos" whenever that
came back empty (which a timed-out/flaky relay does). "My Videos" is fast
because it reads the warm shared cache and only refetches on demand.
Rework loadUserVideos:
- Progressive render: paint the grid as each relay/source returns (deduped,
coalesced, serialized re-render) instead of blocking on the slowest relay.
- Reliable fetch: also route through nostrService.fetchVideosByAuthors — the
same proven path My Videos uses — which streams into the shared cache, so
this grid, its revisits, and every other grid benefit.
- No premature empty: mid-flight renders never pass allowEmptyMessage; the
"No videos" message is shown only on the final render, after every source
has genuinely settled. The cache-first paint is preserved.
- check-file-size: bump channelProfile grandfather for the growth.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Extends the .profile-account-card pattern to the remaining compact account
lists so they all behave consistently on phones:
- Admin lists (whitelist/blacklist/editors): use the shared
.profile-account-card / __actions classes (was bespoke responsive utilities;
now full-width equal-width action buttons on mobile like the others).
- Moderation overrides: the summary+actions row (a nested flex inside an
already-padded card) uses a new padding-less .profile-account-card__row
variant so it stacks on phones too.
The account switcher (single tap-button with a column meta-stack) and DM
conversation rows (tap-to-open, no action buttons) are already mobile-friendly
and structurally different, so they're intentionally left as-is.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
These cards were `flex justify-between` — three action buttons squeezed the
name/npub into a tiny column, so the (break-all) npub wrapped to 4 lines and
the whole card looked crammed on phones.
- Shared .profile-account-card + .profile-account-card__actions classes on the
Subscriptions, Friends and Blocked cards (was raw utility soup).
- Desktop keeps the row layout (summary left, actions right).
- Phones (<sm) stack it: summary on top at full width (name truncates on one
line, npub fits on one line), actions as a full-width row of equal-width
buttons with tighter padding below.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The wallet already rode the same login "restore from Nostr" offer as storage,
but a once-per-device "offered" flag suppressed it — so on a device without
your wallet you fell back to the manual Sync button. Now:
- encryptedSyncItem: add hasLocal(pubkey) (non-null payload => item present
locally, e.g. a connected wallet / configured storage).
- settingsRestorePrompt: re-offer an item whenever it's MISSING locally and a
Nostr backup exists — bypassing the "offered once" flag — so it surfaces
exactly when you'd want it. Only respect the once-per-device flag when the
item IS present locally (no nagging). A new per-device "dismissed" flag,
set when you decline, keeps it quiet after an explicit "no".
- Accepting enables sync, so it auto-pulls silently thereafter (the "both"
behavior: silent auto-pull when enabled + popup when missing & off).
- Unknown services (no hasLocal) keep the legacy behavior.
- tests: re-offer when missing despite prior offer; no re-offer when present;
decline stays quiet.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The "restore storage/wallet from Nostr" confirm was popping over the guided
tour (both fire on login). Gate the restore dialog behind a "tour gate":
- maybeOfferOnboardingTour opens the gate synchronously when the tour will
run (shouldOfferOnboarding), before its own start delay — so ordering can't
race. The tour offer is now called before the restore offer on login.
- launchBitvidTour gains an onEnd(status) hook (fires on finish AND skip);
startOnboardingTour closes the gate on end, and also closes it if the tour
never starts (logged out, no eligible steps) so restore can't hang.
- The restore prompt's confirm now awaits whenOnboardingTourInactive() before
showing — the remote existence check still runs in parallel, only the dialog
is held back. No tour → gate stays closed → restore behaves as before.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Onboarding tour (tourEngine.js):
- On narrow viewports (<640px) dock the popover as a fixed BOTTOM SHEET
instead of anchoring it — kills the off-screen/jump-to-center glitches
caused by short viewports, wrapping buttons and URL-bar resize. The
spotlight still highlights the target, scrolled to center so it sits above
the sheet.
- On desktop, measure the ACTUAL popover size (getBoundingClientRect) and
feed it to computeTourPlacement, instead of the fixed 240/336 guesses that
pushed tall popovers off-screen.
Profile modal mobile tab grid (CSS):
- Equal-height tiles via a min-height token + centered content, so long
labels no longer leave a ragged grid.
- Move the unread dot to the tile's top-right corner (was landing in the
grid gutter at the vertical-nav left edge).
- Tighter line-height/padding + non-shrinking glyph.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Resolved submissions were resurfacing (most visibly after a cache clear)
because the admin resolved-set wasn't durable: it was never mirrored (relays
pruned it) and the read-modify-write could republish a SHRUNK replaceable
list when a cold-load read missed the current set.
- Device-local resolved-id cache (localStorage): grows only, backs up the
relay list. Hides handled items even if a relay read momentarily misses,
and makes markSubmissionResolved loss-proof — it folds every locally-known
resolved id into each republish, so a missed read can't drop entries.
- Mirror the resolved-set on each fetch (re-broadcast) so relays keep it
alive, same as we do for pending submissions.
- Persist relay-known resolved ids into the local cache on fetch.
- Belt-and-suspenders: an application whose applicant is already whitelisted
is treated as handled even if the resolved-set were lost entirely
(callers now pass accessControl.getWhitelist()).
- tests: whitelist-hide, local-hide after a cold read, and loss-proof
republish (earlier id carried forward despite a missed read).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Submissions are signed by throwaway ephemeral keys and public relays prune
events from pubkeys with no social graph, so the queue quietly evaporated.
- Forms: also publish to nos.lol (better retention, already in the app's
query set) alongside snort/damus/primal.
- submissionFacade: on each fetch, re-broadcast the still-pending signed
events back to our relays (publishEventToRelays) so pruning doesn't drop
them. Best-effort, fire-and-forget — never blocks or fails the fetch;
no-ops when no relay pool is available.
- test: fetch still returns the pending queue when a pool is present.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
- adminBadge.js: add applyAdminRing(el, id) for surfaces that already place the
star on a specific element and just need the ring.
- comments: ring + star on admin authors' avatars; round the comment avatar box
so the ring reads as a circle.
- channel page: ring the inner rounded avatar box (star already on the wrap).
- Notification dots: red dot on the profile modal's Admin nav tab and on the
Submissions subtab button when there are pending submissions, so admins know
where to look. profileModalController.setSubmissionsUnreadIndicator toggles
both (Admin nav dot gated on the tab being visible); driven from
applyProfileNotificationDot off app._submissionsPending.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Add a reusable decorateAdminAvatar(avatarEl, id) that applies BOTH the gold
ring (box-shadow, not clipped by the avatar's overflow) and the corner star
(hung off a non-clipped .admin-avatar-wrap it ensures around the avatar).
Idempotent; works on attached or freshly-built detached avatars.
Wired onto:
- profile modal "Acting as" identity pill
- account-switcher saved-profile avatars
- video card creator avatars
- video player creator avatar
- DM conversation list + message bubbles (upgraded from star-only to ring+star)
adminBadge.js: split the star decision from its DOM (setAdminStar) so the
ring and star share one isAdminActor check; added ADMIN_RING_CLASS /
ADMIN_WRAP_CLASS. tests: decorateAdminAvatar (wrap/ring/star, idempotency,
non-admin, detached).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Make the super-admin/admin signal unmistakable on your own profile button:
a gold ring around the avatar (with a thin page-colored gap so it reads as
a ring, not a border) in addition to the corner star.
- applicationBootstrap.js: refreshAdminStar toggles an is-admin class on the
profile button using applyAdminStar's return value.
- CSS: #profileButton.is-admin > div draws the gold ring (box-shadow, not
clipped by the avatar's overflow).
- uiCoordinator.js: refresh the admin star/ring inside applyAuthenticatedUiState
so it also lights on session restore, not only a fresh login — the likely
reason it wasn't showing.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Approving a content appeal now checks WHY the video is hidden and applies
the right fix, instead of only ever touching the per-event block list.
- submissionFacade.js: resolveEventAuthorHex({ eventId }) looks up the event
and returns its author hex — the authoritative author, so we don't rely on
the appellant to supply it.
- submissionsSection.js approveAppeal: branch on the real block state.
* Author-level block (whole npub) → its own strongly-worded confirmation
("restores ALL their content"); on confirm, removeFromBlacklist plus any
event block. Never bundled into the default unblock.
* Event-level block only → unblock this video (as before).
* Neither → distinguish "nothing to unblock" from "couldn't verify on
relays" and just mark the appeal resolved.
- tests: resolveEventAuthorHex (author normalization, blank/not-found).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Extend the admin star to per-message avatars: incoming bubbles from an
admin peer now show the gold star. MessageBubble gains an opt-in adminId
and wraps its avatar img in a non-clipped shell only when the author is an
admin (non-admin bubbles keep the bare <img>). MessageThread passes the
peer pubkey for incoming bubbles; outgoing bubbles are the current user,
whose admin status is already shown on the profile button.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Show a small gold "verified admin" star in the corner opposite the red
notification dot whenever a rendered profile belongs to a bitvid admin
(the super admin or a current list editor).
- adminBadge.js (new): isAdminActor(hex|npub) sync check against
accessControl, and applyAdminStar(container, id) which adds/removes a
single `.admin-star` in a non-clipped, positioned container. Idempotent.
- CSS: `.admin-star` (top-left gold disc, mirrors the top-right dot),
`.dm-avatar-wrap`, and position:relative on the comment avatar wrapper.
- Profile button: app.refreshAdminStar() lights the logged-in user's star;
re-runs on login/logout and on accessControl.onEditorsChange (so a
late-loading editor list still resolves).
- Comments: star on comment-author avatars (commentNodeFactory).
- Channel page: wrap the clipped avatar and star the channel owner.
- DMs: Avatar gains an opt-in adminId — only admin avatars get wrapped so
non-admin rows are byte-identical; ContactRow passes the peer pubkey.
- tests: admin-badge unit coverage (recognition, idempotency, removal).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Make each admin action self-explanatory and give content appeals a real
effect instead of a dead-end "Dismiss".
- submissionsSection.js: per-type action sets, each button paired with a
one-line description. Applications show Approve (adds to whitelist) / Deny;
appeals that name a real event show "Approve & unblock" (removes the video
from the event block list) / "Keep blocked"; other types show "Mark handled".
- submissionService.js: parse an appeal's `e` tag into targetEventId (trusted
only when it's a well-formed 64-char hex id).
- iframe-content-appeals-form.html: add a required "Blocked video link or
event ID" field; regex-validate + normalize a share link / nevent / note /
hex to the hex event id, tag it as `e`, and include it in the body. Block
submission when no valid reference is provided.
- CSS: stacked action + hint styling; iframe-form .form-hint.
- tests: cover targetEventId parsing (valid, malformed, absent).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The profile-button red dot now lights when the logged-in user has either
unseen DMs or (if they can edit admin lists) pending submissions to review.
The Messages-nav dot stays DM-only.
- applicationBootstrap.js: split indicator state into _dmUnread and
_submissionsPending; applyProfileNotificationDot() ORs them for the
profile dot. Added refreshSubmissionsIndicator (gated on FEATURE_SUBMISSIONS
+ canEditAdminLists), a bitvid:submissions-changed listener, a 180s poll,
and a startup refresh.
- authSessionCoordinator.js: refresh the submissions indicator on
auth-login and auth-logout alongside the DM refresh.
- submissionsSection.js: emit bitvid:submissions-changed after approve/deny
so the dot recomputes for all admins.
- check-file-size.mjs: bump authSessionCoordinator grandfather baseline.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Submission cards showed the raw form markdown ("### Bug Report", "- npub: …").
Added a tiny, SAFE renderer: `#…###` headings → bold lines, leading `- `/`* `
bullet markers stripped, `**bold**` honored inline — built entirely with
textContent/createTextNode + <strong>, so no HTML is ever parsed from the
content (submissions are untrusted public events; rendering raw markdown/HTML
would be an XSS hole). Lint (innerHTML check) + build clean; tests 26/26.
Co-Authored-By: Claude Opus 4.8 <[email protected]>