Commit Graph
802 Commits
Author SHA1 Message Date
thePR0M3TH3ANandClaude Opus 4.8 cf4ce5c3b8 [admin] Add sub-tab toolbar + Blocked-videos tool to the admin pane (#25 follow-up)
The admin pane stacked every list (Moderators / Whitelist / Blacklist) in one
scroll, and the per-event block list had no admin UI (block-only via the ⋯ menu,
no view/unblock). This reorganizes the pane into a sub-tab toolbar and adds the
missing Blocked-videos tool.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-30 15:24:29 -04:00
thePR0M3TH3ANandClaude Opus 4.8 3e57ab2ea9 [moderation] #25: per-video admin block list (hide a single video, not the author)
Admin moderation was author/community-level only. Admins (any editor — same gate as
the author blacklist) can now hide a SINGLE offending video.

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

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

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-30 15:19:34 -04:00
thePR0M3TH3ANandClaude Opus 4.8 2e7e2016d3 [ui] B2 storage: use pipx for the CORS CLI install + rename secret field to "Application Key"
- CORS setup helper: the Backblaze B2 instructions now say `pipx install b2` instead of
  `pip install b2` (the recommended way to install a CLI tool).
- Storage form: for the Backblaze B2 provider the "Secret Access Key" field is now
  labelled "Application Key" to match B2's own terminology (the secret half of an
  Application Key). Other providers keep "Secret Access Key".

Test updated to assert the pipx instruction. Build + lint clean.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-30 15:19:22 -04:00
thePR0M3TH3ANandClaude Opus 4.8 1144bde881 [ui] #8: one-click "Delete all" for orphaned storage objects in My Videos
The My Videos tab already lists bucket objects no live note references and offers
per-file delete; this adds the bulk action. A "Delete all (N)" button (shown only when
there's more than one orphan) confirms once, then removes every listed key in a single
deleteStorageKeys({ keys, pubkey }) call — which is already array-capable, unlock-gated,
and scoped to the user's prefix. Refreshes the list on success, reports partial results
honestly ("Removed 2 files (1 could not be deleted)"), and surfaces storage-locked as an
unlock prompt instead of a false success.

Tests: tests/my-videos-bulk-orphan-delete.test.mjs (5 — all keys sent in one call,
cancel deletes nothing, storage-locked prompt, partial-count message, empty no-op).
Build + lint clean; My Videos suite 37/37.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-30 11:39:57 -04:00
thePR0M3TH3ANandClaude Opus 4.8 f0beab2713 [ui] store storage credentials per provider so R2 / B2 / S3 don't clash
The Storage pane saved every provider into a single shared "default" connection id,
so configuring Backblaze B2 overwrote your Cloudflare R2 (and vice-versa). The service
layer, upload path, and sync already supported a MAP of connections keyed by id
(listConnections / defaultForUploads / exportAccountRecord) — only the UI collapsed
them into one slot.

- Save each provider under its own slot keyed by provider id (cloudflare_r2 /
  backblaze_b2 / generic_s3), preserving a single active defaultForUploads and lazily
  migrating the legacy "default" slot to its provider id.
- Switching the provider dropdown now loads that provider's saved credentials (or
  clears the fields for a fresh entry), so each provider keeps its own creds visibly.
- Sync is unchanged: it already exports the whole account record, so every provider's
  connection syncs together.

Logic extracted to js/ui/profileModal/storageConnections.js (saveProviderConnection +
pure helpers) so ProfileStorageController stays under the 1000-line cap. Tests:
tests/storage-connections.test.mjs (7 — pure default/dup logic + integration proving
R2+B2 coexist with one default, the re-default switch, legacy-"default" migration, and
that the synced record carries both). Storage + profile-modal suites 69/69; lint clean.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-30 10:58:30 -04:00
thePR0M3TH3ANandClaude Opus 4.8 6006813b7e [ui] #38: make the CORS setup helper provider-aware (R2 / B2 / Custom S3)
The first cut showed for Custom S3 too but always gave B2-only instructions (b2 CLI +
B2-native JSON), and was hidden for R2. Now the helper shows for all bucket providers
and tailors its content:

- Backblaze B2  -> B2-native rules + `b2 update-bucket --corsRules ...`
- Custom S3     -> standard S3 CORSConfiguration + `aws s3api put-bucket-cors
                   --endpoint-url <endpoint> ...`
- Cloudflare R2 -> standard S3 JSON + Cloudflare dashboard guidance (R2 -> bucket ->
                   Settings -> CORS Policy) and the AWS-CLI command vs the derived R2
                   endpoint.

