Commit Graph
2745 Commits
Author SHA1 Message Date
thePR0M3TH3AN 3474668a83 [client-reliability] client-reliability-8 cancel active URL media after final owner disposal 2026-10-20 16:56:07 -04:00
thePR0M3TH3AN fecf42948f client-reliability-7: notify the current feed on live video updates 2026-10-20 12:14:21 -04:00
thePR0M3TH3AN 7b8100a6a1 storage-cors-2 skip malformed metadata during best-effort storage cleanup 2026-10-20 11:09:39 -04:00
thePR0M3TH3AN bfa36a3f35 Merge branch 'unstable' into area/storage-cors 2026-10-20 09:58:44 -04:00
thePR0M3TH3AN 2fb6a53656 storage-cors-1 preserve existing bucket CORS rules before adding origins 2026-10-20 09:44:01 -04:00
thePR0M3TH3AN 12f927321f fix: stop orphaned URL mirror continuation (client-reliability-5) 2026-10-20 09:37:04 -04:00
thePR0M3TH3AN ce1381f500 fix: cancel orphaned queued URL media probes (client-reliability-4) 2026-10-19 23:17:03 -04:00
thePR0M3TH3AN 607d5ce1a1 client-reliability-3: preserve shared badge callbacks and guard reentrant disposal 2026-10-19 18:02:46 -04:00
thePR0M3TH3AN 712b05cf87 client-reliability-3: cancel queued torrent probes after last card owner disposes 2026-10-19 17:56:25 -04:00
thePR0M3TH3ANandClaude Opus 5 f48150ed6b [ci] Fix the stale tests that had CI red
CI has been failing on unstable for weeks, across four unit files and a set of
e2e specs. None of it was a product regression: every failure is a test that
encodes behavior a later commit deliberately changed. Each is corrected against
the spec that superseded it, not adjusted until it passed.

Unit:

- video-modal-moderation: the fixture set flat `blurThumbnail`/`trustedMuteCount`,
  but normalizeVideoModerationContext derives shouldShow from
  `original.blurThumbnail`/`trustedCount`, so the assertion ran against a
  context whose early return had just deleted the blur it was checking. 4525c1c6
  also made a lone trusted mute a non-blocking ranking signal (its sibling test
  was renamed to say exactly that; this one was missed). Fixture now mirrors
  what moderationDecorator emits.
- trustBootstrap: asserted a subscription to onWhitelistChange that 18e08bf2
  deliberately removed -- buildTrustedSeeds derives seeds from the super admin,
  editors and fallback seeds, so a whitelist change cannot alter them. The
  removal is now pinned so it is not reinstated by reflex.
- moderation-service: 18e08bf2 removed `status.whitelisted ||` from
  isTrustedReporter (being allowed to publish is not a statement that your
  reports carry weight). A whitelisted reporter now counts toward the report
  total but not toward trusted.
- playbackService_forcedSource: forced P2P now falls back to a hosted URL when
  the torrent definitively fails -- playbackService documents the change and its
  rollback. The old assertion did not merely fail, it HUNG: forced sources skip
  the start timeout, so the now-reachable hosted attempt waited forever on a
  <video> that never emits.

E2E:

- ui-modals: asserted exactly one `.bv-modal` per component page, but
  profile-modal.html also bundles #storageCorsModal (d7ad1516). Requires at
  least one and drives the primary, which is what the test's own injected
  querySelector already used.
- coverage-utility-modules: used `author:abc123`, which 9afbc0cb correctly began
  rejecting as undecodable. Uses a real pubkey for the success path and pins the
  rejection separately.
- webseed-playback: asserted the FINAL src equals the CDN URL while also
  tolerating "No playable source found" in its status check -- contradictory,
  since a failed attempt calls resetVideoElement() and clears src. Headless CI
  cannot decode the archive.org H.264 sample. Now records src attachments and
  asserts the URL was attached, which is the behavior under test.
- nostr-runtime-coverage: asserted a legacy plaintext session-actor entry reads
  back as null. Since 8692dac6 added per-account storage, readV1Entry PURGES the
  plaintext entry and the reader falls back to the sole remaining encrypted
  account -- so null no longer describes the security property. Asserts the
  property directly: no plaintext key survives, in the entry or in raw storage.

Also drops the syncAuthUiState share-gate wiring added in d141c316. It was
redundant: Application.updateShareNostrAuthState() already fires on init, login,
logout, pubkey-change and signer-change and forwards to
videoModal.setShareNostrAuthState(). That call short-circuited on its own
`if (!this.videoModal?.setShareNostrAuthState) return;` guard purely because the
method did not exist -- adding it (d141c316) was the whole fix. The tests now
pin that contract instead of a second, competing code path.

All 416 unit files, all three CI shards, both DM suites and lint are green.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 17:37:24 -04:00
thePR0M3TH3ANandClaude Opus 5 244f527cf6 [share] Put author and relay hints in the shared nevent
buildShareUrlFromEventId encoded a BARE event id: no author, no relay hints. A
viewer whose relay set did not happen to carry that event could not resolve it,
so bitvid fell back to the home feed -- the "the shared link just opens
bitvid.network" report.

It is not a rare edge case. The reported video was present on nos.lol but
absent from relay.damus.io, relay.snort.social and relay.primal.net -- three of
bitvid's four bootstrap relays -- so most viewers arriving cold could not open
it at all.

The pointer now carries:

- the video's author, pulled from videosMap, which also lets clients look the
  event up by author+kind if the id itself has aged out of a relay
- up to three relay hints from the client's write relays (falling back to its
  general relay list), deduped and scheme-filtered

