Commit Graph
6614 Commits
Author SHA1 Message Date
thePR0M3TH3ANandClaude Fable 5 e32298cc89 uploads: locked-storage file picks unlock inline instead of dead-ending (#56)
All three paths (upload video pick, upload thumbnail pick, edit "Replace
file") now route through the shared signer gate — silent kept-unlocked
restore, else ONE passphrase prompt — then unlock storage and continue the
same pick. Cancel/bad-passphrase abort quietly; a refused thumbnail pick
restores the picker. Unlock glue extracted to uploadModalStorageUnlock.js
(file-size budget); ensureSigner threaded via initUploadModal/initEditModal.

Tests: tests/upload-edit-storage-unlock-gate.test.mjs (12).

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 21:01:20 -04:00
thePR0M3TH3ANandClaude Opus 4.8 7ead34378e [lint] Reword todo refs in test comments — #11b parses as a hex color
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 23:23:08 -04:00
thePR0M3TH3ANandClaude Opus 4.8 50d9d51a40 [tests] Un-quarantine nostr-publish-rejection (#11b, closes #50's harness gap)
Two fixes bring the file fully green in CI:
1. Force dev mode via globalThis.__BITVID_DEV_MODE_OVERRIDE__ BEFORE any
   config-importing module loads — the dev-default assertions depended on the
   environment (#50's documented test-harness gap).
2. spec_correction: the NIP-71 sub-test asserted publishVideo auto-invokes
   publishNip71Video whenever nip71 metadata is present. Stale on two counts:
   legacy 21/22 auto-publish is deliberately OFF (FEATURE_PUBLISH_NIP71:false;
   the opt-in 34235/36 MIRROR is the interop story, and #58 removed the form UI
   feeding this path), and the internal call uses the module-level function so
   the client-method stub never intercepted anyway. Corrected to assert the
   video note publishes with NO NIP-71 auto-publish while the flag is off.

Removed from the runner QUARANTINE map — remaining quarantined: user-blocks
(hang, needs deterministic rewrite) and nwc-client (mock-injection rework).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 23:22:43 -04:00
thePR0M3TH3ANandClaude Opus 4.8 3bc79f8e48 [edit] Grids refresh immediately after editing a video (#52)
Editing left grids showing the pre-edit version until a manual reload. The edit
flow already re-ran loadVideos(), but the cache still held the OLD version: unlike
new publishes (#46's optimistic insert), the edit never ingested its signed
replaceable event locally, so the cache-first render stayed stale until relays
echoed the update back.

handleEditVideoSubmit now ingests the edited signed event via
ingestLocalVideoEvent — newest-wins replace in the active store + videos:updated —
so the grid shows the new title/thumbnail/description immediately, with the relay
echo confirming behind it.

Test: nostr-service suite +1 (edited event ingested, videos:updated + videos:edited
emitted, result passed through). Lint green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 23:19:15 -04:00
thePR0M3TH3ANandClaude Opus 4.8 9afbc0cbbb [search] Decode npub authors to hex — the Author filter matched nothing (npubs)
The Author field (and the author: token) passed values through as-typed, but the
matcher compares against video.pubkey — HEX. So entering an npub (the only format
users actually have) silently returned zero results; only raw hex worked.

New normalizeAuthorValues (searchFilters.js, injectable decoder for tests):
npub1* decodes via safeDecodeNpub to hex, hex passes through lowercased, and
undecodable values are dropped — surfaced as parse errors on the author: token
path. Applied in BOTH the parser and the filter modal's applyFilters, so the
Author panel field and the token syntax now genuinely filter by creator.

Tests: +2 in tests/search-filters.test.mjs (decode/passthrough/drop with an
injected decoder; parser hex passthrough + garbage error). Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 23:12:20 -04:00
thePR0M3TH3ANandClaude Opus 4.8 a6a6d4b0f7 [search] Wire sort/textScope/relay:, fix dual-type duration, drop dead flag, first tests
Audit found the search pipeline largely real (token parser, local + NIP-50 relay
search, enforced author/tag/date/has/nsfw/kind filters, wired modal) with dead
spots, now fixed:

- sort: offered in the modal but never applied. New pure sortSearchResults —
  recent (default), views (shared view-count cache), trending (views weighted by
  recency), longest — applied in searchView.
- duration filter only read nip71.duration, so bitvid's OWN kind-30078 videos
  (top-level duration) never matched. Both video types now satisfy duration
  filters; same shapes cover the planned live (30311) / shorts kinds.
- textScope now enforced in the local title/description match (all|title|description).
- relay: now scopes the video relay-search to that relay (was parsed-but-unused).
- FEATURE_SEARCH_FILTERS removed: dev-only flag consumed by NOTHING while the
  filter UI ships and works everywhere (plumbing + setter deleted, no consumers).

Tests (tests/search-filters.test.mjs, 4 — surface previously had zero): parser
tokens; matcher enforces tags/date/has:magnet/duration for BOTH duration shapes;
views/longest/recent sorts (no input mutation); trending ranks fresh-riser above
stale-hit. Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 23:07:22 -04:00
thePR0M3TH3ANandClaude Opus 4.8 4591d4a260 [onboarding] Fix popover pushed off-screen by full-height targets (sidebar step)
Step 2 (sidebar) left the user "stuck looking at a glow with nothing to do": the
sidebar is a full-height column, so the below/above-only placement computed a
popover position past the bottom of the viewport — the card existed but was
invisible off-screen.

Placement is now a pure, exported decision (computeTourPlacement): try below →
above → beside-right → beside-left, whichever actually fits the popover, with a
centered-over-the-dim fallback when nothing does (viewport-filling targets) — and
coordinates are clamped so the card can NEVER leave the viewport. The sidebar
step also hints placement: "right". A preferred placement that doesn't fit falls
back to one that does.

Tests: +1 in tests/onboarding-tour.test.mjs — full-height column → beside-right
inside the viewport; button → bottom; near-bottom target → top; viewport-filling
target → centered; unfittable preference falls back and never goes below the
fold. Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 22:50:46 -04:00
thePR0M3TH3ANandClaude Opus 4.8 8e7c6bc325 [onboarding] First-run guided tour with spotlight + accent glow (#59, phase 1)
New-user onboarding per docs/onboarding-plan.md: on the first login of a pubkey
on a device, a skippable guided tour escorts the user around bitvid — a scrim
dims the page with a spotlight CUTOUT over each step's anchor, ringed by a
pulsing glow driven by the theme's accent tokens, plus an anchored popover with
Back/Next/Skip, progress dots, and arrow/Esc keyboard nav.

- js/ui/onboarding/tourEngine.js: dependency-free engine. Spotlight = one
  element whose 200vmax box-shadow (--surface-overlay-darker) dims everything
  except the cutout; an ::after ring animates between --color-accent and
  --color-accent-strong. Positioning via CSS custom properties set from JS (the
  sanctioned dynamic-var pattern; visual styling lives in an injected token-only
  stylesheet). Steps with missing anchors auto-skip, so one script serves all
  layouts. Repositions on resize/scroll.
- js/ui/onboarding/bitvidTour.js: the step script (welcome → feeds →
  subscriptions → upload → profile → setup CTAs that deep-link into the storage
  and wallet panes via profileController.show).
- js/services/onboardingService.js: once-per-pubkey-per-device flags
  (bitvid:onboarding:v1, settingsRestorePrompt-style).
- app.maybeOfferOnboardingTour fires 3.5s after login (feed already rendered
  thanks to the warm cold-boot work; never blocks login);
  app.startOnboardingTour({force}) + a "Take the tour" button in the profile
  modal footer make it re-launchable.
- Lint allowlists: tourEngine.js added to the inline-style allowlist (CSS-var
  positioning, VideoCard precedent) and the design-token ALLOWED_FILES
  (injected stylesheet geometry, RevertModal precedent) — all colors are tokens.

Tests: tests/onboarding-tour.test.mjs (5 — flag lifecycle, tour render/advance/
finish, skip teardown, missing-anchor exclusion, once-vs-force offering, final
CTA deep-link + completion). Lint + build green.

Phases 2-4 (getting-started checklist, empty-state upgrades, fresh-npub create-
account + key backup + kind-0 bootstrap) tracked in the plan + TODO #59.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 22:44:45 -04:00
thePR0M3TH3ANandClaude Opus 4.8 4795d5edbd [auth] Bug pass on login/signer/session surface: 4 fixes before promotion
Requested pre-promotion sweep of login methods, active signers, and anon session
actors. Four real bugs found and fixed:

1. Logging out could wipe EVERY saved account's remembered nsec key. The no-arg
   clearStoredSessionActor() removed the whole per-account v2 map, and it was
   reachable from SignerManager.logout() (non-persisted nsec cleanup) and from
   the blocked-key cleanup in registerPrivateKeySigner — same bug class as the
   NIP-46 session-map wipe fixed earlier. Now: the no-arg form clears only the
   legacy v1 slot; logout targets the departing account (and skips entirely when
   it can't name one); the blocked-key cleanup targets the blocked pubkey
   (mirroring the already-targeted twin in unlockStoredSessionActor).
   Spec-corrected the test that had encoded the wipe-all as intended behavior.

2. Removing a saved profile left its keys on the device. The switcher's
   remove-account flow cleared NWC + the keep-unlocked cache but kept the
   remembered (encrypted) nsec key and the NIP-46 session. New
   client.forgetStoredAccount(pubkey) (SignerManager.forgetStoredSessionActor +
   clearStoredNip46Session) forgets both WITHOUT touching the active session's
   in-memory actors; wired into handleProfileLogoutRequest.

3. Logout never actually re-locked a kept-unlocked device. The forget read
   detail.pubkey / this.pubkey — both already nulled by authService.logout()
   (the detail only carries previousPubkey) — so the cached decrypted key
   silently survived logout. Now reads previousPubkey.

4. restoreUnlockedSigner bypassed access control: a since-blacklisted account's
   cached key would silently restore its signer. It now accepts a validator
   (mirroring unlockStoredSessionActor), forgets the cached key on denial, and
   both app-level call sites (_initAutoLogin, ensureEncryptionCapableSigner)
   pass an accessControl.canAccess gate.

Also verified-safe during the pass (no action): logout's persisted-nsec keep
logic under the ephemeral session-actor overwrite; NIP-46 logout keepStored;
the ephemeral actor as a harmless extra DM-decrypt candidate; corrupt-map
recovery clears; anon blacklist-seeding now always has a signing-capable actor.

Tests: session-actor multi-account suite spec-corrected + re-verified (no-arg
clear keeps per-account keys); 57 tests across session-actor / nip46 /
unlocked-key / switch / signer-gate / feed-gate suites green. Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 21:59:52 -04:00
thePR0M3TH3ANandClaude Opus 4.8 b8e6921ac0 [perf] Stale-while-revalidate cold boot: don't serialize behind admin lists + list sync
Cold refresh stalled on "Fetching moderation filters…" and then (logged in)
"Fetching subscriptions…" before the grid could render. Boot was SERIAL behind
relay fetches even though every stage already had a local cache:

1. _initAccessControl awaited the full admin-list relay refresh (up to 15s)
   before auto-login — but accessControl hydrates whitelist/blacklist from
   localStorage at construction, and isHydrated() existed for exactly this case
   (unused). Now: when cached lists are present (every visit after the first),
   boot proceeds immediately and the refresh continues in the background — the
   existing whitelist/blacklist change listeners re-filter the grids when it
   lands. First-ever visits still wait (a whitelist-mode instance must never
   render unfiltered). The admin-pane refresh no longer blocks boot at all.

2. bootstrapTrustedSeeds awaited the same relay refresh (up to 3.5s more).
   waitForAccessControl now fast-paths on isHydrated(): cached editors seed
   trust immediately, and its onEditors/Whitelist/BlacklistChange listeners
   already re-apply seeds when the real refresh lands.

3. The post-login feed gate held the grid for the FULL list sync + NIP-07
   decryption (up to 8s). New js/app/feedListCacheGate.js: a probe that checks
   all three list services (blocks/subscriptions/hashtags) hold a valid cache
   for the active pubkey, raced into the gate — warm boots open as soon as the
   caches hydrate (first ticks) and the background sync re-filters on arrival.
   Cold boots (no caches) behave exactly as before.

Net effect: warm cold-refresh renders from IndexedDB-cached events with cached
filters/lists near-instantly; relay truth streams in behind it. No moderation
semantics changed — every stage that lacks a cache still waits.

Tests: tests/feed-list-cache-gate.test.mjs (5 — all-three-caches probe incl.
stale-pubkey/missing-service, late-hydration early release, cold-boot timeout,
throwing probe). Access-control + switch suites green. Lint + build green
(coordinator compacted to stay under its size cap).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 15:32:34 -04:00
thePR0M3TH3ANandClaude Opus 4.8 5466d97544 [ui] Remove NIP-71 authoring sections from upload/edit modals (#58)
bitvid authors kind-30078 and MIRRORS to NIP-71; it never authors NIP-71 directly.
Verified before removing: the My Videos mirror (buildNip71MirrorEvent) derives
everything from the bitvid note itself (duration/dims are auto-captured at upload,
contentWarning comes from the NSFW toggle, imeta is built from the video), and the
only consumer of the form's NIP-71 metadata (publishNip71Video) is gated behind
FEATURE_PUBLISH_NIP71: false — the fields fed a dead path and confused uploaders.

Removed from the upload modal: "NIP-71 Technical" (content warning, duration,
summary + Customize toggle) and the IMETA variant editor. Removed from the edit
modal: kind (21/22) selector, published_at, alt, duration, content warning,
summary, IMETA, text tracks, segments, participants, references. KEPT in both:
hashtags (they feed hashtag feeds, the suggestion chips, and the mirror's t tags)
and the web seeds / torrent (xs) inputs (bitvid-note fields, not NIP-71). All the
JS plumbing is missing-DOM tolerant, so no wiring changes were needed to collect.

Compatibility: existing videos' NIP-71 extras survive a save. EditModal keeps the
video's full stored metadata (storedNip71Metadata) and merges the edited hashtags
into it on submit instead of replacing it with the reduced form's empty output
(t mirror key kept in sync).

Also fixes a latent pre-existing bug the reduction exposed: nip71Edited compared
the submit-side copy AFTER publishedAt normalization (string→timestamp,
delete-empty) against an UN-normalized original, so any video with publishedAt
made every save look "edited". Both sides now share normalizeCollectedNip71.

Tests: +2 in edit-modal-submit-state (stored extras survive a hashtag edit;
untouched save keeps nip71Edited false). Modal/payload/mirror suites green
(39 + 7). Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 15:18:10 -04:00
thePR0M3TH3ANandClaude Opus 4.8 84cec15483 [perf] Fix SW image cache slowing cold thumbnail bursts (grid load regression)
"Recently Added" grids loaded visibly slower after the image cache shipped. The
feed's data path was untouched — the regression was the thumbnails: the SW handler
had three compounding costs on a cold cache:

1. The network fetch waited behind caches.open + cache.match, delaying every
   thumbnail's download start.
2. caches.open ran per request instead of once.
3. trimImageCache ran a full keys() enumeration after EVERY put — a grid burst of
   dozens of thumbnails did dozens of O(n) enumerations, all contending with the
   cache.match calls of later images.

Fix: start the network fetch immediately (cold path is now as fast as no SW; a
warm cache still serves the cached copy for first paint), memoize the cache open,
and amortize trims (every 20 puts). SW_VERSION bumped so testers pick it up.

Tests: sw-image-cache (7 — adds a regression test that gates cache.match behind a
promise and asserts the download starts anyway; all prior behaviors re-verified
against the real sw.min.js). Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 14:59:21 -04:00
thePR0M3TH3ANandClaude Opus 4.8 c20938b4aa [perf] Media caching: immutable upload headers + SW image cache (#55)
Verified first that event caching needs nothing: allEvents (incl. ingested NIP-71)
already persists to IndexedDB and every grid renders cache-first + merge-new. The
real gap was MEDIA: thumbnails/avatars/banners re-downloaded across visits.

Part 1 — Cache-Control on uploads (js/storage/s3-multipart.js):
computeCacheControl already stamped most media immutable but missed webp/avif/ico
and .torrent files (which fell to the 1h default). Added them — safe because
buildR2Key namespaces keys by infohash (content-addressed, a key's bytes never
change). Playlists (m3u8/mpd) stay short-lived (live manifests mutate in place).
Exported for tests.

Part 2 — service-worker image cache (sw.min.js):
Cross-origin images (the hosts bitvid does NOT control headers for) are now served
stale-while-revalidate from a versioned Cache-API cache (bitvid-images-v1):
- instant on revisit; background refresh self-heals changed images (and where
  part 1's immutable headers apply, that refresh is served by the HTTP cache — free)
- opaque (no-cors) responses cached — exactly the external-thumbnail case
- LRU-capped (300); quota failures drop the cache and fail open; every handler
  error falls back to plain fetch so a caching bug can never break images
- the activate handler's wipe-all now PRESERVES the current image cache (old
  versions + other caches still wiped); same-origin assets untouched (they ride
  the host HTTP cache + dist cache-purge pipeline)
- app._initServiceWorker registers the SW at boot when none exists, same
  path/scope/options as webtorrent.js's setupServiceWorker so playback's later
  register() resolves to the same registration (playback path untouched)
- SW_VERSION bumped

Tests: storage-cache-control (3, exported policy) + sw-image-cache (6 — evaluates
the REAL sw.min.js in a VM sandbox and drives its handlers: cold/warm/offline/
opaque flows, same-origin + non-image non-interception, activate preserves the
image cache). Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 14:48:18 -04:00
thePR0M3TH3ANandClaude Opus 4.8 9e5cb9f57b [channel] Show view counts on channel-profile cards (were blank)
Channel-profile video cards rendered blank view counts. The main feed subscribes
each card's [data-view-count] element to the shared view counter
(VideoListView.ensureViewCountSubscription / registerVideoViewCountElement), but the
channel-profile grid renders its own VideoCards and never did — so nothing ever
populated the element.

Add js/channelViewCounts.js (subscribeChannelCardViewCount /
clearChannelCardViewCounts) and call it from channelProfile.js after each card's
getRoot(); teardown runs in clearChannelVideoCardRegistry (already invoked on grid
re-render / channel close). Logic lives in a new module so channelProfile.js stays
under its size cap.

On "are they counted correctly?": yes — this subscribes to the SAME batched
kind-30079 view-count transport the feed uses (one shared subscription + the deduped
cache), so the channel now shows the exact same counts the feed does. The counting
itself was never the issue; the channel grid just wasn't displaying it.

Tests: tests/channel-view-counts.test.mjs (3 — non-blank label, safe no-ops,
idempotent teardown). Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 10:44:09 -04:00
thePR0M3TH3ANandClaude Opus 4.8 6b251dbda5 [reactions] Fix "Missing signing primitives" — session actor must have a usable key
Root cause of "Failed to send reaction" (after the dispatch fix made the button
reach the publish path): reactions are published under the anonymous SESSION ACTOR,
signed with its private key. But ensureSessionActor adopted a stored session-actor
entry as that key — and a persisted nsec login is passphrase-encrypted with
privateKey "" ("never exposes plaintext"). So a leftover saved-nsec entry hijacked
the anonymous telemetry key, and signEventWithPrivateKey(event, "") threw
"Missing signing primitives". (Repro: logged in with NIP-07 while a saved nsec
account exists on the device.)

Every ensureSessionActor caller uses it to SIGN telemetry (reactions / view events /
watch-history), so the returned actor must carry a usable plaintext key. Now it only
reuses a current/stored actor that has one; otherwise it tracks the locked entry (for
other flows) and generates a fresh ephemeral signing key — instead of returning a
keyless actor that can sign nothing.

Existing session-actor suites green; lint + build green.

Follow-up: the fresh ephemeral key isn't persisted, so a like isn't remembered across
reloads (different anonymous key) — separate from this crash fix.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 10:02:43 -04:00
thePR0M3TH3ANandClaude Opus 4.8 6e2894e3d8 [video-modal] Fix dead like/dislike buttons — dispatch video:reaction (#54)
The real #54 bug (the signer gate was correct but unreachable): the reactions
controller forwards the raw click event to VideoModal.handleReactionClick, which
called this.reactionsController.handleReaction(event) — a method that does NOT
exist on the video-modal reactions controller (it only binds buttons + does
optimistic UI), passing the DOM event as the "reaction". So clicking like/dislike
threw / silently did nothing and never reached the publish path.

Fix: handleReactionClick now derives +/- from the clicked button
(#modalLikeBtn / #modalDislikeBtn, or a bare "+"/"-" for programmatic callers) and
dispatches "video:reaction" — the event ModalManager already routes to
app.handleVideoReaction -> reactionController.handleReaction (signs + publishes the
kind-7, and runs the ensureEncryptionCapableSigner gate from the earlier #54 pass).

Note: #53 (channel avatar/name link) is already fixed in a20388c5
(handleCreatorNavigation now dispatches "creator:navigate"); if it still looks dead
it's a stale build — rebuild dist / redeploy the branch under test.

Tests: tests/video-modal-controllers.test.mjs (+2). Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 09:40:46 -04:00
thePR0M3TH3ANandClaude Opus 4.8 757fca1059 [edit] Host the .torrent (xs=) for edited external URLs too — close the gap
The edit-modal external-URL webseed was ws=-only because the Edit modal had no
storage credentials wired in. Reuse mediaUploader.resolveActiveConnection() (the
same resolver the Edit modal's file-replace uploads use) to get the active
provider + credentials when storage is unlocked, and pass them to
deriveTorrentForExternalUrl so it hosts the tiny .torrent and emits ws=+xs= — full
P2P bootstrap, matching the upload modal. Storage locked / no connection → ws=-only
(unchanged, best-effort).

Edit-modal submit-state + upload-field + external-url-torrent suites green.
Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 09:17:32 -04:00
thePR0M3TH3ANandClaude Opus 4.8 11c7884366 [edit] Derive a webseed torrent when editing a video's URL to an external link
Mirror the upload-modal external-URL webseed for the Edit modal: when the user
changes a video's URL to an external link with no magnet, auto-derive the webseed
torrent (fetch + hash the remote file → magnet ws=url) so an edited external URL
keeps the P2P benefit. Best-effort + size-capped; on CORS / too-large / any error
it stays URL-only. Runs before the existing magnet normalization so the derived
magnet's ws/xs are extracted the same way a pasted magnet's are.

submit() is now async (it was fire-and-forget from the form handler; the
pendingSubmit guard already prevents re-entry, so it just stays "pending" during
the derivation).

Known gap: hosting the tiny .torrent (xs=) on the edit path needs storage-
credential plumbing the Edit modal doesn't currently have — so the edit path is
ws=-only for now (upload path hosts xs= when storage is unlocked). Tracked as a
follow-up.

Edit-modal submit-state + upload-field tests green. Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 09:12:14 -04:00
thePR0M3TH3ANandClaude Opus 4.8 cf6a830183 [upload] Derive a webseed torrent for external-URL posts (P2P for external links)
Posting an external URL used to skip the torrent/webseed benefit entirely. Now the
upload modal's external tab auto-derives a WebTorrent webseed from the URL at
publish time: fetch the remote file (CORS-permitting), stream it under a hard size
cap, compute the infoHash, and publish a magnet with the URL as the webseed (ws=).
Best-effort — CORS failure / over the cap / any error silently degrades to
URL-only. Only runs when the user didn't paste their own magnet.

- js/utils/externalUrlTorrent.js: fetchBlobWithCap (streams with a 500 MB cap,
  aborts past it, short-circuits on content-length), buildWebseedMagnet, and
  deriveExternalUrlTorrent (reuses createTorrentMetadata). Pure + injectable → unit
  tested without network/WebTorrent.
- mediaUploader.deriveTorrentForExternalUrl: derives the ws= magnet and, when
  storage is unlocked, also hosts the tiny .torrent so the webseed can actually
  bootstrap P2P (xs=) — best-effort, falls back to ws=-only.
- UploadModal.handleExternalFlow calls it; deriveExternalWebseed only hosts the
  .torrent when storage is unlocked, shows progress on the submit button, and
  never throws.

Tests: tests/external-url-torrent.test.mjs (9 — magnet assembly, name parsing,
streamed size-cap enforcement + short-circuit, fetch-failed, derive-with-injected-
hash, failure propagation). Lint + build green.

Next: the same for editing a URL in the Edit modal.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 09:08:32 -04:00
thePR0M3TH3ANandClaude Opus 4.8 a20388c5d6 [video-modal] Fix dead channel/creator link — event-name mismatch (#53)
Clicking the creator avatar/name in the video player modal did nothing. VideoModal
dispatched "navigate:profile", but ModalManager only listens for "creator:navigate"
(→ app.openCreatorChannel), so the event went into the void — same class as the old
action:embed bug noted just above the handler.

Fix:
- VideoModal.handleCreatorNavigation dispatches "creator:navigate" (the listened name)
  with { pubkey }.
- ModalManager's handler forwards event.detail.pubkey to app.openCreatorChannel.
- routerCoordinator.openCreatorChannel(pubkey) accepts an optional pubkey (prefers it,
  falls back to the active video's creator), so it navigates to the modal's own
  creator rather than whatever currentVideo happens to be.

Tests: tests/video-modal-controllers.test.mjs (+2 — dispatches creator:navigate with
the pubkey and never the dead navigate:profile; no-op without a pubkey). Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 08:51:10 -04:00
thePR0M3TH3ANandClaude Opus 4.8 298e456ff9 [dm] Clear the previous account's DMs on account switch (not just the modal view)
Switching accounts (e.g. NIP-07 → nsec) still showed the OLD account's direct
messages. The earlier switch fix reset only the profile modal's DM view, but the
DM data lives in the app-wide nostrService store (this.dmMessages), which the
messages pane reads via listDirectMessageConversationSummaries /
getDirectMessagesForConversation. Logout clears that store
(stopDirectMessageSubscription + clearDirectMessages); the switch path never did,
so the old messages persisted.

Fix: on an identity change, authSessionCoordinator.handleAuthLogin now stops the
old DM subscription and clears the in-memory DM store BEFORE the new account's
subscription starts — so the pane shows the new account's DMs (or empty→new),
never the old.

Uses a new clearDirectMessages({ keepSnapshot: true }) option: it wipes the
in-memory store but preserves each account's PERSISTED per-actor DM snapshot, so
switching back to an account stays fast (per-account DM cache). Logout still fully
clears (keepSnapshot defaults false).

Tests: tests/services/nostr-service.test.mjs — clearDirectMessages empties the
in-memory store + resets the active actor and emits cleared/updated, in both
keepSnapshot modes. Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 21:45:23 -04:00
thePR0M3TH3ANandClaude Opus 4.8 6bf2cf26fd [nip46] Stop connection teardown from wiping ALL saved remote-signer sessions
Switching from a NIP-46 account to another account (e.g. an nsec account) failed to
switch BACK with "No remote signer session is stored on this device." Root cause:
disconnectRemoteSigner() defaults to keepStored:false and calls the no-argument
clearStoredNip46Session(), which removed the entire per-account v2 map
(bitvid:nip46:sessions:v2) — deleting EVERY saved account's session, not just a
teardown of the live connection. Four nip46Connector connect/handshake-error paths
call the same no-arg clear, so any connect hiccup also nuked all sessions.

Fix: the no-argument clearStoredNip46Session() now clears ONLY the legacy v1
"last-connected" default slot and leaves the per-account v2 map intact. A specific
account is still forgotten via clearStoredNip46Session(pubkey) (unchanged), so
removing one account is unaffected while switching between saved NIP-46 accounts
survives connection teardown.

Spec correction: the prior test asserted the buggy "no-arg clear wipes everything";
replaced with the corrected behavior + a switch-away/switch-back regression.
Test: tests/nip46-multi-account.test.mjs (7). Lint + build green.

NOTE: this prevents future wipes; a session already deleted by the bug must be
reconnected once (Login → remote signer) — after which switching persists.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 21:16:55 -04:00
thePR0M3TH3ANandClaude Opus 4.8 3df398d7ad [upload] Suggest one-tap hashtag chips from the user's past videos (#45)
The upload modal's Hashtags section now shows chips of the user's most-used past
hashtags ("Reuse a tag from your videos:"). Clicking one adds it to the NIP-71 't'
repeater; already-added chips dim + disable; the row is hidden for users with no
past tags.

- js/utils/hashtagSuggestions.js: pure rankHashtagsByFrequency(videos) — count =
  number of the user's videos using each tag, normalized (case + '#') and deduped
  per video, ranked by count then alphabetically. Reads video.nip71.hashtags, falls
  back to raw event 't' tags minus bitvid's fixed t=video marker.
- app.getUserHashtagSuggestions() sources the user's own videos via
  nostrService.getActiveVideosByAuthors([pubkey]) and ranks them (top 12), injected
  into the modal as getHashtagSuggestions.
- UploadModal renders the chips on open and wires clicks through the existing
  Nip71FormManager (addRepeaterEntry + setFieldValue, dedup via collectRepeaterValues).

Tests: tests/hashtag-suggestions.test.mjs (5 — per-video counting, normalization,
marker exclusion, tie-break + limit, empty input). Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 20:59:56 -04:00
thePR0M3TH3ANandClaude Opus 4.8 6bdf28fb30 [upload] Auto-unlock storage in the upload modal for a kept-unlocked session (#51)
The upload modal only READ storage's unlock state (isUnlocked) on load/open/refresh
— it never auto-unlocked the way the profile Storage pane does. So after a refresh
with "keep unlocked" ticked, the upload modal still opened with storage Locked even
though the signer had been restored.

Add UploadModal.maybeAutoUnlockStorage(pubkey), called on load(), open(), and
refreshState(): if storage is locked but a decrypt-capable signer is already present
(e.g. a kept-unlocked nsec restored on refresh), unlock silently. Guarded two ways:
  - only when the account ALREADY exists (new storageService.hasStoredAccount) so it
    never auto-creates storage for magnet/URL-only uploaders, and
  - only when the signer is already available, so it never triggers a fresh
    permission prompt on modal open.

Tests: storageService.hasStoredAccount (false before setup, true after unlock,
never creates an account, survives lock). Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 20:20:57 -04:00
thePR0M3TH3ANandClaude Opus 4.8 cebd3dc1e7 [auth] Add "Lock this device" control to re-lock a kept-unlocked session (#51)
The opt-in "keep unlocked" cache had no user-facing way to undo it mid-session
(it cleared only on logout / clear-site-data). Add a "Lock this device" button to
the profile modal footer (desktop + mobile), shown only when the active account
has a cached key to forget (app.isSessionKeptUnlocked -> client.hasCachedUnlockedKey).

Clicking it runs app.lockKeptUnlockedSession(pubkey), which re-locks in one action
everything the signer unlocks:
  - forgets the cached key (both tiers) so future reloads re-prompt for the PIN,
  - drops the in-memory signer (logoutSigner) so the CURRENT session re-locks now
    (the next signing action prompts again via ensureEncryptionCapableSigner),
  - storageService.lock(pubkey), and
  - nwcSettingsService.clearCache() (re-hydrates from the signer next time).

Visibility refreshes on renderSavedProfiles + selectPane. Tests: 2 in
tests/profile-modal-controller.test.mjs (shown only when kept-unlocked; click locks
the active pubkey). Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 20:16:00 -04:00
thePR0M3TH3ANandClaude Opus 4.8 b0a2eeaf72 [auth] Storage + NWC inherit the kept-unlocked signer on refresh (#51)
Storage and NWC have no passphrase of their own — the storage master key and the
NWC settings are encrypted WITH the signer (nip44/nip04 self-encryption) and
unlocked by it. So instead of caching a second decrypted secret, both now inherit
the kept-unlocked nsec signer.

_initAutoLogin restores the cached signer (nostrClient.restoreUnlockedSigner)
BEFORE the boot re-login, so:
  - the login flow's NWC hydrate + list decrypt run with the signer present, and
  - the existing storage auto-unlock (ProfileStorageController / UploadModal call
    storageService.unlock when a signer is available) succeeds silently,
all on refresh with no passphrase and no manual "Unlock storage". This is safe for
nsec (the only way the signer returns on reload, since the key is passphrase-
encrypted) and a no-op for NIP-07/46 (nothing cached; their signer already
persists). authService.login never touches the signer registry, so restoring
before login doesn't get clobbered.

Persistence follows the signer cache: storage/NWC stay unlocked exactly as long as
the kept-unlocked key does (the session tier, or until site data is cleared if the
user checked "keep unlocked"). Logout already forgets the cached key, re-locking
all three together.

Lint + build green; unlock/switch/session-actor suites green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 20:01:54 -04:00
thePR0M3TH3ANandClaude Opus 4.8 1b17bb8fcc [auth] Opt-in "keep unlocked" so a reload doesn't re-prompt for the PIN (#51)
After unlocking a saved nsec key the user had to re-enter the PIN on every reload,
because the key is only passphrase-encrypted and the in-memory signer is lost on
refresh. Add an opt-in unlocked-key cache with two tiers (maintainer's choice):

- session (default, always): sessionStorage — survives refresh/navigation, cleared
  when the tab/browser closes. Same rough exposure as the in-memory signer.
- persistent (opt-in): the unlock prompt now shows a "Keep me unlocked on this
  device" checkbox with an inline unencrypted-at-rest warning; when checked the key
  is also written to localStorage and survives until site data is cleared.

client.restoreUnlockedSigner(pubkey) re-registers the signer from the cache WITHOUT
a passphrase — it verifies the cached key derives to that pubkey and forgets a
mismatch, so it never adopts another account. It runs proactively from
ensureActiveSignerForPubkey (auto-login/refresh) and lazily from
ensureEncryptionCapableSigner before any prompt, so after a reload the signer is
restored silently and storage/hashtags/DMs/reactions/blocks/subs all just work.
Logout and remove-saved-profile forget the cached key (forgetUnlockedSigner).

SECURITY: while cached, the key is stored decrypted — the passphrase no longer
protects it at rest. Session tier is the default; persistence is opt-in + warned.

Tests: tests/unlocked-key-cache.test.mjs (8 — session vs persistent tiers, promote
persistent->session on read, reject non-hex, forget one, clear all, missing-storage
degrade). Related unlock/switch/session-actor suites green. Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 19:51:41 -04:00
thePR0M3TH3ANandClaude Opus 4.8 2dc7397535 [hashtags] Monotonic created_at so a rapid second edit doesn't false-error (#57)
Adding a second hashtag interest/disinterest quickly showed "didn't work" yet the
change persisted after a refresh. Cause: the replaceable preferences event used
created_at = floor(now/1000), so two edits in the same wall-clock second produced
the SAME created_at. Relays reject the second as "not newer" (client shows an
error) while relays that break created_at ties by id still store it (so it appears
after a refresh) — an inconsistent false-negative.

Fix: nextReplaceableCreatedAt(lastCreatedAt) (js/nostr/replaceableCreatedAt.js) —
always advance to max(now, lastCreatedAt + 1), so each publish is strictly newer
than the last and every relay accepts it unambiguously. Reusable for the other
replaceable lists (subscriptions/blocks) that have the same latent collision.

Tests: tests/replaceable-created-at.test.mjs (5 — same-second bump, clock-ahead,
no-prior, rapid successive strictly-increasing, floors fractional). Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 19:41:00 -04:00
thePR0M3TH3ANandClaude Opus 4.8 1622e85d10 [auth] Route reactions, blocks, subscriptions & DM send through the signer gate (#54/#57)
Fast-follows to the ensureEncryptionCapableSigner gate: every remaining
signing/encryption flow now re-unlocks a reloaded nsec signer with one passphrase
prompt instead of dead-ending. Each call site short-circuits ONLY on the
user-driven outcomes (cancel / bad-passphrase); every other case falls through to
the flow's own existing error handling, so non-nsec signers (e.g. an unresponsive
NIP-07 extension) behave exactly as before.

- Reactions (#54): ReactionController.handleReaction gates before the optimistic
  update with need:"sign" (kind-7 reactions don't need encryption) — fixes the
  "like/dislike does nothing" that was a rolled-back publish on a lost signer.
- Blocks: handleProfileBlocklistMutation (need:"encrypt", NIP-04 mute/block lists).
- Subscriptions: the channel Subscribe/Unsubscribe toggle (need:"encrypt", kind 30000).
- DM send: both composers — handleSendProfileMessage and handleDmAppShellSendMessage
  (need:"encrypt", NIP-17/NIP-04).

Reactions get the gate via a new ensureSigner callback on ReactionController; the
modal flows use the services.ensureEncryptionCapableSigner already exposed.

Tests: tests/reaction-signer-gate.test.mjs (3 — cancelled/bad-passphrase block
publish, ok proceeds, asserts need:"sign"). Related suites (decision, DM, profile
modal, switch delegators) green. Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 19:23:04 -04:00
thePR0M3TH3ANandClaude Opus 4.8 1b79ab9d09 [auth] Re-unlock the signer on demand instead of blanket-erroring encryption ops (#57)
A persisted nsec session restores the pubkey + UI on reload but NOT the in-memory
signer (the private key is only passphrase-encrypted — TODO #36). So after a refresh,
adding/editing a hashtag on an nsec account dead-ended with "Connect a Nostr signer
that supports encryption before managing hashtag preferences" — even though the user
looks logged in. Same class blocks subscriptions/blocks/DMs/reactions.

New reusable gate app.ensureEncryptionCapableSigner({ pubkey, need, promptMessage }):
checks the active signer's capabilities; if it can't encrypt/sign AND a MATCHING
locked nsec key exists for the account, prompts once for the passphrase and re-unlocks
the signer app-wide via unlockStoredSessionActor. One unlock re-enables every signing
flow until the next reload. Never unlocks a different account's key (pubkey-mismatch
guard); "no-stored-key" leaves non-nsec cases (e.g. an unresponsive NIP-07 extension)
to the caller's own error. Branching lives in js/nostr/ensureSignerDecision.js (pure,
unit-testable without the app shell).

Wired hashtag add/edit through it (ProfileHashtagController.persistHashtagPreferences):
a cancelled prompt aborts silently, a bad passphrase shows its own toast, and publish
never runs until the signer is confirmed. Exposed to the modal as
services.ensureEncryptionCapableSigner.

This is the shared machinery for the whole reloaded-nsec cluster (#51/#54/#56/#57);
subscriptions/blocks/DM-send/reactions are mechanical fast-follows against the same
gate (tracked in TODO #57).

Tests: tests/ensure-signer-decision.test.mjs (9 — capability + recoverable/fail
branching, never-wrong-key), + 2 wiring tests (gate blocks publish until ok) in
tests/profile-modal-controller.test.mjs. Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 19:11:19 -04:00
thePR0M3TH3ANandClaude Opus 4.8 633e946325 [docs] TODO: log rough edges found in hands-on testing (#51–#57)
From maintainer live testing on unstable. #51 (storage re-unlock every refresh),
#56 (upload/edit unlock prompt + cache) and #57 (nsec/NIP-46 encryption ops fail
after reload — re-prompt for PIN instead of blanket error) are all facets of #36's
root cause (persisted nsec session restores the pubkey/UI but not the in-memory
signer). Also #52 (edit doesn't refresh grids), #53 (channel link in player modal
dead), #54 (like/dislike dead), #55 (grid event + media caching, cache-first +
merge-new). Cross-referenced #36/#46/#48 and commit 87af650b.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 18:24:22 -04:00
thePR0M3TH3ANandClaude Opus 4.8 87af650b38 [profile] Refresh the profile modal on account switch (no manual reload)
Switching accounts with the profile modal open (e.g. NIP-07 -> a saved nsec) left
the PREVIOUS account's DMs, subscriptions, hashtags and lists on screen until the
user refreshed. Two problems:

1. handleAuthLogin ran its identity-reset steps unguarded, so if any one threw
   (renderSavedProfiles, a warm-list call, etc.) the steps after it were skipped —
   including handleActiveDmIdentityChanged, which is what clears the previous
   account's conversation. Result: stale DMs after a switch. Each reset step now
   runs in isolation via runIdentityResetStep, and the DM reset runs first.

2. The switch-with-modal-open refresh called populateOpenProfilePanels(), which
   only paints from current service state and omits lazily-loaded panes (messages,
   watch history). It also fired before the new identity's lists had loaded, with
   no reliable refresh afterward. Replaced with refreshOpenPanesForActiveIdentity(),
   which re-runs the ACTIVE pane through selectPane() — the same load+populate path
   as opening it fresh — so DMs reload, subscriptions/hashtags refetch, etc. It runs
   immediately, again once profile/wallet data settles, and once more from the auth
   coordinator when the new identity's blocks/subscriptions/hashtags finish loading
   (backfill for slow sync). Non-active panes stay lazy and repopulate on select.

Per-user list caches (blocks/subscriptions/hashtags are already keyed per pubkey)
mean the active pane shows cached data instantly on switch, then updates when the
new identity's fetch settles — no manual refresh.

Tests (tests/profile-modal-controller.test.mjs +3): a throwing reset step is
isolated; a throwing DM reset no longer skips the saved-profiles reset; and
refreshOpenPanesForActiveIdentity re-runs the active pane only when the modal is
open. Existing profile-modal / switch-coordinator / stored-nsec-switch suites green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 16:31:01 -04:00
thePR0M3TH3ANandClaude Opus 4.8 b8ac09ef7e [lint] Exclude the four live/shorts plan docs from the hex-color check
check-hex.js flagged planning-doc TODO refs like "**#16c**" / "**#16b**" as
3-digit hex colors (16c, 16b are valid hex), breaking `npm run lint` on HEAD.
Same false positive docs/capacitor-native-app-plan.md was already excluded for.
Add shorts-plan / live-ingest-plan / live-publish-plan / bitvid-bridge-plan.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 16:11:41 -04:00
thePR0M3TH3ANandClaude Opus 4.8 8ee39510ac [sync] Accepting the restore offer now enables ongoing sync (step 3)
Accepting the one-time login restore offer used to pull the encrypted copy once
but never set the per-device sync flag — so users "restored once and stopped
syncing", and the settings toggle showed OFF despite having just restored. That
is exactly the "not syncing / has to keep confirming" behavior we're removing.

Accepting now means "keep this device in sync": after a successful pull we call
setSyncEnabled(pubkey, kind, true), so future saves auto-push (conflict-checked)
with no further prompts. Declining still enables nothing. No immediate push is
needed — we just pulled, so local == remote.

This completes the seamless model: enable once (or accept the offer once) →
saves auto-push on conflict-only confirm, restores pull with a small toast.

Tests: settings-restore-prompt.test.mjs +2 — accept enables the restored item
(and only that item); decline enables nothing.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 16:11:41 -04:00
thePR0M3TH3ANandClaude Opus 4.8 a59a74c976 [sync] Unify wallet + storage sync UI: one confirm, consistent notifications (step 2)
ProfileWalletController and ProfileStorageController each had their own copy of
the sync toggle/restore/overwrite-confirm logic, producing different notifications
and duplicate confirm dialogs. Extract js/ui/profileModal/syncSection.js — one
overwrite-confirm dialog, one status-class mapping, and shared runSyncToggle /
runSyncRestore handlers. Both controllers now delegate, so every syncable item
gets identical toasts + one confirm (conflict-only, via the shared syncConflict).

Intentional divergence preserved: the NWC spending-secret warning stays as the
wallet's preEnableConfirm (a wallet-connect URI can spend), since publishing it
even encrypted warrants a one-time heads-up.

Tests: tests/sync-section.test.mjs (8) — enable/conflict/nothing-to-sync/rollback/
preEnableConfirm/disable/restore, plus wallet-vs-storage produce the same message
shapes. wallet-sync + storage-sync + profile-modal suites green (34).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 16:04:33 -04:00
thePR0M3TH3ANandClaude Opus 4.8 023f37f08f [sync] Unify NWC + Storage sync onto one shared implementation (step 1: backend)
walletSyncService and storageSyncService were near-identical copies (the "two
sync systems"). Extract the shared machinery into createEncryptedSyncItem() —
per-pubkey enable flags, conflict-checked push, cheap remote-exists check,
enable/disable/pull — so each service is now a thin config differing only in
d-tag, kind, availability, and buildPayload/applyPayload. Public surface
(isAvailable/isEnabled/hasRemote/push/pull/enable/disable + the *_DTAG export)
preserved; both dedicated suites pass (8).

Next (step 2): shared confirm dialog + consistent notifications + seamless
auto-sync in the two profile controllers (the user-facing UX unification).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 15:49:42 -04:00
thePR0M3TH3ANandClaude Opus 4.8 a2c9f44307 [auth] Fix "prepareStoredNsecSwitch is not a function" when switching to a saved nsec account
Switching (from NIP-07) to an added nsec account threw
"this.prepareStoredNsecSwitch is not a function".

Root cause: bindCoordinator binds every coordinator method to the Application
instance, so inside handleProfileSwitchRequest `this` === app. Its call to the
sibling `this.prepareStoredNsecSwitch(pubkey)` therefore resolves against the
Application and needs an app-level delegator (like handleProfileSwitchRequest,
handleModerationSettingsChange, and waitForIdentityRefresh all have). The #48 work
added prepareStoredNsecSwitch as a coordinator sibling but not its delegator, and
its tests only exercised authService.switchProfile — not the full app wiring — so
it slipped through.

Fix: add the app.prepareStoredNsecSwitch delegator (-> this._auth.prepareStoredNsecSwitch).

Tests: tests/profile-switch-coordinator-delegators.test.mjs (4) — asserts every
sibling handleProfileSwitchRequest calls via this.X exists as an app method.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 15:34:20 -04:00
thePR0M3TH3ANandClaude Opus 4.8 772d4202c7 [docs] Flag deferred follow-ups: #49 content-publish relay-ack false-negatives (high-stakes), #50 isDevMode test-harness gap
#49 (HIGH-STAKES, deferred): the generic content-publish path (video publish/edit/
delete/revert/rebroadcast in publishHelpers/client.js) still hard-errors on an
all-timeout relay ack — same bug already fixed for replaceable lists. Left out
because the video outcome-describer + delete semantics need careful handling.
Only act if it causes problems.

#50 (LOW): decision recorded to KEEP isDevMode (gates schema validation + dev
logging + dev feature flags across ~26 files). The nostr-publish-rejection test
failure is a test-harness gap (doesn't force dev mode via the override), not an
isDevMode problem — pre-existing on HEAD.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 15:26:03 -04:00
thePR0M3TH3ANandClaude Opus 4.8 47f2a9a865 [nostr] Fix spurious "failed" errors on unconfirmed list publishes across the board
Same class of bug as the subscription fix, applied everywhere an idempotent,
replaceable list is published with an optimistic UI: when no relay ACKs within the
10s window (slow/flaky relays), the event was still sent and almost always
persists (it shows up after a refresh), but assertAnyRelayAccepted threw -> the UI
showed a scary error / reverted, even though it worked.

- nostrPublish.js: add shared helpers relayPublishFailuresAreAllTimeouts() and
  assertAnyRelayAcceptedOrUnconfirmed() — the latter returns a summary flagged
  { unconfirmed: true } on all-timeout instead of throwing; explicit relay
  rejections (and pre-publish signer/encryption errors) still throw.
- Applied to the replaceable-list publishes: subscriptions (kind 30000, refactored
  to the shared helper), user mute/block list (10000), relay list (10002), DM
  relay hints (10050), hashtag preferences (30015).

Left as-is for now (higher-stakes, separate follow-up): the generic content
publish path (video/edit/delete/revert/rebroadcast) in publishHelpers/client.js —
same latent issue but the video outcome-describer needs careful handling.

Tests: tests/subscription-publish-timeout.test.mjs (8) now cover both the
classifier and assertAnyRelayAcceptedOrUnconfirmed (confirmed->summary,
all-timeout->soft, rejection->throws). hashtag-preferences suite green (14).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 15:20:58 -04:00
thePR0M3TH3ANandClaude Opus 4.8 a9271fd0e6 [subscriptions] Stop spurious "Failed to update subscriptions" on unconfirmed publish
Subscribe/unsubscribe intermittently showed an error but "worked after refresh":
the kind-30000 list event WAS sent and persisted, but when no relay ACKed it
within the 10s window, assertAnyRelayAccepted threw -> toggleChannel threw -> the
button handler reverted the optimistic UI and showed "Failed to update your
subscriptions." The event was on the relays the whole time (hence the refresh).

Fix: in publishSubscriptionList, distinguish an all-timeout (unconfirmed) result
from an explicit rejection. The list is an idempotent, replaceable event that was
already sent + locally cached, so an all-timeout is treated as a SOFT SUCCESS
(record the event id, no throw, reconciles on next load) instead of a hard error.
Only an explicit relay rejection (or a pre-publish signer/encryption error, thrown
earlier) still fails loudly. New exported classifier
subscriptionPublishFailuresAreAllTimeouts().

Tests: tests/subscription-publish-timeout.test.mjs (5 — all-timeout=soft,
any-rejection=hard, empty=hard, case-insensitive).

Note: assertAnyRelayAccepted is used the same way by userBlocks/app list publishes
— they likely have the same false-negative and could get the same treatment later.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 14:08:33 -04:00
thePR0M3TH3ANandClaude Opus 4.8 50f1a7c2aa [docs] Document static-first priority principle + the recommended build sequence
Maintainer principle: put as much effort into the static bitvid client as
possible before branching into any separate application.

- bitvid-bridge-plan: prominent "PRIORITY PRINCIPLE — static bitvid first" banner
  up top; the Bridge (a second app) is deliberately DEFERRED until the static
  client's live features (Go-Live Tier-1/2 MVP) ship and are maximally developed;
  build it only when live proves demand for its sovereignty/power features.
- live-publish-plan: new "Build order & the static-first principle" section with
  the recommended sequence — 1) Shorts (#16b), 2) Live ingest (#16 watch
  foundation), 3) Go-Live MVP (#16c P0-1, Tiers 1&2, client-side only, no
  Bridge/VPS), then 4) the Bridge later only if warranted.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 13:53:10 -04:00
thePR0M3TH3ANandClaude Opus 4.8 1d02940bac [docs] Add three-tier provider spectrum (no-VPS options) + reshape Bridge accordingly
Answering "can we avoid the VPS / use a free service like StreamYard?": yes.

live-publish-plan:
- DECISION 1 broadened into a THREE-TIER provider spectrum (bitvid hosts none):
  (1) platform embed (StreamYard/OBS -> YouTube/Twitch, free, iframe),
  (2) serverless RTMP->HLS API (Livepeer/Mux/Cloudflare, no server, NATIVE HLS) --
      the recommended no-VPS default; Livepeer most aligned,
  (3) self-hosted node (Bridge headless on VPS) -- sovereignty + WebTorrent VOD.
- New "Provider options" table/section; StreamYard clarified as a studio (not a
  CDN). Same NIP-53 event + player across all tiers; only a per-tier playback
  adapter differs. Phase 1 reframed as provider-agnostic (Tiers 1 & 2, no infra).

bitvid-bridge-plan:
- Repositioned as the Tier-3 advanced/sovereignty option (serverless is the no-VPS
  default), not the default on-ramp.
- Added the HYBRID that dissolves the NAT/VPS problem: a local Bridge records
  locally + forwards (outbound) to a Tier-2 serverless provider that serves public
  HLS -> home box needs no public reachability, yet keeps local recording +
  full-WebTorrent VOD. Made serverless a first-class restream preset (Phase 3);
  updated the reachability risk + NAT notes to point at the hybrid.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 13:49:25 -04:00
thePR0M3TH3ANandClaude Opus 4.8 1fd3862a95 [docs] Blanket rule: bitvid hosts nothing but a static client — lock publish decisions
Maintainer blanket rule: bitvid.network runs NO servers / media infra, ever. All
media receiving/routing is self-hosted by the creator (the Bridge run headless) or
handled by an external provider.

live-publish-plan:
- Add the blanket rule as a non-negotiable principle in the Core Decision.
- Lock all 6 decisions: D1 self-host OR external only (managed bitvid node
  REMOVED); D2 signing A-then-NIP46, managed account (D) removed; D3 pass-through;
  D4 HLS-first; D5 Bridge IS the self-host path; D6 keep specific flags.
- Sweep every "managed / public BitVid node" reference: the Media Node is always
  self-hosted (= Bridge headless per DECISION 7) or external; Phase 3 reframed as
  a reference node the CREATOR runs; risks reframed (burden is the self-hoster's,
  not bitvid's). Status: decisions locked.

bitvid-bridge-plan:
- Remove "public Media Node" implications; the Bridge is the self-hosting vehicle.
- Add the reachability/NAT reality: a home-only Bridge can't serve public HLS →
  headless-on-VPS is the path (why DECISION 7 matters).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 13:33:17 -04:00
thePR0M3TH3ANandClaude Opus 4.8 01545056af [docs] Harden Go-Live flag-gating + close review gaps in publish/bridge plans
live-publish-plan:
- Expand the config-flags section into a full enumerated "off = no trace" list:
  every Go-Live surface (Studio/entry points, My Videos Live sub-tab, live-event
  builders, Bridge/session UI, restream UI, archive->VOD flow, routes) is absent
  when FEATURE_LIVE_PUBLISH is off. Bridge/restream have their own sub-flags.
- Clarify watch/publish independence (an instance can allow watching without
  broadcasting).
- New gap closed: WHO may go live — publishing should respect the whitelist/WoT
  (operators can restrict broadcasting to whitelisted creators; don't be an open
  ingest relay).

bitvid-bridge-plan:
- Invariant 6: the Bridge respects the connected instance's config (embedded
  webview inherits FEATURE_LIVE_PUBLISH; go-live degrades gracefully when off).
- Invariant 7: bind local ingest to loopback (127.0.0.1) only; auth required for
  any wider bind. Matching risks added.
- Architecture: Studio webview loads the instance's deployed web app (remote,
  cached) so it auto-inherits instance flags and never drifts; instance URL
  configurable.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 13:27:17 -04:00
thePR0M3TH3ANandClaude Opus 4.8 626ef70d3f [docs] Lock all 10 BitVid Bridge decisions (maintainer accepted every rec)
Locked: Wails/Go (D1, tied to D7 one-codebase-two-modes), embedded Studio webview
(D2), receiver-first MVP (D3), NIP-46 + encrypted-nsec signer (D4), RTMP MVP (D5),
bundled rclone (D6), one codebase headless-or-UI (D7), pass-through only (D8),
project-owned code signing (D9), match-bitvid license + no telemetry (D10). Added
a locked summary table; status now "decisions locked."

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 13:24:00 -04:00
thePR0M3TH3ANandClaude Opus 4.8 a2050ccd50 [docs] Dev plan for the BitVid Bridge cross-platform streaming app
Expands live-publish-plan Phase 7 into docs/bitvid-bridge-plan.md: a super-simple
Windows/macOS/Linux app that makes going live turnkey.

Core design: native media core (MediaMTX + FFmpeg + rclone; never sees the Nostr
key) + an embedded bitvid "Studio" webview that signs — talking over in-app IPC,
NOT browser->localhost (dodges mixed-content/PNA and keeps signing in the web
layer, honoring "BitVid signs; Bridge routes").

Surfaced 10 decisions: framework (Wails/Go vs Tauri), control model (embedded
webview vs website-drives-localhost), capture vs receive, webview signer support
(NIP-46 + encrypted nsec), ingest protocols (RTMP MVP), upload (bundled rclone),
one-codebase-two-modes (Bridge desktop == self-hosted Media Node headless),
pass-through-only transcode, code-signing/notarization ownership (the real cost of
"easy install"), license/telemetry.

Phases 0-7 (shell+core -> receive MVP -> record/VOD -> restream -> turnkey/packaging
-> built-in capture -> SRT/WHIP/WebRTC -> headless node + BitVid Box), invariants,
packaging/signing, risks, sources. Linked from the publish plan.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 13:22:02 -04:00
thePR0M3TH3ANandClaude Opus 4.8 8b98de8ccd [docs] Add P2P/WebTorrent story to the live plans + a BitVid Live advantages section
Answering "can live use WebTorrent?": classic WebTorrent can't carry live (its
infohash needs the complete file), but P2P *live* is real via HLS + peer segment
sharing.

- live-ingest-plan: new "P2P-assisted playback" section — P2P Media Loader
  (hls.js-compatible, historically WebTorrent-tracker-based) shares HLS segments
  over WebRTC to offload origin bandwidth. Gated on real concurrency (DECISION 1's
  whitelist scope makes live sparse), so v1 = plain hls.js, P2P later. VOD keeps
  full WebTorrent.
- live-publish-plan: new "Why BitVid Live — advantages" section (P2P offload,
  full-WebTorrent VODs, Nostr-native/no-lock-in, built-in zaps, BYO storage/relays/
  self-host, one-canonical-stream+mirrors, archive = first-class video, self-
  sovereign identity). Playback section cross-references the P2P offload + VOD
  WebTorrent.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 13:18:49 -04:00
thePR0M3TH3ANandClaude Opus 4.8 18d4d29616 [docs] Dev plan for BitVid Live publishing / "Go Live" (#16c)
Third live/short plan, sequenced after #16 (ingest) + #16b (shorts). Turns the
maintainer's BitVid Live design into a repo-accurate, convention-aligned plan:

- Core decision: Nostr-native control layer, NOT a hosting backend. Static client
  stays static (signs, no keys, no server); the one thing a browser can't do
  (receive/route live media) is isolated in an OPTIONAL server-side Media
  Node/Bridge (MediaMTX). External-provider-only mode (Phase 1) needs zero infra.
- Cross-linked to docs/live-ingest-plan.md (#16): the watch side (Live tab,
  player, chat, zaps, hls.js) is a dependency and reused; this doc adds the
  builders + Studio + Media Node/Bridge. Shared Phase 0 schema in nostrEventSchemas.
- Verified against real code: NIP-98 HTTP-Auth (kind 27235) builder exists for
  bridge auth; s-tag + js/utils/storagePointer.js -> .info.json is exactly how
  archive->VOD maps (ended stream becomes an ordinary kind-30078 video + 34235
  mirror); canonical video kind 30078.
- Decisions surfaced: hosting model, signing-during-stream (tab-open vs NIP-46),
  pass-through vs transcode, WebRTC scope, local Bridge scope, flag reconciliation
  (FEATURE_LIVE_INGEST/PUBLISH/BRIDGE/RESTREAM).
- 9 phases (0 flags+schema → 1 external-provider no-infra → 2 mock bridge → 3
  Media Node MVP → 4 recording/VOD → 5 restream → 6 OBS companion → 7 local
  Bridge → 8 WebRTC), 5 engineering-rule invariants, risks, sources.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 13:04:39 -04:00
thePR0M3TH3ANandClaude Opus 4.8 c5f91ce683 [docs] Review pass on shorts + live plans: fix stale/contradictory bits, close gaps
Shorts:
- Status now "decisions locked"; intro lists the real scope (exclusion + publish).
- Remove stale "candidate signals / confirm in Phase 0" preamble (D4 locked).
- Fix inaccuracy: native shorts are kind-30078 + isShort, not kind-22.
- Moderation section says locked + notes the NSFW no-autoplay rule.
- Rewrite Risks to the locked decisions: main-feed-exclusion regression risk,
  native-marker-must-round-trip risk, WebTorrent-swarm-storm on autoplay, search
  scope sub-decision. Drop the now-inverted double-listing/aspect-ratio risks.

Live:
- Status "decisions locked"; scope reads read→post chat + host zaps.
- Config flags corrected: introduces FEATURE_LIVE_INGEST *and* FEATURE_LIVE_CHAT_POST
  (Phase 3b); "two flags" footprint fixed; chat-post UI gated on both.
- Phase 0 now "confirm field mapping + default relays + zap target" (not "lock
  decisions", already locked).
- Moderation: read-chat WoT-mute filtering + post-chat rate/stance as design items.
- Add risks: high-volume chat buffering, planned/scheduled streams, multiple
  streaming renditions, private/token-gated streams. Zap-target resolution noted.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 12:48:58 -04:00
thePR0M3TH3ANandClaude Opus 4.8 a1eb87873d [docs] Lock all live-ingest plan decisions (1-5)
- D1 whitelisted hosts only (empty-when-nobody-live accepted).
- D2 read + post + zaps as the end state, phased: Phase 3 read chat + host zaps,
  Phase 3b post chat (own sub-flag + moderation stance).
- D3 hls.js, lazy-loaded + vendored behind the flag (native HLS is Safari-only).
- D4 one Live tab: live first, then recent ended (VOD) — also mitigates the
  sparse-live empty-tab concern.
- D5 user read relays + a configurable set of live-discovery relays (widens
  where, not who).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 12:44:23 -04:00