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 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]>
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]>
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 wiring was structurally intact (menu item event-details → handler case →
handleEventDetailsAction → EventDetailsModal.open), so the failure was silent:
- handleEventDetailsAction only opened the modal `if (… && payload.video)` and
never caught the async open() rejection — any miss = nothing happens.
- EventDetailsModal.open ran the (throwable) renderVersion BEFORE openStaticModal,
so a render hiccup left the click looking dead.
Hardened:
- handleEventDetailsAction surfaces "No video selected" / "unavailable" instead of
failing silently, falls back to app.eventDetailsModal, and .catch()es open().
- open() now shows the modal (openStaticModal) FIRST, then renders in a try/catch.
- The ⋯ popover is dismissed before opening the modal.
Couldn't repro by inspection, so this both fixes the likely causes and makes any
remaining failure visible (a toast / console warn naming the exact reason).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Two bugs in the #26 wiring surfaced live:
- The ⋯ menu lazy-import used "../../viewCountChart.js" which resolves one level
too high (dist/viewCountChart.js → 404 "Failed to fetch dynamically imported
module"). Corrected to "../viewCountChart.js" (js/ui → js/viewCountChart.js).
- The video-list context path called handleMoreMenuAction without the video, so
view-stats hit "No video selected". Forward detail.video.
Also make the popularity modal single-instance: close any open one (proper
subscription/listener cleanup) before opening a new one, so the two menu paths
firing for one click can't stack duplicates or leak a live subscription.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
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]>
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]>
Clicking "Message" added the user to the sidebar list but the right-hand
message pane stayed on the previously-active conversation. setDirectMessageRecipient
called setFocusedDmConversation (which only tracks read-state) and never set
activeDmConversationId — the value buildDmConversationData reads to choose which
thread the right pane renders — so it kept resolving the stale id.
Set activeDmConversationId directly (mirroring handleDmConversationSelect), and
switch the mobile pane to the thread for explicit selections (not the auto
thread-default).
Regression: tests/ui/dm-set-recipient-active.test.mjs.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Clicking "Message" on a profile (or selecting anyone you'd never DMed) set the
recipient, but buildDmConversationData discarded it: a recipient with no
existing thread wasn't in conversationMap, so it fell back to conversations[0]
(the top conversation). The inbox silently jumped to the top thread, and any
message sent then went to the wrong person ("says sent but I don't see it").
Fixes:
- setDirectMessageRecipient now focuses the selected recipient's conversation
so a stale activeDmConversationId can't win.
- buildDmConversationData synthesizes an empty conversation for a
selected-but-threadless recipient and treats it as active, including an
activeRemotePubkey fallback so the composer targets the right person.
- The legacy renderProfileMessages list no longer clobbers a non-null
recipient with threadsToRender[0].
Regression: tests/ui/dm-conversation-focus.test.mjs.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
ProfileModerationController calls dmController.resolveProfileSummaryForPubkey
when rendering moderation overrides, but the method lives on the DM helper and
the controller didn't delegate it — so opening the profile modal's moderation
settings threw an uncaught "is not a function". Add the helper delegation
(createCompactProfileSummary right after it was already delegated). Covered by
the delegation regression test.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Clicking Send threw "this.controller.handleDmAppShellSendMessage is not a
function". The DM app-shell renderer wires its callbacks on `this.controller`
(ProfileDirectMessageController), but the controller never delegated them to
`this.actions` — so Send, mark-read, mark-all-read, read-receipt/typing
toggles and open-settings all threw and did nothing. Add the six delegations.
The send path itself (encrypt -> sign -> publish) was fine.
Regression test asserts the controller delegates each app-shell callback to
this.actions.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Same dead-event bug as the embed button: handleCopyRequest dispatched
"action:copy", but ModalManager listens for "video:copy-magnet"
(app.handleCopyMagnet) and #copyMagnetBtn has no popover fallback — so the
button did nothing. Dispatch "video:copy-magnet". Extended the regression
test to cover it.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
handleEmbedRequest dispatched "action:embed", but ModalManager listens for
"video:embed" (which opens EmbedVideoModal) and dispatch() does not remap
names — so the embed button fired an event into the void and the modal
never opened. (Share survives the same mismatch only because shareBtn also
toggles a popover directly.) Dispatch "video:embed". Regression test added.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
A static client can't fetch LNURL hosts that omit CORS headers, so zaps to
those creators fail. Add a browser-side reachability probe
(checkLightningAddressZappable) and surface it on both sides: the creator
is warned at wallet-connect time if their own address isn't browser-
zappable, and the zapper sees a heads-up when opening a non-reachable
creator's zap dialog.
Also fix encrypted settings restore: the "already offered" flag was
per-pubkey, so once storage was offered the wallet note was never offered
later. Track it per-item so the wallet is offered independently.
Zappability check extracted to walletZappabilityCheck.js for the size cap.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
- NWC connection was fully broken: parseNwcUri passed a hex secret to
nostr-tools getPublicKey (needs Uint8Array) and threw. Convert hex->bytes.
- A successful zap was shown as a red error when the kind-9735 receipt
couldn't be validated. The NWC preimage already proves payment, so a
paid-but-unconfirmed share is now a success-with-note, not a failure.
- Receipt discovery: query by the reliably-indexed #e/#a/#p tags instead of
#bolt11 (which most relays don't index), and poll a few times since the
receipt is published shortly after payment.
- In-flight "Sending..." status no longer mis-toned as a warning; success
path fully resets the form; an in-flight-open click no longer cancels the
open (which made the popover need many clicks); fee fallback verified
no-bypass.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The shared engine treats every panel as an ARIA menu and preventDefault'd
single-char + arrow keys, so typing in a panel with inputs (the zap
comment/amount fields) was swallowed — "you can't type in the popup".
handleMenuKeydown now ignores editable targets.
Also relocate a pre-existing in-host panel (the in-modal zap dialog) into
the body-level portal on open so position:fixed is viewport-relative
instead of anchoring to the modal edge, restoring it on close. Pure DOM
helpers extracted to popoverPanelDom.js to keep the engine under its cap.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Addresses "some NIP-71 videos don't play / he has multiple links" + "speed
the liveness check + don't render unplayable".
- Phase 1 — multi-source capture: nip71IngestAdapter collectVideoSources()
exposes a `sources` list (all video imeta urls, in order; audio/image
excluded; deduped) so mirrors are preserved (the adapter previously kept
only one url).
- Phase 2 — multi-source liveness: urlHealthController.probeUrlList() probes
every candidate (primary + sources) via the existing video-element
playability probe — healthy as soon as one source plays, offline only if
ALL fail. So a dead primary host no longer hides a video with a live
mirror. (Card hide policy in cardSourceVisibility was already correct.)
- Phase 3 — speed: URL_PROBE_TIMEOUT_MS 8s -> 4s for a fast verdict so dead
cards hide quickly; WebTorrent swarm probe stays generous.
Remaining (TODO #5): player-side fail-over at play time; hide-until-verified
for foreign ingested videos. Tests + mutation-verified (collectVideoSources
video-only filter, probeUrlList first-ok/offline/timeout). 1271/1272 unit
(known UploadModal flake).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Live logs proved ingest works (subscribed to 17 whitelisted authors,
received 61 events, injected 39) — but injecting foreign authors during
cold-start triggers the moderation/WoT stage to hydrate their social graph
(kind 30000), which competes with the initial feed load and stalls it
("Fetching recent videos…" hanging, needs a refresh).
- startWhenFeedReady(): defer the first ingest subscription until the
native feed's first videos:updated (with a 12s fallback for empty
feeds), keeping ingest off the cold-start critical path. Wired in
applicationBootstrap (kept thin so the size cap holds).
- emitRefresh now logs how many ingested videos survive the feed filter
("N ingested of M in filtered feed") so we can tell whether foreign
videos are filtered out vs. injected-but-not-rendered.
Tests: defer-until-feed-ready + fallback-timer; mutation-verified.
1236/1237 unit (known UploadModal flake).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Pulls NIP-71 video events published by other Nostr apps (kinds
21/22/34235/34236), converts them via the ingest adapter, and injects
them into nostrClient.activeMap/allEvents so they flow through the
existing feed pipeline. Gated by FEATURE_NIP71_INGEST.
Mirrors bitvid's whitelist model: since the whitelist is "authors
allowed to publish/render here," ingest scopes its relay subscription to
whitelisted pubkeys while whitelist mode is on (bounded, no firehose) and
re-subscribes when the whitelist changes. When a deployer disables
whitelist mode, ingest opens to all authors (capped by a limit). Foreign
content is still gated by the same render-time filter
(shouldIncludeVideo -> accessControl.canAccess): whitelist, blacklist,
NSFW (content-warning -> isNsfw), private-skip, plus trust/mute/blur from
the feed engine. Native bitvid videos always win a shared root; among
ingested videos newest wins. Refreshes via the existing videos:updated
reactive path.
- accessControl.getWhitelistPubkeys(): hex whitelist for subscription scope.
- Wired in applicationBootstrap (start on init, stop on teardown).
- nostrService untouched (at its size cap); logic is a standalone service.
Mutation-verified: native-wins, whitelist scoping, feature gate.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Adds an opt-in account preference (off by default, per pubkey) that
auto-mirrors newly published eligible public videos to NIP-71
(kinds 34235/34236) so they surface on other Nostr video clients —
no per-video opt-in needed.
- nip71MirrorFlags: isAutoShareEnabled/setAutoShareEnabled (persisted
per pubkey) + pure resolvePublishSync({featureOn,autoShare,eligible}).
- nip71MirrorSync: syncNip71MirrorAfterPublish + a videos:published
subscription in initNip71MirrorSync; converts the canonical 30078
event, checks eligibility (public + hosted URL + ALLOW_NSFW gate),
publishes the mirror, and records the per-video flag so later
edits/deletes stay in lockstep. All best-effort.
- My Videos pane: an "Auto-share new public videos to other Nostr apps"
checkbox wired to setAutoShareEnabled, gated by FEATURE_NIP71_MIRROR.
Tests (mutation-verified): auto-share persistence, resolvePublishSync
(eligible+on=>publish; off/ineligible=>none), and videos:published
subscription wiring.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Auto-captures a video's width/height/duration at file-select and persists them on
the 30078 — the durable foundation for a future "shorts" vertical feed (filter on
height > width) and for the NIP-71 mirror's short-kind selection.
- js/utils/videoProbe.js: probeVideoMetadata(src) loads metadata into a hidden
<video> (element injectable for tests); resolves {width,height,duration} or null.
- UploadModal: probes the selected file (objectURL) in the background and threads
width/height/duration into the publish payload.
- videoPayloadBuilder: persists width/height/duration in content (publish) and
preserves them across edits; normalizers exported.
- convertEventToVideo: exposes video.width/height/duration → the mirror builder
already uses them (portrait ⇒ 34236 short + imeta dim).
tests: probe resolution (incl portrait + error/timeout → null); content persist +
parse; portrait⇒34236 / landscape⇒34235 mirror selection; edit preserves dims.
Mutation-verified earlier on the mirror short-selection.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Closes the dual-event drift the user flagged: an opted-in 34235 mirror now stays
in lockstep with the canonical 30078 automatically — no per-video babysitting.
- js/services/nip71MirrorSync.js: syncNip71MirrorAfterEdit (re-publish if still
eligible; unshare + clear flag if it became ineligible, e.g. flipped private),
syncNip71MirrorAfterDelete (remove + clear flag). Plus initNip71MirrorSync()
which subscribes to nostrService's EXISTING videos:edited/videos:deleted events
— so the size-capped nostrService is untouched.
- js/services/nip71MirrorFlags.js: pure resolveEditSync/resolveDeleteSync decisions.
- applicationBootstrap: one-line initNip71MirrorSync(app.nostrService) wiring.
- All best-effort: a mirror failure never breaks the core edit/delete.
tests: resolver decisions (lockstep/unshare/none) + delete clears the flag +
event wiring is idempotent. Mutation-verified (eligibility branch load-bearing).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Makes the NIP-71 mirror user-facing via the My Videos tab — the natural
manage-my-videos surface — without touching the single-writer publish path.
- js/constants.js: new FEATURE_NIP71_MIRROR flag (default on). Independent of
FEATURE_PUBLISH_NIP71, which is already wired to the LEGACY 21/22 auto-publish in
videoPublisher.js — flipping that would activate the wrong (non-opt-in) model, so
the opt-in mirror gets its own flag and that legacy path stays dormant.
- js/services/nip71MirrorFlags.js: per-video opt-in persistence
(pubkey+videoRootId, off by default) + pure resolveMirrorToggle decision.
- MyVideosController: per-row "Share to apps" / "Shared ✓" toggle (gated by the
flag), wired to nip71MirrorService.publish/remove. Ineligible videos show a
disabled "Share off" with the reason (private / NSFW-not-allowed-here / needs
hosted URL) — the ALLOW_NSFW_CONTENT moderation gate surfaces here too.
tests/nip71-mirror-flags.test.mjs: persistence scoping + toggle decision
(enabled→remove, eligible→publish, ineligible→blocked). Mutation-verified.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Saves push silently as before, but if the relay copy is NEWER than the one this
device last pushed (another device changed it), the user is now asked to confirm
before overwriting — preventing a silent multi-device clobber. No nag on normal
saves.
- js/services/settingsSyncFlags.js: track per-pubkey+kind last-pushed created_at
(getSyncPushedAt/setSyncPushedAt).
- js/services/syncConflict.js: pushWithConflictCheck — peek remote created_at
(list-only, no decrypt), compare to last push, call confirmOverwrite only on a
real conflict, then push strictly-newer than remote so the replace wins.
- storage/walletSyncService push()/enable() route through it and accept a
confirmOverwrite callback.
- ProfileStorage/WalletController supply confirmOverwrite (window.confirm) and
surface a "kept the newer copy" status when the user declines.
tests/sync-conflict.test.mjs: no-remote→silent, newer→prompt(+decline blocks
push), confirm→strictly-newer push, older→no prompt. Mutation-verified
(ignoring the decline fails). Service-test twins gained exists().
Co-Authored-By: Claude Opus 4.8 <[email protected]>
"Test connection" then "Save" removed the wallet. handleWalletTest re-rendered
the pane (refreshWalletPaneState) in its finally, which repopulates the form from
SAVED settings — empty for a URI you typed but hadn't saved — wiping the field.
The next Save then saw an empty field and took the "removed" branch.
Test must preserve what the user entered: refresh only the button state
(applyWalletControlState), not the whole form.
tests/wallet-test-preserves-input.test.mjs: a Test of an unsaved typed URI keeps
it in the field. Mutation-verified (restoring refreshWalletPaneState fails it).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Data-loss bug: on a fresh device the user opened the Wallet pane (field empty,
wallet not yet restored), clicked Save, and the "removed" branch called
walletSync.disable() — publishing a {cleared:true} note that destroyed the good
encrypted copy another device relied on. (Confirmed in logs: a kind-30078
"bitvid:nwc" {cleared:true} event signed right after login.)
An empty save happens routinely (saving before hydration/restore), so treating it
as a deliberate disconnect-that-wipes-the-remote is wrong. Removed the auto-clear
from the empty-save branch: the remote synced note is now changed ONLY via the
explicit sync toggle (enable/disable) or a successful save (push). An empty save
clears just the LOCAL wallet.
tests/wallet-sync-no-clobber.test.mjs: empty save does not call disable/push; a
real save still pushes. Mutation-verified (re-adding the disable fails the test).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Adds encrypted cross-login sync for the NWC wallet connection (todo #15),
reusing the encrypted-sync core under d-tag "bitvid:nwc".
- js/services/settingsSyncFlags.js: shared per-pubkey opt-in flag store
(storage/wallet); storageSyncService refactored onto it (identical behavior,
tests still green).
- js/services/walletSyncService.js: factory (NwcSettingsService is per-app, not a
singleton). push() syncs the current nwcUri (+defaultZap); pull() applies it via
updateActiveNwcSettings; disable() wipes the note.
- Wallet pane (components/profile-modal.html + ProfileWalletController): opt-in
toggle + Restore button + a strong SPENDING warning, shown only when a signer
can encrypt. Enabling requires an explicit window.confirm (the URI can spend).
Re-pushes on save when enabled; and on wallet DISCONNECT while synced it clears
the note so a removed spending URI doesn't linger (encrypted) on relays.
tests/wallet-sync-service.test.mjs: enable-pushes-uri, pull-restores-via-
updateActiveNwcSettings, disable-clears, no-wallet-doesn't-publish. Mutation-
verified (skip-apply + skip-clear both fail).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Wires the storage-credential sync (6f7f4196) into the profile Storage pane.
UI (components/profile-modal.html): a "Cross-device sync" card shown only when
storage is unlocked — an opt-in toggle ("Sync these settings to my Nostr account
(encrypted)"), a "Restore from my Nostr account" button, the public-relay /
key-compromise warning copy, and a status line.
Controller (ProfileStorageController):
- toggle on -> storageSyncService.enable() (encrypt + publish), with rollback +
message if nothing is stored yet; toggle off -> disable() (wipe the note)
- restore -> pull() + re-render the pane so the restored connection shows
- after a successful Save, re-push the synced copy when sync is enabled
Also routes the core's relay READ through the subscription manager (injected
listEvents) instead of pool.list, satisfying lint:pool-access for the new file;
the facade supplies it via nostrClient.getSubscriptionManager().list.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The logged-out delete error: handleWatchHistoryRemoval threw
"watch-history-disabled" whenever the service wasn't synced (isEnabled() false) —
which is the NORMAL state for a logged-out user — so local deletes errored with
"Watch history sync is not available right now" and never touched the local
queue. It conflated "not synced to relays" with "disabled".
Now it only rejects when neither synced NOR local history is supported. For a
local-only actor it proceeds with the snapshot (replace), which rewrites the
local queue (replaceLocalQueue), and SKIPS the relay publish (gated on synced) so
a session-actor list is never pushed to relays. Success copy reflects local vs
synced.
Tests + mutation-verified: local-only removal isn't rejected, snapshots with
replace, and does NOT publish to relays; synced removal still publishes; truly
disabled (no sync, no local) is still rejected. Reverting the gate fails the
local cases.
Build + full lint green.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
THE root cause of "watch history delete does nothing / card vanishes then pops
back": the profile modal's history renderer overrode `remove` to route through
onHistoryReady -> app.handleProfileHistoryEvent(), which is a stub that just
`return null`. So deleting an item in the profile history pane published NOTHING
— the card was removed optimistically, then the refresh re-rendered the full
list and it reappeared. No publish, no logs, no relay change.
This explains why earlier fixes (replace semantics, newest-per-d-tag read,
empty-month publish) didn't help — they were all on the standalone history path /
the publish layer, but the profile pane never reached any of them. The override
was almost certainly a placeholder left over from the synced-vs-local history
refactor that was never implemented.
Fix: drop the broken `remove` override so the profile history pane falls through
to the renderer's working default (defaultRemoveHandler -> snapshot +
updateWatchHistoryList with replace:true) — the same path the standalone view
uses. Validated manually (UI wiring); regression test to backfill.
Build + full lint green.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Watch-history delete still misbehaves for the user; add targeted diagnostics to
pinpoint whether it's the publish, relay propagation, or the local cache/read.
- logWatchHistoryDebug now routes to console (not the prod-suppressed devLogger),
so enabling the existing `bitvid:debug:watch-history` flag surfaces logs on the
hosted site too.
- Instrument the removal path (watchHistoryController): the computed remaining
list (count + values + source), snapshot result, list-update result/finalCount,
and publish failures.
- Instrument the read path (historyView.loadHistory): what a (force) refresh
actually returns (count + values) — directly tests the "is the cache returning
the stale full list?" hypothesis.
Flag-gated and off by default; no behavior change (watch-history tests green).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
After the note-side list renders, the My Videos tab now reconciles against the
bucket: lists the user's objects (unlock-gated), then marks any active note whose
hosted file is gone as a 🟠 "Missing file", and lists bucket objects no current
video references in an "Orphaned storage objects" section — including files left
behind by videos deleted on Nostr (the headline cleanup case). Each orphan has a
"Delete file" button (with a confirm) that calls deleteStorageKeys and refreshes.
Reconciliation runs after the list is shown so the UI stays responsive, and is a
no-op (orphan section hidden) when storage is locked/unconfigured. Rows carry a
data-video-id + a data-role pill so missing status can be applied in place.
Build + full lint green (DOM via createElement/textContent, no innerHTML).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Pure diff of the user's notes against their bucket contents: reconcileStorage
returns MISSING (an active hosted note whose primary video file is absent) and
ORPHAN keys (bucket objects no live note references — including a deleted video's
leftover file, the "deleted on Nostr but still in the bucket" case). Safety
guarantees: a file an active note still uses, plus its sibling .torrent and
thumbnail, is never reported as an orphan; a missing sibling does not mark a row
missing (only a missing primary video file does); external-URL videos can't be
"missing" but unrelated bucket files are still orphans.
Tests + mutation-verified: orphan = present minus referenced (not all present),
and the missing-primary check, are both independently guarded.
Build + full lint green. Next: r2Service.listVideoStorageObjects + wiring.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Each My Videos row now shows the video's thumbnail (lazy, with a placeholder box
that survives a broken/missing image) and, for active videos, Edit and Delete
buttons that route to the app's existing handleEditVideo / handleFullDeleteVideo
flows (closing the full-screen profile modal first so those modals aren't hidden
behind it). Delete reuses the app flow that already cleans the live URL's storage.
Deleted videos intentionally have no per-row action yet: the tombstone scrubbed
their URL, so reclaiming their orphaned bucket file requires the Phase 2 bucket
listing/reconciliation (and Restore) — added there so it's done correctly.
Build + full lint green (DOM via createElement/textContent, no innerHTML).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
selectPane lowercases the requested name, but the My Videos pane/nav were
registered under the camelCase key "myVideos". Every other pane key is a single
lowercase word, so this latent mismatch only surfaced now: clicking My Videos
resolved "myvideos" against panes that only had "myVideos", failed the
availableKeys check, and fell back to Account. Register the pane/nav/selectPane
hook under "myvideos".
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Adds a "My Videos" tab to the profile modal that lists every video the logged-in
user has published — collapsed to one row per video in its current state
(including tombstoned ones) — each with a note-side health pill: red "No playable
source", grey "Deleted on Nostr", green "OK". Shows a summary line
(N videos · M need attention · K deleted) and refreshes from relays on open.
- New MyVideosController (lazy-populated via selectPane, like the other panes):
fetchVideosByAuthors -> collapseUserVideos -> classifyVideoHealth -> render.
Rows built with createElement/textContent (no innerHTML; user titles/URLs are
not interpolated into markup). Status pills reuse the relay-pill token styles.
- Sidebar nav button + pane markup in components/profile-modal.html.
- Wired into profileModalController (cache refs, listeners, selectPane hook);
the existing nav-button loop auto-binds the click.
This is display-only; later steps add the hosted-URL HEAD probe (dead-url), row
actions (edit/delete/revert/storage cleanup), and Phase 2 bucket reconciliation.
Build + full lint green (no new file-size/innerHTML/token violations).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Second pure piece of the "My Videos" tab: collapseUserVideos reduces all of a
user's video events (every edit/revert/deletion the client holds) to one entry
per addressable video, keeping the latest version — which carries the current
state, including deleted:true when the latest is a tombstone (those are gone from
the active feed but still matter here for storage cleanup). Filters to the owner
only, drops invalid notes, sorts newest-first.
Tests + mutation-verified: owner filtering, dedup-to-latest, deleted-latest
preserved, re-publish-after-delete wins, invalid dropped, empty-pubkey returns
nothing (never leak all videos). Flipping latest->oldest fails 3 scenarios.
Build + full lint green.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
First piece of the "My Videos" management tab: classifyVideoHealth decides the
maintenance status for one of the user's own videos from note-side data plus an
optional URL probe result. Pure/synchronous so it's deterministic and testable;
the controller will do the actual HEAD probe and pass the result in.
Statuses: no-source (no url AND no magnet) -> error; hosted-URL-unreachable ->
warning; deleted (tombstoned) -> info; otherwise ok. Key guardrail: only URLs
under the user's storage publicBaseUrl can be flagged dead — external/CORS-opaque
links are never cried wolf on. Deleted is checked before no-source since
soft-delete scrubs url/magnet.
Tests + mutation-verified: dropping the hosted guard (external false-alarm) and
removing deleted-precedence each fail a scenario.
Build + full lint green.
Co-Authored-By: Claude Opus 4.8 <[email protected]>