Both are best-effort: an unknown video or a malformed pubkey still produces the
old bare-id pointer rather than an invalid one, and no relays configured omits
the hint instead of encoding an empty array.

Also restores the path separator. The base URL has its trailing slash stripped,
so shared links read `https://bitvid.network?v=...`. That is legal and browsers
normalize it, but it looks like a typo in a note and some link parsers
mis-detect where the URL ends.

New tests fail 6/9 without the change.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 16:08:34 -04:00
thePR0M3TH3ANandClaude Opus 5 99f8e88969 [playback] Don't yank blocked autoplay into WebTorrent
On mobile a bitvid share link opens with no user gesture, so the browser
refuses autoplay. bitvid handled that correctly -- it showed "Press play to
start the hosted video." and armed a no-stall watchdog to wait -- but the 3s
start timeout kept running underneath and fired anyway. For a video with both a
hosted URL and a magnet that meant:

  Press play to start the hosted video.
  Switching to WebTorrent...

The element was reset out from under the prompt the viewer was about to act on,
and the swarm (often seedless, the webseed being the very URL we just
abandoned) played nothing. Desktop usually hides this because an established
Media Engagement score lets autoplay through, so the same link plays there.

A blocked autoplay is not a stalled source. Suspend the start timeout while we
are waiting on a user gesture and let the attempt settle on its own -- it
resolves when they press play, or when a real media error or stall fires. The
guard is cleared on the `play` gesture so a genuine stall afterwards still
fails over normally.

Reproduced on an emulated phone against the reported video
(v.nostr.build/1eNLjw5el05Uc3KU.mp4, magnet with a webseed): before, statuses
end "Press play..." then "Switching to WebTorrent..." with the P2P path
entered; after, they end at "Press play..." and playback starts on tap. The new
tests fail 2/4 without the guard.

Note for future test authors: createSession() overwrites any
playbackStartTimeout passed in its options with the service's value, so a
per-session override is silently ignored -- set it on the PlaybackService
constructor or the test races the real 3s default and proves nothing.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 15:26:44 -04:00
thePR0M3TH3ANandClaude Opus 5 d141c316a9 [ui] Make "Share on Nostr" a working button
The share menu's "Share on Nostr" entry was never clickable, no matter who was
logged in -- it rendered as inert, dimmed text.

VideoModal initialized `shareNostrAuthState` to { isLoggedIn: false, hasSigner:
false } and nothing ever wrote to it. createVideoShareMenuPanel gates the item
on `isLoggedIn && hasSigner`, so it was always disabled, and the click
delegation in bindShareMenuActions() bails on `button.disabled` -- so the whole
share-to-Nostr flow behind it was unreachable.

The renderer and the publish path were both already correct; only the gate was
missing:

- VideoModal.setShareNostrAuthState() records the state and closes an already
  open share menu when it changes, so a menu opened before login does not keep
  showing the stale disabled item. It is a no-op when nothing changed, to avoid
  slamming the menu shut on every sync.
- syncAuthUiState() computes the gate from isUserLoggedIn() plus a
  signing-capable active signer, mirroring ShareNostrController.handleShare():
  a signer without signEvent would only produce an error toast on click.
- syncAuthUiState() was only ever called at bootstrap, so logging in
  mid-session left the gate closed. It now self-installs a bitvid:auth-changed
  listener on first run (bound once, re-entrancy safe) rather than adding a
  call site in js/app.js, which is at its file-size cap.

Verified in a browser that the rendered item is disabled when logged out or
signer-less and enabled + click-dispatching with both; the new tests fail 5/9
with the gate push removed.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 14:16:18 -04:00
thePR0M3TH3ANandClaude Opus 5 38b51f13a5 [share] Give share-to-Nostr notes a real preview
"Share on Nostr" published a kind-1 note whose body ended with a bare thumbnail
URL. Many clients only inline such a URL when a NIP-92 `imeta` entry describes
it, so the viewer often saw a plain link instead of a preview image.

- buildShareEvent now emits an `imeta` entry for the thumbnail. The media type
  is inferred from the extension and OMITTED when unknown -- a wrong `m` is
  worse than a missing one, since clients use it to decide whether to render
  inline at all, so extensionless Blossom URLs ship url + alt only.
- inferImageMimeTypeFromUrl lives in a SEPARATE map from EXTENSION_MIME_MAP:
  that map gates video upload/publish paths, and folding image types into it
  would let an image pass a video check.
- shareNostrController was passing only { id, pubkey } to buildShareEvent, so
  the tag could never be built; it now forwards title and thumbnail.

When a video has a NIP-71 mirror, the note instead quotes it with
`nostr:naddr1...`, which nostr clients render as a native video quote card --
the closest thing to a rich preview that does not depend on any client fetching
OpenGraph tags (most never do).

- The mirror is addressable (kind 34235/34236, author = video pubkey,
  d = videoRootId), so js/nostr/mirrorPointer.js derives the coordinate with no
  relay lookup, and the pointer survives the author editing the video where a
  plain `nevent` would rot on the next replace.
- The pointer is only embedded after nip71MirrorService.findMirror() confirms
  the mirror exists. A pointer to a missing event renders as a broken/empty
  quote box, which is worse than the plain-link fallback, so a disabled
  service, a throwing lookup, a 2.5s timeout or a missing videoRootId all fall
  back to the thumbnail note.
- resolveMirrorKind prefers the kinds actually observed on relays and only then
  falls back to the same portrait rule the mirror builder uses, so a derived
  pointer matches what would be published rather than guessing independently.
