Closes the real hashtag gap. Verified the upload modal's existing NIP-71 hashtag
editor (Nip71FormManager → nip71.hashtags → buildNip71MetadataTags) ALREADY emits
`t` tags on the 30078, which bitvid's feed scoring already reads — so publish+feed
hashtags work today. The gap was the 34235 mirror reading only `video.hashtags`
(empty for form-authored videos).
buildNip71MirrorEvent now resolves hashtags from whichever source is present —
video.hashtags, video.nip71.hashtags, or the raw `t` tags on video.tags — deduped,
lowercased, with bitvid's internal "video" topic tag dropped. So a shared video
stays categorized on Nostube/Amethyst regardless of how its tags were authored.
(The earlier content.hashtags builder path from a1bf7b15 stays as a harmless
secondary input; the form/t-tag path is the one users actually exercise.)
tests/nip71-mirror.test.mjs: hashtags sourced from nip71.hashtags; from raw t tags
with the 'video' topic dropped + deduped. Mutation-verified.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Polish toward NIP-71 parity + better native discovery. Hashtags now flow through
the video data layer (the input UI in the upload/edit modals is the remaining
step):
- videoPayloadBuilder: sanitizeVideoHashtags (normalize/dedupe, drop reserved
"video"); persists hashtags in 30078 content AND emits ["t", tag] so bitvid's
own feed scoring (exploreScoring/kidsScoring already read t tags) and relay
queries pick them up.
- convertEventToVideo: exposes video.hashtags from content → the NIP-71 mirror
builder already carries them as t tags, so a shared video is categorized on
other apps too.
tests/video-hashtags.test.mjs: sanitize rules, content+t-tag emission, round-trip
to video.hashtags, empty case. Mutation-verified (t-tag emission load-bearing).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Closes the dual-event drift the user flagged: an opted-in 34235 mirror now stays
in lockstep with the canonical 30078 automatically — no per-video babysitting.
- js/services/nip71MirrorSync.js: syncNip71MirrorAfterEdit (re-publish if still
eligible; unshare + clear flag if it became ineligible, e.g. flipped private),
syncNip71MirrorAfterDelete (remove + clear flag). Plus initNip71MirrorSync()
which subscribes to nostrService's EXISTING videos:edited/videos:deleted events
— so the size-capped nostrService is untouched.
- js/services/nip71MirrorFlags.js: pure resolveEditSync/resolveDeleteSync decisions.
- applicationBootstrap: one-line initNip71MirrorSync(app.nostrService) wiring.
- All best-effort: a mirror failure never breaks the core edit/delete.
tests: resolver decisions (lockstep/unshare/none) + delete clears the flag +
event wiring is idempotent. Mutation-verified (eligibility branch load-bearing).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Decided to keep the dual-event model (kind 30078 canonical + additive 34235/36
mirror) rather than collapse to a single canonical 34235 note. Captures the
rationale: private videos can't be public NIP-71 (no encryption), and kind 30078
is the addressing root (watch-history/comments/reactions/views/playlists/naddr/
moderation + hardcoded video filters) so switching it is a large forward-only
migration not worth the interop win right now. Accepted trade-off: keep the mirror
in sync on edit/delete (Phase 1 polish). Phase 0/1 builder/parser remain reusable
if single-canonical is ever revisited.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Makes the NIP-71 mirror user-facing via the My Videos tab — the natural
manage-my-videos surface — without touching the single-writer publish path.
- js/constants.js: new FEATURE_NIP71_MIRROR flag (default on). Independent of
FEATURE_PUBLISH_NIP71, which is already wired to the LEGACY 21/22 auto-publish in
videoPublisher.js — flipping that would activate the wrong (non-opt-in) model, so
the opt-in mirror gets its own flag and that legacy path stays dormant.
- js/services/nip71MirrorFlags.js: per-video opt-in persistence
(pubkey+videoRootId, off by default) + pure resolveMirrorToggle decision.
- MyVideosController: per-row "Share to apps" / "Shared ✓" toggle (gated by the
flag), wired to nip71MirrorService.publish/remove. Ineligible videos show a
disabled "Share off" with the reason (private / NSFW-not-allowed-here / needs
hosted URL) — the ALLOW_NSFW_CONTENT moderation gate surfaces here too.
tests/nip71-mirror-flags.test.mjs: persistence scoping + toggle decision
(enabled→remove, eligible→publish, ineligible→blocked). Mutation-verified.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Adds remove(video, options) to the mirror service for delete / toggle-off:
- NIP-09 kind-5 delete referencing BOTH addressable kinds
(34235:pub:root + 34236:pub:root, with k tags) so it's robust to a
since-changed orientation.
- empty-replace tombstone (same d-tag, title, NO imeta) so clients that cache
addressable events but ignore NIP-09 still see the entry cleared; uses the
short kind for portrait videos.
Published to the write/outbox relay set; unavailable without a signer.
tests/nip71-mirror-service.test.mjs: remove publishes delete (both kinds) +
tombstone; portrait => 34236 tombstone; unavailable without signer.
Mutation-verified (tombstone emission + both-kind a-tags load-bearing).
Note: full unit suite green apart from the documented order-dependent flakes
(uploadModal-reset; a transient login-modal clipboard global-state flake) — both
pass in isolation and are unrelated to this change.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Publish service for the opt-in NIP-71 mirror (kind 34235/36) to the user's
write/outbox relays, built on the Phase 0 builder. Flag still off; not yet wired
into the publish path (next increment).
- js/services/nip71MirrorService.js: createNip71MirrorService (DI) → isAvailable,
canMirror(video), publish(video, options). Signs with the active signer and
publishes to the write set (getDeletePublishRelays). Resolves the moderation
audit item: the site ALLOW_NSFW_CONTENT gate — an instance that won't surface
NSFW must not publish it outward (enforced in both publish() and canMirror(),
defense-in-depth). Private/url rules come from the builder.
tests/nip71-mirror-service.test.mjs: publishes a public video; NSFW blocked when
ALLOW_NSFW_CONTENT=false (and allowed when true); private/url-less refused;
unavailable without signer. Mutation-verified (NSFW gate load-bearing).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Audit (verified in code) of how moderation, web-of-trust, mute/block lists, and the
site ALLOW_NSFW_CONTENT config interact with NIP-71 interop:
- Controls key on author pubkey + event id, so they apply to NIP-71 videos
automatically IF ingested videos share the feed pipeline (blacklist-filter,
moderation/WoT, suppression, kids, nsfw).
- Phase 1 (outbound): gate the mirror on ALLOW_NSFW_CONTENT (don't mirror NSFW from
an instance that forbids it); builder stays pure + always sets content-warning.
- Phase 2 (inbound) gap: mergeNip71MetadataIntoVideo does NOT map content-warning →
isNsfw, so foreign NSFW would bypass the site filter — must add that mapping; plus
route ingest through the full pipeline and trust-gate unknown authors.
- Kids feed already safe (allowlist on isForKids).
No code changes yet (both items land in their phases); captured as checklist items.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Improvements from a second pass over the plan + code:
- Add "Discovery & relays" section: the mirror must publish to the user's NIP-65
OUTBOX write relays (publish kind 10002 first if missing) or outbox clients
(Amethyst) can't find it — discovery is a relay problem, not just format. bitvid
already has publishRelayList/outbox; reuse it.
- Architecture: bitvid never double-renders its OWN mirror (merged onto 30078 by
videoRootId via nip71Cache) — dedup applies from Phase 1, not just Phase 2.
- 3rd hard rule: url must be publicly reachable (gate on verifyPublicAccess).
- Capture: wire existing upload `duration` into imeta; add a loadedmetadata probe
for `dim` + 34235-vs-34236 selection (pulled into v1; trimmed from backlog).
- Mapping: split `.content` (full) vs `alt` (short a11y text); note published_at is
read from the original 30078 so it's stable across edits.
- Surface the mirror toggle in the My Videos tab (existing management surface).
- Delete nuance: NIP-09 (a/k tags) AND empty-replace the addressable event so
NIP-09-ignoring clients still see it cleared.
- Phase 0: add an interop golden-shape test to guard against silent drift.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
- Document that the explore/kids feed ALREADY consumes NIP-71 hashtags (both raw
["t",…] tags and video.nip71.hashtags) — feed algo needs zero changes; only
capture is missing (no per-video hashtags field on 30078 today).
- Add a "Converting existing videos" section: opting in is an edit that maps every
available 30078 field into its NIP-71 home (incl. hashtags → t) and writes the
34235/36 mirror with d=videoRootId; non-destructive, lockstep edits.
- Pull hashtag capture + field conversion explicitly into Phase 1.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Adds docs/nip71-migration-plan.md — the agreed spec to build from:
- Dual-event, opt-in (off by default): keep canonical kind 30078; add addressable
34235/34236 mirror (d=videoRootId, edits in lockstep).
- Research correction: 34235/36 are NOT deprecated — current NIP-71 designates the
addressable pair for content that needs post-publication updates (exactly bitvid's
edit-in-place model).
- WebTorrent rides STANDARD NIP-94 imeta fields (magnet, i) rather than a hack;
private videos never mirrored; HTTPS url required to mirror.
- Phases: 0 retarget builders → 1 outbound publish/edit/delete → 1.5 NIP-89 "Open in
bitvid" handler + NIP-51 kind 30005 portable playlists → 2 inbound ingest.
Also refreshes docs/nips/71.md (was stale: only 21/22; now the current 233-line
spec with 34235/36 addressable kinds, waveform, origin, multi-audio). Updates
TODO #17 to point at the plan.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
After a successful split zap, validation errored: "Unable to initialize a relay
pool for receipt validation." The validator built a fresh `new SimplePool()` and
required `pool.list()`, but nostr-tools 2.x removed SimplePool.list() (the rest of
the app only has it because js/nostr/toolkit.js shims it). So validation always
failed even though the payment went through.
Fix: resolveReceiptListPool() applies the app's legacy .list shim
(shimLegacySimplePoolMethods) to the freshly created pool, and the gate now
accepts a listEvents override instead of hard-requiring pool.list.
tests/zap-receipt-pool.test.mjs: 2.x pool gets .list shimmed; createPool override
shimmed; listEvents override leaves the pool as-is; null when neither available.
Mutation-verified (removing the shim fails it).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
After watching/opening a video, zapping showed "Log in and connect a wallet"
even though logged in. Recording a view creates an anonymous session actor with
source "session" (SignerManager.ensureSessionActor). isUserLoggedIn() treated any
declaredSource on a different-pubkey session actor as a managed alternate
identity and returned false — so the view-event telemetry actor logged the user
out app-wide (zap gate, etc.).
Fix: "session" is the anonymous telemetry source, not a login identity — only a
managed source (e.g. "nsec") for a different pubkey disqualifies. (Commit
7985a481 intended to allow anonymous actors but only covered the sourceless
shape; the real actor carries source "session".)
tests/app/is-user-logged-in.test.mjs: added the real view-event actor shape
(source "session", different pubkey) → logged in; the "nsec" rejection case still
holds. Mutation-verified.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Saves push silently as before, but if the relay copy is NEWER than the one this
device last pushed (another device changed it), the user is now asked to confirm
before overwriting — preventing a silent multi-device clobber. No nag on normal
saves.
- js/services/settingsSyncFlags.js: track per-pubkey+kind last-pushed created_at
(getSyncPushedAt/setSyncPushedAt).
- js/services/syncConflict.js: pushWithConflictCheck — peek remote created_at
(list-only, no decrypt), compare to last push, call confirmOverwrite only on a
real conflict, then push strictly-newer than remote so the replace wins.
- storage/walletSyncService push()/enable() route through it and accept a
confirmOverwrite callback.
- ProfileStorage/WalletController supply confirmOverwrite (window.confirm) and
surface a "kept the newer copy" status when the user declines.
tests/sync-conflict.test.mjs: no-remote→silent, newer→prompt(+decline blocks
push), confirm→strictly-newer push, older→no prompt. Mutation-verified
(ignoring the decline fails). Service-test twins gained exists().
Co-Authored-By: Claude Opus 4.8 <[email protected]>
"Test connection" then "Save" removed the wallet. handleWalletTest re-rendered
the pane (refreshWalletPaneState) in its finally, which repopulates the form from
SAVED settings — empty for a URI you typed but hadn't saved — wiping the field.
The next Save then saw an empty field and took the "removed" branch.
Test must preserve what the user entered: refresh only the button state
(applyWalletControlState), not the whole form.
tests/wallet-test-preserves-input.test.mjs: a Test of an unsaved typed URI keeps
it in the field. Mutation-verified (restoring refreshWalletPaneState fails it).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Data-loss bug: on a fresh device the user opened the Wallet pane (field empty,
wallet not yet restored), clicked Save, and the "removed" branch called
walletSync.disable() — publishing a {cleared:true} note that destroyed the good
encrypted copy another device relied on. (Confirmed in logs: a kind-30078
"bitvid:nwc" {cleared:true} event signed right after login.)
An empty save happens routinely (saving before hydration/restore), so treating it
as a deliberate disconnect-that-wipes-the-remote is wrong. Removed the auto-clear
from the empty-save branch: the remote synced note is now changed ONLY via the
explicit sync toggle (enable/disable) or a successful save (push). An empty save
clears just the LOCAL wallet.
tests/wallet-sync-no-clobber.test.mjs: empty save does not call disable/push; a
real save still pushes. Mutation-verified (re-adding the disable fails the test).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Completes todo #15. On login, if an encrypted copy of the user's storage and/or
wallet settings exists on their Nostr account — and this device hasn't already
opted in — offer to restore it.
- js/nostr/encryptedSync.js: exists(dTag) — cheap list-only existence peek (NO
decrypt, so it doesn't spend the signer budget); decryption only on accept.
- storage/walletSyncService: hasRemote() wrappers over exists().
- js/services/settingsRestorePrompt.js: one-time per-pubkey offer. Only prompts
for items with a remote copy not already enabled locally; if nothing remote
exists it does NOT consume the one-time offer (a note published later can still
prompt next login). On accept, pulls + applies.
- app.js handleLoginModalSuccess: fires maybeOfferSettingsRestore(pubkey) after
login, deferred ~2s so relays connect; best-effort, never blocks login; toasts
on restore.
tests/settings-restore-prompt.test.mjs: offer+restore-only-remote-items, decline,
prompt-once, skip-already-enabled, don't-consume-when-empty. Mutation-verified.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Adds encrypted cross-login sync for the NWC wallet connection (todo #15),
reusing the encrypted-sync core under d-tag "bitvid:nwc".
- js/services/settingsSyncFlags.js: shared per-pubkey opt-in flag store
(storage/wallet); storageSyncService refactored onto it (identical behavior,
tests still green).
- js/services/walletSyncService.js: factory (NwcSettingsService is per-app, not a
singleton). push() syncs the current nwcUri (+defaultZap); pull() applies it via
updateActiveNwcSettings; disable() wipes the note.
- Wallet pane (components/profile-modal.html + ProfileWalletController): opt-in
toggle + Restore button + a strong SPENDING warning, shown only when a signer
can encrypt. Enabling requires an explicit window.confirm (the URI can spend).
Re-pushes on save when enabled; and on wallet DISCONNECT while synced it clears
the note so a removed spending URI doesn't linger (encrypted) on relays.
tests/wallet-sync-service.test.mjs: enable-pushes-uri, pull-restores-via-
updateActiveNwcSettings, disable-clears, no-wallet-doesn't-publish. Mutation-
verified (skip-apply + skip-clear both fail).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
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]>