Files
bitvid/todo/TODO_2026-06-20_pre-launch.md
T
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

1865 lines
137 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# TODO — Pre-launch fixes & audits (2026-06-20)
Consolidated pre-launch backlog. Branch: `unstable` (promote down to beta/main later).
Cadence: audit → small separately-committed fixes → cheat-resistant (mutation-verified)
tests → `npm run build` + `npm run test:unit` green → commit + push.
## Done this session (for context — don't redo)
- [x] Background watch-history preload after login — `512b6c3f`
- [x] Multi-webseed support (upload + edit management)
- [x] 10/10 file-size decompositions (real extraction, not baseline bumps)
- [x] WebTorrent feed-scroll freeze fix (probe concurrency 96→4, maxConns caps) — `126ac75a`
- [x] "No active signer" on storage unlock after refresh — `0031d68b` / `f383cde4`
- [x] Relay removal routing fix (`mainController.runRelayOperation`) — `95071fd1`
- [x] Publish array-return fix (nostr-tools 2.17 per-relay promises; no uncaught-rejection flood) — `6984fb2e`
- [x] Empty-thumbnail-on-publish fix — `f218ec32`
- [x] Relay list tab: live/dead status pills + insecure-relay hints
- [x] Edit modal upload parity (replace video + thumbnail file) — `c8a63c57`
- [x] Edit: clean up superseded thumbnail (orphan removal) — `3aef97f0`
- [x] **Feed never blank**: always reserve live default relays in the feed set — `de515462` (+ spec test `6b63625d`)
- [x] **Publish feedback**: "published to N/M relays" outcome toast — `3f4299a8`
- [x] **Delete safety**: warn when a hosted file is left behind by locked storage — `f0eecec0`
- [x] **Video delete propagation**: tombstones publish to write relays — `afb6200b`
- [x] **Watch-history delete**: replace (not merge) semantics — `5f19f536`
- [x] **My Videos tab** (Phase 1 + 2): per-video health (no-source/dead/deleted),
thumbnails, Edit/Delete, bucket reconciliation (missing-file + orphan cleanup)
— `8cb03d23`…`9d3a0df0`
- [x] **CI**: visual-regression moved to its own job so it no longer blocks deploy — `73a06069`
- [x] **Vercel**: disable auto-deploy for `unstable`; deploy on demand via
`vercel deploy --prod` — `ba1f492f`
## Open — high priority (launch-blocking candidates)
### 33. Login / profile / session persistence broken with NIP-46 (Amber) — AUDIT (BUG)
Reported 2026-06-25. All symptoms observed with a **NIP-46 remote signer (Amber)**.
Likely a shared root cause in session/identity persistence + multi-account handling.
- [ ] **Lost login → "unknown user".** Sometimes the site loses who is logged in: the
profile shows "unknown user", the profile area renders weird, and no videos load.
Only a **logout + hard refresh** recovers it. (Session/identity state desync —
possibly the NIP-46 signer connection or the persisted pubkey dropping after a
refresh/idle, leaving the UI without a resolved identity.)
- [ ] **Switching profiles doesn't work** for logged-in users (multi-account switch is
broken).
- [ ] **Logout logs out everyone.** Logging out the current user appears to log out
**every** signed-in account, not just the active one (shared/global session state
instead of per-account).
- [ ] **Audit the whole login + profile system + user persistence** across refresh and
repeat visits — especially the NIP-46 path: how the active pubkey + signer
connection are stored/restored, how multiple accounts are tracked, and how
logout scopes to one account. Produce a plan (likely `docs/`), then fix.
- [x] **AUDIT done 2026-06-25 (code-level).** Architecture:
`js/state/cache.js` persists a list of **saved profiles** (`bitvid:savedProfiles:v1`)
+ a single **active profile pubkey**. There is ONE active session at a time.
`SignerManager` (`js/nostr/managers/SignerManager.js`) holds a SINGLE shared
`this.nip46Client` (one remote-signer connection). Likely root cause for ALL three
symptoms: the single-shared `nip46Client` + per-profile restore/switch not being
multi-account-aware. Specifics:
- **Logout-logs-out-everyone:** `authService.logout()` IS registry-scoped
(`logoutSignerFromRegistry(previousPubkey)`), but `SignerManager.logout()`
calls `disconnectRemoteSigner()` which destroys the SHARED `nip46Client` — so
for NIP-46 every profile that depends on it loses its signer.
- **Switch-doesn't-work:** `authService.switchProfile` re-runs `requestLogin`
with `expectPubkey`; for NIP-46 the shared client must be re-established for the
target profile — if it doesn't reconnect, the new profile has no working signer.
- **Unknown-user-after-refresh:** `scheduleStoredRemoteSignerRestore` →
`useStoredRemoteSigner` silently fails (the catch logged via the gated
`devLogger`), leaving `this.pubkey` unresolved → UI shows "unknown user".
- [x] **Diagnostics SHIPPED (2026-06-25, TEMP).** Added `[nip46-diag]` `userLogger`
traces (visible on unstable, where `devLogger` is gated off) at:
`scheduleStoredRemoteSignerRestore` (hasStored/storedPubkey + succeeded/FAILED),
`disconnectRemoteSigner` (keepStored/hadClient + caller stack),
`SignerManager.logout` (previousPubkey/hadRemoteClient), and
`authService.switchProfile` (from/to + whether requestLogin resolved the expected
pubkey). **Remove these once the fix lands.**
- [ ] **VERIFY / REPRODUCE (user action):** on the phone with Amber on
unstable.bitvid.network, exercise (1) fresh load, (2) switch profiles, (3) logout,
and capture the `[nip46-diag]` console lines for each — restore succeeded/FAILED,
switchProfile `matchesTarget`, and disconnect `keepStored`/`hadClient`/caller stack.
- [ ] **THEN fix** based on the captured trace (likely: per-profile NIP-46 session keyed
by pubkey; don't tear the shared client on a scoped logout; reconnect on
switch/restore) and REMOVE the temporary `[nip46-diag]` diagnostics.
### 1. Delete is not fully working — tombstoned videos still show in the UI
- [x] **Root cause found + fixed** (`afb6200b`): deletes published only to the CAPPED
read set (<=8) while videos publish to the full write set, so relays outside
the subset kept serving the original → resurrection. Now both delete paths
(soft-delete tombstone + NIP-09) use `getDeletePublishRelays()` (write set).
Tombstone created_at was already bumped strictly-newer, so this closes the gap.
- [x] **getActiveKey mismatch — ROOT-CAUSED + FIXED (2026-06-23).** `js/nostr/client.js`
carried a STALE local duplicate of `getActiveKey` (added before the LEGACY guard
landed in `js/nostr/utils.js` at `1b11cb1b`). The tombstone machinery
(`recordTombstone`/`isOlderThanTombstone`/`applyTombstoneGuard`) used the local
copy, so a deleted legacy video (synthesized `videoRootId = LEGACY:<pubkey>:<dTag>`)
keyed its tombstone as `ROOT:LEGACY:…` while a bare zombie from a relay keyed as
`<pubkey>:<dTag>` → guard never matched → resurrection. Fix: client.js now imports
the single canonical `getActiveKey` from utils.js (duplicate deleted). Regression
test added in `tests/nostr/client.test.mjs` ("tombstone guard (legacy zombie
suppression)") — in the CI suite.
- [x] **Cache / optimistic-reload angle — audited, OK.** `js/nostr/videoEventBuffer.js`
checks tombstones + `deleted` on every buffered event (cache-first reload honors
deletions; lines ~89, 156–172).
- [x] **Mirror-resurrection angle — audited + GAP FIXED (2026-06-23).**
`js/services/nip71MirrorSync.js` listens on `videos:deleted` →
`nip71MirrorService.remove()` (NIP-09 on BOTH addressable kinds 34235/34236 +
empty-replace tombstone). Legacy auto-21/22 publisher stays dormant.
GAP: teardown was gated on the per-video opt-in flag, which is **browser-local
only** (`bitvid:nip71-mirror:v1`). A video shared on one device and deleted
from another (or after a cache clear) skipped teardown → the NIP-71 mirror
orphaned on other apps. Fixed in `resolveDeleteSync` (`js/services/
nip71MirrorFlags.js`): delete now ALWAYS attempts teardown when
FEATURE_NIP71_MIRROR is on, ignoring the local flag (remove() is idempotent —
NIP-09 + empty tombstone are no-ops if no mirror exists). The flag still gates
the UI toggle; the feature flag still fully gates publishing. Spec-corrected
unit test + sync-level spy tests (cross-device teardown + best-effort no-throw)
in `tests/nip71-mirror-flags.test.mjs` + `tests/nip71-mirror-sync.test.mjs`.
- [ ] **Verify with the user** that zombies are gone on the live site after these fixes
(the getActiveKey fix needs `npm run build` + redeploy to unstable to take effect).
- [ ] (Nice-to-have) Add a feed/view-level seed→delete→assert-not-rendered test now that
the client-level guard + buffer tombstone paths are covered by unit tests.
### 2. Watch history delete does not work at all
- [x] **Root cause found + fixed** (`5f19f536`): the removal path called
`watchHistoryService.snapshot(remaining, …)` but snapshot published WITHOUT
`replace:true`, and the default merges incoming items with the cached list —
so the removed item was merged straight back (no effect). Removing the last
item fell through to republishing the pending queue instead of clearing.
Fixed: snapshot honors `replace` (incl. empty = clear), both removal call
sites pass `replace:true`. Mutation-verified.
- [x] Confirmed the NIP-51 list publish already uses the WRITE relays
(`publishRecords` → `getWriteRelays`), so it does NOT have the #1 relay-scope
bug. The replace-semantics fix is the complete fix.
- [x] **Second root cause found + fixed** (`fff2bef2`): reads UNION items across all
events relays return, and a publish only reports ok when ALL relays accept. With
a flaky relay list the reduced event reached some relays while others kept the
old copy; the reader deduped by event id, so stale+fresh versions of the same
month (d-tag) were unioned → removed items resurrected. Now
dedupeNewestPerReplaceableAddress keeps only the newest event per d-tag at both
read union sites. Live on unstable.bitvid.network (module verified served).
- [x] **Third gap fixed** (`a3799f3f`): clearing the LAST item of a month (or
clearing all) didn't propagate — `publishMonthRecord` early-returned on an
empty month, so the clearing event updateWatchHistoryList intended was never
published. Now an empty month publishes a newest EMPTY replaceable event for
its d-tag, so the clear takes effect (and reads see it as empty). Live.
- [x] **THE actual bug for the profile pane** (`4c6415c2`): the profile modal's
history renderer overrode `remove` to route through onHistoryReady →
app.handleProfileHistoryEvent(), a no-op stub (`return null`) left from the
synced-vs-local refactor. So profile-pane deletes published NOTHING (card
vanished optimistically, reappeared on refresh) — which is why the earlier
three fixes (publish/read layer) never helped there. Removed the override so
it uses the working defaultRemoveHandler. Live.
- [x] **Logged-out / local-only delete** (`2f95d448`): snapshot no-op'd for
local-only actors, so removing from the local queue did nothing. A replace
snapshot now rewrites the local queue (replaceLocalQueue). Live.
- [x] **All-relays false-failure fixed** (`565a9618`): publishMonthRecord reported
success only when EVERY write relay accepted, so on a 20+ relay list every
snapshot was "partial-relay-acceptance" -> ok:false -> the logged-in remove
threw "watch-history-snapshot-failed" (even though major relays accepted) AND
new watches never persisted. Now success = acceptedCount > 0. Live.
- [ ] **Verify with the user**: logged-IN profile-pane delete + clear, AND
logged-OUT local delete + clear, all stick across reload; AND that newly
watched videos now appear in history (was blocked by the same all-relays bug).
- [ ] Backfill regression tests: a profile-pane wiring test (remove not routed to a
no-op) — the existing profile-modal-controller test file has an unusual
wrapper, so add carefully — plus a feed/view-level seed→delete→assert test.
(Local + publish/read layers are already unit+mutation tested.)
- [x] **"1970-01" month bucketing FIXED (2026-06-23).** Legacy/migrated history items
with no real `watchedAt` were siloed into a literal `1970-01` month event
(kind 30079) that self-perpetuated (read back as 0 → re-bucketed → re-published
with 0). `canonicalizeWatchHistoryItems` now derives a STABLE watch time from
the pointer d-tag's embedded ms-timestamp (the video creation time) and
backfills it, so those items land in a real month and the 0→1970 loop stops.
Only truly undecodable pointers still use the epoch bucket. Helper
`deriveWatchedAtFromPointer` + `tests/watch-history-bucketing.test.mjs`. NOTE:
the old `1970-01` event already on relays is left as harmless stale data (not
recreated); actively clearing it was declined as extra surgery.
### 3a. Video-modal popover mis-positioning (zap) — FIXED 2026-06-23
The zap popover (`#modalZapDialog`) opened functional but anchored to the modal
edge instead of `bottom-end` under its trigger.
- [x] **Root cause confirmed + fixed** in the shared engine
(`js/ui/overlay/popoverEngine.js`). The zap render fn returns the PRE-EXISTING
in-modal `#modalZapDialog` verbatim (`() => this.modalZapDialog`,
`zapController.js`). The engine only appended panels to the body-level portal
`if (!panel.isConnected)`, so this already-connected panel was never moved
out; its `position: fixed` then resolved against a transformed/contained modal
ancestor → edge anchoring. Fix: the engine now `relocatePanelToPortal()` on
open (records the panel's origin: parent + nextSibling, moves it into the
`#uiOverlay` portal) and `restorePanelToOrigin()` on close (and via destroy),
so the host DOM stays intact and re-open works. Fresh portal-owned panels
(card "⋯" menu, share/more menus, feed-settings gear) are unaffected — they're
already appended to the portal, so relocate is a no-op for them.
Style-safe: `#modalZapDialog` uses the same `popover__panel card …` classes the
already-portaled menus use; no `.modal`-descendant CSS. Regression test in
`tests/ui/popoverEngine.test.mjs` ("relocates a pre-existing in-host panel …");
all 9 engine tests + the menu-controller tests pass; build green.
- [ ] **Verify with the user** on the live site after deploy that the zap popover
now opens `bottom-end` under the Zap button (and re-opens correctly).
- NOTE: the "embed" in the original report is a separate full modal
(`components/embed-video-modal.html`, `#embedVideoModal`), NOT an anchored
popover, so it isn't affected by this engine bug. The share/copy menu uses a
fresh portaled panel and already positions correctly. If any other pre-existing
in-modal panel is found mis-positioned, this same engine fix now covers it.
### 3. Zaps system + platform-fee zap split — DONE 2026-06-24 (full plan in docs/zap-audit-plan.md)
Audit started 2026-06-23. See the doc for architecture map + findings. Summary:
- [x] **Send-error softened** (`dd1ce113`): recipient LNURL unreachable/CORS now
shows a clear message, not raw "Failed to fetch".
- [x] **NWC connection bug FIXED (2026-06-23, launch-blocker)** — `parseNwcUri`
passed a hex secret to nostr-tools `getPublicKey` (needs bytes) → NWC connect
threw `expected Uint8Array` and failed entirely. Now hex→bytes. Guarded by
`tests/nwc-parse-uri.test.mjs`. **This likely explains a lot of the "errors on
send".** VERIFY live: connect a wallet + send a zap end-to-end.
- [x] **Popover positioning (3a)** — FIXED (see 3a).
- [x] **In-flight status + form-reset polish (2026-06-23)** — "Sending…" no longer
mis-toned as a warning; success path fully resets the form (`resetZapForm`).
- [x] **"Have to click the Zap button ~3-4 times before it opens" — ROOT-CAUSED +
FIXED (2026-06-23).** Diagnosed with click instrumentation: the button is
intentionally `hidden`+`disabled` until the creator's Lightning address resolves
(a profile fetch, ~2s) — but `.video-modal__action-button` sets
`display: inline-flex`, which OVERRODE the UA `[hidden] { display:none }`, so the
button rendered and looked clickable while still a disabled no-op. Disabled
buttons don't fire `click`, so the first clicks did nothing until it flipped
enabled. Fix: `.video-modal__action-button[hidden] { display:none }`
(`css/tailwind.source.css`) so the zap button only appears once it's
enabled/ready (restores the intended "show zap only when zappable" design). The
earlier in-flight-open toggle tweak was harmless but NOT the cause. VERIFY live:
the zap button appears a moment after the modal loads and opens on first click.
FOLLOW-UP (optional UX): the ~2s appear-delay is the creator-profile fetch; could
show the button immediately and resolve the address lazily if the delay annoys.
- [x] **Receipt success-reporting + discovery FIXED (2026-06-23)** — a paid zap whose
kind-9735 receipt couldn't be validated was shown as a red error; now it's a
success-with-note (the NWC preimage proves payment). Receipt lookup now queries
the reliably-indexed `#e/#a/#p` tags (not the unindexed `#bolt11`) and polls a
few times for a late receipt. See docs/zap-audit-plan.md.
- [x] **Platform-fee fallback verified no-bypass** — junk override falls back to the
configured fee, not 0.
- [x] **CORS / LNURL — DECIDED 2026-06-24: accept graceful degradation, no proxy.**
Keep the visual zappability flag (`walletZappabilityCheck` /
`lnurl-unreachable`) as the honest fallback when a recipient's LNURL host
sends no CORS headers. Rationale:
- Browser-enforced CORS can't be bypassed from page JS; the only universal
fix is a proxy, which **breaks the static/no-custody rule** AND adds a
real security surface (the proxy can swap the returned bolt11 → steal the
zap unless it verifies `description_hash`). Not worth it for a static app.
- The fee/receipt model is **fundamentally incompatible** with a CORS-blocked
recipient in a static client: the platform-fee split needs to fetch the
creator's invoice in-page, which is exactly what CORS blocks.
- WebLN was evaluated and rejected: its only CORS-bypassing primitive
(`webln.lnurl()`) **pays directly** — no NIP-57 receipt, no split, no
deterministic amount — so it degrades a zap into an unattributed tip and
bypasses the platform fee. Not acceptable as a silent fallback.
Net: the current behavior is the right answer for a static, no-custody client.
- [x] **Remaining audit items — DONE 2026-06-24. #3 fully closed.**
- **Split correctness (`js/payments/zapSplit.js`):** `platformShare =
floor(amount*fee/100)`, `creatorShare = amount - platformShare` — the
creator always gets the remainder, so **no sats are lost or invented**; a
sub-1-sat fee rounds to 0 and the whole zap goes to the creator (no
zero-amount platform send). Fee is `clampPercent`-bounded to 0–100 and a
junk override falls back to the configured default (can't be bypassed or
swallow the zap). **Fixed the creator==platform edge:** when the platform
fee would pay the *same* Lightning address as the creator, the split now
collapses to a single full-amount payment (was sending two invoices to one
wallet → extra routing fee + duplicate receipt). Cheat-resistant tests
added in `tests/zap-split.test.mjs`
(`testCreatorEqualsPlatformCollapsesToOnePayment`,
`testSplitRoundingPreservesEverySat`).
- **"Fee landed in my own wallet" — NOT a bug (expected):** occurs when the
*sender is the platform operator* (the platform fee correctly returns to
them) or on a *self-zap* (you get your own creator share back). The fee
recipient is always the configured platform address, never the sender's.
- **Receipt validation (9735):** already fixed 2026-06-23 (author + bolt11 +
description-hash match, with polling for late receipts); confirmed green
(`tests/zap-receipt-pool.test.mjs`, validator tests in `zap-split`).
- **NWC budget/retry UX:** already actionable — detects budget-exhausted,
shows "Increase your wallet zap limit or reduce the platform fee, then
retry…" plus the remaining-share summary and a Retry button.
- **General clunkiness:** maintainer confirms the flow is working well;
closed as accepted.
- [x] Comment box confirmed WORKING (the "message doesn't work" was the popover
mis-position making it hard to use — see 3a).
### 4. View counter accuracy & reliability — DEEP AUDIT
Audit finding (2026-06-24): counts INFLATE. `viewCounter.js` dedupes locally by
(viewer, time-window) correctly, but two things defeated it: (a)
`generateViewEventDedupeTag` built the kind-30079 `d` tag with random entropy +
exact timestamp, so every view was a UNIQUE event and relays could not dedupe the
parameterized-replaceable event; (b) `exactCountForPointer` runs a NIP-45 COUNT
(raw event count, no per-viewer dedupe) and bumped the displayed total UP to it
(`max(deduped, raw)`). Net: reloads / multi-device / re-watches each added a view.
- [x] **Root fix — deterministic, window-bucketed `d` tag** (`viewEvents.js`):
`scope:viewer:bucket`, no entropy. Kind-30079 view events are now genuinely
replaceable, so relays collapse a viewer's repeat views in a window into ONE
event → the NIP-45 COUNT is accurate AND scalable, and `max(deduped, raw)`
stops inflating. `buildViewEvent` no longer invents a random `d` tag
(`nostrEventSchemas.js`). Transition: pre-existing entropy events age out over
`VIEW_COUNT_BACKFILL_MAX_DAYS`. Tests: `tests/view-event-dedupe.test.mjs`
(same-window dedupe, next-window separate, per-viewer/per-video, no auto-d).
- [x] **`tests/view-counter.test.mjs` un-quarantined + green.** Triaged the whole
monolith: pinned the legacy hydrate/subscribe path (the batched
SubscriptionManager added later silently shadowed the mocks), made the
dedupe-window test bucket-aligned (was flaky on time-of-day), and corrected
the `hydrateVideoHistory` root-timestamp assertions to the refactored unit
boundary (map vs the `syncActiveVideoRootTimestamp` field step). Removed from
the QUARANTINE map. Spec corrections logged in TEST_INTEGRITY.md
(SCN-view-counter-legacy-path, SCN-hydrate-root-timestamp).
- [x] **Cross-relay UNDER-count fixed (2026-06-24).** The "exact" path (watch page
+ popularity chart) relied on the NIP-45 COUNT, but `countEventsAcrossRelays`
returns `bestEstimate` = the SINGLE relay with the most events — it can't
union across relays, so it under-counts when views are spread across relays
(e.g. card showed 19 while the chart's uncapped list union showed ~63).
`exactCountForPointer` now lists view events across ALL relays (uncapped, one
on-demand list — not a storm) and folds the UNION into the deduped
`dedupeBuckets` (one per viewer-window); total = the set size, so it's
accurate AND monotonic (never spuriously decreases). Per-relay COUNT is kept
only as a lower-bound floor when the list truncates (>2000), flagged partial.
The popularity modal now passes `{ exact: true }`, and since the count flows
through the shared pointer state, opening a video corrects its count
everywhere (grid cards converge). Test: `testExactCountUnionsAcrossRelays` in
`tests/view-counter.test.mjs`. NOTE: grid cards (no `exact`) still use the
§17-capped batched backfill (approximate, storm-safe) until the video is
opened — acceptable accuracy/cost tradeoff.
- [x] **`reactionCounter.js` (likes/kind-7) audited — accurate, no fix needed.**
It does NOT use NIP-45 COUNT; it lists kind-7 events and dedupes via
`applyReactionToState` keyed by pubkey (one reaction per user, latest-wins),
with per-content totals. No raw-count inflation. (Kind-7 isn't replaceable,
so listing+dedupe is the only accurate model; the only limit is the
hydration cap for viral content — undercount, not inflate — a future
scalability nicety.) Underpins the popularity chart (#26) / Trending (#27).
### 5. Card-hide / video-liveness check — full plan in docs/video-liveness-plan.md
Audited (2026-06-23). Finding: the hide/show **policy is already correct**
(`cardSourceVisibility.js` hides only when neither CDN nor WebTorrent is healthy;
un-hides when WebTorrent flips green). The real gaps:
- [x] **Speed** (`URL_PROBE_TIMEOUT_MS` 8s → 4s): dead hosts yield a fast verdict
so unplayable cards hide quickly. WebTorrent probe stays generous (swarm).
- [x] **Probe accuracy**: confirmed the card probe already uses the video-element
probe (`confirmPlayable:true`, actually loads media) — accurate, not the
opaque HEAD. No change needed.
- [x] **Multi-source**: ingest adapter exposes `sources` (all video imeta urls;
`collectVideoSources`); `urlHealthController.probeUrlList` probes them in
order — healthy as soon as one plays, offline only if all fail. So a dead
primary host no longer hides a video with a working mirror.
- [x] **Player fail-over at play time (2026-06-24).** Playback now tries each
hosted source in turn before dropping to P2P. The `PlaybackSession` builds an
ordered, de-duped `hostedSourceCandidates` (primary URL first, then the
video's imeta `sources`); `attemptHostedPlayback` takes a `candidateUrl` and
the url-first execution loop advances to the next mirror on probe-fail/stall/
timeout (cleaning up watchdogs + resetting the element between candidates),
only calling WebTorrent after all hosted mirrors fail. `sources` plumbed
coordinator → strategy → session; single-source videos behave exactly as
before. Tests: `tests/services/playbackService_multisource.test.mjs`
(candidate construction; the full async fail-over flow is covered by the
playbackService_order suite in CI). ROLLBACK: revert the loop in
`playbackService.js` to the single `attemptHostedPlayback()` call + drop the
`sources` plumbing.
- [x] **Made the policy config-driven for live A/B testing (2026-06-24).** New
`CARD_LIVENESS_POLICY` (config/instance-config.js): `show-pending` (default,
unchanged) | `hide-foreign` (foreign/ingested hidden until a source verifies;
native stays show-pending) | `hide-all`. Owner's own cards always visible.
Flip it live without a rebuild: `window.__BITVID_CARD_LIVENESS_POLICY__ =
"hide-foreign"` then refresh/scroll. Implemented via a `data-foreign`
provenance flag on the card + `getCardLivenessPolicy()` in
`cardSourceVisibility.js`. Tests: three policy scenarios in
`tests/video-card-source-visibility.test.mjs` (also un-broke 3 pre-existing
VideoCard tests by adding the missing IntersectionObserver JSDOM stub).
- [x] **Probe speed — prefetch ahead of scroll (2026-06-24).** Both liveness
probes were already viewport-gated (IntersectionObserver, on-screen-first,
WebTorrent priority-queued + concurrency-capped). Added a configurable
`LIVENESS_PROBE_PREFETCH_MARGIN` (default `600px`) applied to both observers'
`rootMargin`, so cards just below the fold verify before the user scrolls to
them — makes hide-until-verified feel instant. Override:
`window.__BITVID_LIVENESS_PREFETCH_MARGIN__`.
- [x] **Deadlock fixed (2026-06-24).** First hide-foreign test made ALL foreign/
NIP-71 content vanish: a hide-until-verified card starts `display:none`, which
has no layout box, so the IntersectionObserver never fires for it → its
liveness probe never runs → it never gets a healthy verdict → hidden forever.
Fix: `cardNeedsEagerLivenessProbe()` + eager-probe such cards on
`onCardRegister` (fires regardless of visibility) in both `gridHealth` and
`urlHealthObserver`, instead of waiting for an intersection that can't happen.
Still concurrency-capped/priority-ordered; show-pending unchanged. Default is
now `hide-foreign`.
- [ ] **DECIDE after live A/B**: confirm `hide-foreign` feels right (foreign cards
now verify-then-appear); else flip `CARD_LIVENESS_POLICY` in instance-config.
### 18. Embed button / modal on the video player does not open — FIXED 2026-06-23
- [x] **Root cause: event-name mismatch.** `VideoModal.handleEmbedRequest`
dispatched `"action:embed"`, but `ModalManager` listens for `"video:embed"`
(which opens `EmbedVideoModal`), and `dispatch()` does not remap names — so the
embed button fired an event into the void. (Share survives the same mismatch
only because `shareBtn` also toggles a popover directly.) Now dispatches
`"video:embed"`. Regression test in `tests/video-modal-zap.test.mjs`.
- [x] **Sibling bug FIXED (2026-06-23):** `handleCopyRequest` had the SAME mismatch —
dispatched `"action:copy"` with no listener (manager listens for
`"video:copy-magnet"` → `app.handleCopyMagnet`), and `#copyMagnetBtn` has no
popover fallback, so the standalone copy-magnet button was also dead. Now
dispatches `"video:copy-magnet"`. Covered by the same regression test.
### 19. Direct messages: can read but cannot SEND — FIXED 2026-06-23
- [x] **Root cause: missing controller delegations.** Clicking Send threw
`this.controller.handleDmAppShellSendMessage is not a function`. The DM
app-shell renderer wires its callbacks on `this.controller` (the
`ProfileDirectMessageController`), but the controller was missing the
delegation methods that forward to `this.actions` — so Send AND mark-read,
mark-all-read, read-receipts/typing toggles, and open-settings all threw and
did nothing. Added the six delegations. The send path itself (encrypt → sign →
publish, NIP-04 default / NIP-17) was fine. Regression test
`tests/dm-controller-delegations.test.mjs`. VERIFY live: typing + Send actually
delivers a message.
- [x] **Follow-up: "Message" button targeted the wrong person.** Clicking Message
on a profile (or anyone you'd never DMed) set the recipient, but
`buildDmConversationData` discarded it — a recipient with no existing thread
wasn't in `conversationMap`, so it fell back to `conversations[0]` (the top
conversation). The inbox silently jumped to the top thread and any message
sent went to THAT person ("says sent but I don't see it"). Fixes: (a)
`setDirectMessageRecipient` now focuses the selected recipient's
conversation; (b) `buildDmConversationData` synthesizes an empty
conversation for a selected-but-threadless recipient and honors it as active
(incl. `activeRemotePubkey` fallback for the composer target); (c) the legacy
`renderProfileMessages` list no longer clobbers a non-null recipient.
Regression: `tests/ui/dm-conversation-focus.test.mjs`. VERIFY live: Message a
brand-new person, confirm the thread opens on THEM and the sent message lands
in that conversation after refresh.
### 20. Feed / grid loading reliability — some videos missing (BUG)
Two related intermittent symptoms; likely the same root cause (a one-shot,
all-relays-settle fetch that drops slow-relay results — see 17d).
- [x] **Profile / Channel page sometimes doesn't pull in all of a creator's
videos.** Root cause: `loadUserVideos` rendered ONLY the live fetch, which
REPLACES the grid — so when a relay holding a video transiently failed this
load, that video vanished even though it was known in memory/cache. Fixed by
unioning the live events with already-known events
(`mergeChannelVideoSources`, live-wins-on-id) before render, so the grid is a
superset and never shrinks; existing dedupe-by-root + access checks still
handle versioning/deletes. Regression: `tests/channel-video-merge.test.mjs`.
(Streaming/first-relay-wins UX is still the 17d follow-up.)
- [x] **"Paginating between pages" was tab-switching: videos present in one feed
tab missing in another.** Root cause shared with #21: all general tabs read
the SAME active set and only re-ranked, and the rankers never dropped — so
tabs were near-identical and any apparent "missing" was the lack of a
distinct identity. Addressed by giving each tab a real identity (see #21).
- Remaining: streaming/first-relay-wins incremental render (17d UX) is still open
as a latency follow-up, not a correctness bug.
### 21. "For You" / Explore / Recent: make each a genuinely distinct feed — DONE
Root cause: RECENT, FOR_YOU, EXPLORE all sourced the full active set and only
re-ranked (and the rankers never drop), so with thin signals they all collapsed
toward freshness and looked identical. Gave each a structural identity:
- [x] **For You = "your people first"** (`4873a431`): the scorer tags forYouTier
(2 followed author / 1 interest·watch match / 0 other) and the sorter leads
with tier regardless of raw score, then score within tier; watched already
suppressed. No-signal (logged-out / no follows) falls back to an
author-interleaved discovery order so it never mirrors Recent.
Tests: `tests/for-you-tiering.test.mjs`.
- [x] **Explore = "new-to-you + diverse, less recency"** (`087cf410`): freshness
weight down (0.25→0.12), novelty up (0.30→0.35), a soft followed-author
penalty (reads subscriptionAuthors in the Explore runtime) so non-followed
creators surface, and more variety via the diversity sorter (MMR λ 0.7→0.5).
Test: explore follows-penalty in `tests/feed-engine/explore-scorer.test.mjs`.
- [x] **Recent = chronological** (unchanged).
- [x] **Render bug that masked ALL of the above (2026-06-24):** `VideoListView.render`
re-sorted the grid by date unless `metadata.preserveOrder` was set, and only
Explore set it — so For You's tiering and Kids' scoring were discarded at
render and shown chronologically (looking identical to Recent). Fixed by
setting `preserveOrder` for For You / Trending / Kids in the refreshFeed
cases. This is why the feed-identity work "didn't show" until now.
- [ ] **Follow-up (optional):** Explore per-VIDEO "already watched" suppression
(currently only topic-novelty, not per-video) — needs the watch-history
preload wired into the Explore refresh like For You has. And a Trending tab
(popularity) once view-count/zap data is reliable.
## Open — medium priority
### 6. Test S3 (generic, non-R2) functions — DONE 2026-06-24
- [x] **Audited; was more covered than it looked.** Already tested: upload/thumbnail/
`.torrent` (`s3UploadService.test.mjs`, `s3-upload-*`), CORS guidance
(`s3-upload-cors-guidance.test.mjs`), and **delete/edit cleanup provider-aware
path-style** — incl. the documented vhost bug (`r2-delete-video-storage.test.mjs`
asserts cleanup uses `forcePathStyle:false` for generic S3;
`s3-upload-publish-cleanup-integration.test.mjs` covers the full upload→delete
round-trip). `forcePathStyle` is plumbed through upload + delete.
- [x] **Closed the real gap: public-URL resolution.** The upload tests *mock*
`buildS3ObjectUrl`, so the actual path-style/vhost URL logic in `js/storage/
s3-url.js` had no focused test. Added `tests/storage/s3-url.test.mjs` (11 pure-
function scenarios): path-style → bucket in PATH; vhost → bucket as host
subdomain (no double-prefix); endpoint path-prefix preserved; bare-endpoint
https upgrade; key/slash normalization; explicit `publicBaseUrl` wins; missing
inputs → "". Also covers `s3Service.derivePublicBaseUrl`/`buildS3ObjectUrl`.
- [~] Provider forcePathStyle DEFAULTS (generic→path-style true, aws→vhost false) are
a 3-line internal config map (`resolveForcePathStyle`/`PROVIDER_TESTS`, not
exported); not unit-tested in isolation (would require exposing internals).
Behavior is exercised via the upload/delete tests above. Acceptable minor gap.
### 7. Mobile + video-card layout
- [ ] Improve mobile layout and the video card layout.
- [ ] **Improve mobile UI** broadly (reiterated 2026-06-25) — not just the card: nav/
sidebar, modals, the player, forms, and touch targets across the app.
- [x] **Profile modal tabs → app-grid on mobile (2026-06-25).** Below 1024px (the
full-width menu view), the profile modal's tab nav (`.profile-modal__nav`:
Account/Relays/Wallet/Storage/…) renders as a 3-column grid of icon tiles
with the title centered below each icon, instead of the vertical list.
Desktop keeps the vertical nav. Token-only; validated live in-browser.
`css/tailwind.source.css`. (NOTE: the sidebar is NOT a grid — an earlier
sidebar version was a misread and was reverted.)
- [ ] Consider removing the CDN/WebTorrent source badge from the card (clutter).
- [ ] Run `npm run test:visual` after layout changes; update baselines deliberately.
### 41. Native browser dialogs → site notification system
- [x] **DONE 2026-06-30.** Replaced every `alert()`/`confirm()` (no `prompt()` existed)
with the app's notification system so dialogs are styled + consistent.
- `alert()` (13) → toasts: `this.showError` where the component already had it
(UploadModal), else `notifyError`/`notifySuccess` via a new `js/ui/appNotify.js`
(routes to the app's NotificationController).
- `confirm()` (~13) → a new promise-based **`showConfirm()`** dialog
(`js/ui/confirmDialog.js`) — a `bv-modal modal-always-on-top` overlay returning
`Promise<boolean>` (Confirm/Cancel/backdrop/Escape, danger variant focuses
Cancel). Importable anywhere, so future confirmations use it too. Stacks above
other modals and is typable thanks to the stacked-modal focus fix.
- Migrated: UploadModal, MyVideosController, ProfileStorage/Wallet/Relay
controllers, profileModalController, subscriptionHistoryController,
settingsRestorePrompt (default `confirm` now `showConfirm`), nostr/client
(deleteAllVersions). `confirmSyncOverwrite` helpers made async (the sync layer
already `await`s `confirmOverwrite`). Tests: `tests/confirm-dialog.test.mjs` (6).
Build + lint clean; affected suites green. NOTE: `npm run build`/`lint` do NOT
parse JS — verify migrations by importing the module / running its test.
### 42. Admin whitelist edit fails with nsec / NIP-46 (only NIP-07 worked) — FIXED
- [x] **DONE 2026-06-30.** Editing the admin whitelist/blacklist worked with a NIP-07
extension but silently failed with nsec and NIP-46 (Amber). Root cause:
`adminListStore.persistNostrState` signed with `window.nostr.signEvent` directly
(and required `requestDefaultExtensionPermissions`), so non-extension logins threw
"nostr-extension-missing". Fix: sign with the **active signer**
(`getActiveSigner()` from the registry — works for extension/nsec/NIP-46, all expose
`signEvent`), and only run the extension-permission prompt when the signer is an
extension (`type === "extension"/"nip07"`). Regression test added to
`tests/admin-list-store.test.mjs` (nsec signer used while `window.nostr.signEvent`
throws). Build + lint clean.
- [x] **AUDIT DONE 2026-06-30 — clean (adminListStore was the only bug).** Swept every
`.signEvent(`, `window.nostr`, and `ensureExtensionPermissions`/
`requestDefaultExtensionPermissions` caller:
- **Signing:** NO `window.nostr` is used to sign/encrypt anywhere. Every publish
path signs with the active signer (`signer.signEvent`): subscriptions, userBlocks,
hashtag prefs, relay prefs, moderationService reports, nip71MirrorService,
encryptedSyncFacade, DM seal + legacy DM (`nostr/client` signingAdapter defaults
to the active signer), DM relay list, admin lists.
- **Extension-permission gates:** every caller (reactions, comments, subscriptions,
userBlocks, watchHistory, relayManager, DM relays) guards the request behind a
signer-type (`extension`/`nip07`) or capability check, so nsec/NIP-46 skip it and
go straight to active-signer signing — they never hit the `extension-missing`
failure that broke admin lists.
- **Decrypt:** gates on the active signer's nip04/nip44 capability with `window.nostr`
only as a fallback; nsec (closures) + NIP-46 (remote) both decrypt fine.
- **Cleanup note (low priority):** `js/auth/signingAdapter.js`
(`createNip07SigningAdapter`, defaults to `window.nostr`) is dead production code —
referenced only by its own test, no app callers. Harmless; remove or repoint at
the active signer if ever reused.
### 43. Logged-in profile card at the top of the profile modal (UX)
- [ ] Show the **logged-in user's profile photo + name** at the **top-center** of the
profile modal, styled as a "profile card" that sits **half-on / half-off** the
modal edge (overlapping the top border) for a polished look. Goal: users can see
WHICH profile they're acting as (e.g. uploading as) at a glance. Pull avatar/name
from the profile cache for the active pubkey; update on profile switch.
### 44. In-modal storage selector for uploads (UX)
- [ ] Make storage unlock + selection easier from the **video upload modal**: let the
user **pick any configured storage connection** (R2 / B2 / Custom S3) and unlock it
**without leaving the upload modal** — today they must close it, open Profile →
Storage, change the default/selected connection, then reopen upload. Add a provider
picker (driven by `storageService.listConnections`) + inline unlock in the upload
modal's storage section; selecting one sets it as the active upload target for this
upload (and optionally the default). Reuse `loadFromStorage` / the per-provider
connection model.
### 45. Upload modal: suggest tags from the user's previous videos (UX)
- [x] **DONE 2026-07-01.** The Hashtags section of the upload modal now shows one-tap
chips of the user's most-used past hashtags ("Reuse a tag from your videos:").
Source: `app.getUserHashtagSuggestions()` reads the user's own videos
(`nostrService.getActiveVideosByAuthors([pubkey])`) and ranks their hashtags via
`js/utils/hashtagSuggestions.js` `rankHashtagsByFrequency` — count = number of
videos using each tag, normalized (case + `#`) and deduped per video, ties broken
alphabetically (top 12). Tags come from `video.nip71.hashtags` (falls back to raw
`t` tags minus bitvid's fixed `t=video` marker). Clicking a chip adds it to the
NIP-71 `t` repeater (skips duplicates); already-added chips dim + disable. Hidden
for users with no past tags. Rendered on modal open. Tests:
`tests/hashtag-suggestions.test.mjs` (5, pure ranking). Lint + build green.
### 46. Feed auto-refreshes after posting a video (UX)
- [x] **DONE 2026-06-30.** `publishVideoNote` now (1) **optimistically injects** the
just-published legacy event into the shared client cache via a new
`nostrClient.ingestLocalVideoEvent(rawEvent)` (+ thin `nostrService` wrapper), so the
new video shows in the feed **instantly**, before the relays echo it back; and (2)
refreshes via the canonical `refreshAllVideoGrids({ reason: "video-published",
forceMainReload: true })` instead of a bare `loadVideos()` — so it refreshes
**whichever feed is active** (For You / Explore / Recent) plus the subscription and
channel grids, and forces a render (bypassing the load cooldown) so the injected
video is actually shown. `ingestLocalVideoEvent` mirrors live ingestion (convert →
root created_at → tombstone guard → allEvents/activeMap), so it obeys the same
dedupe/active-key rules (#20/#21): a later relay copy of the same event (same
`created_at`) won't duplicate it, and an older revision can't overwrite a newer
active entry. Tests: `tests/optimistic-feed-insert.test.mjs` (6 — surfaced instantly,
no-duplicate on re-ingest, newer-wins, older-doesn't-clobber, invalid→null, garbage
handled).
### 47. "Most Zapped" sidebar tab (trending-by-zaps)
- [ ] Add a **Most Zapped** sidebar tab: like Trending (#27, view-count) but ranked by
**total zaps** (sats / zap count) per video. Reuse the zap accounting (kind-9735
receipts) already used for the zap system; build a feed source/ranker that sorts by
zap total over a window, wired into the feed engine like Trending. Depends on
reliable zap data (couples with the zap system already shipped in #3).
### 48. Fluent profile switching across signing methods (nsec / NIP-46 / NIP-07)
- [x] **nsec switching FIXED 2026-06-30.** Switching to a saved nsec profile used to fail
silently ("secret-required") — `switchProfile` called the nsec provider with no
passphrase and never prompted. Now `handleProfileSwitchRequest` detects an nsec
target, checks the stored key (`getStoredSessionActorMetadata` +
`evaluateStoredNsecSwitch` — must be a persisted nsec for THIS account), prompts for
the PIN/passphrase via a new `showPasswordPrompt` dialog
(`js/ui/promptDialog.js`), and calls `switchProfile(pubkey, { unlockStored: true,
passphrase })`. `authService.switchProfile` now forwards `unlockStored`/`passphrase`
→ `requestLogin` → the nsec provider's `unlockStoredSessionActor` restores the signer
and activates the account. Wrong PIN → "Incorrect PIN/passphrase" toast; no stored
key / different account → a clear "log in with its nsec" toast. So you can switch
NIP-07 → nsec fluently. Tests: `tests/stored-nsec-switch.test.mjs` (6, pure gate) +
`tests/password-prompt.test.mjs` (6, dialog). Build + lint clean; auth suites 25/25.
- [x] **NIP-46 switching FIXED 2026-06-30.** Confirmed the same bug: switching to a saved
NIP-46 account called the provider with no `reuseStored`, so it started a fresh
handshake (needing a new connect URI/QR) instead of reconnecting. Now
`handleProfileSwitchRequest` sends `{ providerId: "nip46", reuseStored: true }` for
NIP-46 targets → `switchProfile` forwards `reuseStored` → the nip46 provider's
`useStoredRemoteSigner` reconnects the stored session and activates the account. On
failure → "Couldn't reconnect to this account's remote signer" toast.
- [x] **Wrong-account guard (all methods) 2026-06-30.** `requestLogin` now enforces
`expectPubkey` (only set by `switchProfile`): if a single-slot stored session
resolves to a DIFFERENT account than requested, it rejects 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 now fails clearly instead of grabbing
the extension's active account). Tests in `tests/authService.test.mjs` (option
forwarding + expectPubkey rejection).
- [x] **Multiple nsec accounts — DONE 2026-06-30.** The encrypted-key store is now
per-pubkey (`bitvid:sessionActors:v2` = `{ [pubkeyLower]: { privateKeyEncrypted,
encryption, createdAt } }`), following the existing `bitvid:<thing>:<npub>`
convention (profileCache, nwcSettings, …). Several saved nsec accounts each keep
their own key on the device, so the switcher can move between them. The legacy v1
single slot is kept in sync as the "last-saved default" (boot restore / no-arg
reads) and is migrated into the map on first read. `readStoredSessionActorEntry`,
`getStoredSessionActorMetadata`, and `unlockStoredSessionActor` all take an optional
target pubkey; the nsec provider threads `expectPubkey` through so a switch unlocks
the requested account (not the last-saved one), with the `expectPubkey` guard as a
backstop. IndexedDB mirror is now keyed per-pubkey too. Added
`listStoredSessionActorPubkeys()`. Tests: `tests/session-actor-multi-account.test.mjs`
(7 — per-account resolve, list, clear-one-keeps-others, no-arg default, no-arg wipe,
v1→v2 migration, missing-account returns null).
- [x] **Multiple NIP-46 accounts — DONE 2026-06-30.** The remote-signer session store is
now per-user-pubkey (`bitvid:nip46:sessions:v2` = `{ [userPubkeyLower]: session }`),
mirroring the nsec store. Several saved NIP-46 accounts each keep their own
reconnectable session, so the switcher can move between them. The legacy v1 single
slot is kept in sync as the last-connected default (boot restore) and migrated into
the map on first read. `readStoredNip46Session` and `getStoredNip46Metadata` take an
optional target pubkey; `writeStoredNip46SessionSync` writes both the map (keyed by
`userPubkey`) and the v1 default; `clearStoredNip46Session(pubkey)` forgets one
account (no-arg wipes all, for logout). `SignerManager.useStoredRemoteSigner({ pubkey
})` selects the target account's session and, on access-denial, forgets only that
account; the nsec/nip46 providers thread `expectPubkey` through so a switch
reconnects the requested account (with the `expectPubkey` guard as a backstop). Added
`listStoredNip46SessionPubkeys()`. (`nip46Connector.js` is dead code — the live path
is entirely in `SignerManager`.) Tests: `tests/nip46-multi-account.test.mjs` (7).
### 8. Orphan storage garbage-collection tool
- [x] **Largely delivered by the My Videos tab** (`9d3a0df0`): lists bucket objects no
live note references and offers per-file delete (under the user's prefix only).
- [x] **Bulk "Delete all" orphans — DONE 2026-06-30.** Added a "Delete all (N)" button to
the orphaned-objects header in My Videos (shown only when >1 orphan). One
confirmation → a single `deleteStorageKeys({ keys: allOrphans, pubkey })` call
(already array-capable + unlock-gated + prefix-scoped). Refreshes the list on
success and reports partial outcomes honestly ("Removed 2 files (1 could not be
deleted)"); surfaces storage-locked as an unlock prompt. Tests:
`tests/my-videos-bulk-orphan-delete.test.mjs` (5 — all-keys-in-one-call, cancel,
storage-locked, partial, empty no-op). Build + lint clean; My Videos suite 37/37.
- [ ] Remaining (optional): surface the same orphan control from the Storage settings
pane (currently only in My Videos).
### 9. Cap the cold-login relay-REQ storm further
- [x] **Biggest source fixed** (`a8e9f520`): the community-blacklist load fired one
kind-30000 REQ *per curator* (~28-32 parallel) at cold start. Collapsed into a
single batched multi-author REQ (`js/adminListBatch.js`), routed through the
subscription manager. This was the dominant `kind 30000` storm.
- [ ] **VERIFY the batching actually reduced it.** A later logged-OUT load
(2026-06-23 00:45) STILL showed `kind 30000=32` — either that build wasn't
loaded, or there's ANOTHER kind-30000 source besides
`loadCommunityBlacklistEntries` (admin editor/whitelist/blacklist lists? a
per-author follow-set fetch?). Re-check on a fresh hard-refresh; if still ~32,
trace the actual emitter.
- [x] **`from_hex` root-caused + FIXED (2026-06-23).** The sanitizer
(`HEX64_FILTER_FIELDS`, toolkit.js) covered `ids/authors/#e/#p/#q` but NOT the
NIP-22 UPPERCASE root tags `#E`/`#P`, which relays also hex-decode and which
the kind-1111 comment subscriptions (`commentEvents.js`) query by. An
odd-length root event-id/author there bypassed the sanitizer and got the whole
REQ rejected ("uneven size input to from_hex"). Added `#E`/`#P` to the
sanitized fields; test in `tests/filter-hex-sanitize.test.mjs`.
- [x] **Relay `from_hex` REQ rejects fixed** (`733cb593`): one odd-length/non-hex
value in `ids/authors/#e/#p/#q` made strict relays reject a whole REQ; now
sanitized at the pool choke point (`normalizeFilterList`).
- [ ] **Remaining: logged-IN cold-start WoT/social-graph fan-out** (~94-104 REQ/s,
bracketed by `[lists-sync-start]`…`[lists-sync-complete]`). Logged-OUT is now
clean. The spike is per-contact hydration over the user's ~25 follows/trusted
contacts: `kind 30000` (follow sets), `30002` (block lists), `30005/30015`
(interest sets), `10050` (DM relay hints), plus `0` (profiles). NOTE: mutes
(`kind 10000`) are ALREADY batched (moderationService.refreshTrustedMuteSubscriptions,
one multi-author filter). Fix = apply the same multi-author batching to the
other per-contact fetches (userBlocks 30002, hashtagPreferences 30005/30015,
follow-set/contacts hydration 30000, DM 10050) and/or DEFER non-feed-critical
ones (DM relays, interest sets) until after first render. Multi-subsystem +
sensitive (trust) → do as a dedicated perf pass, not a rushed change.
Source still to pin: the loop issuing 30000/30002/30005/30015 per contact.
- [x] **REQ tracer SHIPPED (2026-07-03)** to pin those emitters empirically —
static search found NO per-contact list loops (mutes already batched), so
the hypothesis needs data. `js/nostr/reqTelemetry.js` hooks the shimmed
`pool.sub` choke point (every sub/list flows through it) and aggregates
kinds + relay fan-out + emitting call site per 10s window. Zero-cost off.
**To trace:** on unstable run `localStorage.setItem("bitvid:reqTrace","1")`,
reload (cold login), read the `[req-trace]` console logs (or
`window.__bitvidReqTrace.report()`); the top rows name the emitters and
the batching/deferral pass follows directly from that data.
### 22. Anti-spam: validate npub on application / submission forms
- [x] **DONE 2026-06-24.** Added a shared, testable NIP-19 validator
(`js/utils/validateNpub.js`, exposes `window.validateNpubHex`) and wired it
into all four embed submission forms (`components/iframe_forms/`):
- **Application** (`applicantNpub`, required): submit is blocked unless the
npub decodes to a 64-char hex pubkey; the hex is also written into the
application body for canonical storage/whitelist matching.
- **Feature-request / bug-fix / feedback** (`userNpub`, optional): the npub
stays optional, but a non-empty value must be a valid npub or submit is
blocked with a clear inline error.
Validation decodes via the real nostr-tools `nip19` (checksum + prefix +
hex shape), so spam/free-text/`note1…`/tampered npubs are rejected.
Unit tests: `tests/validate-npub.test.mjs` (real nostr-tools). Pairs with #23
(submissions should become structured data, not DMs).
- [ ] **VERIFY live**: open each form embed — application rejects a junk npub and
only sends with a valid npub1…; the optional forms send when the npub is
blank but reject a malformed one.
### 23. Admin submissions tab — structured submissions + approve/deny UI (SCALING)
- [ ] Replace the current DM-based submission flow with **custom structured data**
(a dedicated Nostr event kind / addressable record, or app data) instead of
free-form DMs, so submissions are parseable and actionable.
- [ ] Add an **Admin → Submissions tab** with a nicely formatted UI: each submission
rendered with its fields and an **Approve / Deny** action that applies the
corresponding change (add to whitelist, add to blacklist, etc.) without manual
list-editing. This is foundational for scaling moderation as the platform grows.
- [ ] Define the submission schema + the admin action → list-mutation mapping; reuse
the existing admin list stores (whitelist/blacklist) for the writes.
### 24. User-level allowlist override for the Web-of-Trust mute list
- [ ] Let a user keep (and import) a personal **allowlist** that the client ALWAYS
shows content from, overriding the inherited WoT/trusted-mute list. So even if a
trusted curator mutes an author, the user can force that author's content to
appear in their own feeds. Persist it (NIP-51 list, encrypted/opt-in), make it
importable, and apply it at the render-time moderation filter as a final
allow-override after the WoT mute/block checks.
### 25. Per-video / per-event admin block list (granular moderation)
- [x] **DONE 2026-06-30 (full vertical slice).** Admins (any editor — same gate as the
author blacklist) can now hide a SINGLE video without blocking its author.
- **Record:** a published kind-30000 NIP-51 list under d-tag
`bitvid:admin:event-blacklist`, carrying blocked event ids as `e` tags
(`NOTE_TYPES.ADMIN_EVENT_BLACKLIST` schema; `participantTagName: "e"`).
Loaded/persisted through the existing admin-list machinery
(`adminListStore` loadNostrState/persistNostrState) and cached like the others.
- **accessControl:** `eventBlacklist` Set + `getEventBlacklist()`/
`isEventBlacklisted()`/`addToEventBlacklist()`/`removeFromEventBlacklist()`
(editor-gated, normalizes hex or nevent/note) + `onEventBlacklistChange`.
- **Render filter:** already existed (`app.blacklistedEventIds`, honored by every
feed/grid/search/playback path) but was fed only by the static
`ADMIN_INITIAL_EVENT_BLACKLIST` config. Now `app._rebuildBlacklistedEventIds()`
merges that static base with the dynamic admin list, and an
`onEventBlacklistChange` listener rebuilds + refreshes the grids on change.
- **UI:** a "Block this video" item in the per-video ⋯ menu (editor-only, via
`videoMenuRenderers`), handled by `handleBlacklistEventAction`
(`js/ui/moreMenu/blacklistEventAction.js`).
- Helpers extracted to `js/adminEventBlacklistHelpers.js` to keep adminListStore /
accessControl / moreMenuController under their size caps. Tests:
`tests/admin-event-blacklist.test.mjs` (9 — record helpers + editor-gated action,
fallbacks, refusal, publish-failure). Build + lint clean; admin/access/moderation
suites 56/56.
- [x] **Follow-up DONE 2026-07-01.** The admin pane is now organized into a sub-tab
toolbar (Whitelist / Blacklist / Blocked videos / Moderators — Moderators tab
super-admin-only) and a **Blocked videos** tool surfaces the per-event list:
view every blocked video (title+author when cached, else shortened id), **unblock**
per row (confirm → `removeFromEventBlacklist`), and **add by nevent/hex id**. Reuses
the existing accessControl event-blacklist API + `onEventBlacklistChange` (live
re-render) and refreshes the grids on change; no backend changes. Row logic in
`js/ui/profileModal/blockedVideosSection.js`; wiring in `ProfileAdminController`.
Tests: `tests/profile-blocked-videos.test.mjs` (7) + updated profile-modal-controller
admin test for the sub-tab layout.
### 26. Video popularity / view-count chart (public, three-dots menu) — DONE 2026-06-24
- [x] **Shipped.** A "Popularity" item in the ⋯ menu opens a public views-over-time
chart (`js/viewCountChart.js`). `buildViewCountTimeSeries` buckets the raw
kind-30079 view events by the counter's dedupe window (one view per viewer
per day) into a cumulative series; `buildViewCountChartSvg` renders a
token-colored (currentColor/`text-accent`) SVG area+line — no chart lib. The
modal fetches via `listVideoViewEventsWithDefaultClient`, shows the total,
and **updates live** by subscribing to `subscribeVideoViewEventsWithDefaultClient`
(debounced re-render); cleans up on close (Esc/backdrop/✕). Lazy-loaded from
the menu handler. Tests: `tests/view-count-chart.test.mjs`. Built on the
trustworthy #4 counts.
- [ ] FUTURE: feed the same data into a **creator dashboard** showing the creator
their videos' ranking + performance over time (separate, larger effort).
### 27. "Trending" tab — recently-added sorted by view count — DONE 2026-06-24
- [x] **Shipped.** New `FEED_TYPES.TRENDING` tab: the active (recently-added)
source ranked by VIEW COUNT (recency tiebreak, trusted-muted last). Gated by
`FEATURE_TRENDING_FEED` (default ON; flag-off hides the sidebar link and skips
registration). Relies on the now-trustworthy view counts (#4).
- `createTrendingSorter` (sorters.js) reads counts from the injected
`runtime.getViewCount`, which resolves each video's pointer (the app's
canonical `deriveVideoPointerInfo`) against the shared viewCounter cache —
the same counts the grid cards already load.
- **Live re-rank:** `viewCounter` now emits a coalesced `onViewCountsChanged`
signal; while the Trending view is active it debounce-re-runs the feed
(cheap re-rank over the in-memory active set, no relay re-fetch), so the
order settles into true trending as counts stream in. Cold cache ⇒ reads as
recency, then improves.
- Pipeline extracted to `js/feedEngine/trendingFeed.js` (feedCoordinator is at
its size cap); thin delegations + a `getVideoViewCountSnapshot` export.
- Wiring: FEED_TYPES + flag, feedCoordinator load/refresh cases, app.js
wrappers, applicationBootstrap registration, viewManager init +
`js/trendingView.js`, `views/trending.html`, sidebar link.
- Tests: `tests/trending-sorter.test.mjs`.
- [ ] FUTURE: benefits from the unified streaming grid (#17d); consider a time-
windowed "trending" (views in the last N days) once view-over-time data (#26)
lands, instead of all-time totals.
### 31. ⋯ menu "Event Details" button does nothing (BUG) — fix landed, VERIFY
The wiring is structurally intact (menu item `event-details` → handler case →
`handleEventDetailsAction` → `EventDetailsModal.open`), so the failure was a
SILENT one: `handleEventDetailsAction` only opened the modal `if (… && payload
.video)` and never caught the async `open()` rejection, and `open()` ran the
(throwable) `renderVersion` BEFORE actually showing the modal. Hardened:
- [x] `handleEventDetailsAction` no longer fails silently — surfaces "No video
selected" / "unavailable", falls back to `app.eventDetailsModal`, and
`.catch()`es the open promise.
- [x] `EventDetailsModal.open` now calls `openStaticModal` FIRST, then renders in
a try/catch, so a render hiccup can't leave the click looking dead.
- [x] The ⋯ popover is dismissed before opening the modal.
- [x] **Actual root cause (2nd live test):** BOTH Event Details and Popularity
reported "No video selected" from the **video modal's** ⋯ button. The modal
trigger declares its context as `data-more-dropdown="modal"`, but
`attachMoreMenuHandlers` read only `data-context` → defaulted to `"card"` →
skipped the modal-only video resolution → opened the popover with a **null
video**, so the panel's `data-event-id` was empty and every action failed.
The panel is built from `entry.context.video`, so this nulled the whole menu.
Fixes (all in `moreMenuController.js`):
1. Read context from `data-more-dropdown` as well as `data-context`.
2. Modal branch falls back to `this.videoModal.activeVideo` when
`getCurrentVideo()` is null (deep-link opens don't set playback's
currentVideo).
3. Defense-in-depth: `handleEventDetailsAction` resolves the video from
`app.videosMap.get(eventId)`, and `view-stats` uses a new
`getVideoByEventId` callback (dropping a risky `|| currentVideo`).
Regression tests added in `tests/more-menu-controller.test.mjs`.
- [ ] **VERIFY live**: click ⋯ → Event Details AND ⋯ → Popularity in the video
modal AND on a grid/channel card; both should open the correct video.
### 32. Whitelisted author's video won't play — "not from a whitelisted author" (BUG)
Reported 2026-06-25: a video from a known-whitelisted author refused to play
("This content is not from a whitelisted author." at `playbackCoordinator
.playVideoByEventId`). The source URL was confirmed 100% healthy (HTTP 200,
video/mp4, range + CORS `*`) — the block is the whitelist gate, not the source.
- [x] **Root cause + fix: `canAccess` format asymmetry.** `accessControl` keeps the
whitelist in TWO sets — `whitelistPubkeys` (hex) and `whitelist` (npub) — and
`canAccess` checked only ONE based on the caller's input format (npub→npub set,
hex→hex set) without cross-deriving. `playbackCoordinator` passes the author's
**npub**, so it consulted only the npub set; if the entry was present in only
the hex set (e.g. the hex decode hadn't run / a normalization mismatch), a
genuinely whitelisted author read as "not allowed." Fixed `canAccess` to
cross-derive both forms and check BOTH sets (false-negatives only; a
non-whitelisted author is still denied). Tests:
`tests/access-control-canaccess.test.mjs`.
- [x] Added a dev-gated diagnostic at the playback rejection
(`playbackCoordinator`) logging hex/npub, whitelistMode, set sizes, and
`inNpubSet`/`inHexSet` — so if it still denies, we know instantly whether the
author is genuinely off the list (→ then it's the separate "ingested foreign
content shown in a whitelist-mode feed but unplayable" question) vs a load race.
- [ ] **VERIFY live**: replay the failing nevent. If it plays → fixed. If it still
denies, paste the new `[playback] access denied — whitelist diagnostic` line.
### 28. Beacon torrent app stuck — spinner never resolves (BUG) — FIXED 2026-06-24
- [x] **Root cause:** the processing overlay (`torrent/app.js`) was gated on
WebTorrent's metadata-ready callback (`client.add(magnet, opts, cb)` /
`client.seed`). That callback only fires once metadata arrives from peers, so
a magnet with **no live seeders** never resolved it and the spinner span
forever — and a torrent that emits its own `'error'` (bad/duplicate magnet)
didn't always reach the client-level handler. (NOT the `null.fill` crash
(#10); the client constructs fine — this was a missing timeout/failure path.)
- [x] **Fix:** added a watchdog around the overlay. `beginProcessing()` arms a
30s `PROCESSING_TIMEOUT_MS` timer; if the op hasn't completed it drops the
overlay and warns ("still searching for peers — it'll keep trying in the
background") while the torrent stays in the table and keeps announcing.
`handleTorrentReady`, the client error handler, and `destroy()` all clear the
watchdog. `addMagnet`/`seedFiles` now wrap `add`/`seed` in try/catch and
attach a per-torrent `'error'` listener so bad magnets fail visibly.
- [x] **Tests:** `tests/torrent/beacon-watchdog.test.mjs` (JSDOM) — a no-peers
magnet drops the spinner + warns; a ready torrent resolves it and a stale
watchdog can't re-fire.
- [ ] **VERIFY live**: open the beacon, paste a dead magnet → spinner should clear
with a warning after ~30s instead of hanging; paste a live magnet → resolves.
### 29. Admin-whitelisted users bypass the Web-of-Trust (anti-abuse) — DEFERRED (YAGNI)
**DECISION 2026-06-24: defer until there's a real brigading incident to scope against.**
The premise is sound (an admin vouch is a stronger, non-brigadeable signal than
crowd-sourced WoT mutes/flags), but it's a speculative anti-abuse feature and the
right *scope* is best decided against a real case. Low risk to defer at small scale:
WoT mutes come from *trusted* muters, so brigading requires coordinating people
already inside the trust graph. **One caveat:** there's currently no admin override
to rescue a creator who *does* get WoT-hidden, so if it happens, ship this promptly.
**Scaffolding already exists — when needed, this is a small, localized change:**
- `js/feedEngine/stages.js` (~1143): `adminWhitelist` is already computed per video
(`adminStatus?.whitelisted === true`) and there's an `adminWhitelistBypass = false`
placeholder right beside the hide decision (~1210-1229) + a `hideBypass` framework
(`"viewer-override"` / `"feed-policy"`). Wire `"admin-whitelist"` as another reason.
- **Scope it correctly (the design refinements):**
1. Bypass only the **WoT mute/report HIDE** layer — that's the brigadeable part.
2. **Never** override a viewer's OWN block/mute — those are a separate, earlier
filter (`reason: "viewer-block"`, stages.js:934), so the bypass naturally won't.
3. **Open scope question** (decide against the real case): bypass *soft* report
categories (nudity/spam/general mutes) only and still HIDE hard/illegal
categories — vs. bypass everything. Categories ARE distinguished (`reportType`),
so either is feasible. Leaning soft-only so a whitelist can't force-show illegal.
- [ ] When a real example appears: flip the placeholder with the scope above + tests.
### 30. Blossom storage support (bring to par with R2 / S3)
- [ ] Add **Blossom** (BUD-01/02 blob storage over Nostr) as a storage provider
alongside Cloudflare R2 and generic S3. Bring it to functional parity where
possible: upload, thumbnail, `.torrent`, public URL resolution, and
delete/edit cleanup. Slot it into the existing storage-provider abstraction
(`r2Service.js` / `storageService.js`) and the Storage settings pane; auth is
a signed Nostr event per Blossom rather than S3 keys. Research the BUD spec
coverage needed for bitvid's upload/delete flows.
### 34. NIP-71 mirror: videos show "not mirrored" + duplicate on re-mirror (BUG)
Reported 2026-06-25. Relates to #17 (NIP-71 interop) / the bitvid→NIP-71 mirror.
- [ ] **Mirrored videos report as NOT mirrored.** All videos were mirrored, but the UI
now shows them un-mirrored even though the NIP-71 mirror events exist. The
"is this mirrored?" detection is wrong/stale (likely the mirror-state lookup
doesn't find the existing kind-21/22 mirror, or a flag isn't persisted/derived).
- [ ] **Re-mirroring creates DUPLICATES.** Because they read as un-mirrored, mirroring
again publishes a SECOND NIP-71 event, so NIP-71 clients show two copies of the
same video. The mirror should be idempotent (addressable/replaceable, or detect
the existing mirror and update it) instead of creating a duplicate.
- [ ] Delete/un-mirror correctly removes BOTH copies (so the dedup key is shared) —
confirms the publish path is creating a true duplicate, not a distinct event.
- [ ] Fix: make mirror detection reliable + the mirror publish idempotent. See
`js/nostr/nip71Mirror.js` / `js/services/nip71MirrorFlags.js`.
- [x] **AUDIT done 2026-06-25.** Two root causes:
1. **Detection is device-local.** `isMirrorEnabled(pubkey, videoRootId)`
(`nip71MirrorFlags.js`) reads a localStorage flag (`FLAG_KEY`), NOT the actual
published mirror events — so a cache clear / other device / lost flag reports
"not mirrored" even though the 34235/34236 events exist on relays.
2. **Kind is unstable → cross-kind duplicate.** `buildNip71MirrorEvent`
(`nip71Mirror.js:177`) uses `short = hasDims && height > width` → kind 34236,
else 34235. The d-tag is deterministic (`["d", videoRootId]`), but
`(34235,pubkey,d)` and `(34236,pubkey,d)` are DISTINCT addressable events. When
a video has dimensions on one mirror attempt but not another, the kind flips, so
re-mirroring publishes the OTHER kind → two copies in NIP-71 clients. "Delete
removes both" confirms the shared d-tag / two-kind duplicate.
- [x] **FIX (a) Idempotent publish + self-heal — DONE 2026-06-25.**
`nip71MirrorService.publish()` now looks up the author's existing mirror (both
kinds) via the SubscriptionManager chokepoint and **reuses the existing kind** so
a re-publish REPLACES the same addressable coordinate instead of creating the
cross-kind duplicate. If both kinds already exist (the bug's aftermath), it
**self-heals** by NIP-09-deleting the stale-kind coordinate. An explicit
`options.short` override still wins. Also added `findMirror(video)` (relay-truth
`{mirrored, kinds, duplicate}`). Dependency-injected; 4 new scenario tests in
`tests/nip71-mirror-service.test.mjs` (12/12 green); lint clean (no direct
pool.list — uses `getSubscriptionManager().list`).
- [x] **FIX (b) Relay-truth detection in the UI — DONE 2026-06-29.** Added batched
`nip71MirrorService.findMirrors(videos)` → `Map<videoRootId, {mirrored, kinds,
duplicate}>` (one relay lookup per root via `Promise.all`, all through the
SubscriptionManager chokepoint). `MyVideosController.renderRows` now fires
`refreshMirrorStates(rows)` after the synchronous (local-flag) render: it looks up
relay truth, **reconciles the device-local flag to match** (so the flag becomes a
correct cache, not the source of truth), and corrects each mirror button's label
("Shared ✓" / "Share to apps"). Buttons are tagged `data-mirror-root` for the
batched update. Fire-and-forget — never blocks render; detection failures fall back
to the local flag. 4 new behavioral tests in
`tests/my-videos-mirror-relay-truth.test.mjs` (jsdom: promote unmirrored→shared,
clear stale shared, per-row batch reconcile, button tagging); build + lint clean.
- [ ] **VERIFY on unstable with Amber:** re-mirror an already-mirrored video → it should
replace (one copy), and a previously-duplicated video should collapse to one.
### 35. Admin whitelisting tool is slow & cumbersome — improve with application forms
- [ ] The current admin whitelist tool is slow and clunky to use. Improve the UX when
we integrate the **application/submission forms** (#22) and the **Admin
Submissions tab** (#23) — e.g. approve-an-application → add-to-whitelist in one
action, batch operations, and faster list mutation. Couples with #22/#23.
### 36. Storage unlock doesn't work with nsec login (BUG)
- [x] **ROOT-CAUSED + FIXED 2026-06-29.** Audited the whole nsec unlock path and built
an empirical repro: the crypto layer is **correct** — with real nsec cipher
closures (`createPrivateKeyCipherClosures`), `storageService.unlock` round-trips
master-key encrypt→lock→decrypt perfectly (NIP-44). Both nsec login paths
(`registerPrivateKeySigner`, `unlockStoredSessionActor`) register a signer exposing
`nip04Decrypt`/`nip44Decrypt`. **The real failure is session-shape after a page
reload** (user-confirmed repro): a persisted "remember this key" nsec session
restores the logged-in **pubkey + UI but NOT the in-memory signer** (the private key
is only passphrase-encrypted). `ProfileStorageController`'s fallback
(`ensureActiveSignerForPubkey`) can only rebuild an *extension* signer, so an nsec
user hit a dead-end: storage unlock found no signer and showed the misleading "No
active signer found. Please login." even though they look logged in.
**Fix:** detect this exact case (`getLockedStoredNsecSession` — a saved nsec key for
the account being unlocked via `getStoredSessionActorMetadata`) and surface an
**actionable** error/status ("Your saved key is locked after reload — re-enter your
passphrase from the Login menu to unlock it, then unlock storage") in both the
Unlock-button flow and the passive Storage-pane status text, instead of the generic
no-signer / no-decryptor messages. New error code
`storage-unlock-locked-nsec-session`. Tests:
`tests/storage-unlock-locked-nsec.test.mjs` (6, behavioral). Build + lint clean.
- [x] **Follow-up (UX nicety) — DONE 2026-06-30.** Clicking "Unlock Storage" on a
locked persisted-nsec session now **auto-opens the login modal** straight to its
existing unlock-saved-key (passphrase) flow, so the user re-unlocks in one step.
Added a side-effect-free `openLoginModal` service to `profileModalServices`
(`app.loginModalController.openModal` — no add-account callback, idempotently
ensures the modal exists first); `reportLockedNsecSession({ autoOpenLogin })` calls
it only from the user-initiated unlock click, NOT the passive pane render (no
surprise popups). After the user unlocks, the signer is active app-wide and
re-opening the storage pane auto-unlocks. Tests extended (8 total: asserts auto-open
fires on click, stays closed on passive render).
- [ ] **VERIFY on unstable:** nsec-login with "remember key" + passphrase, reload the
page, open Profile → Storage → the status should read the actionable re-unlock hint
(not "No active signer"); after re-entering the passphrase via Login, storage
unlocks normally. (`[storage-unlock]` console line confirms the locked-key branch.)
- [x] **Same fix for the UPLOAD modal — DONE 2026-06-30.** The Upload modal's "Unlock
storage" had its OWN unlock path (`UploadModal.handleUnlock`) that dead-ended on a
reloaded persisted-nsec session with "No signer available to unlock storage."
Added `promptStoredNsecUnlock(pubkey)`: when the active account has a locked saved
nsec key (`getStoredSessionActorMetadata`), it shows an actionable message and opens
the login modal's unlock-saved-key (passphrase) flow (`onRequestUnlock`, wired in
`initUploadModal` → `app.loginModalController.openModal`) instead of the alert. After
the user re-enters their passphrase the signer is active and the upload proceeds.
Tests: `tests/upload-modal-nsec-unlock.test.mjs` (5). Build + lint clean.
### 37. Channel playlists — creator-curated custom note lists
- [ ] Let creators build **playlists** (custom curated lists of their videos / notes)
surfaced on their channel. Likely a NIP-51 set (kind 30005 "curation sets" or a
bitvid-specific addressable list) referencing video events, with create/edit/
reorder UI on the channel page and a playlist view. Research the list shape +
where it slots into channel profile + the feed engine.
### 38. Generic S3 doesn't work with Backblaze B2 — add B2 as a first-class provider
- [x] **DONE 2026-06-30.** Added **Backblaze B2** as its own provider
(`PROVIDERS.B2 = "backblaze_b2"`). Root issue: B2's S3 endpoint is *region-scoped*
(`s3.<region>.backblazeb2.com`) and a public bucket is addressed virtual-hosted, so
the public download URL is `https://<bucket>.s3.<region>.backblazeb2.com/<key>` —
not derivable from a pasted generic-S3 endpoint. Implementation:
- `deriveB2Endpoint(region)` (`js/storage/s3-url.js`, re-exported from `s3Service`):
region → `https://s3.<region>.backblazeb2.com`; returns "" for `auto`/blank/pasted
host so callers can require a real region.
- `storageService`: `PROVIDERS.B2` + `resolveB2Endpoint` (explicit endpoint wins,
else derive from region) + a `PROVIDER_TESTS[B2]` handler (derived endpoint,
`forcePathStyle:false`).
- **Public URL strategy = S3-style virtual-hosted** (user-chosen): auto-derives to
`https://<bucket>.s3.<region>.backblazeb2.com`; an explicit Public Access URL
(custom domain/CDN) overrides it — the save path now passes the user's URL to
`prepareS3Connection` (also fixes generic-S3 silently ignoring a custom CDN URL).
- UI (`profile-modal.html` + `ProfileStorageController`): "Backblaze B2" option +
helper; for B2 the raw Endpoint field is hidden (region drives it), Region shows a
`us-west-004` example, force-path-style toggle hidden (B2 is virtual-hosted), and
save/test derive the endpoint from region (clear "enter your region" error if
missing). `UploadModal` shows B2-specific labels.
- Tests: `tests/storage-b2-provider.test.mjs` (8 — endpoint derivation, virtual-
hosted public URL, end-to-end validate, explicit override, object URLs). All
s3/storage suites green (50); build + lint clean.
- [x] **VALIDATED against a real B2 account 2026-06-30.** Read-only `b2_authorize_account`
on the live `bitvid` bucket confirmed the derivation EXACTLY: B2's reported
`s3ApiUrl` = `https://s3.us-west-004.backblazeb2.com` = `deriveB2Endpoint("us-west-004")`;
bucket is `allPublic` → S3-style playback URL works. Key file caps
(read/write/list/delete) ✓.
- [x] **CORS helper modal (provider-aware) — DONE 2026-06-30.** Empirically confirmed
the gotcha: B2's web-console "Share everything…" presets write DOWNLOAD-only CORS
(`s3_get/s3_head`, `b2_download_*`) — **no `s3_put/s3_post`** — so browser uploads
CORS-fail. Added a "CORS setup help" button (shown for **all** bucket providers)
opening a **provider-aware** modal:
- **Backblaze B2** → B2-native rules JSON (upload + ranged-playback ops) + the
`b2 update-bucket --corsRules …` command.
- **Custom S3** → standard S3 `CORSConfiguration` JSON + `aws s3api put-bucket-cors
--endpoint-url <endpoint> …`.
- **Cloudflare R2** → standard S3 JSON + Cloudflare-dashboard guidance (R2 → bucket
→ Settings → CORS Policy) and the AWS-CLI command against the derived R2 endpoint.
Origins pre-filled from `getCorsOrigins()`; copy buttons for JSON + command. bitvid
still auto-applies CORS on Save (R2/S3 via `ensureBucketCors`) — this is the manual
fallback for when the key lacks CORS-write permission. Logic in
`js/ui/profileModal/storageCorsHelp.js` (`buildCorsHelpContent` switches on
provider; keeps the controller <1000 lines). Tests:
`tests/storage-cors-help.test.mjs` (10 — per-provider rules/command + DOM switch).
Build + lint clean.
- [ ] **VERIFY live** on unstable: pick Backblaze B2, enter region (`us-west-004`) +
bucket + key/secret, Test → passes; apply the CORS helper's rules; upload a video →
plays from `https://<bucket>.s3.<region>.backblazeb2.com/...`.
- [x] **Per-provider connections (no overwrite/clash) — DONE 2026-06-30.** The UI saved
every provider into a single shared `"default"` connection id, so configuring B2
overwrote R2 (etc.). The service layer + upload path + sync already supported a
MAP of connections keyed by id (`listConnections`/`defaultForUploads`/
`exportAccountRecord`) — only the controller collapsed them. Now each provider type
gets its own slot keyed by provider id (`cloudflare_r2`/`backblaze_b2`/`generic_s3`):
save writes that slot (preserving a single active `defaultForUploads`, and lazily
migrating the legacy `"default"` slot to its provider id), and switching the provider
dropdown loads that provider's saved credentials (or clears the fields for a fresh
entry). Sync already carries the whole record, so all providers sync together.
Logic extracted to `js/ui/profileModal/storageConnections.js` (`saveProviderConnection`
+ pure helpers; keeps the controller under the 1000-line cap). Tests:
`tests/storage-connections.test.mjs` (7 — pure default/dup logic + integration:
R2+B2 coexist with one default, re-default switch, legacy-`default` migration, and
the export carries both). Storage + profile-modal suites 69/69; build + lint clean.
- [x] **Upload wrong-service routing FIXED 2026-06-30 (multi-provider regression).** With
both B2 + Cloudflare configured, an upload could fail with **"S3 endpoint is
required."** when the upload modal's tracked `activeProvider` drifted out of sync
with `activeCredentials` (user: "selector on Backblaze but the modal set for
Cloudflare") — an R2 connection (accountId, no endpoint) got routed through the S3
service. Fix: `MediaUploader.uploadVideo`/`uploadThumbnail` now route by the
CREDENTIALS' own provider (`credentials.provider || meta.provider || provider`), so
a stale modal provider can't pick the wrong service. Test:
`tests/media-uploader-provider-routing.test.mjs` (4). Build + lint clean.
- [ ] **Follow-up (cosmetic):** the upload modal's summary provider label can still show
a stale provider until reloaded — refresh `loadFromStorage` when the storage default
changes in the Storage pane (functional routing is already correct).
- [x] **B2 / generic-S3 upload contract mismatch FIXED 2026-06-30.** Uploading to a
Backblaze B2 (or any generic-S3) bucket failed with **"S3 endpoint is required."**
Root cause: `MediaUploader` calls `service.prepareUpload(npub, { credentials })`
(the `r2Service` contract), but `s3UploadService.prepareUpload` took `(settings, …)`
— so the npub string became the settings (no endpoint). Also a
`storageService.getConnection()` result keeps `bucket`/`endpoint`/`region` under
`.meta`, which `validateS3Connection` reads at the top level. Fix:
`s3UploadService.prepareUpload` now accepts `(npub, { credentials })` and flattens
meta → top-level via `flattenS3Settings`. (R2 was unaffected because its service
already used that contract.) Also: the storage **Test** now derives/passes the B2
public URL so it stops falsely warning "Public Bucket URL is missing." Tests:
`tests/services/s3-upload-prepare-contract.test.mjs` (3 — flatten + npub-ignored +
back-compat). Build + lint clean; upload/storage suites 46/46.
### 39. Image uploads should prefer uploading to configured storage (UX)
- [x] **Profile image + banner upload — FIXED 2026-06-30.** The UI was already wired
(`ProfileEditController.handleUpload`: resolve connection → `uploadFile` → auto-fill
the URL field), but it was **broken**: it passed a **hex** pubkey to
`r2Service.resolveConnection()`, which only decoded `npub1…` strings
(`safeDecodeNpub`) → hex resolved to `null` → "Storage configuration missing" and the
Upload button stayed disabled even with storage configured. (Thumbnails worked
because `mediaUploader` uses `listConnections(pubkey)` directly.)
**Fix:** `resolveConnection` now accepts a hex pubkey OR an npub. The rest of the
chain is already provider-agnostic — `resolveConnection` returns the
`defaultForUploads` connection (any provider) with its `endpoint`/`baseDomain`/
`forcePathStyle`, `uploadFile` routes R2 vs generic-S3 by provider, and
`buildPublicUrl` builds the right URL — so this now works for R2 / B2 / Custom S3.
Test: `tests/r2-resolve-connection-hex.test.mjs` (3, with `nip19.decode` throwing on
hex to prove the hex path, not npub decoding). Build + lint clean.
- [ ] **VERIFY live:** unlock storage, Edit Profile → upload a picture/banner file →
uploads to the active provider's bucket and the URL auto-fills.
- [ ] **Extend the pattern** to any remaining image-URL inputs that still only accept a
pasted link (video thumbnail in upload/edit already uploads via `mediaUploader`).
Audit for others (e.g. future channel/branding image fields) and reuse the helper.
## Open — lower priority / infra
### 10. WebTorrent `null.fill` seeding crash
- [ ] Investigate the vendored `webtorrent.min.js` `null.fill` error on wire handshake;
may require a bundle update or patch.
### 11. Harden flaky tests (CI gate reliability)
- [ ] `tests/ui/uploadModal-reset.test.mjs` ("UploadModal Reset Logic") intermittently
hangs/cancels (jsdom/webtorrent async-hang flake; documented in KNOWN_ISSUES,
reproduced on pre-refactor `1b11cb1b`). Make it deterministic so it can be a
trusted release gate. Audit e2e parallel-load flakiness too.
### 11b. SILENTLY-EXCLUDED unit tests — 20 files never run in CI (found 2026-06-23)
> **2026-07-03 addendum — stale-default failure was hiding in local runs:**
> `tests/app/hash-change-handler.test.mjs` had been failing since 04621efc
> (2026-06-24) made TRENDING the deliberate logged-out landing — the test still
> expected `most-recent-videos`. It went unnoticed locally because
> `npm run test:unit | tail` reports the PIPE's exit code, not npm's (lesson:
> never pipe the suite when reading its exit status). Spec-corrected to assert
> `views/trending.html` (integrity note in the test file); all 3 tests pass.
`scripts/run-unit-tests.mjs` only collects `*.test.mjs|*.test.js` files whose
**content contains `node:test`** (line ~54). 20 test files use bare `assert` +
top-level await instead, so the runner skips them WITHOUT WARNING and they are
referenced by no other CI script. They rot invisibly (this is how the delete-flow
test below was failing on `main` unnoticed).
- [x] **`tests/nostr-delete-flow.test.mjs` — FIXED + brought into CI** (converted to
node:test; stale write-relay config + removed-pool.sub subscription harness
corrected; see TEST_INTEGRITY.md 2026-06-23).
- [ ] **Add a guard so this can't recur**: make `run-unit-tests.mjs` emit a visible
WARNING (or fail) when it finds a `*.test.{mjs,js}` lacking a `node:test` import,
listing each skipped file. (Decide warn-vs-fail — fail would immediately red-CI
on the ~11 broken files below until they're triaged.)
- [x] **Runner guard SHIPPED (2026-06-23).** `scripts/run-unit-tests.mjs` now collects
EVERY `*.test.{mjs,js}` (no more node:test content filter) and runs both styles.
Known-broken files are listed in an explicit `QUARANTINE` map — loudly reported
on every run (never silently skipped), and a stale-entry check flags any
quarantine path that no longer exists. The 10 PASSING orphans below now run in
CI automatically (no per-file conversion needed).
- PASS, now in CI: `nostr-view-event-bindings`, `nostr-rebroadcast-guard`,
`watch-history-feed`, `subscriptions-feed`, `discussion-count-service`,
`feed-engine`, `zap-split`, `nostr-view-events`,
`watchHistory/watch-history-telemetry`, `unit/ui/thumbnailBinder`.
- [ ] **Triage + un-quarantine the 10 broken files** (first-error diagnosis 2026-06-23;
each needs the stale-vs-real-bug investigation the delete-flow file got):
- `watch-history` (#2) — DONE + UN-QUARANTINED 2026-06-23. Three stale tests,
all spec-corrected to shipped behavior (see TEST_INTEGRITY.md):
(1) `testWatchHistoryPartialRelayRetry` asserted snapshot THROWS on partial
acceptance (contradicting `565a9618`) → now asserts success / no-throw /
no-republish / queue-emptied; (2) `testWatchHistoryServiceIntegration` set a
logged-in pubkey different from the queried actor (history files under the
logged-in pubkey now) → aligned; (3) same test asserted `session===true` for a
logged-in watch (the pre-fix bug) → corrected to `session!==true`. The full
2669-line file passes via the CI runner and was removed from QUARANTINE.
- `nostr-boost-actions` — ✅ DONE + UN-QUARANTINED 2026-06-25: STALE SPEC. The
test required `buildRepostEvent` to throw `/missing-event-relay/` with no relay
hint, but a relay hint is a NIP-18 SHOULD (not MUST) — the builder degrades to a
two-element `e` tag so a user can always repost. Replaced with an exact assertion
of the valid output (kind 16 + e/p/k tags). See TEST_INTEGRITY.md.
- `view-counter` (#4) — ✅ DONE + UN-QUARANTINED 2026-06-24: the failures were
(a) the deterministic-d-tag accuracy fix (no more random `d` tag, so no
auto-`d` either), (b) the batched SubscriptionManager silently shadowing
the legacy mocks, (c) a time-of-day-flaky dedupe-window setup, and (d) a
moved root-timestamp unit boundary. All fixed; full file green. See
TEST_INTEGRITY.md.
- `zap-shared-state` (#3) — ✅ DONE + UN-QUARANTINED 2026-06-23: stale fee-fallback
test (expected 0; the function correctly falls back to the configured default so
a junk override can't bypass the fee) — corrected.
- `video-modal-zap` (#3) — ✅ DONE + UN-QUARANTINED 2026-06-23: surfaced TWO real
zap bugs (in-flight status mis-toned `warning`→`neutral`; success path now uses
the holistic `resetZapForm`) + spec/harness fixes (completion-count reset+complete;
receipt render asserts on the inner zapController's element after the
VideoModal→sub-controller refactor).
- `nwc-client` (#3) — REAL production bug FIXED 2026-06-23: `parseNwcUri` passed a
hex secret to nostr-tools `getPublicKey` (needs bytes) → NWC connection broken.
Fixed + guarded by the real-tools `tests/nwc-parse-uri.test.mjs`. The mock-based
`nwc-client.test.mjs` STAYS quarantined — its nostr-tools mock is shadowed by the
frozen canonical toolkit the bootstrap installs; needs a mock-injection rework.
- `nostr-count-fallback` — ✅ DONE + UN-QUARANTINED 2026-06-25: STALE TEST after a
refactor. `countUnsupportedRelays` moved from `NostrClient` to its
`ConnectionManager`; the test read it off the client (undefined.has). Repointed the
assertion at `client.connectionManager.countUnsupportedRelays`. No behavior change.
- `admin-list-store` — ✅ DONE + UN-QUARANTINED 2026-06-25: STALE HARNESS (no
production change, no assertion weakened). Community curator lists are fetched via
the BATCHED SubscriptionManager (cold-start relay-storm fix) which the harness
didn't mock; the mock-"hex" curator values started with "npub" so
parseCommunityBlacklistReferences decoded them twice; and createListEvent omitted
the `d` tag that selectNewestEventsForReferences matches on. Fixed all three to be
faithful to production. See TEST_INTEGRITY.md.
- `nostr-publish-rejection` — STILL QUARANTINED. Partially diagnosed 2026-06-25:
multi-precondition setup. (1) `testPublishVideoNoteDefaultsToLiveModeInDev` needs
dev mode — set `globalThis.__BITVID_DEV_MODE_OVERRIDE__=true` BEFORE the first
(dynamic) config-importing import (~line 50). (2) The `publishNip71Video` sub-test
then fails — `publishVideo`'s NIP-71 invocation path needs FEATURE_PUBLISH_NIP71 /
condition triage; possibly more after. Quarantine note carries the steps.
- `nostr-publish-rejection`, `nwc-client`, `user-blocks` remain quarantined
(see QUARANTINE map in run-unit-tests.mjs for the precise reason on each).
- `user-blocks` — STILL QUARANTINED: HANGS (async leak); needs a deterministic rewrite.
### 12. Promotion: `unstable → beta`
- [ ] After this batch soaks and the high-priority items land, promote `unstable → beta`
for hosted-domain QA (per the release pipeline in CLAUDE.md). Never push directly
to beta/main — always promote from the previous stage.
## Open — My Videos tab follow-ups (deferred from the build)
### 13. My Videos enhancements
- [ ] **Restore (un-delete) action** for deleted rows — republish a prior live version
so a tombstoned video can be brought back from the management tab.
- [ ] **`info.json` sidecar on upload** — write a small metadata file next to each
uploaded object (title, event id, root id, date) so orphaned files can be
identified by name even after their note is gone from relays. PRIVACY GUARD:
skip private videos (the bucket is public) or only mirror already-public fields.
Path-based reconciliation stays the baseline (sidecar is additive, future-only).
- [ ] Show orphan **size/last-modified** (from the ListObjectsV2 metadata) on orphan
rows to make triage easier; consider grouping by folder.
- [ ] Replace the `window.confirm` on orphan delete with the app's confirm/modal for
a consistent, less jarring destructive-action UX.
- [ ] External (non-bucket) URL liveness is unverifiable from the browser — currently
left "OK/unverifiable" on purpose. Optionally add a server-side/HEAD check later
if false-confidence becomes a problem.
## Open — deploy / CI infra (from this session)
### 14. Deploy workflow + CI gate health
- [ ] **On-demand deploy is now the norm for `unstable`** (auto-deploy disabled).
Deploy with `vercel deploy --prod` (repo is linked to `bitvid-unstable`). Decide
whether to apply the same `git.deploymentEnabled:false` to `beta`, and whether to
ever set the Vercel **Production Branch = unstable** if auto-deploy is wanted again.
- [ ] **Visual-regression baseline is environment-sensitive / stale** (committed
baseline from 2026-03 vs current CI Chromium/font rendering → ~1.4% diff). It's
no longer deploy-blocking (own job) but the job is red. Regenerate the baseline
inside CI's environment (or pin fonts / run visual in a fixed container) so it's
a trustworthy gate again — relates to #11.
## Open — enhancements (security-sensitive)
### 15. Encrypted cross-login sync of storage credentials + NWC wallet (opt-in)
Goal: optionally sync the S3/R2 storage credentials AND the NWC zap wallet URI
across logins/devices via an encrypted Nostr note, unlocked by the logged-in
signer — so users don't re-enter them on every device. **Opt-in only; off by
default.**
Status (2026-06-21): COMPLETE — shipped to unstable. Core + Storage + NWC wallet
sync + offer-to-pull-on-login all live. Follow-ups only: cross-device merge
(restore currently REPLACES) and upgrading the login confirm() to a styled modal.
Design / approach:
- [x] Shared **core** `js/nostr/encryptedSync.js` (DI, unit-tested): NIP-78 app
data (kind 30078) replaceable events, namespaced d-tags, **NIP-44-encrypted
to self** with NIP-04 fallback. Facade `js/nostr/encryptedSyncFacade.js`
wires it to the live client/signer. (commits 0abb82b9, 6f7f4196, 7376ff91)
- [x] **Storage creds**: re-encrypt the WHOLE on-disk account record to self
(its `meta` is plaintext — bucket/endpoint names — so transporting the bare
envelope would leak those). `storageService.exportAccountRecord` /
`importAccountRecord`; `storageSyncService` (d-tag `bitvid:storage-connections`).
- [x] **NWC URI** is a bearer SPENDING secret stored via `nwcSettingsService` —
encrypted to self under d-tag `bitvid:nwc`; gated behind an explicit
window.confirm spend warning. `walletSyncService` + Wallet pane UI.
Disconnecting the wallet while synced also wipes the note.
- [x] **Relay/replaceable lessons applied**: publish to the WRITE relays
(`getDeletePublishRelays`), created_at forced strictly-newer, read takes the
**newest event per d-tag** (reads routed through the subscription manager,
not pool.list). NWC will reuse the same core.
- [x] **Storage UX**: opt-in "Sync these settings to my Nostr account (encrypted)"
toggle + "Restore" button + public-relay/key-compromise warning in the
Storage pane (shown only when unlocked). Re-pushes on save when enabled.
- [x] **Wallet UX**: toggle/restore in the Wallet pane + spend warning + confirm.
- [x] **Offer-to-pull on login**: one-time per-pubkey prompt
(`settingsRestorePrompt` + `encryptedSync.exists` list-only peek), wired in
`app.maybeOfferSettingsRestore` (deferred, non-blocking). Reuses the pulls.
- [x] **Clear/disable**: `clear()` publishes a cleared marker so the wipe
propagates (empty-replaceable-publish lesson); `disable()` calls it.
Threat model / cautions (write these into the feature + docs):
- The encrypted blob lives on PUBLIC relays: NIP-44 hides contents but not the
fact that this pubkey stores bitvid creds. Acceptable, but document it.
- Anyone who compromises the user's Nostr key can decrypt these (same trust root
as everything else they sign) — make the opt-in copy explicit about that.
- NWC URI = spending capability; treat as highest-sensitivity. Consider a
confirm + "this lets any device with your key spend from this wallet" warning.
- Never log decrypted creds; clear in-memory copies on logout (existing rule).
- Decryption goes through the (single-threaded) signer — respect the NIP-07/46
decrypt budget + circuit-breaker invariants; don't block login on it.
## Open — new content-type support (needs research first)
> Note: live streams and short-form video are almost certainly NOT the same
> thing — they're two different NIPs. Confirm the exact kinds/tags before building.
> **Shared requirement for #16 + #16b:** each is a NEW left **sidebar tab**, and each
> must be independently **enable/disable-able from the instance config file**
> (`config/instance-config.js`, like the existing feature flags). When a tab is
> disabled it must be completely absent — no tab, no subscriptions, no trace — exactly
> as if it were never added (the current default state). Build both behind their own
> config flags from the start.
### 16. Nostr live streams — INGEST / watch-only (zap.stream, shosho.live)
> **Dev plan: `docs/live-ingest-plan.md`** (decisions open + Phase 0 research spike).
> Ingesting live streams (watch others' streams) and PUBLISHING a stream
> ("go live", #16c) are TWO separate functions and must each have their OWN config
> flag — the maintainer wants to enable/disable ingest and publish independently.
- [ ] Likely **NIP-53 Live Activities**: kind **30311** (live event; carries the
`streaming` URL — usually HLS .m3u8 — plus `status` live/planned/ended,
title, host `p` tags), and kind **1311** (live chat). zap.stream and
shosho.live publish these.
- [ ] Watch-only scope: discover/list live (status=live) events, render the HLS
stream in the player, optionally show live chat (read).
- [ ] New **"Live" sidebar tab**, gated behind its own INGEST config flag (see
shared requirement above) — disabled by default until shipped.
- [ ] Research: confirm NIP-53 kinds + tag shapes, how zap.stream vs shosho.live
populate `streaming`/`recording`, and HLS playback support in the player.
### 16c. Publish live streams — "Go Live" from bitvid (like zap.stream)
> **Dev plan: `docs/live-publish-plan.md`** (FUTURE — after #16 + #16b; static
> client + optional Media Node/Bridge split; MediaMTX; NIP-98 bridge auth;
> archive→VOD reuses the s-tag/info.json model. Decisions open.)
- [ ] Let users **broadcast their own stream** (camera / desktop / arbitrary source)
to Nostr via bitvid, the way zap.stream does: publish a NIP-53 kind-30311 live
event (status live → ended), push the media to a streaming endpoint (HLS), and
keep the event updated. This is the OUTBOUND counterpart to the #16 ingest.
- [ ] **Separate config flag from ingest** — the maintainer wants to enable/disable
"go live" independently of watching live streams.
- [ ] Develop a PLAN first (this is a large effort): the streaming pipeline
(getUserMedia/getDisplayMedia → encoder → HLS/WHIP ingest server), where the
media is hosted (zap.stream uses an external streaming server — bitvid likely
needs the same or a configurable endpoint), event lifecycle, and chat publish
(kind 1311). Note: pure-static bitvid can't host the media itself; identify the
streaming-server dependency early (relates to the CORS/edge constraints).
### 16b. Nostr short-form video notes — watch-only (new sidebar tab)
> **Dev plan: `docs/shorts-plan.md`** (decisions open; ingest already exists).
- [ ] Short-form (vertical/portrait) video is **NIP-71 kind 22** (the short-form
counterpart to kind 21 normal video) — distinct from live streams. bitvid
already ingests NIP-71 (see #17), so much of the parsing exists; this is about
a dedicated **"Shorts" sidebar tab** with a short-form-appropriate UI (vertical
player, swipe/next feel) that lists kind-22 notes.
- [ ] Gated behind its own config flag (see shared requirement above) — disabled by
default until shipped.
- [ ] Research: confirm kind-22 tag shape vs kind 21, and whether to reuse the
NIP-71 ingest adapter / feed pipeline or a dedicated shorts feed.
- OPEN QUESTIONS for the maintainer (raised 2026-06-23): (1) Should "Shorts" pull
from the SAME whitelisted authors as the main feed, or its own discovery scope?
(2) Does short-form need its own moderation/NSFW handling, or inherit the
existing filters? (3) Same for "Live" — scope of who/what is listed?
### 17. NIP-71 interop (full plan in `docs/nip71-migration-plan.md`)
Research done; decisions locked. **See the plan doc** — it supersedes the rough
notes below. Summary:
- Opt-in, off by default. Dual-event: keep canonical kind 30078; add an addressable
**34235/34236** mirror (`d`=`videoRootId`, edits in lockstep). 34235/36 are NOT
deprecated — current NIP-71 designates them for editable content.
- WebTorrent rides standard NIP-94 `imeta` fields (`magnet`, `i`); private videos
never mirrored; HTTPS `url` required to mirror.
- [x] Phase 0 (commit 0e2f683b): `buildNip71MirrorEvent` (js/nostr/nip71Mirror.js)
maps a bitvid video → addressable 34235/36 event; magnet/i/ox added to the
imeta builder+parser; mutation-verified tests. Flag still off, no UX.
- [ ] Phase 1 (in progress):
- [x] 1a (e20191d6): `nip71MirrorService.publish` + the ALLOW_NSFW_CONTENT
gate (instance that forbids NSFW won't mirror it outward).
- [x] 1b (58f19095): `remove` teardown — NIP-09 delete (both addressable
kinds) + empty-replace tombstone.
- [x] 1c/1d (783c7df0): My Videos "Share to apps" opt-in toggle wired to the
mirror service via new FEATURE_NIP71_MIRROR flag (on) +
nip71MirrorFlags (per-video opt-in). Ineligible videos show the reason
(private / NSFW-blocked / no-url). LIVE on unstable.
- [x] Auto-sync (89d9a62b): mirror stays in lockstep on edit/delete,
event-driven via nostrService videos:edited/deleted (no nostrService
growth). Edit re-publishes (or unshares if now-private); delete tears
down + clears the flag.
- [x] Hashtags (a1bf7b15, e793b359): the upload modal's existing NIP-71
hashtag editor already emits `t` tags (feed scoring reads them); the
mirror now carries them too (from t tags / nip71.hashtags). So
publish + feed + mirror hashtags all work.
- [ ] Remaining polish (low value): capture dimensions at upload (short 34236
+ imeta dim — defaults to 34235 now); optional account-level
"auto-share new public videos".
- NOTE: legacy FEATURE_PUBLISH_NIP71 (videoPublisher.js auto-21/22) stays
OFF/dormant — superseded by the opt-in 34235/36 mirror; clean up later.
- [ ] Phase 1.5: NIP-89 handler reg (kind 31990 → "Open in bitvid" elsewhere);
NIP-51 kind 30005 portable playlists.
- [x] Phase 2: inbound ingest of external NIP-71 videos — LIVE on unstable.
- `js/nostr/nip71IngestAdapter.js` (foreign 21/22/34235/34236 → bitvid video;
content-warning→isNsfw; skips bitvid mirrors; surfaces nip71.publishedAt),
`js/services/nip71IngestService.js` (whitelist-scoped subscription,
hydration retry, deferred-until-feed-ready, throttled refresh),
wired in applicationBootstrap. Admin toggle `FEATURE_NIP71_INGEST`
(instance-config) default ON.
- Whitelist model: ingest is scoped to whitelisted authors and reuses the
existing render-time filter (whitelist/NSFW/blacklist/private). When the
admin disables whitelist mode, ingest opens to all authors (capped).
- KEY FIX: the feed's resolve-posted-at stage was firing a per-video
hydrateVideoHistory() network fetch for each ingested video (no kind-30078
history exists) → relay storm + feed hang. Fixed by surfacing
nip71.publishedAt so the stage short-circuits.
- Verified live: Goblinbox (Nostube) content renders; liveness check works.
### 17c. NIP-71 on-boarding + cross-ecosystem dedup (IMPORTANT — full plan in docs/nip71-onboarding-plan.md)
Make NIP-71 interop two-way and guarantee a video is never shown twice.
**See `docs/nip71-onboarding-plan.md` for the full dev plan.** Locked decisions:
creator-initiated self-import (model A — can't forge others' notes), dedup on
explicit import-link + content hash/infohash (no fuzzy url/title), import grants
CDN url + WebTorrent (bitvid extras come free), one-shot import + re-run button.
- [x] **Phase 1: cross-ecosystem dedup** (`js/utils/videoDeduper.js`):
`collapseCrossEcosystem` keyed per-author on `fileSha256/ox/infoHash` + an
`eid` namespace linking on-board provenance (`importedFrom`); prefers the
bitvid (kind-30078) version. Composed into `dedupeVideos` (root-dedup THEN
cross-ecosystem) and routed through the shared chokepoint
`app.dedupeVideosByRoot` → covers the feed stage + channel + search at once.
Pure + mutation-verified. (Hash-less dual-posts still can't dedup — documented
limitation; covered once on-boarded videos carry the provenance link.)
- [ ] **Phase 2: creator-initiated import** — discover the logged-in creator's own
NIP-71 videos lacking a bitvid version; offer FULL import (re-host to THEIR
storage + optional WebTorrent) or REFERENCE import (keep external URL, no
re-host); publish a kind-30078 signed by them with an `["imported-from", <orig>]`
provenance tag. Same-pubkey-only guard; ALLOW_NSFW gate; reuses ingest adapter
+ storage/upload + videoPublisher.
- [ ] **Phase 2b: externally-managed storage flag** — imports NOT produced by our
storage system (reference imports / external URLs) must be explicitly flagged
(`externalStorage: true`, durable — not just the `isUrlUnderBase` heuristic) so
storage tooling never treats them as bitvid-owned: My Videos shows an external
badge (never "missing from bucket"), orphan reconciliation excludes them, the
delete "hosted file left behind" warning is suppressed, liveness stays
unverifiable. Full (re-hosted) imports set the flag false.
### 17d. Unified video-grid loading (streaming + optimistic) — follow-ups
Goal: every grid loads with the same fast UX. Status: cross-ecosystem dedup
(17c Phase 1) is live at the shared chokepoint; main feed already streams +
has a persisted cache; channel/search now render optimistically from
`allEvents` (incl. ingested NIP-71). Remaining:
- [ ] **Streaming channel/search fetch.** `loadUserVideos` does a one-shot
`pMapSettled(relays, pool.list)` and renders only after ALL relays settle,
so a slow relay holds up the grid (~2-3s). Switch to the subscription
manager's streaming + render incrementally (first-relay-wins), like the main
feed's buffered subscription. Extract the fetch into `channelProfileVideos.js`
(channelProfile.js is at its size cap).
- [x] **Per-channel persisted cache** (`channelProfileVideos.js`,
`bitvid:channel-videos:v1`): cold hard-refresh of a profile now paints the
last-seen videos from localStorage before relays connect, then the live fetch
replaces them. Bounded (60 videos/channel, 20 channels LRU), strips raw tags.
Tests + mutation-verified.
- [ ] **My Videos: include NIP-71** in its `allEvents` read so a creator's own
cross-posted videos appear in management too (reuse the cross-ecosystem
dedupe so the bitvid version wins).
- [ ] **Consolidate** the bespoke loaders (channel/search/My Videos) behind one
shared streaming grid source so future grids inherit streaming + optimistic +
dedupe for free.
### 17b. Channel Profile wall shows only bitvid videos, not NIP-71 (LAUNCH-BLOCKER)
- [x] **Fixed** (`js/channelProfileVideos.js`): the channel grid now fetches the
author's NIP-71 videos (kinds 21/22/34235/34236) alongside kind-30078 via
`buildChannelVideoFilters`, and `convertChannelEvent` routes NIP-71 events
through `buildVideoFromNip71Event` (skips bitvid mirrors). Existing
dedupe-by-root + canAccess in `buildRenderableChannelVideos` handle
precedence/gating. Logic extracted to a helper so channelProfile.js stayed
under its size cap. Tests + mutation-verified.
### 40. Dead @media queries: `var()` in media conditions is invalid — FIXED 2026-06-25
Found 2026-06-25 while fixing the profile-modal mobile grid. Many media queries use
`@media (max-width: calc(theme("screens.lg") - var(--breakpoint-edge-offset)))`.
CSS custom properties are NOT allowed inside `@media` conditions — Chromium/Brave
drop the ENTIRE block, so all those rules are DEAD (confirmed via matchMedia: the
`var()` form returns false where it should match; the literal `calc(... - 0.02px)`
works). The `--breakpoint-edge-offset` token was introduced to satisfy the
design-token lint (no raw measurements), but it silently broke every media query
that used it.
- [ ] Audit all `var(--breakpoint-edge-offset)` (and any other `var()`) inside
`@media` conditions in `css/tailwind.source.css` and replace with a valid
literal-resolving form (e.g. `@media (max-width: theme("screens.lg"))`, which
compiles to a literal). The profile-modal nav grid (#7) already uses this.
- [ ] Decide whether the token-lint should EXEMPT @media conditions (so the offset
token can stay) or whether to drop the offset pattern entirely.
- [ ] Re-verify the responsive rules that were silently dead (e.g. the profile-modal
menu/pane-hide block, and the line ~1398/1433 player/modal rules).
- [x] **FIXED 2026-06-25.** Replaced `var(--breakpoint-edge-offset)` with the literal
`0.02px` in all 4 `@media` conditions (`css/tailwind.source.css`). `0.02px` was
ALREADY exempt in the design-token lint, so the offset token was never needed —
using it just produced invalid, dead media queries. Verified valid via matchMedia.
This RE-ACTIVATES 3 previously-dead mobile blocks (intended behavior that had
silently never worked): full-screen modals on mobile, profile-modal menu/pane
switching, and the video-card tap-active state — plus the profile-nav app-grid.
- [ ] **VERIFY live on a phone**: modals should now be full-screen; tapping a video
card shows the active lift/accent/marquee; profile modal tabs are the app-grid.
### 49. Relay-ack false-negatives on the generic content-publish path (HIGH-STAKES)
> Same bug class as the list-publish fix (commit 47f2a9a8): all-timeout ≠ rejection.
- [x] **DONE 2026-07-03 — generic content publish is unconfirmed-tolerant, with
HONEST outcome messaging at every consumer.** The three bare
`assertAnyRelayAccepted` call sites now use
`assertAnyRelayAcceptedOrUnconfirmed` (all-timeout → `summary.unconfirmed`,
soft success; explicit rejections STILL throw `RelayPublishError`):
- `signAndPublishEvent` (js/nostr/publishHelpers.js — video publish, edit,
repost, mirror, videoPublisher) + `rebroadcastEvent`.
- `revertVideo` (js/nostr/client.js) — so a slow-ack tombstone no longer
aborts `deleteAllVersions` midway (kind-5 hard delete now always runs).
Honesty plumbing (the reason this was deferred):
- The relay tally (`attachRelayPublishSummary`, now also attached on EDIT)
carries `unconfirmed`; `describePublishOutcome` maps accepted=0 +
unconfirmed → WARNING "sent, but no relay has confirmed receiving it
yet" — never a fake "published to N relays", never a hard failure.
Zero-accepted WITHOUT the flag stays the hard error.
- Edit: editModalController reads the tally and says "update sent, not
confirmed yet" instead of "updated successfully" when unconfirmed.
- Revert: revertModalController tracks per-entry summaries and only says
"Reverted…" when a relay ACKed; otherwise "sent, not confirmed yet".
- [x] **Delete semantics — decided CONSERVATIVE.** A false "deleted" is worse
than a false "failed": `anyRelayAcceptedInSummaries` (js/nostrPublish.js)
scans every tombstone + kind-5 summary; `handleDeleteModalConfirm` only
claims "All versions deleted successfully!" when ≥1 relay ACKed something,
else "Delete request sent, but no relay has confirmed it yet — the video
may reappear until relays catch up." (Empty set = nothing to publish =
confirmed.) Tests: `tests/relay-ack-unconfirmed-content.test.mjs` (6, incl.
end-to-end signAndPublishEvent all-timeout vs rejection). Lint + build green.
- [ ] **VERIFY on unstable:** with a slow/unreachable relay set, publish + edit +
delete a test video — messages should read "sent/unconfirmed", never
"failed", and the content should be correct after a refresh.
### 50. isDevMode — keep it; fix the test-harness gap (LOW priority)
- Decision (2026-07): **KEEP `isDevMode`.** It's used in ~26 files, chiefly to gate
**runtime schema validation** in `js/nostrEventSchemas.js` (event builders validate
against their schema only in dev — catches malformed events without the prod cost),
plus dev-only verbose logging and dev-only feature flags (`FEATURE_SEARCH_FILTERS`).
Removing it would drop that safety net or force validation into prod. Not a candidate
for removal.
- [x] **DONE 2026-07-02 (quarantine triage):** `tests/nostr-publish-rejection.test.mjs`
fixed + un-quarantined — dev-mode override set before config import, and the
stale NIP-71 auto-publish expectation spec-corrected (FEATURE_PUBLISH_NIP71 is
deliberately off; the mirror is the interop path). File passes fully in CI.
Original note: `tests/nostr-publish-rejection.test.mjs`
asserts `isDevMode === true` but never sets `globalThis.__BITVID_DEV_MODE_OVERRIDE__`
before importing `js/config.js`, so it fails in a non-dev env (`isDevMode` falls back
to `IS_DEV_MODE` = false). Fix: force dev mode via the override in that test's setup.
Fails identically on committed HEAD — pre-existing, low priority.
- [ ] (Optional) sweep other tests that assume dev mode without forcing the override, for
consistency.
## Open — rough edges found in hands-on testing (2026-07-01)
Reported by the maintainer after live testing on `unstable`. Several share a root
cause with **#36** (a persisted **nsec** session restores the pubkey + UI but NOT the
in-memory signer after a page reload, because the private key is only
passphrase-encrypted). Items 51, 56, 57 are all facets of that.
### 51. Shouldn't have to re-"unlock" storage after every refresh (UX / session)
- [ ] **Symptom:** after a page refresh the user must unlock storage again every time.
- [ ] **Root cause:** same as **#36** — the nsec signer + unlocked storage master key
live only in memory, so a reload drops them. Today the fix (#36) makes the error
*actionable* (re-enter passphrase), but the user still has to do it every reload.
- [x] **DONE 2026-07-01 — opt-in "keep unlocked" cache.** Decision (maintainer):
**opt-in** tiers. On a successful nsec unlock the decrypted key is cached in a
new `js/nostr/unlockedKeyCache.js`:
- **session tier (default, always):** `sessionStorage` — survives refresh /
navigation, auto-cleared on tab close. Roughly the same exposure as the
in-memory signer, so no more PIN on every reload within a session.
- **persistent tier (opt-in checkbox):** 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 the user clears site data.
`client.restoreUnlockedSigner(pubkey)` re-registers the signer from the cache
WITHOUT a passphrase (verifies the key derives to that pubkey; a mismatch
forgets it). It's called proactively from `ensureActiveSignerForPubkey` (the
auto-login/refresh path) and lazily from `ensureEncryptionCapableSigner` before
any prompt — so after a reload the signer is restored silently and every
signing/encryption flow (storage, hashtags, DMs, reactions, blocks, subs) just
works. Logout / remove-saved-profile forget the cached key
(`forgetUnlockedSigner`). Tests: `tests/unlocked-key-cache.test.mjs` (8). Lint +
build green.
- [x] **Storage + NWC inherit the kept-unlocked signer — DONE 2026-07-01.** Storage
and NWC don't have their own passphrase: the storage master key and the NWC
settings are ENCRYPTED WITH THE SIGNER (nip44/nip04 self-encryption) and unlocked
by it. So rather than caching a second secret, both now inherit the kept-unlocked
nsec signer. `_initAutoLogin` calls `nostrClient.restoreUnlockedSigner(pubkey)`
BEFORE the boot re-login, so the login flow (NWC hydrate, list decrypt) and the
existing storage auto-unlock (ProfileStorageController / UploadModal call
`storageService.unlock` when a signer is present) all succeed silently on refresh
— no passphrase, no manual "Unlock storage". Tied to the signer cache: persists
exactly as long as the kept-unlocked key does (session or until site-data clear).
(NIP-07/46 profiles no-op — nothing is cached; their extension/remote signer
already persists.)
- [ ] **VERIFY on unstable:** unlock once (box unchecked) → refresh → no PIN, and
Storage shows Unlocked + the wallet/NWC connection is live without re-entry;
close the tab + reopen → PIN again. Check the box → close/reopen the browser →
still no PIN and storage/NWC still ready; Log out or clear site data → PIN again.
- [x] **"Lock this device" control — DONE 2026-07-01.** A footer button in the profile
modal (desktop + mobile), shown only when the active account has a cached
"keep unlocked" key to forget (`app.isSessionKeptUnlocked`). Clicking it runs
`app.lockKeptUnlockedSession(pubkey)`, which re-locks everything the signer
unlocks in one action: forgets the cached key (both tiers → future reloads
re-prompt), drops the in-memory signer (`logoutSigner` → the current session
re-locks now), `storageService.lock(pubkey)`, and `nwcSettingsService.clearCache()`.
Visibility refreshes on `renderSavedProfiles` + `selectPane`. Tests:
2 in `tests/profile-modal-controller.test.mjs` (visibility gate + click locks the
active pubkey). Lint + build green.
- [ ] **Follow-up (optional):** surface whether the current session is *persistently*
remembered (disk vs session tier) in the UI, and consider a "keep unlocked" toggle
in the storage/wallet panes for users who set it up there rather than via a prompt.
### 52. After editing a video, the video grids don't refresh to show the update (BUG)
- [x] **FIXED 2026-07-02.** The edit flow already re-ran `loadVideos()`, but the cache
still held the PRE-edit version: unlike new publishes (#46), the edit never
ingested its signed replaceable event locally, so grids re-rendered stale until
relays echoed the update. `nostrService.handleEditVideoSubmit` now ingests the
edited event via `ingestLocalVideoEvent` (newest-wins replace in the active
store + `videos:updated`), so grids show the new title/thumbnail/etc.
immediately. Test: `tests/services/nostr-service.test.mjs` (+1).
### 53. Channel-profile link in the video player modal doesn't work (BUG)
- [x] **FIXED 2026-07-02.** Event-name mismatch: `VideoModal.handleCreatorNavigation`
dispatched `"navigate:profile"`, but ModalManager only listens for
`"creator:navigate"` → the click went into the void (same class as the old
`action:embed` bug). Renamed the dispatch to `"creator:navigate"` and threaded the
modal's own creator pubkey through: the handler now passes `event.detail.pubkey`
to `app.openCreatorChannel(pubkey)`, and `routerCoordinator.openCreatorChannel`
accepts an optional pubkey (falls back to the active video's creator). Tests:
`tests/video-modal-controllers.test.mjs` (+2 — dispatches creator:navigate with
the pubkey, never the dead event; no-op without a pubkey). Lint + build green.
### 54. Like / dislike buttons in the video player modal don't work (BUG)
- [ ] The reaction (like/dislike) buttons in the player modal are unresponsive / don't
persist. Audit `reactionController` wiring into the video modal (event binding,
active-signer availability, publish path). Note: reactions require signing — verify
this isn't another "lost signer after refresh" (#57) surfacing as a dead button; if
so, prompt to unlock rather than silently no-op.
- [x] **Lost-signer variant addressed 2026-07-01.** `ReactionController.handleReaction`
now runs the `ensureEncryptionCapableSigner` gate (`need:"sign"`) before
publishing, so a reloaded nsec session re-unlocks with one passphrase prompt
instead of the reaction silently failing (rolled-back button + generic error).
Tests: `tests/reaction-signer-gate.test.mjs`. **Still VERIFY on unstable** whether
any residual dead-button behavior remains (event binding / pointer availability),
which would be a separate wiring bug from the signer issue.
- [x] **The actual dead-button bug — FIXED 2026-07-02.** `VideoModal.handleReactionClick`
never derived the reaction or reached the publish path: it called
`this.reactionsController.handleReaction(event)` — a method that does NOT exist on
the video-modal reactions controller (which only binds buttons + does optimistic
UI), passing the raw DOM click event as the "reaction". So the #54 signer gate was
correct but unreachable. Now `handleReactionClick` derives `+`/`-` from the clicked
button (`#modalLikeBtn`/`#modalDislikeBtn`) and dispatches `video:reaction` — the
event ModalManager already routes to `app.handleVideoReaction` →
`reactionController.handleReaction` (which signs/publishes + runs the signer gate).
Tests: `tests/video-modal-controllers.test.mjs` (+2 — derives the reaction and
dispatches; unrelated target is a no-op). Lint + build green.
### 55. Video-grid event caching — faster loads, cache-first + merge-new (PERF / UX)
- [x] **VERIFIED 2026-07-02: event caching already exists — no gap.** Traced end to
end: `allEvents` (incl. ingested NIP-71 videos via `nip71IngestService.injectVideo`)
is persisted to IndexedDB (`bitvid:eventsCache:v1`, `PersistenceManager` +
`EventsCacheStore`), restored on boot (`restoreLocalData`), and `loadVideos` +
the channel grid render **cache-first then merge-new** (never blank+refetch).
Bullets 1–2 were already built; the remaining slowness is only a genuinely-cold
first visit, already bounded by the coalescing/timeout/parity fixes.
- [x] **Media caching part 1 — Cache-Control on uploads — DONE 2026-07-02.**
`computeCacheControl` (js/storage/s3-multipart.js) already stamped most media
immutable; added the missing `webp`/`avif`/`ico` **and `.torrent`** (safe:
`buildR2Key` namespaces by infohash → content-addressed). Playlists (m3u8/mpd)
stay short-lived. Exported + tested (`tests/storage-cache-control.test.mjs`, 3).
- [x] **Media caching part 2 — SW image cache — DONE 2026-07-02.** `sw.min.js` now
serves **cross-origin images** (thumbnails/avatars/banners — the hosts bitvid
doesn't control headers for) **stale-while-revalidate** from `bitvid-images-v1`
(Cache API): instant on revisit, background refresh self-heals changed images,
LRU-capped at 300 entries, quota errors fail open (drop the cache, never break
image loading), and every handler error falls back to plain `fetch`. The
activate handler's wipe-all now **preserves the current image cache** (old
versions + other caches still wiped). Same-origin assets are untouched (they
ride the host HTTP cache + dist cache-purge pipeline). `app._initServiceWorker`
now registers the SW at boot when none exists (same path/scope/options as
`webtorrent.js`'s `setupServiceWorker`, so playback's later `register()` is a
no-op on the same registration). SW_VERSION bumped. Tests:
`tests/sw-image-cache.test.mjs` (6 — runs the REAL sw.min.js in a VM sandbox:
cold/warm/offline/opaque flows, non-interception, activate preservation).
- [ ] **VERIFY on unstable:** revisit a feed/channel — thumbnails + avatars should
load instantly from the SW cache (DevTools → Network shows "(ServiceWorker)");
after an SW update the image cache survives. Optional follow-up: pull-to-refresh
/ manual refresh affordance for forced re-fetch (nice-to-have; background
merge-new already keeps grids fresh).
### 56. Upload & edit modals: prompt to unlock storage (PIN popup), don't just error (UX)
- [x] **DONE 2026-07-03.** All three locked-storage file-pick paths now unlock
INLINE and continue with the same pick, via the shared signer gate
(`app.ensureEncryptionCapableSigner`: silent kept-unlocked restore, else ONE
passphrase prompt with the keep-unlocked checkbox):
- **Upload modal, video file:** was `showError("Please unlock storage…")`
→ now `ensureStorageUnlockedForUpload()` (new
`js/ui/components/uploadModalStorageUnlock.js`; UploadModal delegates —
also absorbed `promptStoredNsecUnlock` for the file-size budget).
- **Upload modal, thumbnail:** was a SILENT no-op → now the same inline
unlock; a refused unlock resets the picker (placeholder + URL input).
- **Edit modal, "Replace file" (video + thumbnail):** was "Unlock your
storage in the profile modal, then try again" → `ensureUploadable`
(js/ui/components/editModalUpload.js) now gates the signer, runs
`storageService.unlock`, and re-resolves the connection in-gesture
(extension signers still get the permission request first).
Cancel / bad-passphrase abort quietly (the gate toasts a wrong passphrase
itself); other failures fall through to the pre-existing messaging.
`ensureSigner` is threaded through initUploadModal / initEditModal from app.
Tests: `tests/upload-edit-storage-unlock-gate.test.mjs` (12). Lint + build green.
- [x] Combine with **51**: satisfied by the gate itself — it restores the cached
keep-unlocked key first, so an unlocked-once session never re-prompts.
- [ ] **VERIFY on unstable:** locked nsec session → pick a video file in the upload
modal (one PIN prompt, upload proceeds), pick a thumbnail (same), and use
Edit → Replace file (same). Cancel each prompt once — no error spam, picker
state restored.
### 57. nsec / NIP-46 accounts: encryption ops fail after refresh — re-prompt, don't blanket-error (BUG)
- [ ] **Symptom:** adding/editing a hashtag on an **nsec**-logged-in account errors with
*"Connect a Nostr signer that supports encryption before managing hashtag
preferences."* Same class blocks other NIP-44/encryption list ops (subscriptions,
blocks, DMs) after a reload.
- [ ] **Root cause (likely #36):** the reload dropped the in-memory nsec signer, so
`nip44Encrypt`/`nip44Decrypt` aren't available even though the user "looks" logged
in. The guard then emits a blanket "connect a signer that supports encryption."
- [ ] **Fix intent:** ALL signing/encryption functions must work across ALL signer types
(nsec / NIP-46 / NIP-07). When the op needs a locked nsec key, **prompt to re-enter
the PIN/passphrase to re-unlock the signer** (reuse #36's unlock flow) instead of a
dead-end error. Cross-ref **#48** (fluent switching) and the account-switch refresh
work (commit `87af650b`). Audit every "signer that supports encryption" guard and
route it to the unlock prompt when a saved nsec key exists for the active pubkey.
- [x] **Shared gate built + hashtags wired — DONE 2026-07-01.** New reusable
`app.ensureEncryptionCapableSigner({ pubkey, need, promptMessage })`
(`js/app.js`, pure branching in `js/nostr/ensureSignerDecision.js`): 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
flow until the next reload). Never unlocks a different account's key
(pubkey-mismatch guard). Exposed to the profile modal as
`services.ensureEncryptionCapableSigner`. **Hashtag add/edit** now routes through
it (`ProfileHashtagController.persistHashtagPreferences`): a cancelled prompt
silently aborts, a bad passphrase shows its own toast, and publish never runs
until the signer is confirmed. Tests: `tests/ensure-signer-decision.test.mjs` (9),
+ 2 wiring tests in `tests/profile-modal-controller.test.mjs`. Lint + build green.
- [x] **Fast-follows wired — DONE 2026-07-01.** All remaining signing/encryption
call sites now route through `ensureEncryptionCapableSigner`, short-circuiting
only on the user-driven outcomes (cancel / bad-passphrase) so every other case
still falls through to the flow's own error handling:
- **Reactions (#54):** `ReactionController.handleReaction` gates before the
optimistic update with `need:"sign"` (kind-7 reactions don't need encryption).
Tests: `tests/reaction-signer-gate.test.mjs` (3).
- **Blocks:** `authSessionCoordinator.handleProfileBlocklistMutation`
(`need:"encrypt"`, NIP-04 mute/block lists).
- **Subscriptions:** the channel-profile Subscribe/Unsubscribe toggle
(`js/channelProfile.js`, `need:"encrypt"`, kind-30000).
- **DM send:** both composers — `handleSendProfileMessage` and
`handleDmAppShellSendMessage` (`need:"encrypt"`, NIP-17/NIP-04).
Lint + build + related suites (decision, reaction, DM, profile-modal, switch)
green.
- [ ] **VERIFY on unstable:** with an nsec account, reload, then (a) add a hashtag,
(b) like/dislike a video, (c) block/unblock a creator, (d) subscribe to a channel,
(e) send a DM — each should show ONE passphrase prompt (not a blanket error), then
work; the single unlock should cover the rest until the next reload.
### 58. Remove NIP-71 authoring UI from upload/edit modals (clean UI)
- [x] **DONE 2026-07-02.** Decision (maintainer): bitvid authors kind-30078 and
MIRRORS to NIP-71 — it does not author NIP-71 directly — so the NIP-71 form
sections were confusing dead weight. Verified before removing: the My Videos
mirror (`buildNip71MirrorEvent`) derives EVERYTHING from the bitvid note
(title/url/magnet/thumbnail/description, duration+dims auto-captured at upload,
contentWarning from the NSFW toggle, hashtags), and the only consumer of the
form's NIP-71 metadata (`publishNip71Video`) is gated behind
`FEATURE_PUBLISH_NIP71: false`.
Removed: upload modal's "NIP-71 Technical" (content warning / duration /
summary+Customize) + IMETA variant editor; edit modal's kind selector,
published_at, alt, duration, content warning, summary, IMETA, text tracks,
segments, participants, references. KEPT: hashtags in both modals (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).
Compatibility: existing videos' NIP-71 extras are PRESERVED on save — EditModal
keeps the full stored metadata (`storedNip71Metadata`) and merges the edited
hashtags into it instead of replacing it with the reduced form's empty output.
Also fixed a latent bug this exposed: `nip71Edited` compared a normalized
submit-side copy against an unnormalized original (publishedAt string vs
timestamp), so every save looked "edited"; both sides now share
`normalizeCollectedNip71`. Tests: +2 in `tests/edit-modal-submit-state.test.mjs`
(extras survive a hashtag edit; untouched save → nip71Edited false).
- [ ] **Post-launch cleanup (Option B, optional):** delete the dormant
`FEATURE_PUBLISH_NIP71` auto-publish path, the unused `Nip71FormManager`
sections (imeta/text-track/segment/p/r + scalar inputs), and the nip71
plumbing through `videoNotePayload` that only the removed forms fed.
RevertModal keeps its READ-ONLY use of the manager (version-history display).
### 59. First-run onboarding experience (guided tour) — plan in docs/onboarding-plan.md
- [x] **Phase 1 BUILT 2026-07-02.** Guided tour for fresh logins: spotlight scrim
with a cutout over each step's anchor + a pulsing accent-glow ring
(`--color-accent`/`--color-accent-strong` theme tokens), anchored popover with
Back/Next/Skip + progress dots, keyboard nav (arrows/Esc). Hand-rolled engine
(`js/ui/onboarding/tourEngine.js`, no deps) positioning via CSS custom
properties; steps with missing anchors auto-skip (responsive-safe). Steps:
welcome → feeds (#sidebar) → subscriptions → upload → profile → final card with
"Set up storage" / "Connect wallet" deep-links into the profile panes. Offered
once per pubkey per device (`bitvid:onboarding:v1`, settingsRestorePrompt-style
flags), triggered 3.5s after first login, re-launchable via "Take the tour" in
the profile modal footer. Tests: `tests/onboarding-tour.test.mjs` (5).
- [ ] **VERIFY on unstable:** log in with a pubkey that has never completed the tour
→ tour appears ~3.5s after the feed renders; spotlight glow follows each anchor;
Skip/Esc dismisses and never re-offers; "Take the tour" replays it; the final
card's buttons open the storage/wallet panes.
- [ ] **Phase 2:** "Getting started" checklist card (relays ✓, follow 3 channels ✓,
storage ✓, wallet ✓, first upload ✓) — persistent + dismissible.
- [ ] **Phase 3:** empty-state upgrades (teach + CTA; suggested channels from trust
seeds in empty For You / Subscriptions).
- [ ] **Phase 4:** fresh-npub bootstrap — "Create account" via the existing generate
provider with a REQUIRED key-backup step, then inline kind-0 profile setup
(name + avatar), then the tour.