- While quoting, the raw thumbnail URL is dropped from the body and the imeta
  tag is skipped: the quote card already shows the thumbnail, and emitting both
  rendered the same image twice. An `a` tag links the note to the coordinate.

Known limitation: a client rendering the quote card still has to play the
video, so mirrors whose imeta url is an .m3u8 hit the same HLS wall -- title and
thumbnail render, playback depends on the client.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 13:59:10 -04:00
thePR0M3TH3ANandClaude Opus 5 4404728410 [playback] Add HLS support and stop blaming stale MediaErrors
bitvid attached every hosted source with `videoEl.src = url`, which only plays
HLS on Safari-family browsers. Firefox 146 fails with MEDIA_ERR_SRC_NOT_SUPPORTED
(canPlayType returns ""), so every .m3u8 note -- e.g. anything published by
nostube/slidestr -- was unplayable there and the URL-health badge showed
"offline". Chrome/Brave 149 happen to play HLS natively, which is why this
looked browser-specific.

Vendor hls.js (npm run build:hls) and route HLS sources through it:

- js/services/hlsPlayback.js owns detection, engine choice and the hls.js
  lifecycle. Engine choice keys off MediaSource, NOT canPlayType: Chrome
  returns "maybe" for every HLS type, so a native-first branch would hang.
  Native is used only when MSE is genuinely unavailable.
- The bundle is the FULL build, not hls.js/light -- light drops the alternate
  audio stream controller and real ladders demux audio into an EXT-X-MEDIA
  group. It is lazy-imported only when a .m3u8 is encountered, so ordinary
  MP4/WebTorrent playback never pays the ~500KB.
- modalCoordinator tears the instance down first; clearing `src` alone leaves
  the transmuxing worker and segment fetches running after the modal closes.
- urlHealthController probes HLS by fetching the playlist for #EXTM3U. The old
  bare-<video> probe could not load it and on Chrome emitted no events at all,
  burning the full timeout on every HLS card.
- HLS_PLAYBACK_START_TIMEOUT widens the 3s start window, which fired
  mid-startup: the bundle, master playlist, variant playlist, init segment and
  first media segment are four-plus serial round trips.

Also fixes two bugs found while verifying this:

- A stale MediaError was reported as the current failure. Teardown clears `src`
  and calls load(), leaving MEDIA_ERR_SRC_NOT_SUPPORTED ("Empty src attribute")
  on the element; a later watchdog fallback rendered that as "Hosted playback
  failed: source not supported or blocked." over a stream that was playing
  fine. Only a MediaError produced by the current attempt is surfaced now.
  Sources are also bound before the watchdogs are armed, so hls.js's own
  attachMedia() src swap cannot read as a playback failure.
- `audio/x-mpegurl` is a legacy alias for an HLS playlist of any kind, not an
  audio-only assertion. isAudioOnlyVideoObject misfiled HLS video ladders
  published with it as podcasts and dropped them from every video feed.

Verified end-to-end in Chromium, Chrome 149, Brave 149 and Firefox 146 against
a live HLS stream: Firefox goes from "No playable source found." to playing.
Rollback: stop calling attachHlsSource() and the previous `src` assignment
returns; see docs/playback-fallback.md.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 13:58:55 -04:00
thePR0M3TH3AN bb6614e3d3 Render cached feeds before recommendation refresh 2026-07-26 18:38:03 -04:00
thePR0M3TH3AN 687f8c438d Optimize feed lifecycle and navigation rendering 2026-07-26 18:27:57 -04:00
thePR0M3TH3AN b774de6d76 Harden profile data loading 2026-07-26 17:59:50 -04:00
thePR0M3TH3AN 5f439d6863 Lazy-load recommendation indexes 2026-07-26 17:59:45 -04:00
thePR0M3TH3AN fe976b3ab7 Harden Nostr startup performance 2026-07-26 17:20:52 -04:00
thePR0M3TH3AN 4525c1c657 Improve moderation restoration and mobile card actions 2026-07-26 15:35:20 -04:00
thePR0M3TH3AN 4dd150b010 fix(reactions): load legacy event-tagged video reactions 2026-07-26 14:45:38 -04:00
thePR0M3TH3AN e8e2b04de7 fix(nip71): keep upload mirrors in sync 2026-07-26 12:51:00 -04:00
thePR0M3TH3AN f0a3ede3e5 Preserve autoplay preference on fallback; reload open comment thread after login
### Motivation

- Prevent a browser-policy muted-autoplay retry from overwriting a viewer's explicit mute/unmute preference and ensure fallback mutes are transient.
- Ensure that when a user logs in while a video comment modal is open, the open thread is reloaded (not just refreshed) so comments continue to display correctly after relay-backed services are reconfigured.

### Description

- Add `js/utils/autoplayPreference.js` with `muteForAutoplayFallback` and `consumeAutoplayFallbackMute` helpers to mark and consume transient fallback mutes.
- Update `js/services/playbackService.js` to treat sound as default unless the viewer explicitly chose mute, persist explicit choices via `localStorage`, ignore fallback-generated volume changes using `consumeAutoplayFallbackMute`, and expose `muteForAutoplayFallback` delegating to the new util.
- Replace direct `videoElement.muted = true` calls with `playbackService.muteForAutoplayFallback` in `js/app/playbackCoordinator.js` and `js/webtorrent.js` so fallback mutes are marked as transient.
- Change `js/app/authSessionCoordinator.js` to reload the currently open video comment thread with `commentController.load(activeModalVideo)` after login if a modal video is active, instead of only calling `refreshAuthState`.
- Update tests in `tests/services/playbackService.test.mjs` and add a test in `tests/app/login-defers-dms.test.mjs` to cover the login behavior that reloads an open comment thread.

