Commit Graph
138 Commits
Author SHA1 Message Date
thePR0M3TH3ANandClaude Fable 5 975b996f4c [nostr-core] Pin the NIP-46 multi-account lifecycle contract (TODO #33)
The 2026-06-25 audit blamed "logout logs out everyone" / broken profile
switching / unknown-user-after-refresh on the single shared nip46Client and
single-slot session storage. Re-auditing the current code shows every named
suspect has since been fixed piecemeal: per-account v2 session map with v1
last-connected default, keepStored logout, scoped no-arg clear (v1 slot only),
reuseStored+expectPubkey switch wired end-to-end into
useStoredRemoteSigner({pubkey}), and a validator-enforced boot restore.

None of that was pinned at the manager level, so any layer could regress
silently. tests/nip46-signer-lifecycle.test.mjs now exercises the real
SignerManager against the real signer registry and real (polyfilled) storage:

- logging out the active NIP-46 account destroys its live client exactly once
  while BOTH accounts' stored sessions and the other account's registry signer
  survive
- a failed targeted restore (passphrase-locked or missing session) fails typed
  and deletes nothing
- disconnectRemoteSigner with and without keepStored never touches the
  per-account map

Mutation-checked: reinjecting the historical bugs (logout without keepStored;
no-arg clear wiping the v2 map) fails 2/6; real code passes 6/6.

TODO #33 updated: remaining work is the on-phone Amber verification the item
already gates on — and if that still misbehaves, the failure is now known to
live ABOVE this contract (UI/state reconciliation on restore failure), which
narrows the search considerably.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LHH3wPBsoruwCaJo8Rpf6a
2026-07-30 11:33:26 -04:00
thePR0M3TH3AN 726c0d26ed Update performance plan progress 2026-07-26 18:41:34 -04:00
thePR0M3TH3AN 2c4aefe872 Document channel follower count scope 2026-07-26 17:59:50 -04:00
thePR0M3TH3AN fe976b3ab7 Harden Nostr startup performance 2026-07-26 17:20:52 -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
thePR0M3TH3ANandClaude Opus 4.8 16aa7557ab docs: add Blossom storage dev plan (#30) — SDK-based, multi-server, flag-gated
Writes docs/blossom-plan.md (house format) from the July 2026 research: use the
maintained blossom-client-sdk v5 (only dep @noble/hashes, already vendored); the
spec's current modular BUD map (BUD-01 retrieval, 02 /upload, 11 auth kind-24242,
12 list/delete, 03 kind-10063 discovery, 04 mirror, 05 /media, 06 preflight);
the existing service.uploadFile seam as the integration point; and 10 decisions
(SDK vs hand-roll, multi-server+mirror, discovery, /upload vs /media, signer
reuse, torrent parity, management, keyless config UI, FEATURE_BLOSSOM_STORAGE,
public-only privacy). Notes Blossom removes the encrypted-cred-sync surface
(no secret — server URLs + signer) and the lift is medium (config UI is the main
cost). Expands TODO #30 with the plan pointer + decisions.

Local commit (kept with the unpushed Bitcoin Connect batch); not pushed.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-09 15:23:42 -04:00
thePR0M3TH3ANandClaude Opus 4.8 6dd40ce330 docs: add Bitcoin Connect (NWC connect UX) integration plan + TODO #61
Requested: integrate getAlby/bitcoin-connect as a smooth NWC "connect wallet"
front-end, keeping manual URI entry as backup. Plan (docs/bitcoin-connect-plan.md)
grounds it in bitvid's existing NWC stack: BC's NWC connector exposes the raw
pairing URI (provider.client.nostrWalletConnectUrl) which feeds the existing
validateWalletUri → per-npub store → zap-split/pay_invoice pipeline unchanged.
BC is a connect adapter, not a payment layer. Covers delivery (vendor+lazy import
vs esm.sh; CSP already allows esm.sh), persistence ownership, NWC-only connector
scope, least-privilege requestMethods, UI placement, FEATURE_BITCOIN_CONNECT flag,
and the security constraints (bearer spending secret, Alpha lib pin/vendor).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-09 14:19:17 -04:00
thePR0M3TH3ANandClaude Opus 4.8 0df1f7e2ad fix(beacon): stop the processing overlay covering the page on load
beacon.html "just shows a spinner": the `data-ds="new"` redesign's
`.ds-overlay-backdrop { display: flex }` (an author rule) overrides the UA
`[hidden] { display: none }`, so the processing overlay — `hidden` in the markup
— rendered on load as a full-screen backdrop over the whole page. (Not the #28
watchdog, which is compiled into the bundle correctly.)

Add `.ds-overlay-backdrop[hidden] { display: none }` (specificity 0,2,0 beats the
base rule) so the `hidden` attribute actually hides it — the same guard the video
modal already uses. No JS change; the JS inline-style toggle still works. The
overlay now only shows while a torrent is being added. Rebuilt CSS + dist.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-09 12:29:14 -04:00
thePR0M3TH3ANandClaude Opus 4.8 3d9f940a8f docs: add audio/music/podcast integration dev plan (#60)
Pulls the audio thread together into docs/audio-integration-plan.md, matching the
house format of the shorts/live plans: kind/NIP landscape (NIP-A0 voice 1222/1244,
NIP-73 podcast GUIDs, NIP-53 live audio, NIP-71 audio variants, native 30078
m audio/*, non-standard music), 10 open decisions (D1–D10) with recommendations,
config flags (FEATURE_AUDIO_INGEST, off = no trace), the shared media-category
resolver (video|audio|live|short) used by sidebar + channel + My Content tabs,
audio player model (modal → persistent mini-player), UX best practices, flag-gated
phases, moderation, V4V/zaps, risks. Links it from TODO #60.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-09 10:09:33 -04:00
thePR0M3TH3ANandClaude Opus 4.8 8a1a5d8214 docs(todo): #60 — correct audio NIP refs, record supported/unsupported kinds
Surveyed the local NIP mirror for audio/podcast kinds. Fixes the earlier wrong
"NIP-54-style podcast events" ref (NIP-54 is Wiki) and records the real targets:
NIP-A0 Voice Messages (kind 1222/1244), NIP-73 External Content IDs (podcast:guid
/ item / publisher — the Podcasting 2.0 bridge), NIP-53 live audio (30311/30312,
overlaps #16), NIP-71 audio variants (imeta m audio/* + waveform), and notes that
the podcasts we filter today are bespoke native kind-30078 with imeta m audio/*,
while music apps (Wavlake/Stemstr) use non-standard custom kinds.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-09 10:04:16 -04:00
thePR0M3TH3ANandClaude Opus 4.8 0375bed068 docs(todo): #60 — "My Videos" → "My Content" with category tabs
Adds the creator-side mirror of the channel tabs: rename the profile-modal
My Videos pane (MyVideosController) to "My Content" with Videos/Audio/Live/Shorts
tabs, each scoping the management rows (edit/delete/mirror/storage) to one
category. Reuses the shared category resolver so items bucket identically on the
owner view and the public channel.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-09 10:01:04 -04:00
thePR0M3TH3ANandClaude Opus 4.8 bae517a4b3 docs(todo): #60 — add channel-profile category tabs (Videos/Audio/Live/Shorts)
Expands the Audio-tab item with a channel-profile tab strip carrying the same
media categories (Videos / Audio / Live-Streams / Shorts), each its own lazily-
loaded per-tab playlist, empty categories hidden, mirrored on the sidebar. Adds a
shared media-category resolver (video|audio|live|short from kind + imeta m + t)
used by both the sidebar feeds and the channel tabs — the natural home for the
#17 unified media grid. Cross-refs #16 (NIP-53 live + kind-22 shorts) / #17.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-09 10:00:08 -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 1a4e53bdb1 docs(todo): add #60 — Audio tab (podcasts/music) + near-term audio-leak guard
Records the future Audio sidebar tab (dedicated <audio>/podcast player) plus the
near-term launch-hygiene fix to stop audio-only kind-30078 notes from rendering
as broken video in the grid. Captures the root cause (convertEventToVideo accepts
audio-only urls; .ogg maps to video/ogg; no <audio> fallback; the ingest-adapter
audio guard doesn't cover the native path) with the real example event, and
cross-refs #16 (live streams) / #17 (unified media grid).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-09 09:04:37 -04:00
thePR0M3TH3ANandClaude Opus 4.8 311d44c8a7 docs(todo): de-stale pre-launch list — mark completed items, record #24 core
Audited the pre-launch TODO against the codebase. Several items were fully
implemented (with DONE/FIXED notes in their bodies and code verified present)
but their section headers never got a completion marker, making the list look
larger than it is. Updated headers for:
  #4 view-counter, #20 feed reliability, #41 native-dialogs, #45 tag
  suggestions, #46 post-publish refresh, #48 fluent profile switching,
  #25 per-event block list, #52 edit-refresh, #53 channel link, #54 like/
  dislike buttons, #34 NIP-71 mirror (dev done; live Amber VERIFY remains).

Recorded #24 (WoT per-author allow-override): core render-time override +
both UI entry points landed this session (a0131c71, 5844a36a); narrowed the
remaining open scope to the deferred NIP-51 sync + import follow-up.

No code changes — documentation only.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-08 17:22:32 -04:00
thePR0M3TH3ANandClaude Opus 4.8 567a12c758 tests(11): make uploadModal-reset zombie-callback test deterministic (correct mock boundary)
The "guard against zombie callbacks" subtest passed its asserts but leaked async
activity after the test ended (node:test flags that as failure). Root cause: it
mocked modal.generateTorrentMetadata / resolveUploadIdentifier, but
handleVideoSelection drives the upload through this.mediaUploader.uploadVideo —
a DIFFERENT object with its own helpers — so the mediaUploader still ran real
createTorrentMetadata()/hashing on a synthetic {name:"video.mp4"} file ("invalid
input type" async) and captureVideoMetadata() ran URL.createObjectURL(file)
("must be an instance of Blob"). Not intermittent — a consistent wrong-boundary mock.

Fix: mock at the real DI boundary — modal.mediaUploader.uploadVideo = () =>
delayedUpload (controllable pending promise) — and stub captureVideoMetadata.
Hermetic, deterministic (3/3 exit 0, # pass 3). Mutation-verified: breaking the
videoUploadId zombie guard makes the test fail. Production code untouched. See
TEST_INTEGRITY.md.

NOTE: the runner is fail-fast, so the full suite surfaces pre-existing failures one
at a time. tests/unit/hashChangeHandler.test.mjs is next (consistent AssertionError
on "loads default view if hash is empty" — likely the same stale-default bug the
sibling tests/app/hash-change-handler.test.mjs already had when TRENDING became the
logged-out default). Enumerating full scope next.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-05 11:29:36 -04:00
thePR0M3TH3ANandClaude Opus 4.8 a326d04330 tests(11b): un-quarantine dm-block-filter — hermetic block-list subscription; QUARANTINE now empty
Fully closes #11b's un-quarantine work: the QUARANTINE map is now EMPTY.

dm-block-filter.test.mjs (node:test) PASSED all subtests then hung, so the
node:test runner never exited. Diagnosed via process.getActiveResourcesInfo():
the "integration with loaded user block list" test's manager.loadBlocks() opens a
LIVE block-list subscription through nostrClient's real SimplePool aimed at the
test's fake relay URLs, leaking 4 TCP sockets + reconnect timers. It is NOT the
decrypt-retry timer (verified absent). Nulling nostrClient.pool did not help —
loadBlocks calls ensurePool() (userBlocks.js:967) to re-create+connect a real
pool when pool is falsy.

Fix (node:test-SAFE, no force-exit): make the test hermetic — (1) a TRUTHY stub
pool so ensurePool() is skipped, (2) a no-op getSubscriptionManager so
startBlockListSubscription opens nothing. The block-merge logic under test still
runs fully through the already-stubbed fetchListIncrementally. Restored in finally.
Verified BOTH directions: a clean run exits 0 (# pass 12), AND a deliberately
broken assertion still exits 1 (# fail 1) — failures are NOT masked. A force
process.exit in an after() hook was rejected precisely because it masked failures
(process.exitCode isn't set when the hook fires). See TEST_INTEGRITY.md.

NOTE: the full suite still has one SEPARATE, pre-existing red unrelated to #11b —
tests/ui/uploadModal-reset.test.mjs (TODO #11): its "zombie callbacks" subtest
feeds a plain object where webtorrent expects a Blob ("must be an instance of
Blob") and leaks async activity after the test ends. Untouched here; tracked
under #11 (harden flaky tests), not the #11b quarantine work.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-05 11:03:03 -04:00
thePR0M3TH3ANandClaude Opus 4.8 d6ac9429c1 tests(11b): un-quarantine user-blocks + nwc-client + nostr-publish-rejection; quarantine dm-block-filter
Closes the #11b un-quarantine work: the three target files now pass and run in
CI. A full-suite run with a per-file timeout then surfaced a FOURTH, pre-existing
hang that had never been in the map (dm-block-filter) — quarantined with a triage
note rather than left to hang CI.

user-blocks.test.mjs — post-completion HANG. The decrypt-timeout section installs
a never-resolving nip44Decrypt whose background decrypt-retry timer outlives the
run (manager.reset() races it). Added the repo's established bare-assert
`setTimeout(() => process.exit(0), 50)` exit after the final assertion.

nostr-publish-rejection.test.mjs — post-completion HANG (was NOT in the map, so it
had been silently hanging local `npm run test:unit`, which has no per-file timeout
unless UNIT_TEST_TIMEOUT_MS is set). All assertions pass, then the imported app
stack (app.js + subscriptions + userBlocks) keeps the loop alive. Same
`process.exit(0)`-after-cleanup fix. (The dev-mode + NIP-71 spec fixes were
already in the file.)

nwc-client.test.mjs — (1) mock shadow: readToolkitFromScope prefers the bootstrap's
frozen canonical toolkit over window.NostrTools, so per-section mocks never took
effect and real @noble rejected the fake keys. Remove the scope canonical so the
resolver falls through to each mock. (2) spec correction: one section expected a
rejection when the toolkit "lacks nip44", but mergeWithCanonical ALWAYS backfills
the canonical nip44 (even explicit `nip44:null`) — nip44_v2 is never absent. The
wallet now advertises a genuinely-unsupported scheme (nip44_v3) to exercise the
same UNSUPPORTED_ENCRYPTION path, and asserts error.code too. See TEST_INTEGRITY.md.

dm-block-filter.test.mjs — QUARANTINED (pre-existing, node:test). All subtests pass,
then it hangs: manager.loadBlocks() opens a live block-list subscription through
nostrClient's real SimplePool pointed at the test's fake relay URLs, leaking TCP
sockets + reconnect timers that node:test can't drain. Needs a node:test-safe fix
(inject a mock pool so no real socket opens) — a force process.exit in after() is
UNSAFE (verified: process.exitCode isn't set yet, so it masks failures).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-05 10:45:55 -04:00
thePR0M3TH3ANandClaude Fable 5 ce4904f1a4 docs: add dual-line popularity chart (views + zaps over time) to the zap plan
Extends docs/zap-tally-plan.md §5.9: a second orange zaps-over-time line on the
per-video Popularity chart beside the existing red views line, a legend
(red=Views / orange=Zaps sats), and VISIBLE date labels (the current chart only
puts the range in the SVG aria-label). Reuses the --color-zap token from the
badges, buckets cumulative sats from 9735 + verified tally events (deduped by
payment_hash, same rule as the store), and can partially ship on 9735-only data
before the tally event lands. Adds checklist items, a chart Y-axis open
decision (D5), test targets, and updates goals/scope + TODO #47.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-04 15:27:47 -04:00
thePR0M3TH3ANandClaude Fable 5 ae7cbf22ba docs: dev plan for bitvid-native, preimage-verified zap tally (#47)
Full spec for making Most Zapped + zap badges global/durable/cross-user
without depending on external wallets to publish NIP-57 receipts (which
custodial providers like Strike don't). Key insight: the payer holds the
preimage after NWC pay_invoice, and the invoice's description hash commits
to the zap request's pointer+amount — so a payer-signed event carrying
{zapRequest, bolt11, preimage} is verifiable with the same cryptographic
strength as a recipient-signed 9735, reusing bitvid's existing bolt11 decode
in zapReceiptValidator.js.

docs/zap-tally-plan.md covers: the event schema, the verify algorithm,
cross-source dedup by payment_hash (9735-preferred), file-by-file code
changes, security/edge cases, a feature-flagged rollout, a test plan, open
decisions, and an ordered task checklist so the build stays neat across
sessions. TODO #47 points at it. No code changes yet.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-04 15:13:34 -04:00
thePR0M3TH3ANandClaude Fable 5 86d7db4b1a cards: orange right-aligned zap-total badge next to view counts (#47 follow-up)
Video cards show the total sats from the shared zapTotals cache: new
--color-zap token (bitcoin orange, both themes), badge pushed to the
engagement row's end so views/comments stay left-aligned, hidden until a
nonzero total is known. The list view's zap binder fills [data-zap-total]
on bind (also scheduling the batched receipt fetch) and one shared change
listener updates every bound card as receipt batches land.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 21:49:35 -04:00
thePR0M3TH3ANandClaude Fable 5 8dfd2034b0 feeds: "Most Zapped" sidebar tab ranked by zap totals (#47)
Trending-pattern clone ranked by sats from kind-9735 receipts:

- js/zapTotals.js: per-pointer receipt aggregation. The feed's metric getter
  returns the cached total and schedules a batched fetch (#a/#e chunks via
  the L1 SubscriptionManager) for unknown/stale pointers; amounts parse via
  nip57 bolt11 with the zap request's amount tag as fallback; receipts are
  deduped by id so re-fetches never double-count; zero-receipt answers are
  cached (2-min TTL).
- sorters.js: shared count-ranked sorter (metric desc, recency tie-break,
  author spread, trusted-muted sink); Trending delegates unchanged, Most
  Zapped ranks on runtime.getZapTotal.
- Full wiring: feed definition, view + info popover, change-signal re-rank
  (order settles as receipts stream in), sidebar link, viewManager registry,
  feedCoordinator case/loader, FEED_TYPES.MOST_ZAPPED, and a
  FEATURE_MOST_ZAPPED_FEED flag (default on; off hides the tab).

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 21:38:53 -04:00
thePR0M3TH3ANandClaude Fable 5 35a50611fe upload: in-modal "Upload destination" storage picker (#44)
The storage summary gains a connection <select> (hidden with fewer than two
connections) listing every configured connection with the default labelled.
Selection is a per-modal override — loadFromStorage re-resolves the summary
and credentials immediately, stale selections fall back to the default, and
the account default stays untouched. Picker logic lives in
uploadModalStorageUnlock.js for UploadModal's file-size budget.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 21:24:13 -04:00
thePR0M3TH3ANandClaude Fable 5 27be0301c5 todo: #10 bounded investigation — null.fill is a streamx/fast-fifo use-after-destroy; fix is a vendored 3.0.16 update as its own soaked task
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 21:04:29 -04:00
thePR0M3TH3ANandClaude Fable 5 096fe12c19 nostr: dev-gated REQ tracer at the pool.sub choke point (#9)
The cold-login REQ storm's remaining emitters are unpinned (the suspected
per-contact list loops don't exist; mutes are already batched). Every
sub/list flows through the shimmed pool.sub, so recordReq() there aggregates
kinds + relay fan-out + emitting call site per 10s window. Zero-cost when
off; arm with localStorage bitvid:reqTrace=1 (auto in dev mode), read
[req-trace] logs or window.__bitvidReqTrace.report().

One traced cold login on unstable will name the storm's sources; the
batching/deferral pass follows from that data.

Tests: tests/req-telemetry.test.mjs (3).

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 21:02:27 -04:00
thePR0M3TH3ANandClaude Fable 5 99d1b1dd36 tests: spec-correct four stale tests hidden by piped exit codes
A per-file sweep (local suite runs were reading the PIPE's exit code, not
npm's) surfaced four tests failing on HEAD:

- hash-change-handler: logged-out default is deliberately TRENDING since
  04621efc; test still expected most-recent-videos
- session-actor / sessionActor / nsec-login: encoded the pre-fix "no-arg
  clearStoredSessionActor wipes EVERY account" behavior that 2026-07-02
  deliberately removed (one account's logout must not destroy other saved
  accounts' keys). Replaced with stronger assertions: multi-account survival
  on no-arg clear, targeted forget, and the blocked-key security property
  asserted on the blocked account specifically.

Integrity notes in each file; TODO #11b addendum documents the pipe lesson.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 21:01:45 -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 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 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 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 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 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 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 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 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 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 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 163f7e11d5 [docs] Dev plans for live-stream ingest (#16) and shorts (#16b)
Two flag-gated, watch-only content types, each spec'd following the repo's
docs/*-plan.md convention with open decisions embedded as DECISION callouts.

- docs/shorts-plan.md (#16b): NIP-71 kind 22. Ingest already exists
  (nip71IngestAdapter handles kind 22), so this is an isShort marker + a Shorts
  sidebar tab + vertical UI + a shorts feed source. Behind FEATURE_SHORTS.
- docs/live-ingest-plan.md (#16): NIP-53 kind 30311 (+1311 chat), HLS playback.
  Leads with a Phase 0 research spike (confirm tag shapes from zap.stream/
  shosho.live, de-risk hls.js + CORS). Behind FEATURE_LIVE_INGEST; publishing
  (#16c) is explicitly out of scope with its own future flag.

Both docs specify the "off = no trace" wiring (no tab, no feed registration, no
subscriptions, no route, no lazy assets) per the locked shared requirement.
TODO #16/#16b now link their plans.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 11:10:38 -04:00
thePR0M3TH3ANandClaude Opus 4.8 d216ccb003 [docs] Mark #25 per-event blocklist admin-UI follow-up done
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 10:56:57 -04:00
thePR0M3TH3ANandClaude Opus 4.8 61e4b25de7 [feed] Auto-refresh + optimistic insert after publishing a video (#46)
After a successful publish, the new video now appears in the feed instantly
without a manual page reload.

- Add nostrClient.ingestLocalVideoEvent(rawEvent) + a thin nostrService wrapper:
  optimistically insert the just-published event into the shared active-video
  cache. Mirrors live ingestion (convert -> root created_at -> tombstone guard
  -> allEvents/activeMap), so it obeys the same dedupe/active-key rules — a
  later relay echo of the same event won't duplicate it and an older revision
  can't overwrite a newer active entry (#20/#21).
- app.publishVideoNote: inject publishResult.legacy, then refresh via the
  canonical refreshAllVideoGrids({ reason: "video-published", forceMainReload:
  true }) instead of a bare loadVideos() — refreshes whichever feed is active
  (For You / Explore / Recent) plus subscription/channel grids and forces a
  render (bypassing the load cooldown) so the injected video shows.

Tests: tests/optimistic-feed-insert.test.mjs (6).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-30 22:49:02 -04:00
thePR0M3TH3ANandClaude Opus 4.8 95fe88784a [nostr-core] Per-account NIP-46 session storage for multi-account switching (#48)
Mirror the per-account nsec key store for remote-signer sessions: move from a
single slot (bitvid:nip46:session:v1) to a per-user-pubkey map
(bitvid:nip46:sessions:v2) so several saved NIP-46 accounts each keep their own
reconnectable session and the profile switcher can move between them.
Previously only the last-connected session survived, so switching among saved
NIP-46 accounts reconnected the wrong one (or failed).

- nip46Client.js: new per-pubkey session map keyed by userPubkey.
  readStoredNip46Session takes an optional target pubkey; writeStoredNip46-
  SessionSync writes both the map and the v1 last-connected default;
  clearStoredNip46Session(pubkey) forgets one account (no-arg wipes all). Legacy
  v1 slot migrated into the map on first read. Adds listStoredNip46SessionPubkeys().
- SignerManager: useStoredRemoteSigner({ pubkey }) selects the target account's
  session and, on access-denial, forgets only that account. getStoredNip46-
  Metadata(pubkey) is per-account.
- nip46 provider threads expectPubkey into the reuse call so a switch reconnects
  the requested account, with the expectPubkey guard as a backstop.

nip46Connector.js is dead code; the live path is entirely in SignerManager.
Tests: tests/nip46-multi-account.test.mjs (7).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-30 22:40:22 -04:00
thePR0M3TH3ANandClaude Opus 4.8 8692dac671 [nostr-core] Per-account nsec key storage for multi-account switching (#48)
Move the encrypted-key store from a single slot to a per-pubkey map so
several saved nsec accounts each keep their own key on the device and the
profile switcher can move between them. Previously bitvid:sessionActor:v1
held one key (last-saved wins), so switching among saved nsec accounts
silently failed.

- sessionActor.js: new bitvid:sessionActors:v2 map keyed by pubkey (follows
  the existing bitvid:<thing>:<npub> convention). readStoredSessionActorEntry,
  persistSessionActor, clearStoredSessionActor take an optional target pubkey;
  legacy v1 slot is kept in sync as the last-saved default and migrated into
  the map on first read. IndexedDB mirror keyed per-pubkey. Adds
  listStoredSessionActorPubkeys().
- client.js: getStoredSessionActorMetadata / unlockStoredSessionActor accept a
  target pubkey; access-denial cleanup forgets only the blocked account.
- nsec provider threads expectPubkey into the unlock so a switch unlocks the
  requested account, with the expectPubkey guard as a backstop.
- authSessionCoordinator: look up the target account's stored-key metadata.

NIP-46 multi-account (single slot bitvid:nip46:session:v1) remains as a
follow-up. Tests: tests/session-actor-multi-account.test.mjs (7).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-30 22:33:03 -04:00
thePR0M3TH3ANandClaude Opus 4.8 abdd11b18c [auth] #48: NIP-46 switch reconnects stored session + reject wrong-account switches
Completes fluent switching across signing methods (NIP-07 / nsec / NIP-46):

- NIP-46 switching: it had the same bug — switchProfile called the provider with no
  reuseStored, so it started a fresh handshake (needs a new connect URI/QR) instead of
  reconnecting. Now handleProfileSwitchRequest sends { providerId: "nip46",
  reuseStored: true } for NIP-46 targets; authService.switchProfile forwards reuseStored;
  the nip46 provider's useStoredRemoteSigner reconnects the stored session and activates
  the account. On failure -> a clear "reconnect your remote signer" toast.

- Wrong-account guard (all methods): requestLogin now enforces expectPubkey (only set by
  switchProfile). A single-slot stored nsec/NIP-46 session resolving to a DIFFERENT
  account than requested is rejected with `pubkey-mismatch` instead of silently switching
  to the wrong profile. Also protects NIP-07 (switching to an account the extension isn't
  currently on fails clearly instead of grabbing the extension's active account).

Tests: tests/authService.test.mjs (+3 — unlockStored/passphrase + reuseStored forwarding,
expectPubkey rejection). Build + lint clean; auth suites green.

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-30 22:11:43 -04:00
thePR0M3TH3ANandClaude Opus 4.8 577e5d27c5 [docs] add TODOs #45-#48 (tag suggestions, feed auto-refresh, Most Zapped tab, fluent profile switching)
Per maintainer: #45 upload-modal tag suggestions from the user's previous videos;
#46 auto-refresh the feed after posting a video; #47 "Most Zapped" sidebar tab
(trending-by-zaps); #48 fluent profile switching across signing methods.

#48 verified NOT already done: switching to a saved nsec profile throws "secret-required"
(switchProfile -> requestLogin -> nsec provider.login with no passphrase) and silently
fails — it never prompts for the PIN. Same root gap as #36 but in the switch path;
NIP-46 likely needs an analogous reconnect on switch.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-30 22:00:15 -04:00