Wires the storage-credential sync (6f7f4196) into the profile Storage pane.
UI (components/profile-modal.html): a "Cross-device sync" card shown only when
storage is unlocked — an opt-in toggle ("Sync these settings to my Nostr account
(encrypted)"), a "Restore from my Nostr account" button, the public-relay /
key-compromise warning copy, and a status line.
Controller (ProfileStorageController):
- toggle on -> storageSyncService.enable() (encrypt + publish), with rollback +
message if nothing is stored yet; toggle off -> disable() (wipe the note)
- restore -> pull() + re-render the pane so the restored connection shows
- after a successful Save, re-push the synced copy when sync is enabled
Also routes the core's relay READ through the subscription manager (injected
listEvents) instead of pool.list, satisfying lint:pool-access for the new file;
the facade supplies it via nostrClient.getSubscriptionManager().list.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Wires the encrypted-sync core (0abb82b9) to the live client + active signer and
adds the storage-credential sync service. No UI yet — the Storage pane toggle is
the next increment.
- js/nostr/encryptedSyncFacade.js: default encryptedSync instance bound to
nostrClient (write set via getDeletePublishRelays, read relays, pool) and the
active signer's signEvent. Keeps the core pure/testable.
- js/services/storageService.js: exportAccountRecord / importAccountRecord — get
the on-disk account record and restore it on another device. Import REPLACES
the local record (same-pubkey envelope unlocks with the same signer); cross-
device merge is a follow-up.
- js/services/storageSyncService.js: enable/disable/push/pull under d-tag
"bitvid:storage-connections", with a per-pubkey opt-in flag (off by default).
enable() pushes immediately; disable() wipes the published note.
tests/storage-sync-service.test.mjs: enable-pushes-exported-record, pull-restores
on a fresh device, disable-clears, empty-record-doesn't-publish. Mutation-verified
(skip-import and skip-clear both fail).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Shared core for the opt-in encrypted sync of bitvid settings (storage creds, NWC
wallet) recorded in todo #15. Publishes settings as NIP-78 app-data (kind 30078)
REPLACEABLE events, NIP-44-encrypted to the user's own key (NIP-04 fallback when
the signer lacks NIP-44).
js/nostr/encryptedSync.js — createEncryptedSyncManager(deps):
- isAvailable(): signer present + can encrypt to self
- push(dTag, payload): encrypt-to-self -> publish to WRITE relays, created_at
forced strictly-newer so a replace actually wins
- pull(dTag): read newest-per-d-tag from read relays -> decrypt (a stale device
cannot clobber newer settings)
- clear(dTag): publish a cleared marker so the wipe propagates (empty-
replaceable-publish lesson)
Dependency-injected (pure) so it unit-tests without a live signer/relay. No UI
wiring yet — no behavior change. Storage pane wiring is the next increment.
Security: encryption hides contents but not that this pubkey stores bitvid
settings (public relays); anyone with the user's key can decrypt; NWC URIs are
spending secrets (caller must gate behind a warning); never log decrypted data.
tests/encrypted-sync.test.mjs — round-trip, no-cleartext-leak, newest-wins,
clear, NIP-04 fallback. Mutation-verified (newest-selection + encryption-bypass
both fail the suite).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Watch-history cards rendered "Untitled video" / "Unknown" for newly watched
videos. nostrClient.allEvents stores CONVERTED video objects
(convertEventToVideo), which carry tags/pubkey but no `kind` field — only raw
relay events do. resolveEventAddress() did Number(event.kind) → NaN and bailed
with "", so the address (cache-by-"a"-pointer) scan matched nothing. Every video
pointer (all are "a" addresses) was forced to a relay round-trip, and when that
returned nothing the card showed Unknown — even though the just-watched video
was sitting right there in allEvents.
Fix: default to the video kind (30078) when a cached object has no `kind`, so the
local cache scan resolves freshly-watched "a" pointers without a relay round-trip.
Adds tests/watch-history-hydration-cache.test.mjs: relays return nothing, so a
pass can only come from the cache scan (mutation-verified against the NaN-reject
regression). Exports createWatchHistoryHydrationStage for boundary testing.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
View events are always signed by the anonymous session actor (privacy for the
public view counter). publishView read the watched-actor from that view event's
pubkey, so a logged-in user's new watches were enqueued — and republished via
updateWatchHistoryList — under the SESSION actor. The read path queries
authors:[loggedIn] only, so those session-authored months were never
discovered: newly watched videos never appeared in the synced watch history
(e.g. a 2026-06 month that never showed up despite watching June videos).
Fix:
- publishView now resolves the watch-history actor to the logged-in pubkey
first, falling back to the view event / session actor only when logged out.
- A watch is flagged session/local-only only when nobody is logged in
(previously it was flagged whenever the view actor differed from the
logged-in user, which was always true given anonymized views).
Adds tests/watch-history-records-under-login.test.mjs (mutation-verified: both
the actor-resolution and the session-flag fixes are independently load-bearing).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
This is why logged-in removal "worked but threw a flag error", and likely why
new watches stopped appearing in history. publishMonthRecord reported success
only when EVERY write relay accepted (acceptedCount === relays.length). With a
20+ relay list many always reject (auth-required/rate-limited/not-in-write-set),
so every snapshot — removals AND new-watch tracking — was marked
"partial-relay-acceptance" -> ok:false -> "watch-history-snapshot-failed". The
data WAS published (major relays accepted), but the UI showed a failure and the
queue treated it as not-yet-synced.
A watch-history event is durable if any relay accepted it (reads take the newest
copy per d-tag), so success = acceptedCount > 0; only a zero-accept publish is a
retryable failure. Also dropped the redundant partialAcceptance alias to stay
within the file-size budget.
Tests + mutation-verified: 1-of-3 accept -> ok/non-retryable; 0-of-3 ->
failure/retryable. Reverting to the all-relays criterion fails the partial case.
Build + full lint green.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The logged-out delete error: handleWatchHistoryRemoval threw
"watch-history-disabled" whenever the service wasn't synced (isEnabled() false) —
which is the NORMAL state for a logged-out user — so local deletes errored with
"Watch history sync is not available right now" and never touched the local
queue. It conflated "not synced to relays" with "disabled".
Now it only rejects when neither synced NOR local history is supported. For a
local-only actor it proceeds with the snapshot (replace), which rewrites the
local queue (replaceLocalQueue), and SKIPS the relay publish (gated on synced) so
a session-actor list is never pushed to relays. Success copy reflects local vs
synced.
Tests + mutation-verified: local-only removal isn't rejected, snapshots with
replace, and does NOT publish to relays; synced removal still publishes; truly
disabled (no sync, no local) is still rejected. Reverting the gate fails the
local cases.
Build + full lint green.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The other half of the synced-vs-local design: for logged-out / session
(local-only) actors, watch history lives in a localStorage queue (loadLatest ->
collectQueueItems), but snapshot no-op'd for local-only actors — so a removal
never touched the queue and the item reappeared on reload (same symptom as the
logged-in bug, different layer).
Now a REPLACE snapshot for a local-only actor rewrites the local queue to exactly
the provided items (new replaceLocalQueue helper): removals prune it, an empty
replace clears it. Normal watch-tracking adds still flow through the queue via
recordVideoView, unchanged.
Tests + mutation-verified: local replace seeds then prunes the queue; empty
replace clears it. Reverting to the no-op fails both.
Build + full lint green (watchHistoryService under file-size budget).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
THE root cause of "watch history delete does nothing / card vanishes then pops
back": the profile modal's history renderer overrode `remove` to route through
onHistoryReady -> app.handleProfileHistoryEvent(), which is a stub that just
`return null`. So deleting an item in the profile history pane published NOTHING
— the card was removed optimistically, then the refresh re-rendered the full
list and it reappeared. No publish, no logs, no relay change.
This explains why earlier fixes (replace semantics, newest-per-d-tag read,
empty-month publish) didn't help — they were all on the standalone history path /
the publish layer, but the profile pane never reached any of them. The override
was almost certainly a placeholder left over from the synced-vs-local history
refactor that was never implemented.
Fix: drop the broken `remove` override so the profile history pane falls through
to the renderer's working default (defaultRemoveHandler -> snapshot +
updateWatchHistoryList with replace:true) — the same path the standalone view
uses. Validated manually (UI wiring); regression test to backfill.
Build + full lint green.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diagnostics revealed the delete does NOT go through watchHistoryController
(app.watchHistoryController is undefined — it lives on modalCoordinator, not the
app root), so the renderer's `remove` handler always falls through to the
uninstrumented defaultRemoveHandler. That's why no [watch-history:remove] logs
appeared even though a refresh fired. Instrument defaultRemoveHandler to log the
published (reduced) item count + snapshot/list-update results, so we can confirm
whether the publish reduces correctly and isolate the read/cache layer.
Flag-gated; no behavior change.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Watch-history delete still misbehaves for the user; add targeted diagnostics to
pinpoint whether it's the publish, relay propagation, or the local cache/read.
- logWatchHistoryDebug now routes to console (not the prod-suppressed devLogger),
so enabling the existing `bitvid:debug:watch-history` flag surfaces logs on the
hosted site too.
- Instrument the removal path (watchHistoryController): the computed remaining
list (count + values + source), snapshot result, list-update result/finalCount,
and publish failures.
- Instrument the read path (historyView.loadHistory): what a (force) refresh
actually returns (count + values) — directly tests the "is the cache returning
the stale full list?" hypothesis.
Flag-gated and off by default; no behavior change (watch-history tests green).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Two new content-type backlog items (research-first): watch-only Nostr live
streams from zap.stream/shosho.live (likely NIP-53 kind 30311 + 1311 chat), and
consuming external short-form/NIP-71 video notes (likely kind 22/21) from apps
like Amethyst. Flags that these are two distinct NIPs, not the same thing.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Captures the requested feature: optionally sync S3/R2 storage credentials and the
NWC zap wallet URI across logins via a self-encrypted NIP-78 (kind 30078) note,
unlocked by the signer. Includes the design path (reuse the existing envelope
encryption for storage creds; NIP-44-to-self for the NWC URI), the relay/
replaceable lessons from this session (write relays, strictly-newer created_at,
newest-per-d-tag on read, empty-publish to clear), opt-in UX, and the threat
model (public-relay blob, key-compromise trust root, NWC = spending capability,
signer decrypt budget).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Third and final watch-history-delete gap: removing the LAST item of a month (or
clearing all) left the file's items live on relays. updateWatchHistoryList
already sets records[month]=[] to clear an emptied month, but publishMonthRecord
early-returned on an empty month (`if (!items.length) return {ok:true}`), so the
clearing event was never published and relays kept serving the old, full copy.
Now an empty month still publishes a newest, EMPTY replaceable event for that
d-tag, overwriting the old copy so the clear actually takes effect (and reads —
which take the newest per d-tag after fff2bef2 — see it as empty).
Tests + mutation-verified: an emptied month publishes a clearing event with the
right d-tag and no pointer tags, while a month that still has items publishes its
remaining pointers. Restoring the early-return fails the clearing scenario.
Build + full lint green (watchHistory.js under file-size budget).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Second root cause of "watch history delete doesn't work / list isn't getting
overwritten": reads UNION items across every event the relays return, and a
publish only reports success when ALL relays accept. With a flaky relay list the
reduced (post-removal) event reaches some relays while others keep serving the
old, larger copy — and the reader deduped only by event id, so both the stale
and fresh versions of the same month (d-tag) were unioned, resurrecting removed
items.
Add dedupeNewestPerReplaceableAddress and apply it at both read union sites (the
chunk-events path and the pointer-events path): keep only the newest event per
d-tag (month) before unioning, so a lagging relay can't reintroduce removed
items. Distinct months are all preserved; events without a d-tag are kept by id.
Tests + mutation-verified: newest-per-month wins (incl. the fewer-items removal
case), distinct months preserved, newest-first ordering, no-d-tag kept by id.
Reverting to id-dedup fails the resurrection scenarios.
Build + full lint green (watchHistory.js back under file-size budget).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Adds this session's deferred/discovered work: My Videos Restore action, info.json
upload sidecar (with privacy guard), orphan size/date metadata, confirm-modal
polish, external-URL liveness caveat; plus the on-demand deploy workflow decision
(beta?) and the env-sensitive visual-regression baseline that needs CI-side
regeneration. Marks the orphan-GC item as largely delivered by the My Videos tab,
and logs the completed My Videos / delete / watch-history / CI / Vercel work.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Pushing to unstable triggered a Vercel Preview build on every commit (and the
manual promote rebuilt again) — wasted build time on a fast-moving dev branch we
deploy on demand anyway. Set git.deploymentEnabled.unstable=false so pushes no
longer auto-build; deploys are now explicit via `vercel deploy --prod` when we
want unstable.bitvid.network updated. beta/main keep auto-deploying (intentional,
low-frequency promotions).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
After the note-side list renders, the My Videos tab now reconciles against the
bucket: lists the user's objects (unlock-gated), then marks any active note whose
hosted file is gone as a 🟠 "Missing file", and lists bucket objects no current
video references in an "Orphaned storage objects" section — including files left
behind by videos deleted on Nostr (the headline cleanup case). Each orphan has a
"Delete file" button (with a confirm) that calls deleteStorageKeys and refreshes.
Reconciliation runs after the list is shown so the UI stays responsive, and is a
no-op (orphan section hidden) when storage is locked/unconfigured. Rows carry a
data-video-id + a data-role pill so missing status can be applied in place.
Build + full lint green (DOM via createElement/textContent, no innerHTML).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The bucket listing returns raw keys, but deleteVideoStorage only works from note
objects — so orphan cleanup needs a key-based delete. Adds
r2Service.deleteStorageKeys({ keys, npub|pubkey }) (unlock-gated; deletes exactly
the given keys, records per-key failures without aborting the batch). Extracts a
shared resolveStorageClient (unlock-gated connection+client resolution) now used
by both listVideoStorageObjects and deleteStorageKeys, removing the duplication.
Tests + mutation-verified: never deletes while locked, empty-list no-op, deletes
exactly the given keys, per-key failure isolation. The shared lock gate is
mutation-verified across both list and delete paths.
Build + full lint green (r2Service under its file-size budget).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Adds the bucket-enumeration primitive the My Videos tab needs to find orphan and
missing files:
- s3 layer: ListObjectsV2Command wired into the SDK wrapper + a paginated
listObjects({ s3, bucket, prefix, maxKeys }) helper (follows continuation
tokens, bounded by maxKeys).
- r2Service.listVideoStorageObjects({ npub|pubkey }) — unlock-gated, lists under
the user's own u/<npub>/ prefix; returns { ok, reason, keys }. Body lives in
r2ServiceHelpers (with a shared buildProviderClient) so r2Service stays under
its file-size budget; ensureS3SdkLoaded is injectable via ctx for testing.
Tests + mutation-verified: never lists while locked (storage-locked, no call),
no-connection / missing-bucket gating, and unlocked listing is scoped to the
user's own u/<npub>/ prefix (empty-prefix mutation fails). Removing the lock gate
or widening the prefix each fail a scenario.
Build + full lint green (r2Service back under limit).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Pure diff of the user's notes against their bucket contents: reconcileStorage
returns MISSING (an active hosted note whose primary video file is absent) and
ORPHAN keys (bucket objects no live note references — including a deleted video's
leftover file, the "deleted on Nostr but still in the bucket" case). Safety
guarantees: a file an active note still uses, plus its sibling .torrent and
thumbnail, is never reported as an orphan; a missing sibling does not mark a row
missing (only a missing primary video file does); external-URL videos can't be
"missing" but unrelated bucket files are still orphans.
Tests + mutation-verified: orphan = present minus referenced (not all present),
and the missing-primary check, are both independently guarded.
Build + full lint green. Next: r2Service.listVideoStorageObjects + wiring.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Each My Videos row now shows the video's thumbnail (lazy, with a placeholder box
that survives a broken/missing image) and, for active videos, Edit and Delete
buttons that route to the app's existing handleEditVideo / handleFullDeleteVideo
flows (closing the full-screen profile modal first so those modals aren't hidden
behind it). Delete reuses the app flow that already cleans the live URL's storage.
Deleted videos intentionally have no per-row action yet: the tombstone scrubbed
their URL, so reclaiming their orphaned bucket file requires the Phase 2 bucket
listing/reconciliation (and Restore) — added there so it's done correctly.
Build + full lint green (DOM via createElement/textContent, no innerHTML).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
selectPane lowercases the requested name, but the My Videos pane/nav were
registered under the camelCase key "myVideos". Every other pane key is a single
lowercase word, so this latent mismatch only surfaced now: clicking My Videos
resolved "myvideos" against panes that only had "myVideos", failed the
availableKeys check, and fell back to Account. Register the pane/nav/selectPane
hook under "myvideos".
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Adds a "My Videos" tab to the profile modal that lists every video the logged-in
user has published — collapsed to one row per video in its current state
(including tombstoned ones) — each with a note-side health pill: red "No playable
source", grey "Deleted on Nostr", green "OK". Shows a summary line
(N videos · M need attention · K deleted) and refreshes from relays on open.
- New MyVideosController (lazy-populated via selectPane, like the other panes):
fetchVideosByAuthors -> collapseUserVideos -> classifyVideoHealth -> render.
Rows built with createElement/textContent (no innerHTML; user titles/URLs are
not interpolated into markup). Status pills reuse the relay-pill token styles.
- Sidebar nav button + pane markup in components/profile-modal.html.
- Wired into profileModalController (cache refs, listeners, selectPane hook);
the existing nav-button loop auto-binds the click.
This is display-only; later steps add the hosted-URL HEAD probe (dead-url), row
actions (edit/delete/revert/storage cleanup), and Phase 2 bucket reconciliation.
Build + full lint green (no new file-size/innerHTML/token violations).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Second pure piece of the "My Videos" tab: collapseUserVideos reduces all of a
user's video events (every edit/revert/deletion the client holds) to one entry
per addressable video, keeping the latest version — which carries the current
state, including deleted:true when the latest is a tombstone (those are gone from
the active feed but still matter here for storage cleanup). Filters to the owner
only, drops invalid notes, sorts newest-first.
Tests + mutation-verified: owner filtering, dedup-to-latest, deleted-latest
preserved, re-publish-after-delete wins, invalid dropped, empty-pubkey returns
nothing (never leak all videos). Flipping latest->oldest fails 3 scenarios.
Build + full lint green.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
First piece of the "My Videos" management tab: classifyVideoHealth decides the
maintenance status for one of the user's own videos from note-side data plus an
optional URL probe result. Pure/synchronous so it's deterministic and testable;
the controller will do the actual HEAD probe and pass the result in.
Statuses: no-source (no url AND no magnet) -> error; hosted-URL-unreachable ->
warning; deleted (tombstoned) -> info; otherwise ok. Key guardrail: only URLs
under the user's storage publicBaseUrl can be flagged dead — external/CORS-opaque
links are never cried wolf on. Deleted is checked before no-source since
soft-delete scrubs url/magnet.
Tests + mutation-verified: dropping the hosted guard (external false-alarm) and
removing deleted-precedence each fail a scenario.
Build + full lint green.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The visual-regression step lived inside the build job, and deploy-cache-state
(the Cloudflare CDN cache-purge that makes new content visible) needs: build.
So an environment-sensitive pixel diff — CI's Chromium/font rendering differs
from the committed baseline (locally the baseline is byte-identical, so it's not
a content regression) — failed the build job and silently blocked every deploy.
The unstable.bitvid.network CDN was stuck on the 2026-06-17 build as a result.
Move the visual steps into a standalone visual-regression job so they still run
and report a failure, but no longer block the build job or the deploy. Real
lint/build/artifact failures still gate the deploy via build.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Root cause of "watch history delete does not work at all": the removal paths
call watchHistoryService.snapshot(remaining, …), but snapshot published via
updateWatchHistoryList WITHOUT replace:true. The default merges incoming items
with the existing cached list, so the just-removed item was merged straight back
and the deletion silently had no effect. Removing the LAST item was worse: an
empty list fell through to republishing the pending queue instead of clearing.
- snapshot now honors options.replace: forwards it to updateWatchHistoryList,
treats an explicit empty replace as a clear (publishes empty instead of
short-circuiting or falling back to the queue), and skips the cached-items
merge when replacing. Adds (the default) keep merge semantics.
- Both removal call sites (watchHistoryController.handleWatchHistoryRemoval and
historyView.defaultRemoveHandler) now pass replace:true.
Tests (snapshot at the updateWatchHistoryList boundary): removing an item
publishes the remaining list with replace:true; removing the last item publishes
an empty replace (not a queue republish / no-op); a normal watch-tracking
snapshot still merges (replace not forced). Mutation-verified — dropping the
replace forwarding fails 2 cases and reverting the empty-clear handling fails the
clear case.
Build + full lint green.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Root cause of "deleted/tombstoned videos still show in the UI": normal video
publishes fan out to the uncapped WRITE relay set, but both delete paths — the
soft-delete tombstone (revertVideo) and the NIP-09 deletion — published only to
this.relays, the CAPPED read set (<=8). A video published broadly was therefore
tombstoned on just a subset; every relay outside that subset kept serving the
original event, so the video resurrected on the next load (the tombstone's
created_at was already bumped strictly-newer, so replacement worked — it just
never reached the relays that mattered).
Add getDeletePublishRelays() (prefer write set, fall back to read set, then
bundled defaults) and use it for both delete publishes, mirroring publishVideo.
Also removed a stray `const a = 1` debug leftover at end of client.js.
Tests: getDeletePublishRelays prefers the write set / falls back to read; and a
call-site test that revertVideo publishes the tombstone to the full 12-relay
write set rather than the 2-relay capped read subset. Mutation-verified —
restoring `this.relays` at the call sites fails the propagation test.
Build + full lint green.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Spec correction following de515462 (feed always reserves live default
aggregators). The existing "uses the healthy relays when they are available"
case asserted deepEqual(subRelayArgs, healthy) — i.e. ONLY the healthy relays.
That expectation is now wrong: the feed deliberately also reserves known-good
defaults so a healthy-looking but dead user list can't hang the feed. Replaced
with a stricter behavioral assertion (healthy relays still present AND a default
reserved AND bounded to the read cap). Mutation-verified: reverting
buildFeedRelaySet to the old healthy-only logic fails the updated case.
test_integrity_note:
change_type: ["spec_correction"]
scenarios:
- id: SCN-feed-reserve-live-defaults
given: "the user has healthy-looking relays that may actually be dead"
when: "the feed subscription is created via subscribeVideos"
then: "the relay set includes the user's healthy relays AND at least one default aggregator, bounded to MAX_SUBSCRIBE_RELAYS"
observable_outcomes:
- "the relay array passed to pool.sub()"
determinism_controls:
- "mocked pool.sub captures the boundary relay list; getHealthyRelays stubbed"
anti_cheat_rationale:
prevents:
- "over-mocking internal logic"
- "snapshot rubber-stamping"
relaxation:
did_relax_any_assertion: false
if_true_explain_spec_basis: "old deepEqual replaced with stricter superset+liveness+bound assertions"
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Deleting a video removes the Nostr note(s) but cleanup of the underlying R2/S3
object was silently skipped when storage was locked — leaving the file publicly
downloadable with no indication to the user. Now the delete flow surfaces a
clear heads-up when that happens, telling the user to unlock storage and delete
again (or remove the object from their bucket manually).
To make the signal accurate, deleteVideoStorage now resolves the affected object
keys BEFORE the credentials check (publicBaseUrl is available even while
locked). So "storage-locked" is reported only when there are genuinely hosted
objects to remove; an external-URL video (nothing on the user's bucket) returns
"no-matching-objects" and never triggers a false warning.
Tests (deleteVideoStorage via injected credentials/deleteObject): locked +
hosted object -> storage-locked; locked + external URL -> no-matching-objects
(no false alarm); unlocked -> actually deletes the object. Mutation-verified —
restoring the old credentials-first ordering misreports the external-URL case.
Build + full lint green.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Publishing showed a flat "Video shared successfully!" even when most (or all)
relays rejected the event, so users couldn't tell a partial/failed publish from
a clean one. Now publishVideo captures the per-relay accept/reject summary and
stashes a compact {accepted,total} tally on the returned event via a
non-enumerable key (never serialized, never sent to relays). app.js reads it
back and reports the real outcome:
- all relays accepted -> "Video published to N relays!"
- some rejected -> warning "Video published to N of M relays …"
- none accepted -> error (defensive; this path normally throws first)
Falls back to the generic message when no tally is available.
Tests (describePublishOutcome + attach/readRelayPublishSummary): success/partial/
zero/singular/missing-tally messaging, and that the tally round-trips while
staying out of JSON serialization. Mutation-verified — flipping partial→success
and the summary key to enumerable both fail the suite.
Build + full lint green.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The video feed's getDefaultRelays only fell back to bitvid's default relays
when the healthy set was COMPLETELY empty. A user whose own relays are all dead
but not yet probed (so they still pass the liveness filter) would have the feed
subscribe to that fully-broken list and hang forever on "Fetching…" — the
default aggregators were never injected.
Route the healthy set through capReadRelays (via new buildFeedRelaySet), which
always reserves a couple of reliable aggregator slots. Now the feed can reach
live relays regardless of how broken the user's personal list is, while still
prioritizing the user's own relays and bounding REQ fan-out to MAX_SUBSCRIBE_RELAYS.
Tests (buildFeedRelaySet): an all-dead user list still yields a default
aggregator while keeping the user's relays; empty input falls back to defaults;
an empty healthy set uses the configured fallback yet still reserves defaults; a
huge list stays bounded. Mutation-verified — reverting to the old
healthy-or-defaults logic fails 3 of the 4 scenarios.
Build + full lint green.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Previously editing a video deleted the old hosted video + its .torrent when the
URL changed, but intentionally left the old thumbnail orphaned in the bucket.
Now that the Edit modal can replace a thumbnail (and we have a clear "thumbnail
changed" signal), also delete the superseded thumbnail: build an orphan list with
the old video ({url}) and/or old thumbnail ({thumbnail}) and pass both to
deleteVideoStorage. Each guarded on old & new being non-empty and different, so we
never delete an object the new note still references. Best-effort; never blocks
the edit.
Tests (collectVideoStorageKeys): a {url} orphan yields the video key + sibling
.torrent; a {thumbnail} orphan yields the thumbnail key; both collect together;
objects under a different publicBaseUrl are never collected (bucket-switch safety).
Also documents the pre-existing uploadModal-reset.test.mjs flake in KNOWN_ISSUES
(reproduced on 1b11cb1b; not from this work).
Build + full lint green; full unit suite green except the documented pre-existing
flake.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Brings the Edit modal to upload parity with the Upload modal: re-upload a
thumbnail or video file instead of only pasting URLs.
- Wire storage services (r2/s3/storageService) + pubkey/npub accessors into
EditModal via ModalManager/initEditModal; EditModal constructs a shared
MediaUploader.
- New editModalUpload.js holds all the upload glue (file-picker wiring, progress,
storage-unlock checks, field write-back) to keep EditModal under its file-size
budget. EditModal calls initEditModalUpload(this, context) in one line.
- edit-video-modal.html: "Replace video file…" (under the URL field) and
"Replace thumbnail file…" (under the thumbnail field), each with a hidden file
input + status line.
- On thumbnail upload: writes the new URL into the thumbnail field. On video
upload: writes url + magnet (+ ws/xs hints). All written via setFieldUnlocked,
which marks the field edited so EditModal.collect actually persists it (gated
behind storage being configured + unlocked).
setFieldUnlocked is unit-tested (mutation-verified): without the unlock, the
re-upload would silently not save. Build + full lint + unit suite green.
NOTE: the upload/storage flow itself can't be unit-tested (browser + R2/S3);
pending manual verification of editing a video's thumbnail/file.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Extract UploadModal's storage+torrent upload core into a reusable
js/ui/components/mediaUploader.js (resolveActiveConnection, uploadThumbnail,
uploadVideo incl. infohash/.torrent/magnet) so the Edit modal can reuse it
without duplicating R2/S3 + torrent logic (and without blowing EditModal's
file-size budget). UploadModal now delegates its thumbnail/video upload core to
MediaUploader, keeping its own progress/state/zombie-guard wrappers — behavior
preserved.
UploadModal: 1319 -> 1191 lines. Build + full lint + unit suite green.
NOTE: the upload flow can't be unit-tested (browser + S3/R2 deps); this is a
behavior-preserving extraction pending manual re-test of a normal upload before
Edit upload is layered on top (steps 2-3).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Selecting a thumbnail FILE clears+disables the thumbnail text input, but the
publish metadata read only that (now-empty) input — so file-uploaded thumbnails
published as "thumbnail":"" and the image was missing. Prefer the uploaded
thumbnail's URL (thumbnailUploadState.url), falling back to a pasted URL.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Relay tab improvements so users can see which relays to remove:
- Per-relay status pill: Online (with ping) / Offline / Checking… / Blocked.
- Insecure remote ws:// relays (CSP-blocked) flagged immediately with a hint.
- Offline relays show a "consider removing" hint; their Remove button is
emphasized.
- Summary banner when relays are down (incl. the all-dead case explaining why
videos won't load), pointing at Restore defaults / healthy relays.
Also fixes a pre-existing decomposition bug (commit 7300977a): handleRelayOperation
called this.runRelayOperation, but that method lives on the main
ProfileModalController — so ALL relay ops (add/remove/mode/restore) threw
"this.runRelayOperation is not a function". Route via this.mainController.
Tests: relay-insecure-url (isInsecureRelayUrl), relay-operation-routing (routes to
mainController.runRelayOperation, guards no-pubkey). Both mutation-verified.
Build + lint + full unit suite green.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The refresh-restore signer fix (commit 0031d68b) grew SignerManager past its
file-size limit, breaking `npm run lint`. Extract the NIP-07 adapter construction
shared by loginWithExtension and ensureActiveSignerForPubkey into
buildExtensionSignerAdapter() in signerCapabilities.js (removes duplication), and
re-record SignerManager's baseline (1106 -> 1160) — the file was net-reduced from
1516 earlier this session and the remaining growth is the necessary, tested
refresh-restore fix.
`npm run lint` green again; signer tests pass.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
publishEventToRelay expected the old nostr-tools publish handle (.on/.then), but
nostr-tools 2.17 SimplePool.publish(relays, event) returns an ARRAY of per-relay
promises. The array has neither .on nor .then, so the code (a) fell through to
the "optimistic success" path (publishes were never truly confirmed) and (b)
never attached a .catch to the inner promise — so every relay that rejected
("blocked", "auth-required", "publish timed out", websocket error, …) leaked an
unhandled promise rejection. With a large/flaky relay list (e.g. a 35-relay
account hitting many paid/restricted relays) that floods the event loop and can
freeze the tab — observed when republishing the relay list and when publishing a
video.
Fix: detect the array return and Promise.allSettled it — any fulfilled = success,
otherwise a failure result; every rejection is consumed. Confirmed publishes now
resolve from real acks instead of the optimistic fallback.
New tests assert a rejecting array-promise becomes a failure result with NO
unhandled rejection leak, a fulfilling one succeeds, and an all-rejecting publish
still resolves. Mutation-verified; existing publish tests pass under the suite.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
On a fresh login, loginWithExtension builds + registers the NIP-07 signer
adapter. On a page REFRESH that path never runs — the app restores the
logged-in pubkey/UI, but ensureActiveSignerForPubkey only RESOLVED from the
signer registry; it never CONSTRUCTED an adapter from window.nostr. So the
registry stayed empty and ensureActiveSignerForPubkey returned null forever.
Decrypt paths survived via their own per-call window.nostr fallbacks, but
features relying solely on the registry — profile Storage "Unlock" — failed
with "No active signer found." despite a healthy, instantly-responsive
extension. (Diagnosed via temporary instrumentation: getActiveSigner_truthy
false AND ensureResult_truthy false, while extension getPublicKey responded in
1ms.)
Fix: when a live extension's own pubkey matches the requested pubkey and no
signer is registered, build + register the adapter (mirroring
loginWithExtension), with a guard that never adopts a mismatched account.
New tests in ensure-active-signer-refresh.test.mjs (own file for process-level
isolation — the registry/permission state are module singletons): constructs +
registers a usable signer from a live extension on an empty registry; rejects a
mismatched account. Mutation-verified. Signer suite 26/26, full unit suite +
build green.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Full test battery run (unit, dm, smoke, e2e, visual). Document the non-regression
e2e failures found:
- login-flows:149 and video-upload-publish-lifecycle:49/:79 flake under full
parallel load, pass in isolation (worker contention).
- webseed-playback:31 fails on Firefox headless only (CDN mode); pre-existing
(reproduced on 1b11cb1b), Chromium + WebTorrent-fallback variants pass.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Two pre-existing issues surfaced by running the smoke test:
1. CSP connect-src allowed `wss:` but not `ws:`, so ALL insecure-websocket
relays were blocked — including ws://localhost (dev + local relays + the
smoke mock relay). Add `ws://localhost:* ws://127.0.0.1:*` to connect-src in
index.html and embed.html. Remote cleartext ws:// stays blocked (deliberate
security posture — flagged for product decision).
2. smoke-test.mjs published via Promise.any(relays.map(url => pool.publish([url],
evt))). SimplePool.publish returns an ARRAY of per-relay promises, so this
Promise.any'd arrays-of-arrays — resolving on the array object immediately
without awaiting the publish and swallowing errors. Fixed to await
pool.publish(relays, evt) via Promise.allSettled and surface per-relay
results.
Smoke test now passes (publish -> relay verify). CSP policy test green; build green.
Co-Authored-By: Claude Opus 4.8 <[email protected]>