### Testing

- Ran the `PlaybackService` unit tests (`tests/services/playbackService.test.mjs`) which assert `prepareVideoElement` behavior, explicit mute persistence, and the transient nature of fallback mute, and they passed.
- Ran the auth login unit tests including `tests/app/login-defers-dms.test.mjs` which verifies DM deferral and reload of an open comment thread, and they passed.

(cherry picked from commit ce14004725)
2026-07-26 12:33:57 -04:00
thePR0M3TH3AN d8e5113efd fix: keep autoplay fallback from saving mute
(cherry picked from commit f373bb9ce7)
2026-07-26 12:33:57 -04:00
thePR0M3TH3AN 18e08bf2cb feat: refine trust controls and harden BitLogin 2026-07-25 22:25:50 -04:00
thePR0M3TH3ANandClaude Sonnet 5 4d1116530b Surface BitLogin sign-in rejections instead of silently swallowing them
devLogger.warn is a no-op outside dev mode (IS_DEV_MODE = false on
this deployment), so when AuthService.requestLogin() rejected a
BitLogin sign-in -- e.g. the invite-only access-control check
("Access restricted to admins and moderators users only.") -- nothing
ever told the user why. The widget itself doesn't know bitvid rejected
it, so it kept showing its own "Signed in" screen, and the login modal
never closed, looking exactly like a hung/broken close button.

On rejection: route the error through app.handleLoginModalError (the
same visible-banner path every other sign-in method already uses) and
reset the widget via widget.logout() so it doesn't keep claiming a
session bitvid never actually granted.

Also added a WeakSet guard so wireBitloginLogin() can never attach two
"bitlogin-login" listeners to the same element, regardless of how
attachWidget() ends up getting invoked -- cheap insurance, no observed
bug currently triggers it.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DY9UMTuF43Ekc2U4E1eDwX
2026-07-22 00:19:43 -04:00
thePR0M3TH3ANandClaude Sonnet 5 f2d390ff18 Wire up NIP-04 in the BitLogin adapter, flip capabilities.nip04 to true
bitlogin's widget now implements NIP-04 (nip04Encrypt/nip04Decrypt),
closing the one gap left relative to a real NIP-07 extension. bitvid's
own nip07Adapter/nsecAdapter/nip46Adapter all already wire this up and
report it via signerCapabilities.nip04 -- BitLogin's adapter was the
one exception, hardcoded to nip04: false because the widget genuinely
didn't support it before. js/nostr/client.js's DM encrypt/decrypt path
only offers NIP-04 to a caller when that capability flag is true, so a
BitLogin-signed-in user previously had no working legacy-DM path at
all. Re-vendored the widget bundle (bitlogin's CHANGELOG.md, 2026-07-21
entry, third one) and updated the one test that hardcoded the old
(now-incorrect) capabilities shape.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DY9UMTuF43Ekc2U4E1eDwX
2026-07-21 23:32:27 -04:00
thePR0M3TH3AN 88c906fd34 Add BitLogin as the primary sign-in method (gated, unstable)
The login modal now shows <bitlogin-auth> -- a portable Nostr identity
behind a login name and password -- as the default sign-in path,
styled with bitvid's own brand tokens (accent-strong, surfaces, radii).
The existing NIP-07 extension, nsec, and NIP-46 remote-signer options
still work exactly as before, just collapsed under a new "Other login
options" fold instead of a flat "Available methods" grid; nip07 no
longer carries the stale "Recommended" eyebrow now that BitLogin has
that role.

BitLogin isn't a single button-click-then-await-a-promise flow like
the other providers -- it's a whole multi-screen sign-in/create/recover
UI -- so it gets a permanent, statically-declared widget in
components/login-modal.html (js/ui/bitloginModalIntegration.js relocates
it, never destroys and recreates it, since removal tears down its crypto
Web Worker) instead of a grid button. It still goes through the exact
same session-establishment path as every other signer: a new "bitlogin"
provider (js/services/authProviders/bitlogin.js) lets AuthService
resolve and activate it via the normal requestLogin -> handleAuthLogin
path, while providersForModal excludes it from the grid so it isn't
rendered twice.

Two things this needed beyond the wiring itself:
- js/app.js is one line from its lint/file-size ceiling, so the only
  change there is renaming an import binding (providers -> providersForModal),
  a net-zero line change. The actual widget/event wiring lives in a new,
  unconstrained module instead.
- The login modal's HTML (including the permanent widget element) loads
  asynchronously after bootstrap runs, so wireBitloginLogin() waits via
  MutationObserver rather than assuming a specific call order.

Vendored via scripts/build-bitlogin-widget.mjs (mirrors the existing
Bitcoin Connect/Blossom vendor steps): BitLogin isn't published to npm,
so the widget build is copied verbatim from the sibling ../bitlogin
checkout into committed vendor/bitlogin/, then lazy-imported (like
Bitcoin Connect) so its ~350KB never weighs on visitors who never open
the login modal.

Gated behind FEATURE_BITLOGIN (config/instance-config.js), default on
for this unstable-branch experiment per AGENTS.md §1 -- reset to false
before this reaches main until verified end-to-end against production
relays.

