Commit Graph
6506 Commits
Author SHA1 Message Date
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 63dcb5f6ed fix(more-menu): make ⋯ "Event Details" robust + non-silent (#31)
The wiring was structurally intact (menu item event-details → handler case →
handleEventDetailsAction → EventDetailsModal.open), so the failure was silent:
- handleEventDetailsAction only opened the modal `if (… && payload.video)` and
  never caught the async open() rejection — any miss = nothing happens.
- EventDetailsModal.open ran the (throwable) renderVersion BEFORE openStaticModal,
  so a render hiccup left the click looking dead.

Hardened:
- handleEventDetailsAction surfaces "No video selected" / "unavailable" instead of
  failing silently, falls back to app.eventDetailsModal, and .catch()es open().
- open() now shows the modal (openStaticModal) FIRST, then renders in a try/catch.
- The ⋯ popover is dismissed before opening the modal.

Couldn't repro by inspection, so this both fixes the likely causes and makes any
remaining failure visible (a toast / console warn naming the exact reason).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-24 17:53:32 -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 1bc2b06395 fix(popularity): headline reads the authoritative view counter, not a re-count
The chart showed a different (higher, fluctuating) total than the card's view
count because it re-derived its own total from a one-off, unwindowed relay
listVideoViewEvents — which varies by relay response and counts a wider set than
the counter.

Now the modal headline subscribes to the same viewCounter the card uses (the
deduped, cached, authoritative total), so it always matches the number shown
everywhere and is stable. The chart below is purely the time DISTRIBUTION of
recorded views, listed within the counter's backfill window
(VIEW_COUNT_BACKFILL_MAX_DAYS) so it lines up with the headline's window. The
count subscription is cleaned up on close.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-24 14:07:20 -04:00
thePR0M3TH3ANandClaude Opus 4.8 6911df075a fix(popularity): correct lazy-import path + forward video; single-instance modal
Two bugs in the #26 wiring surfaced live:
- The ⋯ menu lazy-import used "../../viewCountChart.js" which resolves one level
  too high (dist/viewCountChart.js → 404 "Failed to fetch dynamically imported
  module"). Corrected to "../viewCountChart.js" (js/ui → js/viewCountChart.js).
- The video-list context path called handleMoreMenuAction without the video, so
  view-stats hit "No video selected". Forward detail.video.

Also make the popularity modal single-instance: close any open one (proper
subscription/listener cleanup) before opening a new one, so the two menu paths
firing for one click can't stack duplicates or leak a live subscription.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-24 13:59:09 -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 c967789257 todo: note the preserveOrder render bug that masked the feed identities
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-24 13:14:16 -04:00
thePR0M3TH3ANandClaude Opus 4.8 04621efc52 feed: stop the grid re-sorting ranked feeds chronologically; Trending defaults
The real reason every tab looked identical: VideoListView.render re-sorts
displayVideos by date UNLESS metadata.preserveOrder is set, and only Explore set
it. So For You's tiering, Trending's view-count ranking, and Kids' scoring were
all discarded at render and shown in plain chronological order — i.e. the #21
feed-identity work was invisible. Set preserveOrder for For You, Trending, and
Kids so their feed-engine order survives to the grid.

Also per request:
- Sidebar: Trending now sits above Recently Added.
- Default homepage: logged-out users land on Trending (gated by
  FEATURE_TRENDING_FEED, falls back to Recent); logged-in stays For You. The
  logout-from-For-You redirect follows the same rule.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-24 13:13:45 -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 926652156d todo: record feed-identity work (#21 done, #20 tab-switching resolved)
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-24 08:51:26 -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 ebea4434de dm: make the selected conversation clearly distinct in the sidebar
The active contact row only swapped to --surface-sunken with a --border-strong
border, nearly identical to the hover state, so it was hard to tell which
conversation was selected. Give it an accent-tinted background, an accent
border, an inset left accent bar, and accent-colored bold name text.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-23 20:39:43 -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 00bcff7bb5 docs(todo): mark #18 embed fixed; note copy-button sibling event mismatch
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-23 20:10:01 -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 deabac064b docs(todo): add backlog items (view-counter deep audit, popularity chart,
Trending tab, beacon spinner bug, admin-whitelist WoT bypass, Blossom
storage, live-stream go-live publishing)

File and prioritize 7 more maintainer-reported items. Split the live-stream
work into separate INGEST (#16, watch-only) and PUBLISH (#16c, go-live)
functions, each with its own config flag per the maintainer's request.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-23 20:04:33 -04:00
thePR0M3TH3ANandClaude Opus 4.8 72c280aaea zap: hide the disabled zap button instead of showing a dead one
Root cause of "have to click the Zap button several times before it opens":
the button is intentionally hidden+disabled until the creator's Lightning
address resolves (~2s profile fetch), but .video-modal__action-button sets
display: inline-flex, which overrode the UA `[hidden] { display:none }`. So
the button rendered and looked clickable while still a disabled no-op, and
disabled buttons don't fire click events. Add
`.video-modal__action-button[hidden] { display:none }` so it only appears
once enabled/ready (restoring the intended "show zap only when zappable"
behaviour).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-23 19:37:34 -04:00
thePR0M3TH3ANandClaude Opus 4.8 c98c92633b docs(todo): add maintainer backlog items (embed, DMs, feed reliability,
For-You audit, anti-spam npub validation, admin submissions tab,
user allowlist override, per-video block list, live + short-form tabs)

Articulate and file 10 maintainer-reported items into the pre-launch TODO
by priority. Also reopen the "Zap button needs many clicks to open" item —
the first fix attempt did not resolve it.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-23 15:16:10 -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 380de0922a docs: update zap-audit plan, pre-launch TODO, and test-integrity notes
Record this session's fixes (delete/zombie, NIP-71 mirror teardown,
watch-history bucketing, the zap chain, the test-runner quarantine work)
and the test spec-corrections.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-23 14:53:51 -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 7e67471a4b test-infra: run all unit test files; quarantine known-broken loudly
The unit runner only collected files that imported node:test, silently
excluding ~20 bare-assert files (several were failing on main, unnoticed).
Collect every *.test.{mjs,js}; known-broken files live in an explicit
QUARANTINE map that is reported on every run (never silently skipped) with
a stale-entry check.

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 58047c2589 docs: mark Capacitor + NIP-46 plans on hold
Defer the Capacitor native-app plan and the NIP-46 roadmap enhancements
(current NIP-46 login works well) while the pre-launch backlog clears.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-23 14:53:11 -04:00
thePR0M3TH3ANandClaude Opus 4.8 07b42788d5 docs: zap-audit plan + capture cross-session learnings in TODO
- docs/zap-audit-plan.md: architecture map, findings (LNURL CORS send-error,
  comment box confirmed working, popover mis-position), and prioritized next
  steps (CORS/LNURL proxy decision first, then popover, fee-split correctness,
  receipt validation, NWC UX). Notes the injectable-fetcher/deps test seams.
- TODO #3 points to the doc; #9 adds two unverified learnings: confirm the
  community-blacklist batching actually dropped the kind-30000 storm (still saw
  =32 in a later load), and trace the `from_hex` malformed-hex source via the
  new sanitizer dev-log.

Planning/docs only.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-22 22:26:07 -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 5e2b22156f docs: video-liveness plan (speed, playability probe, multi-source)
Audited the card liveness/health system. Policy (cardSourceVisibility) is
already correct — hides only when neither CDN nor WebTorrent is healthy,
un-hides when WebTorrent flips green. Documents the real gaps (slow probes;
opaque HEAD no-cors inaccuracy for cross-origin NIP-71 CDNs; single-source
adapter dropping extra imeta) and a 3-phase plan: multi-source capture,
video-element playability probe across sources, and speed tuning. Adds
docs/video-liveness-plan.md + expands pre-launch TODO #5. Planning only.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-22 21:26:20 -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 1075d3f822 channel: optimistic render includes ingested NIP-71 videos
getCachedChannelVideoEvents early-returned kind-30078-only rawEvents and
skipped allEvents, so a creator's ingested NIP-71 videos didn't show in the
optimistic (pre-fetch) render. Now it merges raw 30078 (first, so they win
the downstream id-dedupe) with processed allEvents (which holds ingested
foreign videos) → the wall paints instantly on revisit / when the feed
already loaded that author. Cross-ecosystem dedupe collapses any overlap.

Also records TODO 17d (unified grid loading follow-ups): streaming
channel/search fetch, per-channel persisted cache, My Videos NIP-71
inclusion, and consolidating bespoke loaders behind one shared streaming
grid source.

1262/1263 unit (known UploadModal flake).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-22 20:00:36 -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 c404618137 docs: flag externally-managed storage for NIP-71 reference imports
Imports not produced by bitvid's storage system (reference imports / external
URLs) must be explicitly flagged externally-managed so storage tooling never
treats them as bitvid-owned: external badge (never "missing from bucket"),
excluded from orphan reconciliation, delete "file left behind" warning
suppressed, liveness unverifiable. Adds import modes (full re-host vs
reference) + Phase 2b to the plan and TODO 17c. Notes the existing
isUrlUnderBase heuristic is the baseline but a durable flag is needed.

Planning only; no code changes.

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