Origins pre-filled from getCorsOrigins(); intro/command-label/notes set per provider;
copy buttons for both blocks. bitvid still auto-applies CORS on Save (R2/S3 via
ensureBucketCors) — this is the manual fallback when the key lacks CORS-write perms.

buildCorsHelpContent() switches on provider; modal text is now data-driven. Tests
extended to 10 (per-provider rules + command + a DOM provider-switch). Build + lint
clean; storage suites 40/40.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-30 10:45:50 -04:00
thePR0M3TH3ANandClaude Opus 4.8 d7ad151698 [ui] #38: add a CORS setup helper modal for B2 / S3-compatible buckets
Confirmed against the real B2 account: the web-console "Share everything..." presets
write DOWNLOAD-only CORS (s3_get/s3_head, b2_download_*) with NO s3_put/s3_post, so
bitvid's browser uploads CORS-fail. The web UI can't set custom rules — it points
users at the B2 CLI.

Adds a "CORS setup help" button (shown for B2 / Custom S3, hidden for R2) that opens a
modal with:
- the exact CORS rules JSON in B2-native shape (upload + ranged-playback ops; origins
  pre-filled from getCorsOrigins()), with a Copy button,
- the `b2 update-bucket --corsRules '<json>' <bucket> allPublic` command to apply it,
- step-by-step + the "use a key with writeBucketCors and bitvid sets it for you" tip.

CORS logic extracted to js/ui/profileModal/storageCorsHelp.js (pure builders +
StorageCorsHelp modal controller) so ProfileStorageController stays under the 1000-line
cap. Tests: tests/storage-cors-help.test.mjs (8 — asserts the rules include the upload
ops the presets omit, origin pre-fill, command shape, modal open/close/visibility).
Build + lint clean; profile-modal + storage suites 32/32.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-30 10:37:24 -04:00
thePR0M3TH3ANandClaude Opus 4.8 e79595b902 [storage] #38: add Backblaze B2 as a first-class storage provider
B2 doesn't map onto the generic-S3 "paste an endpoint" model: its S3-compatible
endpoint is region-scoped (s3.<region>.backblazeb2.com) and a public bucket is
addressed virtual-hosted-style, so the public download URL is
https://<bucket>.s3.<region>.backblazeb2.com/<key> — not derivable from a pasted
generic endpoint. So B2 silently failed under "Custom S3".

- s3-url: deriveB2Endpoint(region) -> https://s3.<region>.backblazeb2.com (empty for
  auto/blank/pasted-host so callers can require a real region); re-exported from
  s3Service.
- storageService: PROVIDERS.B2 + resolveB2Endpoint (explicit endpoint wins, else
  derive) + PROVIDER_TESTS[B2] (derived endpoint, forcePathStyle false).
- Public URL = S3-style virtual-hosted, auto-derived; an explicit Public Access URL
  (custom domain/CDN) overrides it. The save path now forwards the user's public URL
  to prepareS3Connection (also fixes generic-S3 ignoring a custom CDN URL when blank
  -> still derives, so no regression).
- UI: "Backblaze B2" provider option + helper; raw Endpoint hidden for B2 (region
  drives it), region shows a us-west-004 example, force-path-style toggle hidden;
  save/test derive endpoint from region with a clear "enter your region" error.
  UploadModal shows B2-specific labels.
- Tests: tests/storage-b2-provider.test.mjs (8). All s3/storage suites green (50);
  build + lint clean.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-30 10:05:53 -04:00
thePR0M3TH3ANandClaude Opus 4.8 35bb1bc576 [ui] #36: auto-open the unlock-saved-key flow from storage when an nsec session is locked
Follow-up to the locked-nsec detection: clicking "Unlock Storage" on a locked
persisted-nsec session (no in-memory signer after a page reload) now auto-opens the
login modal straight to its existing unlock-saved-key (passphrase) flow, so the user
re-unlocks in one step instead of hunting for the Login menu. Once unlocked, the
signer is active app-wide and re-opening the storage pane auto-unlocks.

- applicationBootstrap: add a side-effect-free openLoginModal service to
  profileModalServices (app.loginModalController.openModal — no add-account callback;
  idempotently ensures the modal is initialized first).
- ProfileStorageController: reportLockedNsecSession({ autoOpenLogin }) opens the modal
  only from the user-initiated unlock click, never from the passive pane render (no
  surprise popups).
- Tests extended to 8 (auto-open fires on click; stays closed on passive render).

