Commit Graph
1218 Commits
Author SHA1 Message Date
thePR0M3TH3ANandClaude Opus 4.8 e4e5c552c7 feat(forms): validate submitter npub on submission forms (#22)
The embed submission forms accepted any free-text identifier and published it,
so they were an easy spam vector. Add a shared, testable NIP-19 validator and
require a well-formed npub before a submission is sent.

- js/utils/validateNpub.js: validateNpubHex() decodes via nostr-tools nip19 and
  returns the 64-char hex pubkey, or null for anything that isn't a real npub
  (free text, wrong prefix, bad checksum, wrong NIP-19 entity). Also exposed as
  window.validateNpubHex for the classic iframe form scripts.
- Application form (required npub): submit is blocked unless valid; the decoded
  hex is written into the application body for canonical storage/matching.
- Feature-request / bug-fix / feedback (optional npub): stays optional, but a
  non-empty value must be a valid npub or submit is blocked with an inline error.
- Tests: tests/validate-npub.test.mjs (real nostr-tools, no implementation mirror).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-25 08:31:45 -04:00
thePR0M3TH3ANandClaude Opus 4.8 fdcf034464 fix(beacon): stop the processing spinner hanging forever (#28)
The torrent beacon's processing overlay was gated on WebTorrent's metadata-ready
callback (client.add/seed), which only fires once metadata arrives from peers. A
magnet with no live seeders never resolved it, so the spinner span forever, and a
torrent that emitted its own 'error' didn't always reach the client-level handler.

- beginProcessing() arms a 30s watchdog that drops the overlay and warns ("still
  searching for peers — it'll keep trying in the background") instead of hanging;
  the torrent stays in the table and keeps announcing.
- handleTorrentReady, the client error handler, and destroy() clear the watchdog.
- addMagnet/seedFiles wrap add/seed in try/catch and attach a per-torrent 'error'
  listener so bad/duplicate magnets fail visibly.
- Tests: tests/torrent/beacon-watchdog.test.mjs (JSDOM).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-24 21:57:11 -04:00
thePR0M3TH3ANandClaude Opus 4.8 1c256808ff fix(more-menu): resolve modal ⋯ video so Event Details + Popularity work (#31)
The video modal's ⋯ trigger declares its context via `data-more-dropdown="modal"`,
but attachMoreMenuHandlers read only `data-context`, so it defaulted to "card",
skipped the modal video resolution, and opened the popover with a null video. The
panel is built from that video, so every menu item got an empty data-event-id and
both Event Details and Popularity reported "No video selected."

- Read context from `data-more-dropdown` as well as `data-context`.
- Modal branch falls back to the modal's `activeVideo` when getCurrentVideo() is
  null (deep-link opens don't set the playback service's currentVideo).
- Defense-in-depth: handleEventDetailsAction resolves the video from
  app.videosMap.get(eventId); view-stats resolves via a new getVideoByEventId
  callback instead of a risky `|| currentVideo` that could show the wrong stats.
- Regression tests in tests/more-menu-controller.test.mjs.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-24 18:48:49 -04:00
thePR0M3TH3ANandClaude Opus 4.8 36f3c6e127 views: exact count unions across relays (fixes cross-relay under-count) (#4)
The watch-page/chart "exact" count relied on the NIP-45 COUNT, but
countEventsAcrossRelays returns bestEstimate = the single relay with the MOST
events — it can't union across relays. So when views are distributed across
relays it under-counted (a video's card showed 19 while the chart's uncapped
list union showed ~63).

exactCountForPointer now lists view events across ALL relays (one on-demand
list, not a storm) and folds the UNION into the deduped dedupeBuckets (one entry
per viewer-window). total = the set size, so it is accurate AND monotonic (never
spuriously decreases on partial relay responses). The per-relay COUNT is kept
only as a lower-bound floor when the list truncates (>2000 events), flagged
partial. The popularity modal passes { exact: true }; since the count flows
through shared pointer state, opening a video corrects its count everywhere.

Grid cards (no exact) still use the §17-capped batched backfill (approximate,
storm-safe) until opened — an accepted accuracy/cost tradeoff.

Also note the ⋯ menu "Event Details" button does nothing (new TODO #31).

Tests: testExactCountUnionsAcrossRelays. Grandfathered viewCounter.js size.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-24 17:29:53 -04:00
thePR0M3TH3ANandClaude Opus 4.8 575befb83c feed: add public popularity (views-over-time) chart in the ⋯ menu (#26)
A "Popularity" item in the video ⋯ menu opens a public views-over-time chart
(view data is public). Builds on the now-trustworthy view counts (#4).

- buildViewCountTimeSeries buckets 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 chart — no charting library, no raw colors.
- The modal fetches via listVideoViewEventsWithDefaultClient, shows the total,
  and updates LIVE by subscribing to view events (debounced re-render); cleans
  up on close (Esc / backdrop / ✕). Lazy-loaded from the menu handler so it
  only ships when opened.

New size tokens for the modal; reuses .ds-overlay-backdrop for the scrim.

Tests: tests/view-count-chart.test.mjs (cumulative dedupe, junk filtering,
SVG line/area + empty/single-point).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-24 13:51:41 -04:00
thePR0M3TH3ANandClaude Opus 4.8 0a37e625bb playback: fail over across hosted mirrors before dropping to P2P (#5)
The liveness probe already tries all of a video's sources, but actual playback
only ever tried the single primary URL and then fell straight to WebTorrent. Now
playback fails over across hosted mirrors first.

- PlaybackSession builds an ordered, de-duped hostedSourceCandidates (primary URL
  first, then the video's imeta `sources`; accepts string or {url} entries).
- attemptHostedPlayback takes a candidateUrl; the url-first execution loop tries
  each candidate and advances to the next mirror on probe-fail/stall/timeout,
  cleaning up watchdogs + resetting the <video> between candidates, only calling
  WebTorrent after every hosted mirror fails.
- `sources` plumbed coordinator -> strategy -> session. Single-source videos
  (no `sources`) behave exactly as before.

Tests: tests/services/playbackService_multisource.test.mjs (candidate
construction; the full async fail-over flow is covered by the existing
playbackService_order suite in CI — that mock-timers harness doesn't run in this
sandbox). ROLLBACK: revert the loop to the single attemptHostedPlayback() call
and drop the sources plumbing.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-24 13:30:30 -04:00
thePR0M3TH3ANandClaude Opus 4.8 02854d4fb5 feed: add Trending tab — recently-added ranked by view count (#27)
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). Builds on the now-trustworthy view counts (#4).

- createTrendingSorter reads counts from runtime.getViewCount, which resolves
  each video's pointer via the app's canonical deriveVideoPointerInfo against the
  shared viewCounter cache (new getVideoViewCountSnapshot export) — 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; bumped the cap for the thin load/refresh delegations).
- Wiring: FEED_TYPES + flag, feedCoordinator cases, app.js wrappers,
  applicationBootstrap registration (hides the sidebar link when flag off),
  viewManager init + js/trendingView.js, views/trending.html, sidebar link.

Tests: tests/trending-sorter.test.mjs.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-24 13:06:34 -04:00
thePR0M3TH3ANandClaude Opus 4.8 8deba55cdf views: un-quarantine view-counter test; audit reactionCounter (#4)
Triaged the monolithic tests/view-counter.test.mjs and got the whole file green,
then removed it from the QUARANTINE map:
- Pinned nostrClient.getSubscriptionManager() to null so the test exercises the
  legacy per-pointer hydrate/subscribe path it mocks. The batched
  SubscriptionManager path (added with the relay-storm work) was silently
  shadowing the mocked legacy APIs.
- Made the dedupe-window test bucket-aligned (it used Date.now(), so the
  +window/2 offset could straddle a fixed-bucket boundary and flake by time of
  day).
- Corrected the hydrateVideoHistory root-timestamp assertions to the refactored
  unit boundary: hydrateVideoHistory populates the rootCreatedAtByRoot MAP; the
  video object's rootCreatedAt FIELD is applied by syncActiveVideoRootTimestamp.

reactionCounter.js audit (kind-7 likes): 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.

Spec corrections documented in TEST_INTEGRITY.md (SCN-view-counter-legacy-path,
SCN-hydrate-root-timestamp).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-24 12:22:02 -04:00
thePR0M3TH3ANandClaude Opus 4.8 a9cb8a94b4 views: deterministic d-tag so relays dedupe a viewer's repeat views (#4)
View counts inflated. viewCounter.js dedupes locally by (viewer, time-window)
correctly, but the published events couldn't be deduped by relays:
generateViewEventDedupeTag built the kind-30079 `d` tag with random entropy +
exact timestamp, so every view was a UNIQUE event. And exactCountForPointer runs
a NIP-45 COUNT (raw event count, no dedupe) and bumped the displayed total up to
it (max(deduped, raw)). Net: reloads / multi-device / re-watches each added a
view.

Make the `d` tag deterministic and window-bucketed (scope:viewer:bucket, no
entropy) so kind-30079 view events are genuinely parameterized-replaceable:
relays keep one event per (viewer, video, window), so the cheap NIP-45 COUNT
becomes accurate AND scalable and the raw count stops inflating. buildViewEvent
no longer invents a random `d` tag (the only production caller, publishViewEvent,
always supplies the deterministic one). Pre-existing entropy events age out over
the backfill horizon.

Tests: tests/view-event-dedupe.test.mjs.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-24 12:01:25 -04:00
thePR0M3TH3ANandClaude Opus 4.8 087cf410cb feed: make Explore "new-to-you + diverse, less recency"
Explore shared the full active set with Recent/For You and leaned on freshness,
so it read like a reshuffled Recently-added. Give it a discovery identity:

- Weights: drop freshness (0.25 -> 0.12) and bump novelty (0.30 -> 0.35) so
  recency stops dominating and topic-novelty leads.
- Add a soft followed-author penalty (weight 0.3): the scorer now reads
  subscriptionAuthors from the Explore runtime and deprioritizes creators you
  already follow, so non-followed creators surface ("new to you"). A penalty,
  not a filter, so the feed stays full if you follow lots of people.
- More variety: lower the diversity sorter's MMR lambda (0.7 -> 0.5).

Also compacted For You's subscriptionAuthors reader to a one-liner to stay
under feedCoordinator's size cap while adding the same reader to Explore.

Tests: explore follows-penalty scoring; existing explore-scorer/diversity +
feedCoordinator suites green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-24 08:50:50 -04:00
thePR0M3TH3ANandClaude Opus 4.8 4873a43135 feed: make For You lead with "your people first" (tiering + discovery fallback)
For You shared the full active set with Recent/Explore and only re-ranked by a
blended score, so when a user's signals were thin it collapsed toward freshness
and looked identical to Recently-added.

Give it a structural identity:
- The scorer tags each item forYouTier: 2 = followed author, 1 = interest/
  watch-topic match, 0 = other.
- The sorter leads with tier (your people first) regardless of raw score, then
  ranks within tier; trusted-muted still sinks last.
- When NOTHING is tiered (logged-out / no follows / no interests) it falls back
  to an author-interleaved discovery order (new interleaveByAuthor) so the feed
  never reads as a chronological clone of Recently-added.

Tests: tests/for-you-tiering.test.mjs (tier beats score, tiers stack, muted
last, no-signal interleave, scorer tier tagging). Existing for-you-scorer suite
still green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-24 08:46:30 -04:00
thePR0M3TH3ANandClaude Opus 4.8 f05ba5edf9 channel: union live fetch with known events so the grid never shrinks
loadUserVideos rendered only the live relay fetch, and the render REPLACES the
grid container. So when a relay holding a creator's video transiently failed or
EOSE'd late, that video disappeared from the wall even though it was already
known in memory/cache — the "channel doesn't pull in all of a creator's videos"
bug (#20, symptom 1).

Union the live events with the already-known author events
(mergeChannelVideoSources, live wins on id collision) before building the
renderable list, so the rendered set is a superset and can't shrink on a
transient relay gap. Existing dedupe-by-root + access/delete filtering still
decide precedence downstream, so newer versions and deletions still win.

Helper + tests live in channelProfileVideos.js (channelProfile.js is at its
size cap); the call site stays net-zero. Streaming/first-relay-wins render is
still the separate 17d UX follow-up.

Regression: tests/channel-video-merge.test.mjs.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-24 08:19:37 -04:00
thePR0M3TH3ANandClaude Opus 4.8 2c62b959fd lists: cap login list subscriptions to the §17 read set (≤8)
The cold-login REQ storm came from list subsystems subscribing/fetching across
the user's full NIP-65 relay set (often ~20) instead of the documented §17 read
cap. The fetchListIncrementally paths already route through capReadRelays, but
the live subscriptions and direct pool.list calls bypassed it:

- userBlocks.ensureBlockListSubscription: cap the live block-list subscription
- subscriptions.ensureSubscriptionListSubscription: cap the live follows-list
  subscription
- hashtagPreferencesService: cap the relay set used by pool.list + fetch

capReadRelays keeps the user's own relays first and guarantees the reserved
defaults, bounded to MAX_SUBSCRIBE_RELAYS (8). Trade-off accepted: a list event
living only on a user's 9th+ relay may sync on a later pass rather than at login.

Regression: tests/user-blocks-relay-cap.test.mjs.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-24 07:07:57 -04:00
thePR0M3TH3ANandClaude Opus 4.8 7f6a0f3adf dm: try NIP-04 before NIP-44 for legacy kind-4 messages
orderDecryptors compared candidate `priority` before the `preferLegacy` flag.
The signer registers its nip44 candidate at an earlier priority (-20) than
nip04 (-10), so priority always decided the order and preferLegacy was dead
code — every kind-4 DM tried nip44 first, which fails on the `?iv=` NIP-04
ciphertext ("invalid base64: Unknown letter ?") before falling back to nip04.
That doubled the serialized extension round-trips per message on cold inbox
load and spammed the console.

Move the legacy preference (and it already had the explicit-hint check) above
the priority comparison so kind-4 messages attempt nip04 first. Gift-wrap
(preferGiftWrap) and same-scheme priority ordering are unchanged.

Regression: tests/dm-decryptor-order.test.mjs.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-23 21:36:15 -04:00
thePR0M3TH3ANandClaude Opus 4.8 fd546fa1e5 dm: prompt for login when messaging while logged out
The "Message" button opened the profile modal's messages tab even with no user
logged in, leaving an unusable composer (DMs need a signing identity). Gate
openDirectMessageComposer behind isUserLoggedIn(): surface a login message and
open the login modal (falling back to authService.requestLogin) instead of
opening the messages pane.

Regression: tests/app/dm-composer-login-gate.test.mjs.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-23 21:22:03 -04:00
thePR0M3TH3ANandClaude Opus 4.8 ab68ba51d0 dm: make a selected recipient the active conversation in the right pane
Clicking "Message" added the user to the sidebar list but the right-hand
message pane stayed on the previously-active conversation. setDirectMessageRecipient
called setFocusedDmConversation (which only tracks read-state) and never set
activeDmConversationId — the value buildDmConversationData reads to choose which
thread the right pane renders — so it kept resolving the stale id.

Set activeDmConversationId directly (mirroring handleDmConversationSelect), and
switch the mobile pane to the thread for explicit selections (not the auto
thread-default).

Regression: tests/ui/dm-set-recipient-active.test.mjs.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-23 21:01:04 -04:00
thePR0M3TH3ANandClaude Opus 4.8 d29287a580 dm: focus the selected recipient instead of the top conversation
Clicking "Message" on a profile (or selecting 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 then went to the wrong person ("says sent but I don't see it").

Fixes:
- setDirectMessageRecipient now focuses the selected recipient's conversation
  so a stale activeDmConversationId can't win.
- buildDmConversationData synthesizes an empty conversation for a
  selected-but-threadless recipient and treats it as active, including an
  activeRemotePubkey fallback so the composer targets the right person.
- The legacy renderProfileMessages list no longer clobbers a non-null
  recipient with threadsToRender[0].

Regression: tests/ui/dm-conversation-focus.test.mjs.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-23 20:37:30 -04:00
thePR0M3TH3ANandClaude Opus 4.8 4f7baa3e99 dm: delegate resolveProfileSummaryForPubkey to helper (fix moderation UI crash)
ProfileModerationController calls dmController.resolveProfileSummaryForPubkey
when rendering moderation overrides, but the method lives on the DM helper and
the controller didn't delegate it — so opening the profile modal's moderation
settings threw an uncaught "is not a function". Add the helper delegation
(createCompactProfileSummary right after it was already delegated). Covered by
the delegation regression test.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-23 20:26:51 -04:00
thePR0M3TH3ANandClaude Opus 4.8 eda212e187 dm: fix sending — add missing app-shell callback delegations
Clicking Send threw "this.controller.handleDmAppShellSendMessage is not a
function". The DM app-shell renderer wires its callbacks on `this.controller`
(ProfileDirectMessageController), but the controller never delegated them to
`this.actions` — so Send, mark-read, mark-all-read, read-receipt/typing
toggles and open-settings all threw and did nothing. Add the six delegations.
The send path itself (encrypt -> sign -> publish) was fine.

Regression test asserts the controller delegates each app-shell callback to
this.actions.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-23 20:25:44 -04:00
thePR0M3TH3ANandClaude Opus 4.8 084faa3543 video-modal: fix copy-magnet button doing nothing (wrong event name)
Same dead-event bug as the embed button: handleCopyRequest dispatched
"action:copy", but ModalManager listens for "video:copy-magnet"
(app.handleCopyMagnet) and #copyMagnetBtn has no popover fallback — so the
button did nothing. Dispatch "video:copy-magnet". Extended the regression
test to cover it.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-23 20:11:38 -04:00
thePR0M3TH3ANandClaude Opus 4.8 a5b284682c video-modal: fix Embed button doing nothing (wrong event name)
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 and the modal
never opened. (Share survives the same mismatch only because shareBtn also
toggles a popover directly.) Dispatch "video:embed". Regression test added.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-23 20:09:27 -04:00
thePR0M3TH3ANandClaude Opus 4.8 0e3dab475f nostr: sanitize NIP-22 uppercase #E/#P tags (fix from_hex REQ rejects)
The hex-filter sanitizer covered ids/authors/#e/#p/#q but not the NIP-22
uppercase root-scope tags #E (root event id) and #P (root author pubkey).
Relays hex-decode those too, and the kind-1111 comment subscriptions query
by them, so an odd-length value bypassed the sanitizer and got the whole
REQ rejected with "uneven size input to from_hex" (dropping that
subscription's results). Add #E/#P to the sanitized fields.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-23 15:00:10 -04:00
thePR0M3TH3ANandClaude Opus 4.8 f7f246835a zap/wallet: flag non-zappable Lightning addresses; per-item settings restore
A static client can't fetch LNURL hosts that omit CORS headers, so zaps to
those creators fail. Add a browser-side reachability probe
(checkLightningAddressZappable) and surface it on both sides: the creator
is warned at wallet-connect time if their own address isn't browser-
zappable, and the zapper sees a heads-up when opening a non-reachable
creator's zap dialog.

Also fix encrypted settings restore: the "already offered" flag was
per-pubkey, so once storage was offered the wallet note was never offered
later. Track it per-item so the wallet is offered independently.
Zappability check extracted to walletZappabilityCheck.js for the size cap.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-23 14:53:51 -04:00
thePR0M3TH3ANandClaude Opus 4.8 c5589a1514 zap: fix NWC connection, dialog UX, and receipt reporting/discovery
- NWC connection was fully broken: parseNwcUri passed a hex secret to
  nostr-tools getPublicKey (needs Uint8Array) and threw. Convert hex->bytes.
- A successful zap was shown as a red error when the kind-9735 receipt
  couldn't be validated. The NWC preimage already proves payment, so a
  paid-but-unconfirmed share is now a success-with-note, not a failure.
- Receipt discovery: query by the reliably-indexed #e/#a/#p tags instead of
  #bolt11 (which most relays don't index), and poll a few times since the
  receipt is published shortly after payment.
- In-flight "Sending..." status no longer mis-toned as a warning; success
  path fully resets the form; an in-flight-open click no longer cancels the
  open (which made the popover need many clicks); fee fallback verified
  no-bypass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-23 14:53:38 -04:00
thePR0M3TH3ANandClaude Opus 4.8 1921356558 ui/popover: let panel inputs receive keys; portal in-modal panels
The shared engine treats every panel as an ARIA menu and preventDefault'd
single-char + arrow keys, so typing in a panel with inputs (the zap
comment/amount fields) was swallowed — "you can't type in the popup".
handleMenuKeydown now ignores editable targets.

Also relocate a pre-existing in-host panel (the in-modal zap dialog) into
the body-level portal on open so position:fixed is viewport-relative
instead of anchoring to the modal edge, restoring it on close. Pure DOM
helpers extracted to popoverPanelDom.js to keep the engine under its cap.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-23 14:53:38 -04:00
thePR0M3TH3ANandClaude Opus 4.8 d8e1b12992 watch-history: bucket timestamp-less items by d-tag time, not 1970-01
Legacy/migrated items with no watchedAt were siloed into a literal
"1970-01" month event that self-perpetuated (read back as 0 -> re-bucketed
-> re-published with 0). Derive a stable watch time from the pointer
d-tag's embedded ms-timestamp and backfill it so they land in a real month
and the loop stops. Bucketing extracted to watchHistoryBucketing.js to keep
watchHistory.js under its size cap.

Also fixes three stale tests in watch-history.test.mjs (partial-acceptance,
actor alignment, session flag) so the file is un-quarantined.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-23 14:53:24 -04:00
thePR0M3TH3ANandClaude Opus 4.8 2194213d23 nip71: tear down the addressable mirror on delete regardless of local flag
resolveDeleteSync gated mirror teardown on the per-video opt-in flag,
which lives only in this browser's localStorage. A video shared on one
device and deleted from another (or after a cache clear) skipped teardown,
orphaning the NIP-71 mirror on other apps. Delete now always attempts
teardown when the feature is on (remove() is idempotent). The flag still
gates the UI toggle and publishing.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-23 14:53:24 -04:00
thePR0M3TH3ANandClaude Opus 4.8 29785783fe nostr: fix legacy-zombie resurrection via canonical getActiveKey
client.js carried a stale duplicate of getActiveKey that lacked the
LEGACY: guard added to nostr/utils.js. The tombstone machinery used the
local copy, so a deleted legacy video keyed its tombstone as
ROOT:LEGACY:... while a bare zombie keyed as <pubkey>:<dTag> — the guard
never matched and the video resurrected. Import the single canonical
getActiveKey and drop the duplicate.

Also convert tests/nostr-delete-flow.test.mjs to node:test (it was
silently excluded from the runner and failing), fix its stale write-relay
config, and rewrite its subscription assertions against the real
VideoEventBuffer. New client-level regression test for the guard.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-23 14:53:11 -04:00
thePR0M3TH3ANandClaude Opus 4.8 dd1ce113c1 zap: graceful error when a recipient's Lightning endpoint is unreachable
Sending a zap to a creator whose LNURL endpoint is down or doesn't send
CORS headers (e.g. unitypay.cash/.well-known/lnurlp/<name>) made the browser
fetch reject with a raw "Failed to fetch", which bubbled up as the zap's
error toast — the "clunky errors" reported during the zap audit.

fetchPayServiceData now catches the network/CORS rejection and throws a
clear, coded error ("Couldn't reach this recipient's Lightning address. It
may be offline or blocking browser requests."), preserving the original as
`cause`. Non-OK HTTP and valid responses are unchanged.

Part of the zap audit (TODO #3). Also recorded: the video-modal popover
mis-positioning (3a) and that the deeper CORS issue (static client can't
fetch CORS-less LNURL hosts) may need a proxy — tracked separately.

Tests + mutation-verified. 1274 pass (known UploadModal flake).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-22 22:22:18 -04:00
thePR0M3TH3ANandClaude Opus 4.8 9dbe1286c0 liveness: multi-source video support + faster CDN verdict (#5 phases 1-3)
Addresses "some NIP-71 videos don't play / he has multiple links" + "speed
the liveness check + don't render unplayable".

- Phase 1 — multi-source capture: nip71IngestAdapter collectVideoSources()
  exposes a `sources` list (all video imeta urls, in order; audio/image
  excluded; deduped) so mirrors are preserved (the adapter previously kept
  only one url).
- Phase 2 — multi-source liveness: urlHealthController.probeUrlList() probes
  every candidate (primary + sources) via the existing video-element
  playability probe — healthy as soon as one source plays, offline only if
  ALL fail. So a dead primary host no longer hides a video with a live
  mirror. (Card hide policy in cardSourceVisibility was already correct.)
- Phase 3 — speed: URL_PROBE_TIMEOUT_MS 8s -> 4s for a fast verdict so dead
  cards hide quickly; WebTorrent swarm probe stays generous.

Remaining (TODO #5): player-side fail-over at play time; hide-until-verified
for foreign ingested videos. Tests + mutation-verified (collectVideoSources
video-only filter, probeUrlList first-ok/offline/timeout). 1271/1272 unit
(known UploadModal flake).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-22 21:48:51 -04:00
thePR0M3TH3ANandClaude Opus 4.8 f760abde3d nip71 ingest: cache into videosMap + faster refresh (consistent feed presence)
Two symptoms: (1) on warm refresh, NIP-71 videos appear a few seconds after
bitvid ones; (2) navigating profile -> home sometimes showed only bitvid.
Root cause: ingest wrote only nostrClient.activeMap (the live feed-engine
source), not nostrService.videosMap (the persisted/author-feed cache), and
the refresh nudge was throttled at 8s — so NIP-71 entered the feed late and
inconsistently across render paths.

- Ingest now also nostrService.cacheVideos(injected) so the videos persist and
  appear in author-scoped feeds + optimistic/persisted render paths, not just
  the live activeMap engine run.
- Refresh throttle 8s -> 2.5s so the post-inject feed re-render fires promptly.

Tests + mutation-verified (injected videos forwarded to cacheVideos).
1268/1269 unit (known UploadModal flake).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-22 21:00:30 -04:00
thePR0M3TH3ANandClaude Opus 4.8 80f058f67c channel: per-channel persisted cache for instant cold-load paint
A cold hard-refresh on a profile previously sat on "Loading videos…" for
~5s — the optimistic in-memory cache is empty on cold start (ingest hasn't
injected yet) and the grid waited on relay connection.

Now channelProfileVideos.js persists the last-rendered wall per author
(localStorage bitvid:channel-videos:v1) and loadUserVideos paints it
immediately on cold load, before relays connect; the live fetch then
replaces it. Bounded (60 videos/channel, 20 channels LRU-evicted) and
strips the heavy raw `tags` array. Same proven pattern as the main feed's
optimistic cache.

Closes TODO 17d per-channel cache. Tests + mutation-verified (round-trip,
tag-strip, per-channel + channel-count caps). 1267/1268 unit (known flake).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-22 20:23:51 -04:00
thePR0M3TH3ANandClaude Opus 4.8 065706a3cf videos: cross-ecosystem dedup (prefer bitvid, show one) across all grids
Phase 1 of the on-boarding plan, and the shared layer for unified grid
loading. A video that exists as both a bitvid kind-30078 note and a NIP-71
mirror/original previously could appear twice (e.g. independent dual-posts).

js/utils/videoDeduper.js: collapseCrossEcosystem() groups events by a
per-author identity (fileSha256 / originalFileSha256 / infoHash, plus an
`eid` namespace that links an on-boarded note's `importedFrom` to the
original foreign event) via union-find, and keeps the bitvid-native version
(else newest). dedupeVideos() = root-dedup THEN cross-ecosystem collapse.

Routed through the shared chokepoint app.dedupeVideosByRoot (playback
coordinator), so the feed stage, channel profile, and search all collapse
duplicates identically — "show one, prefer bitvid" everywhere. Channel
fallback path updated too.

Limitation (documented): hash-less independent dual-posts can't be matched
until the bitvid side carries on-board provenance (Phase 2).

Tests + mutation-verified (prefer-bitvid, per-author identity, infohash,
provenance link, root+cross composition). 1262/1263 unit (known flake).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-22 19:51:43 -04:00
thePR0M3TH3ANandClaude Opus 4.8 7236496a16 nip71: show creators' NIP-71 videos on their channel-profile wall (17b)
The channel-profile grid fetched only kind-30078, so a creator's NIP-71
videos (incl. their Nostube cross-posts) appeared in the main feed via
ingest but were absent from their profile wall.

New js/channelProfileVideos.js:
- buildChannelVideoFilters(pubkey): native (kind 30078, #t=video) + NIP-71
  (kinds 21/22/34235/34236) filters for the author.
- convertChannelEvent(entry): routes NIP-71 kinds through
  buildVideoFromNip71Event (which rejects bitvid's own outbound mirrors so
  they don't duplicate the canonical note); kind-30078 uses the standard
  converter.

channelProfile.js now uses these (net fewer lines, stays under size cap).
Existing buildRenderableChannelVideos dedupe-by-root + canAccess handle
precedence and whitelist/blacklist gating, so foreign videos inherit the
same gating as native ones.

Closes pre-launch TODO 17b. Tests + mutation-verified (NIP-71 routing,
mirror skip, dual-filter build). 1253/1254 unit (known UploadModal flake).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-22 19:20:48 -04:00
thePR0M3TH3ANandClaude Opus 4.8 a8e9f5202a adminLists: batch community-blacklist fetch (kill cold-start kind-30000 storm)
Cold-start fired one kind-30000 REQ per community-blacklist curator
(loadCommunityBlacklistEntries did Promise.all over references, each a
fetchLatestListEvent) — ~28-32 parallel REQs that dominated the login
relay-REQ spike and starved the feed.

A Nostr filter ORs `authors` and `#d`, so all curators collapse into ONE
batched REQ. New js/adminListBatch.js (pure, mutation-tested):
buildBatchedReferenceFilter + selectNewestEventsForReferences (newest per
author:d-tag, discards author×d-tag cross-products) +
fetchBatchedReferenceEvents (routes through the subscription manager's
list — health-gated/timeout-bounded, not raw pool). Helpers extracted to
their own module so adminListStore stays under its size cap.

Best-effort: on batch failure we return nothing and the cached admin
state carries over (readCachedAdminState).

Addresses pre-launch TODO #9 (cold-login REQ storm). Tests + mutation-
verified (newest selection, cross-product discard). 1249/1250 unit
(known UploadModal flake).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-22 18:47:00 -04:00
thePR0M3TH3ANandClaude Opus 4.8 733cb5937b nostr: sanitize hex-only filter fields (fix relay "from_hex" REQ rejects)
Strict relays (primal) reject a whole REQ with "uneven size input to
from_hex" when any ids/authors/#e/#p/#q entry isn't 64-char hex — silently
dropping that subscription's results. One malformed value anywhere in a
filter poisoned the request.

Sanitize at the universal pool choke point (normalizeFilterList, used by
both the shimmed pool.sub and pool.list): lowercase/trim and drop non-
64-hex entries from the hex-only fields; if a once-populated hex field ends
up empty, drop that filter rather than silently broadening the query.
Non-hex fields (#a/#d/#t/kinds/…) are untouched. Dev-logs what it strips
(with the filter's kinds) so the malformed source can be traced.

Also records pre-launch TODO items: NIP-71 inbound ingest is now live
(Phase 2, incl. the resolve-posted-at history-fetch fix), and a new
launch-blocker — channel-profile walls fetch only kind-30078 so a
creator's NIP-71 videos don't show on their profile.

Tests: filter-hex-sanitize (strip/keep/lowercase/drop-empty/leave-non-hex);
mutation-verified. 1244/1245 unit (known UploadModal flake).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-22 18:24:05 -04:00
thePR0M3TH3ANandClaude Opus 4.8 96a985ce89 nip71: fix feed hang — surface published_at so ingest skips history fetch
Deep dive into the "nothing loads / Fetching recent videos… hangs" symptom
found the real blocker: it was NOT the moderation/WoT stage (that uses
cached reads and passes unknown authors through clean). It was the feed's
resolve-posted-at stage.

resolveVideoPostedAt() short-circuits when a video has nip71.publishedAt,
otherwise it does a per-video `nostrClient.hydrateVideoHistory()` network
fetch (looking up kind-30078 history). Foreign NIP-71 videos have no such
history, and the ingest adapter wasn't setting nip71.publishedAt — so a
burst of ~32 ingested videos triggered ~32 blocking history fetches that
stalled the render and flooded relays.

Fix: the ingest adapter now surfaces nip71.publishedAt from the NIP-71
published_at tag (falling back to created_at). The feed short-circuits and
never fetches history for ingested videos. Re-enables FEATURE_NIP71_INGEST
by default now that the hang is addressed.

Tests: adapter published_at extraction + created_at fallback;
mutation-verified. 1237/1238 unit (known UploadModal flake).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-22 16:54:41 -04:00
thePR0M3TH3ANandClaude Opus 4.8 d1246dc113 nip71: defer ingest until feed renders; diagnose display path
Live logs proved ingest works (subscribed to 17 whitelisted authors,
received 61 events, injected 39) — but injecting foreign authors during
cold-start triggers the moderation/WoT stage to hydrate their social graph
(kind 30000), which competes with the initial feed load and stalls it
("Fetching recent videos…" hanging, needs a refresh).

- startWhenFeedReady(): defer the first ingest subscription until the
  native feed's first videos:updated (with a 12s fallback for empty
  feeds), keeping ingest off the cold-start critical path. Wired in
  applicationBootstrap (kept thin so the size cap holds).
- emitRefresh now logs how many ingested videos survive the feed filter
  ("N ingested of M in filtered feed") so we can tell whether foreign
  videos are filtered out vs. injected-but-not-rendered.

Tests: defer-until-feed-ready + fallback-timer; mutation-verified.
1236/1237 unit (known UploadModal flake).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-22 16:23:45 -04:00
thePR0M3TH3ANandClaude Opus 4.8 c4df08d5a1 nip71: make ingest observable + throttle its feed refreshes
Two stabilization fixes after live testing showed ingest was invisible
and could amplify feed load:

1. Diagnostics were using devLogger.log, which is gated by IS_DEV_MODE
   (false in all current builds) — so the [nip71Ingest] subscribe/flush
   lines never printed and the feature was impossible to observe. Switched
   the subscribe + per-flush lines to userLogger.info so we can confirm in
   the console whether it subscribed (author count) and whether events are
   arriving/injecting. (Temporary while stabilizing.)

2. Coalesce ingest-driven "videos:updated" signals behind an 8s throttle.
   A burst of incoming events previously emitted a refresh per flush; each
   refresh re-runs the feed and re-subscribes moderation/social context for
   every video, which can contribute to a REQ storm. Injected videos are
   already in the active store, so throttling only delays the visual
   refresh, not the data.

Tests: throttle coalescing test (one immediate refresh per window, both
videos still stored); mutation-verified. 1234/1235 unit (known flake).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-22 16:05:01 -04:00
thePR0M3TH3ANandClaude Opus 4.8 dcce28cc97 nip71: fix ingest never subscribing due to whitelist-hydration race
The admin whitelist hydrates asynchronously (remote fetch, then a second
hex-pubkey rebuild once NostrTools loads) and does not always emit a
change we can hook — e.g. loaded-from-cache-unchanged, or the async hex
rebuild that emits nothing. So at startup getWhitelistPubkeys() was empty,
resolveFilters() returned null, no subscription opened, and the
onWhitelistChange restart never fired: ingest stayed silent even when a
whitelisted author had foreign NIP-71 videos on the relays.

Fix: bounded retry (every 2s, ~60s) that re-attempts opening the
subscription until the author scope hydrates, in addition to the
onWhitelistChange listener. Author-scoping is kept deliberately — a broad
kinds:[21,22,...] subscription would be a REQ firehose that violates
bitvid's relay fan-out invariants.

Also adds dev-mode logging (subscribe author/kind count; per-flush events
in / injected) so ingest is observable in the console.

Tests: new race test (empty whitelist at start -> authors hydrate without
an emit -> retry subscribes); mutation-verified.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-22 15:46:28 -04:00
thePR0M3TH3ANandClaude Opus 4.8 c849969312 nip71: inbound ingest service (whitelist-scoped, default on)
Pulls NIP-71 video events published by other Nostr apps (kinds
21/22/34235/34236), converts them via the ingest adapter, and injects
them into nostrClient.activeMap/allEvents so they flow through the
existing feed pipeline. Gated by FEATURE_NIP71_INGEST.

Mirrors bitvid's whitelist model: since the whitelist is "authors
allowed to publish/render here," ingest scopes its relay subscription to
whitelisted pubkeys while whitelist mode is on (bounded, no firehose) and
re-subscribes when the whitelist changes. When a deployer disables
whitelist mode, ingest opens to all authors (capped by a limit). Foreign
content is still gated by the same render-time filter
(shouldIncludeVideo -> accessControl.canAccess): whitelist, blacklist,
NSFW (content-warning -> isNsfw), private-skip, plus trust/mute/blur from
the feed engine. Native bitvid videos always win a shared root; among
ingested videos newest wins. Refreshes via the existing videos:updated
reactive path.

- accessControl.getWhitelistPubkeys(): hex whitelist for subscription scope.
- Wired in applicationBootstrap (start on init, stop on teardown).
- nostrService untouched (at its size cap); logic is a standalone service.

Mutation-verified: native-wins, whitelist scoping, feature gate.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-22 15:26:06 -04:00
thePR0M3TH3ANandClaude Opus 4.8 286621dd4a nip71: foreign event -> bitvid video ingest adapter
buildVideoFromNip71Event() converts a NIP-71 video event published by
another Nostr app (kinds 21/22/34235/34236) into a bitvid video object
shaped like convertEventToVideo's output, so ingested videos flow through
the existing feed pipeline (moderation, trust, NSFW gating, rendering).

Reads the imeta variant + standard NIP-71 tags (not bitvid's JSON
content): url/magnet, dim->width/height, image->thumbnail, x/ox hashes,
i/magnet->infoHash, title, summary->description, t->hashtags, duration.

Safety:
- content-warning -> isNsfw so ALLOW_NSFW_CONTENT + blur thresholds apply.
- skips bitvid's own outbound mirrors (["client","bitvid"]) to avoid
  double-listing content already present as canonical kind-30078.
- never marks foreign content private (no encryption in NIP-71).
- stamps source:"nip71-ingest"/foreign:true provenance markers.

Pure + dependency-free; mutation-verified (mirror-skip, nsfw mapping).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-22 15:02:11 -04:00
thePR0M3TH3ANandClaude Opus 4.8 685e52656b nip71: account-level "auto-share new public videos" toggle
Adds an opt-in account preference (off by default, per pubkey) that
auto-mirrors newly published eligible public videos to NIP-71
(kinds 34235/34236) so they surface on other Nostr video clients —
no per-video opt-in needed.

- nip71MirrorFlags: isAutoShareEnabled/setAutoShareEnabled (persisted
  per pubkey) + pure resolvePublishSync({featureOn,autoShare,eligible}).
- nip71MirrorSync: syncNip71MirrorAfterPublish + a videos:published
  subscription in initNip71MirrorSync; converts the canonical 30078
  event, checks eligibility (public + hosted URL + ALLOW_NSFW gate),
  publishes the mirror, and records the per-video flag so later
  edits/deletes stay in lockstep. All best-effort.
- My Videos pane: an "Auto-share new public videos to other Nostr apps"
  checkbox wired to setAutoShareEnabled, gated by FEATURE_NIP71_MIRROR.

Tests (mutation-verified): auto-share persistence, resolvePublishSync
(eligible+on=>publish; off/ineligible=>none), and videos:published
subscription wiring.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-22 14:06:19 -04:00
thePR0M3TH3ANandClaude Opus 4.8 115521928c feat(video): capture & persist dimensions/duration at upload (enables shorts)
Auto-captures a video's width/height/duration at file-select and persists them on
the 30078 — the durable foundation for a future "shorts" vertical feed (filter on
height > width) and for the NIP-71 mirror's short-kind selection.

- js/utils/videoProbe.js: probeVideoMetadata(src) loads metadata into a hidden
  <video> (element injectable for tests); resolves {width,height,duration} or null.
- UploadModal: probes the selected file (objectURL) in the background and threads
  width/height/duration into the publish payload.
- videoPayloadBuilder: persists width/height/duration in content (publish) and
  preserves them across edits; normalizers exported.
- convertEventToVideo: exposes video.width/height/duration → the mirror builder
  already uses them (portrait ⇒ 34236 short + imeta dim).

tests: probe resolution (incl portrait + error/timeout → null); content persist +
parse; portrait⇒34236 / landscape⇒34235 mirror selection; edit preserves dims.
Mutation-verified earlier on the mirror short-selection.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-22 11:44:02 -04:00
thePR0M3TH3ANandClaude Opus 4.8 e793b359c3 feat(nip71): mirror carries the video's hashtags (from t tags / nip71 metadata)
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]>
2026-06-22 11:30:12 -04:00
thePR0M3TH3ANandClaude Opus 4.8 a1bf7b151f feat(video): hashtags data layer (content + t tags + parser)
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]>
2026-06-22 10:41:46 -04:00
thePR0M3TH3ANandClaude Opus 4.8 89d9a62b87 feat(nip71): auto-sync the mirror on edit/delete (event-driven, no nostrService growth)
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]>
2026-06-22 10:22:27 -04:00
thePR0M3TH3ANandClaude Opus 4.8 783c7df0b7 feat(nip71): My Videos opt-in "Share to apps" mirror toggle (1c/1d)
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]>
2026-06-22 09:40:09 -04:00
thePR0M3TH3ANandClaude Opus 4.8 58f1909570 feat(nip71): Phase 1b — mirror teardown (NIP-09 delete + empty-replace tombstone)
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]>
2026-06-22 09:18:31 -04:00
thePR0M3TH3ANandClaude Opus 4.8 e20191d6bc feat(nip71): Phase 1a — mirror publish service with site NSFW gate
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]>
2026-06-22 09:03:41 -04:00