Verified: npm run lint (clean), the full auth/login test suite (43/43,
including new coverage for the provider + adapter), and a manual
Chrome pass against the built dist/ -- widget renders themed correctly,
"Other login options" still shows the original four methods unchanged.
2026-07-21 20:56:01 -04:00
thePR0M3TH3ANandClaude Opus 4.8 e81e65ff40 feat(blossom): Phase 2 management — blob delete-on-orphan + kind-10063 server list (#30)
- deleteByUrl maps a Blossom URL back to its content-addressed blob (blobSha256FromUrl) and deletes it via BUD-12, defaulting to the URL's own origin. Wired into the edit-modal orphan cleanup so a superseded Blossom video/thumbnail is reclaimed (best-effort, never blocks the edit).

- BUD-03 kind-10063 server-list discovery: read (serversFromServerListEvent) + build (buildBlossomServerListEvent) + an extracted, testable import/publish helper (blossomServerList.js), plus 'Import from my server list' / 'Publish my server list' controls in the Storage pane's Blossom section.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-12 10:32:22 -04:00
thePR0M3TH3ANandClaude Opus 4.8 51072e4871 fix(storage): let a Blossom-only (keyless) account sync across devices
importAccountRecord hard-required encryptedMasterKey, but a Blossom-only account is keyless (no encrypted payloads) and legitimately has none — so its synced record failed to import. Require the envelope ONLY when the record carries an encrypted connection that actually needs it to unlock.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-12 10:22:22 -04:00
thePR0M3TH3ANandClaude Opus 4.8 ef2e48912d fix(playback): P2P peer discovery for Blossom videos + forced-torrent URL fallback (#30)
Two torrent-path robustness fixes surfaced while testing WebTorrent peering:

- streamVideo now sets announce: [...WSS_TRACKERS]. A .torrent-buffer add (Blossom companion piece-map) has an EMPTY embedded announce list, so without this it reached no tracker, found no WebRTC peers, and streamed webseed-only (looked like a stuck '1 peer'). WebTorrent merges these with any trackers already on the source.

- A forced-torrent playback now falls back to the hosted URL when the torrent DEFINITIVELY fails (e.g. a transient companion-lookup failure under relay congestion, or a dead swarm) instead of hard-erroring 'No playable source found'. Only triggers on real failure, not a slow swarm. Rollback noted inline.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-11 19:04:14 -04:00
thePR0M3TH3ANandClaude Opus 4.8 4906745bff feat(storage): live-refresh the open upload modal when connections sync from Nostr
storageService now emits onConnectionsChanged (from importAccountRecord, the sync-import path); the upload modal subscribes and, when open + active user, re-derives its storage destination in place (re-attempt auto-unlock, recompute unlocked state, reload, repaint) — so credentials synced from Nostr are reflected without reopening. Bumped UploadModal's file-size baseline for the growth.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-11 19:04:06 -04:00
thePR0M3TH3ANandClaude Opus 4.8 d23cfa6697 feat(blossom): P2P for Blossom videos via a WebTorrent-metadata companion event (#30)
Blossom servers reject a .torrent (415), so a Blossom video's magnet is webseed-only and can't bootstrap WebTorrent. This restores P2P without hosting a .torrent:

- Upload hardening: correct Content-Type (overrides empty/generic application/octet-stream), HTTP status surfaced in errors, and best-effort .torrent sidecar so a rejected sidecar no longer sinks the upload (URL-first fallback).

- Publish: when the .torrent can't be hosted, publish the piece-map as an infohash-keyed companion event (kind 30078, d=bitvid:torrent:<infohash>) and ship a ws= magnet. Gated by FEATURE_BLOSSOM_TORRENT_METADATA (default off = no trace); size-capped.

- Playback: torrentMetadataService lazily fetches the companion, VERIFIES the reconstructed infohash === the magnet's btih (untrusted content), sources the webseed from the video event, and hands the .torrent buffer to WebTorrent so it streams with zero peers. Relay reads go through the subscription manager.

Inherited by any upload-based media (shorts, audio-publish); excludes ingest/live. See docs/blossom-torrent-metadata-plan.md. Includes a Prettier reflow in css/tailwind.source.css.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-11 14:08:53 -04:00
thePR0M3TH3ANandClaude Opus 4.8 56aff1ada0 fix(storage): default the storage-tab provider selector to the active default connection
fillStorageForm read the provider only from conn.provider (top-level), but getConnection returns { ...payload, meta } with the provider in meta (and no top-level provider for keyless Blossom) — so the selector always fell back to cloudflare_r2. Mirror connectionProvider()'s conn.provider || conn.meta?.provider fallback so the pane reflects the loaded default connection.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-11 14:08:41 -04:00
thePR0M3TH3ANandClaude Opus 4.8 c22fa23353 fix(blossom): push the encrypted synced copy after saving Blossom servers [LOCAL] (#30)
The keyless Blossom save returned early without triggering storageSyncService.push
(the S3 path does it after saving), so changing Blossom servers never updated the
synced copy — it looked like the settings didn't sync. Blossom servers already
live in the account record's plaintext meta (which exportAccountRecord ships), so
the same push the S3 branch uses now runs after a keyless save: the record is
re-encrypted to self and published, and it roams like S3.

NOTE: sync export/import needs the account's encryptedMasterKey, which exists once
any secret-bearing (S3) connection has been unlocked/saved. A Blossom-ONLY account
(no S3 ever) has no master key envelope yet — encrypted sync for that case is a
follow-up.

Lint + build clean. Local only.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-09 22:00:21 -04:00
thePR0M3TH3ANandClaude Opus 4.8 18ea5428eb fix(blossom): upload directly per-server instead of the failing /mirror flow [LOCAL] (#30)
Real-device failure: multiServerUpload uploads to one server then has the OTHERS
fetch it server-to-server via PUT /mirror — those /mirror requests fail from a
browser (net::ERR_FAILED / CORS), so the whole upload returned no blob URL
("Blossom upload did not return a usable blob URL").

Switch blossomService.uploadFile to upload DIRECTLY to each server (BUD-02
PUT /upload) in parallel via uploadBlob, resilient: first success wins, per-server
failures are collected, and if ALL fail the error names each server + the likely
cause (CORS / size / payment) and suggests blossom.band. A fresh signed upload
auth (BUD-11) is created per request.

Tests: blossom-service 11/11 (added partial-failure fallback + all-fail error).
Lint + build clean. Local only.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-09 21:49:50 -04:00
thePR0M3TH3ANandClaude Opus 4.8 ba62b35ada fix(storage): make Blossom actually usable in the upload modal [LOCAL] (#30)
Two root causes for "Blossom set as default but not showing / not usable":

1. SAVE required unlock. saveConnection throws "Storage is locked" without a
   master key — but Blossom has no secret. Added
   storageService.saveKeylessConnection() (writes plaintext meta, no encryption,
   no unlock) and the Blossom save branch now uses it. So a Blossom connection
   saves even if the user never unlocked storage.

2. UploadModal treated Blossom like a locked S3 connection: it only set
   activeCredentials when isStorageUnlocked, showed "Locked 🔒", blocked the
   file-pick behind an unlock prompt, and mislabeled it "S3 Compatible". Now:
   getProviderLabel → "Blossom"; loadFromStorage resolves servers from the
   plaintext meta (no unlock); the summary shows "N servers / Nostr-native"; the
   two upload gates and updateLockUi bypass unlock for Blossom
   (activeProviderIsBlossom). Uploads authorize with the Nostr signer instead.

Bumped UploadModal size baseline (+gating code). Lint + build clean. Local only.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-09 21:40:18 -04:00
thePR0M3TH3ANandClaude Opus 4.8 5935702e75 feat(storage): Blossom Phase 2 — BUD-12 delete/list primitives [LOCAL] (#30)
The management primitives, self-contained + unit-tested. Not yet wired into the
S3-shaped orphan-GC / delete-flow (that reconcile system assumes bucket/key
semantics — its own browser-tested increment, same pattern as the upload).

- Vendor entry: export createDeleteAuth (+ rebuilt bundle, still ~27KB).
- blossomService.deleteFile({ server, sha256, signer }) — BUD-12 DELETE /<sha256>
  with a signed kind-24242 delete auth (BUD-11).
- blossomService.listFiles({ server, pubkey, signer }) — BUD-12 GET /list/<pubkey>,
  optional list auth.

Tests: blossom-service 9/9 (added delete/list guards + happy paths). Lint + build
clean.

REMAINING Phase 2: wire deleteFile into video-delete cleanup (resolve
server+sha256 from the blossom storagePointer), orphan-GC parity, and the
kind-10063 discovery UI (import/publish server list — needs relay fetch/publish
plumbing).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-09 16:36:31 -04:00
thePR0M3TH3ANandClaude Opus 4.8 c5e8c7f9f1 feat(storage): Blossom Phase 1b — keyless multi-server config UI [LOCAL, flag ON] (#30)
Makes Blossom configurable + uploadable end-to-end (local testing).

- Storage pane: a "Blossom (Nostr-native)" provider option (revealed only when the
  flag is on) that swaps the S3 field group (#storageS3Fields) for a keyless
  #storageBlossomSection — a multi-server textarea (one URL per line). No
  access-key/secret/bucket/CORS.
- ProfileStorageController: parseBlossomServers(); save branch persists the server
  list in the PLAINTEXT connection meta ({servers}, no encrypted secret) via the
  per-provider slot; provider-change loads servers from meta WITHOUT an unlock;
  Test does a best-effort reachability ping; visibility branch hides S3 fields +
  CORS helper for Blossom.
- mediaUploader.resolveActiveConnection: Blossom connections resolve from plaintext
  meta with no unlock (uploads authorize via the Nostr signer, not a stored key),
  so credentials = { provider, servers, meta } flow straight into the Phase-1a
  engine.
- instance-config: FEATURE_BLOSSOM_STORAGE = true for LOCAL testing (reset to false
  before pushing).

Tests: blossom-service 7/7 (isAvailable now flag-relative). Lint + build clean.
Blossom UI present in dist.

VERIFY locally: Storage → Provider → Blossom → add a server → Save → upload a
video → it plays from <server>/<sha256>. Watch the UploadModal's own storage
gating (may need a tweak for the no-secret/no-PIN Blossom case).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-09 16:31:34 -04:00
thePR0M3TH3ANandClaude Opus 4.8 0eafd38ec4 feat(storage): Blossom Phase 1a — upload engine [LOCAL] (#30)
The Blossom upload orchestration, isolated + unit-tested. Only routes when a
connection's provider is "blossom", so the S3/R2 path is completely unaffected
(that branch can't trigger until the Phase-1b config UI can create a Blossom
connection).

- blossomService now owns the orchestration (WebTorrent-free, so it's Node-
  testable): uploadFile (multiServerUpload + BUD-11 auth) and uploadVideo (blob →
  injected torrent generator → upload .torrent as a 2nd blob → magnet with ws=the
  Blossom video url + xs=the Blossom .torrent url → blossom storagePointer). Same
  result shape as the S3 path, so publish is untouched. Exports isBlossomProvider
  + resolveBlossomServers.
- mediaUploader: thin Blossom branch at the top of uploadThumbnail/uploadVideo
  (delegates to blossomService, injecting generateTorrentMetadata so the service
  never imports WebTorrent); new getSigner dep. UploadModal + EditModal pass
  getSigner: () => getActiveSigner().

Tests: tests/blossom-service.test.mjs (7 — guards, mirror, torrent/magnet
orchestration, storagePointer). Lint + build clean. Flag still off.

NEXT (Phase 1b): the keyless multi-server Storage-pane config UI + connection
model (servers in plaintext connection meta — no secret) + resolveActiveConnection
Blossom branch, so a server can be configured and uploads run end-to-end.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-09 15:56:05 -04:00
thePR0M3TH3ANandClaude Opus 4.8 02e60f1842 feat(storage): Blossom Phase 0 — flag + vendored SDK + service skeleton (#30)
Phase 0 of docs/blossom-plan.md. No UX; off-by-default ("off = no trace").

- Vendored: pinned [email protected] bundled by scripts/build-blossom-sdk.mjs
  → vendor/blossom-client-sdk.bundle.min.js (esbuild, browser ESM, ~27KB). The
  optional @cashu/cashu-ts peer dep (BUD-07 payments, only reached on a lazy 402
  branch bitvid never hits) is aliased to a throwing stub. `npm run build:blossom-sdk`.
- Flag: FEATURE_BLOSSOM_STORAGE wired like FEATURE_AUDIO_INGEST / _BITCOIN_CONNECT
  (instance-config → config → constants), default FALSE.
- Service: js/services/blossomService.js implements the same
  uploadFile({…}) → {url,key} contract as s3UploadService (so a later
  isBlossomProvider branch drops into mediaUploader). Lazy-loads the SDK; core
  path = createUploadAuth(signer, sha256) + multiServerUpload (BUD-11 auth via
  bitvid's existing signer, BUD-04 mirror). Torrent parity + result-shape mapping +
  mediaUploader wiring + Storage-pane config land in Phase 1.

Tests: tests/blossom-service.test.mjs (5 — availability gate + input guards).
Lint + build clean. Nothing wired into the app yet.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-09 15:42:19 -04:00
thePR0M3TH3ANandClaude Opus 4.8 1add0bebec feat(wallet): tuck manual URI under an "Advanced" disclosure (Bitcoin Connect D5)
When FEATURE_BITCOIN_CONNECT is on, "Connect wallet" is the primary path and the
raw Wallet Connect URI field collapses under an "Advanced — paste a connection
manually" <details> disclosure (backup, de-emphasized). When the flag is off, the
controller forces the disclosure open and hides its summary, so the URI field
shows plainly exactly as before (no behavior change). Default-zap field stays
always visible. Adds a small token-based rotating-caret style for the summary.

Still LOCAL (flag ON for testing); not pushed, not deployed.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-09 14:56:00 -04:00
thePR0M3TH3ANandClaude Opus 4.8 69e87c6e15 feat(wallet): Bitcoin Connect NWC "Connect wallet" flow [LOCAL — flag ON, do not push as-is]
Phase 0+1 of docs/bitcoin-connect-plan.md (#61). Adds Alby's Bitcoin Connect as a
one-tap NWC connect front-end that feeds bitvid's EXISTING wallet pipeline; manual
URI entry stays as the backup.

- Vendored: pinned @getalby/[email protected].3 bundled by
  scripts/build-bitcoin-connect.mjs → vendor/bitcoin-connect.bundle.min.js (esbuild,
  browser ESM). crypto-shim aliases the L402-only `import('crypto')` bitvid never
  calls. `npm run build:bitcoin-connect` rebuilds it.
- Flag: FEATURE_BITCOIN_CONNECT wired like FEATURE_AUDIO_INGEST
  (instance-config → config → constants).
- UI: a "Connect wallet" button in the Wallet Connect pane (hidden unless the flag
  is on); the manual URI field stays below as the backup.
- Controller: ProfileWalletController.handleBitcoinConnect() lazy-imports the bundle
  (only on click — the ~700KB never touches the main bundle), inits NWC-only with
  least-privilege requestMethods and persistConnection:false, then on onConnected
  extracts provider.client.nostrWalletConnectUrl → the SAME validate→save path as
  the manual field → disconnect() so bitvid solely holds the spending secret. The
  URI is never logged.

NOTE: config/instance-config.js FEATURE_BITCOIN_CONNECT is TRUE for local testing
only. Reset to false (shipping default) before pushing/deploying. Local commit;
not pushed, not deployed.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-09 14:49:37 -04:00
thePR0M3TH3ANandClaude Opus 4.8 793d609d6a fix(video-modal): wire the share popover's Copy URL / Magnet / CDN buttons
The share popover's menu items (Copy URL, Copy Magnet, Copy CDN Link, Share on
Nostr) only set `data-action` — like the ⋯ menu — but unlike the ⋯ menu (which
moreMenuController delegates) nothing wired their clicks, so they did nothing.
Only the bottom "forced-source" test links self-wire. Same dead-button class as
the old embed/creator-link bugs.

Add a delegated click handler on the share panel (bindShareMenuActions) that
routes each action to the event videoModalController already handles:
copy-magnet → video:copy-magnet, copy-cdn → video:copy-cdn, share-nostr →
video:share-nostr, and share (Copy URL) → a new video:copy-url (buildShareUrl
makes the plain `?v=<nevent>` deep link; controller copies it with a toast).
Forced-source links are skipped (they self-wire).

Tests: tests/video-modal-controllers.test.mjs (+5 routing scenarios, 24/24).
Lint + build clean.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-09 12:46:37 -04:00
thePR0M3TH3ANandClaude Opus 4.8 cd556f9c1f feat(config): wire FEATURE_AUDIO_INGEST flag + lock audio-plan decisions
Adds the Audio/Music/Podcast feature flag to the instance config so a deployer
enables/disables it in one place like the other flags. Follows the exact
FEATURE_NIP71_INGEST wiring: export in config/instance-config.js → re-export in
js/config.js → DEFAULT_FLAGS + runtimeFlags snapshot + `export let` in
js/constants.js. Defaults FALSE ("off = no trace") — only an explicit `true`
lights it up; with it off nothing audio renders and the shipped audio guard keeps
audio-only notes out of the video feeds. No consumers yet (build only).

Also locks docs/audio-integration-plan.md decisions D1–D10 to the agreed
recommendations (status → DECISIONS LOCKED).

Verified: constants + config re-export FEATURE_AUDIO_INGEST === false by default.
Lint + build clean. docs/audio-integration-plan.md, TODO #60.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-09 10:19:03 -04:00
thePR0M3TH3ANandClaude Opus 4.8 0c09f2854c fix(feed): harden audio detection — audio/mp4 (.f4a/.m4b) podcasts
Follow-up to the audio-only feed guard. Real case: a Nodesignal-Talk podcast
published with `imeta m audio/mp4` + a `.f4a` URL. It was already filtered via
its imeta hint (audio/mp4 → hasAudio), but unlike the .ogg/.mp3 ones it actually
PLAYS in a <video> element (AAC-in-MP4 = audio + poster), which is why it looked
like it "worked." It's still a podcast, so it stays out of the video grid.

Closes the fallback gap for audio/mp4 notes that lack an imeta audio hint: added
.f4a/.m4b/.mka to AUDIO_ONLY_URL_PATTERN (AAC-in-MP4 audio / audiobooks /
Matroska audio). .mp4/.m4v remain video and are explicitly asserted not to match.

Tests: video-schema-and-conversion (+2, 13/13). Lint + build clean. TODO #60.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-09 09:50:52 -04:00
thePR0M3TH3ANandClaude Opus 4.8 efdf497c59 fix(feed): filter cached audio-only notes at render + hydration, not just ingest
The convert-time guard (17b48cbf) only blocks FRESH events. Audio notes cached
before it existed are rehydrated straight from IndexedDB/localStorage as
already-converted video objects (`sig: "unavailable (cached)"`), bypassing
convertEventToVideo — so they kept showing in the main feed and channel grids.

Extract the audio check into a reusable object-level predicate
`isAudioOnlyVideoObject(video)` (url + magnet + imeta tags) and apply it at every
boundary a cached object can slip through:
  - convertEventToVideo now delegates to it (fresh ingest, unchanged behavior)
  - PersistenceManager.applyCachedPayload drops audio-only on hydration (purges
    the cache going forward)
  - NostrClient.getActiveVideos filters it (main feed, immediate effect)
  - nostrService.shouldIncludeVideo filters it above the viewer-is-author
    short-circuit (channel/author grids, incl. the creator's own channel)

Audio-only = no magnet AND no imeta `m video/*` AND (imeta `m audio/*` OR an
unambiguous audio url); `.ogg` alone stays video. Bumped client.js size baseline
(+6 lines). Tests: video-schema-and-conversion (+3 predicate scenarios, 11/11);
nostr-service + ingest-adapter suites green. Lint + build clean. TODO #60.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-09 09:36:56 -04:00
thePR0M3TH3ANandClaude Opus 4.8 17b48cbfd7 fix(feed): keep audio-only notes (podcasts/music) out of the video grid
A podcast/music note published as a native kind-30078 (content url = an .ogg/
.mp3 audio file, imeta `m audio/*`) was accepted by convertEventToVideo (it has
a url) and rendered as a broken <video> — black frame + audio on Chrome/FF,
MEDIA_ERR_SRC_NOT_SUPPORTED on Safari/iOS — since bitvid has no <audio> player.
The existing audio guard only covered the NIP-71 ingest path, not native events.

convertEventToVideo now marks such notes invalid ("audio-only source (no video)")
so every ingestion path that already drops invalid filters them: main feed buffer,
author/channel batch fetch, and local ingest. Audio-only = no magnet AND no
`imeta m video/*` AND (an `imeta m audio/*` variant OR an unambiguous audio URL
.mp3/.m4a/.aac/.wav/.flac/.opus/.oga/.weba). `.ogg` alone is NOT filtered
(ambiguous → video/ogg); only when imeta says audio. A video imeta variant wins.
Direct open-by-nevent is intentionally unaffected — an explicit link still loads.

Near-term half of TODO #60 (the future dedicated Audio tab remains open).

Tests: tests/video-schema-and-conversion.test.mjs (+5, 8/8). Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-09 09:20:12 -04:00
thePR0M3TH3ANandClaude Opus 4.8 5844a36a54 Add "Always show creator" overlay button for account-level WoT override
Adds a second entry point (alongside the Safety-tab npub input) for the
per-author moderation override. On blurred/warned video surfaces, an
"Always show creator" button now sits beside "Show anyway". Clicking it
dispatches a `video:trust-author` document CustomEvent; the single app-level
listener in applicationBootstrap.js applies the account-level override and
refreshes affected videos, clearing the web-of-trust warning for every video
from that creator until reset.

Wired into VideoCard, VideoModal (persistent reused button + teardown
cleanup), and SimilarContentCard.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-08 17:01:49 -04:00