Build + lint clean.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-30 09:35:40 -04:00
thePR0M3TH3ANandClaude Opus 4.8 a669040740 [ui] #36: clear, actionable storage-unlock error for a locked nsec session after reload
Root cause (user-confirmed repro): a persisted "remember this key" nsec session
restores the logged-in pubkey + UI on reload, but NOT the in-memory signer (the
private key is only passphrase-encrypted). Storage unlock then has no signer, and
the fallback (ensureActiveSignerForPubkey) can only rebuild an EXTENSION signer —
so an nsec user dead-ended on the misleading "No active signer found. Please login."
even though they still look logged in. The crypto layer itself is correct: an
empirical repro shows storageService.unlock round-trips master-key encrypt/decrypt
fine with real nsec cipher closures.

Fix: detect the locked persisted-nsec case (getLockedStoredNsecSession via
getStoredSessionActorMetadata, scoped to the account being unlocked) and surface an
actionable error/status ("re-enter your passphrase from the Login menu to unlock
your saved key, then unlock storage") in both the Unlock-button flow and the passive
Storage-pane status text, instead of the generic no-signer / no-decryptor messages.
New error code storage-unlock-locked-nsec-session.

Tests: tests/storage-unlock-locked-nsec.test.mjs (6 behavioral). Build + lint clean;
existing storage-service tests still 14/14.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-29 22:19:45 -04:00
thePR0M3TH3ANandClaude Opus 4.8 eb4d071203 [ui] #34(b): My Videos mirror toggle reflects relay truth, not just the local flag
The per-row "Share to apps" toggle read only the device-local opt-in flag
(nip71MirrorFlags), so a fresh device / cleared cache / other device reported a
video as un-shared even when its NIP-71 mirror events (34235/34236) exist on the
user's relays.

- nip71MirrorService: add batched findMirrors(videos) -> Map<videoRootId,
  {mirrored, kinds, duplicate}>, one lookup per root via the SubscriptionManager
  chokepoint (no direct pool.list).
- MyVideosController: tag each mirror button with data-mirror-root; after the
  synchronous (local-flag) render, refreshMirrorStates() looks up relay truth,
  reconciles the local flag to match (flag stays a cache hint), and corrects each
  button label. Fire-and-forget; detection failures fall back to the local flag.
- tests/my-videos-mirror-relay-truth.test.mjs: 4 jsdom behavioral tests
  (promote unmirrored->shared, clear stale shared, per-row batch reconcile,
  button tagging). Build + lint clean; mirror+my-videos suites 48/48.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-29 21:45:23 -04:00
thePR0M3TH3ANandClaude Opus 4.8 ccbe8d9bb2 feat(liveness): config-driven card visibility policy + probe prefetch (#5)
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]>
2026-06-25 09:13:52 -04:00
thePR0M3TH3ANandClaude Opus 4.8 1c256808ff fix(more-menu): resolve modal ⋯ video so Event Details + Popularity work (#31)
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]>
2026-06-24 18:48:49 -04:00
thePR0M3TH3ANandClaude Opus 4.8 63dcb5f6ed fix(more-menu): make ⋯ "Event Details" robust + non-silent (#31)
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]>
2026-06-24 17:53:32 -04:00
thePR0M3TH3ANandClaude Opus 4.8 6911df075a fix(popularity): correct lazy-import path + forward video; single-instance modal
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]>
2026-06-24 13:59:09 -04:00
thePR0M3TH3ANandClaude Opus 4.8 575befb83c feed: add public popularity (views-over-time) chart in the ⋯ menu (#26)
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]>
2026-06-24 13:51:41 -04:00
thePR0M3TH3ANandClaude Opus 4.8 02854d4fb5 feed: add Trending tab — recently-added ranked by view count (#27)
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]>
2026-06-24 13:06:34 -04:00
thePR0M3TH3ANandClaude Opus 4.8 ab68ba51d0 dm: make a selected recipient the active conversation in the right pane
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]>
2026-06-23 21:01:04 -04:00
thePR0M3TH3ANandClaude Opus 4.8 d29287a580 dm: focus the selected recipient instead of the top conversation
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]>
2026-06-23 20:37:30 -04:00
thePR0M3TH3ANandClaude Opus 4.8 4f7baa3e99 dm: delegate resolveProfileSummaryForPubkey to helper (fix moderation UI crash)
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]>
2026-06-23 20:26:51 -04:00
thePR0M3TH3ANandClaude Opus 4.8 eda212e187 dm: fix sending — add missing app-shell callback delegations
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]>
2026-06-23 20:25:44 -04:00
thePR0M3TH3ANandClaude Opus 4.8 084faa3543 video-modal: fix copy-magnet button doing nothing (wrong event name)
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]>
2026-06-23 20:11:38 -04:00
thePR0M3TH3ANandClaude Opus 4.8 a5b284682c video-modal: fix Embed button doing nothing (wrong event name)
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]>
2026-06-23 20:09:27 -04:00
thePR0M3TH3ANandClaude Opus 4.8 f7f246835a zap/wallet: flag non-zappable Lightning addresses; per-item settings restore
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]>
2026-06-23 14:53:51 -04:00
thePR0M3TH3ANandClaude Opus 4.8 c5589a1514 zap: fix NWC connection, dialog UX, and receipt reporting/discovery
- 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]>
2026-06-23 14:53:38 -04:00
thePR0M3TH3ANandClaude Opus 4.8 1921356558 ui/popover: let panel inputs receive keys; portal in-modal panels
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]>
2026-06-23 14:53:38 -04:00
thePR0M3TH3ANandClaude Opus 4.8 9dbe1286c0 liveness: multi-source video support + faster CDN verdict (#5 phases 1-3)
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]>
2026-06-22 21:48:51 -04:00
thePR0M3TH3ANandClaude Opus 4.8 d1246dc113 nip71: defer ingest until feed renders; diagnose display path
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]>
2026-06-22 16:23:45 -04:00
thePR0M3TH3ANandClaude Opus 4.8 c849969312 nip71: inbound ingest service (whitelist-scoped, default on)
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]>
2026-06-22 15:26:06 -04:00
thePR0M3TH3ANandClaude Opus 4.8 685e52656b nip71: account-level "auto-share new public videos" toggle
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]>
2026-06-22 14:06:19 -04:00
thePR0M3TH3ANandClaude Opus 4.8 115521928c feat(video): capture & persist dimensions/duration at upload (enables shorts)
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]>
2026-06-22 11:44:02 -04:00
thePR0M3TH3ANandClaude Opus 4.8 89d9a62b87 feat(nip71): auto-sync the mirror on edit/delete (event-driven, no nostrService growth)
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]>
2026-06-22 10:22:27 -04:00
thePR0M3TH3ANandClaude Opus 4.8 783c7df0b7 feat(nip71): My Videos opt-in "Share to apps" mirror toggle (1c/1d)
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]>
2026-06-22 09:40:09 -04:00
thePR0M3TH3ANandClaude Opus 4.8 dec5cf8fe1 feat(sync): warn before overwriting a newer remote copy (conflict guard)
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]>
2026-06-21 21:21:54 -04:00
thePR0M3TH3ANandClaude Opus 4.8 c829a42a45 fix(wallet): testing the connection no longer wipes an unsaved typed URI
"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]>
2026-06-21 21:07:00 -04:00
thePR0M3TH3ANandClaude Opus 4.8 c26ef89a93 fix(sync): empty wallet save must not wipe the synced copy on relays
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]>
2026-06-21 20:52:20 -04:00
thePR0M3TH3ANandClaude Opus 4.8 d552e9017c feat(sync): NWC wallet sync (opt-in, spend-gated) + shared opt-in flags
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]>
2026-06-21 17:57:39 -04:00
thePR0M3TH3ANandClaude Opus 4.8 7376ff91e0 feat(sync): Storage pane UI for encrypted settings sync (opt-in toggle + restore)
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]>
2026-06-21 17:17:35 -04:00
thePR0M3TH3ANandClaude Opus 4.8 0756dd36d8 fix(watch-history): allow logged-out (local) removal instead of "disabled"
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]>
2026-06-20 19:00:51 -04:00
thePR0M3TH3ANandClaude Opus 4.8 4c6415c2c4 fix(watch-history): profile-pane delete was wired to a no-op (real fix)
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]>
2026-06-20 18:40:00 -04:00
thePR0M3TH3ANandClaude Opus 4.8 4a2ba2cc2f chore(watch-history): production-visible opt-in diagnostics for removal/read
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]>
2026-06-20 18:16:10 -04:00
thePR0M3TH3ANandClaude Opus 4.8 9d3a0df02a feat(my-videos): wire bucket reconciliation into the tab (Phase 2, step 3b)
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]>
2026-06-20 15:00:43 -04:00
thePR0M3TH3ANandClaude Opus 4.8 bbb8dd0fd8 feat(my-videos): storage reconciliation core (Phase 2, step 1)
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]>
2026-06-20 14:48:54 -04:00
thePR0M3TH3ANandClaude Opus 4.8 f847bce0d5 feat(my-videos): thumbnail + Edit/Delete row actions (Phase 1, step 5)
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]>
2026-06-20 14:43:14 -04:00