Commit Graph
6523 Commits
Author SHA1 Message Date
thePR0M3TH3ANandClaude Opus 4.8 39eeee9ccc docs(todo): record profile-modal app-grid under #7 (sidebar reverted)
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-29 14:49:56 -04:00
thePR0M3TH3ANandClaude Opus 4.8 e38866c372 feat(mobile): profile modal tabs become an app-grid (#7)
On mobile (<1024px, the full-width menu view), the profile modal's tab nav
(Account, Relays, Wallet, Storage, …) renders as a 3-column grid of icon tiles
with the title centered below each icon, instead of the vertical list. Desktop
(>=lg) keeps its vertical nav, unchanged.

Scoped to `.profile-modal__nav` inside the existing mobile media query; token-only;
labels wrap at spaces. Validated live in-browser.

(Note: the earlier sidebar app-grid was a misread of the request and was reverted;
this is the intended component.)

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-29 14:49:27 -04:00
thePR0M3TH3AN ab20640b9b Revert "feat(mobile): sidebar nav becomes an app-grid on mobile (#7)"
This reverts commit d0f8859f8b.
2026-06-29 14:42:47 -04:00
thePR0M3TH3ANandClaude Opus 4.8 d0f8859f8b feat(mobile): sidebar nav becomes an app-grid on mobile (#7)
Below the desktop breakpoint (<1024px), the open/expanded sidebar's primary nav
renders as a 3-column grid of icon tiles with the title centered below each icon
(app-grid feel), instead of the desktop vertical list. Desktop and the collapsed
icon rail are unchanged.

- Scoped to `.sidebar-nav` so the footer "More" links are untouched.
- Token-only (no raw measurements), high specificity via the sidebar-expanded
  prefix (no !important). Labels wrap at spaces, not mid-word.
- Validated live in-browser; lint clean.

Option A (grid shows on the open mobile menu). An always-on variant was deferred —
it would require widening/overlaying the mobile sidebar.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-29 12:52:17 -04:00
thePR0M3TH3ANandClaude Opus 4.8 e73b627ddd docs(todo): add reported items (#33-#39) + mobile-UI note to #7
From 2026-06-25 user report:
- #33 (HIGH): NIP-46/Amber login+profile+persistence broken — "unknown user" /
  no videos until logout+hard refresh, profile switching broken, logout logs out
  all accounts; audit the login/profile/session-persistence system.
- #34: NIP-71 mirror shows mirrored videos as "not mirrored"; re-mirror creates
  duplicates in NIP-71 clients; make detection reliable + the publish idempotent.
- #35: admin whitelisting tool slow/cumbersome — improve alongside #22/#23.
- #36: storage unlock fails with nsec login.
- #37: channel playlists (creator-curated note lists).
- #38: generic S3 doesn't work with Backblaze B2 (endpoint/public-URL mismatch) —
  add B2 as its own provider.
- #39: image uploads (profile image/banner first) should prefer uploading to the
  user's configured storage and auto-grab the URL.
- #7: broadened to "improve mobile UI" across the app, not just the card.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-29 11:02:11 -04:00
thePR0M3TH3ANandClaude Opus 4.8 ce4f006e9d test(11b): un-quarantine nostr-count-fallback; document publish-rejection setup
nostr-count-fallback: STALE TEST after a refactor — countUnsupportedRelays moved
from NostrClient to its ConnectionManager, and the test read it off the client
(undefined.has). Repointed the assertion at client.connectionManager
.countUnsupportedRelays. No behavior change, no assertion weakened.

Also recorded the precise multi-precondition diagnosis for the still-quarantined
nostr-publish-rejection (dev-mode override before the first dynamic config import;
then a NIP-71 invocation sub-test) in the QUARANTINE note + TODO, so it's a quick
finish next time.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-29 10:55:27 -04:00
thePR0M3TH3ANandClaude Opus 4.8 fe653706a1 test(11b): un-quarantine nostr-boost-actions + admin-list-store
Triaged two more silently-excluded test files back into CI.

- nostr-boost-actions: STALE SPEC. Test required buildRepostEvent to throw
  /missing-event-relay/ with no relay hint, but a relay hint is a NIP-18 SHOULD
  (not MUST) — the builder degrades to a two-element `e` tag so a user can always
  repost. Replaced with an exact assertion of the valid output (kind 16 + e/p/k).

- admin-list-store: STALE HARNESS (no production change, no assertion weakened).
  Community curator lists are fetched via the batched SubscriptionManager
  (cold-start relay-storm fix) the harness never mocked; the mock "hex" curator
  values started with "npub" so parseCommunityBlacklistReferences decoded them
  twice; and createListEvent omitted the `d` tag selectNewestEventsForReferences
  matches on. Fixed all three to be faithful to production.

Removed both from the QUARANTINE map; TEST_INTEGRITY.md + TODO updated.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-29 10:11:52 -04:00
thePR0M3TH3ANandClaude Opus 4.8 8ab531b241 fix(access): canAccess cross-derives hex+npub so whitelisted authors aren't blocked (#32)
A video from a genuinely whitelisted author refused to play with "not from a
whitelisted author" (the source URL was confirmed healthy — HTTP 200, video/mp4,
range, CORS *). Root cause: accessControl keeps the whitelist in two sets —
whitelistPubkeys (hex) and whitelist (npub) — and canAccess consulted only one
based on the caller's input format without cross-deriving. playbackCoordinator
passes the author's npub, so it checked only the npub set; an entry present in
only the hex representation read as "not allowed."

- canAccess now derives BOTH hex and npub from the candidate and checks both sets
  (only fixes false-negatives; a non-whitelisted author is still denied).
- Dev-gated diagnostic at the playback rejection logs hex/npub, whitelistMode,
  set sizes, and inNpubSet/inHexSet to pinpoint any remaining denial.
- Tests: tests/access-control-canaccess.test.mjs.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-25 10:39:11 -04:00
thePR0M3TH3ANandClaude Opus 4.8 f5bf3d115c test(storage): cover generic-S3 public-URL resolution / forcePathStyle (#6)
Audited the generic-S3 path: upload/thumbnail/.torrent, CORS guidance, and the
delete/edit cleanup path-style (incl. the documented vhost bug) were already
tested. The real gap was public-URL resolution — the upload tests mock
buildS3ObjectUrl, so the actual path-style/vhost logic in js/storage/s3-url.js
was unverified.

Added tests/storage/s3-url.test.mjs (11 pure-function scenarios): path-style puts
the bucket in the PATH; vhost puts it in the host subdomain (no double-prefix);
endpoint path-prefix preserved; bare-endpoint https upgrade; key/slash
normalization; explicit publicBaseUrl wins; missing inputs → "". Also covers
s3Service.derivePublicBaseUrl / buildS3ObjectUrl. Closes #6.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-25 10:21:53 -04:00
thePR0M3TH3ANandClaude Opus 4.8 fea5af34cf docs(todo): defer #29 (admin-whitelist WoT bypass) as YAGNI
Decision: defer until a real flag-brigading incident exists to scope against.
The premise is sound but speculative; low risk at small scale (WoT mutes come
from trusted muters, so brigading needs in-graph coordination). Recorded the
analysis + exact implementation pointer (stages.js adminWhitelistBypass
placeholder + hideBypass framework) and the scoping refinements (bypass the WoT
hide layer only; never override a viewer's own block/mute; lean toward bypassing
soft report categories while still hiding hard/illegal ones) so it's a small,
well-scoped change when needed.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-25 10:11:27 -04:00
thePR0M3TH3ANandClaude Opus 4.8 2e510b2330 fix(liveness): don't bury WebTorrent-only content under hide-until-verified
A WebTorrent-only card's sole liveness signal is the swarm probe (20s timeout,
5min cache), and P2P liveness is slow + intermittent — a live-but-slow video is
indistinguishable from a dead one until that probe finishes. Hiding such a card
until the swarm verifies could bury perfectly good P2P content for minutes.

hide-until-verified now only suppresses a card while the FAST, reliable CDN probe
is still running. Once the CDN resolves dead (or is absent, i.e. P2P-only), it
falls back to show-pending for the WebTorrent part: shown while the swarm is
checked, hidden only once it too is confirmed dead. Burying live content is worse
than a brief flash of a dead one, and only the slow P2P probe is at issue.
cardNeedsEagerLivenessProbe likewise skips P2P-only cards (they start visible).

Tests: hide-foreign does-not-bury-WebTorrent-only coverage in
tests/video-card-source-visibility.test.mjs.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-25 09:48:55 -04:00
thePR0M3TH3ANandClaude Opus 4.8 129d877d38 fix(liveness): eager-probe hide-until-verified cards (fixes vanished foreign feed)
Setting CARD_LIVENESS_POLICY=hide-foreign made all foreign/NIP-71 content
disappear: a hide-until-verified card starts display:none, which has no layout
box, so the IntersectionObserver never fires for it — its liveness probe never
runs, it never gets a healthy verdict, and it stays hidden forever (deadlock).

Fix: cardNeedsEagerLivenessProbe() identifies cards governed by a
hide-until-verified policy (foreign under hide-foreign; all non-owner under
hide-all). Both health observers now kick the probe for those cards from
onCardRegister — which fires for every card regardless of visibility — instead of
waiting for an intersection that a hidden card can never produce. The URL probe is
deferred one microtask so attachUrlHealthBadges has wired state.onCheck after
observe(). Probes stay concurrency-capped + priority-ordered; show-pending is
unchanged (no eager probing).

Tests: cardNeedsEagerLivenessProbe predicate coverage in
tests/video-card-source-visibility.test.mjs.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-25 09:33:41 -04:00
thePR0M3TH3ANandClaude Opus 4.8 a3b66f8841 chore(liveness): default CARD_LIVENESS_POLICY to hide-foreign (#5)
Set the default policy to hide-foreign for live feel-testing: foreign/ingested
cards stay hidden until a source verifies (no flash of dead strangers), while
native/own cards keep showing instantly. Revert to "show-pending" if it feels
worse.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-25 09:21:00 -04:00
thePR0M3TH3ANandClaude Opus 4.8 ccbe8d9bb2 feat(liveness): config-driven card visibility policy + probe prefetch (#5)
Makes the card liveness-visibility behavior switchable so it can be A/B
feel-tested, and probes ahead of the scroll for a snappier result.

- CARD_LIVENESS_POLICY (config/instance-config.js): "show-pending" (default,
  unchanged) | "hide-foreign" (foreign/ingested cards hidden until a source
  verifies; native cards stay show-pending) | "hide-all". Owner's own cards are
  always visible. Flip live for testing without a rebuild:
  window.__BITVID_CARD_LIVENESS_POLICY__ = "hide-foreign" then refresh/scroll.
  Implemented via a data-foreign provenance flag on the card +
  getCardLivenessPolicy() in cardSourceVisibility.js.
- LIVENESS_PROBE_PREFETCH_MARGIN (default 600px): both liveness probes were
  already viewport-gated (IntersectionObserver, on-screen-first, WebTorrent
  priority-queued + concurrency-capped); this expands their rootMargin so cards
  just below the fold verify before the user reaches them.
- Tests: three policy scenarios in tests/video-card-source-visibility.test.mjs;
  also un-broke 3 pre-existing VideoCard tests by adding the missing
  IntersectionObserver JSDOM stub.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-25 09:13:52 -04:00
thePR0M3TH3ANandClaude Opus 4.8 df99bdb65f fix(zap): collapse split when creator == platform; close #3 audit
Finishes the remaining #3 zap audit items.

- zapSplit: when the platform fee would pay the SAME Lightning address as the
  creator (e.g. the creator is the platform operator), collapse to a single
  full-amount payment instead of sending two invoices to one wallet — avoids an
  extra routing fee and a confusing duplicate receipt for the same total.
- Audited split correctness: floor(amount*fee/100) with the creator taking the
  remainder loses no sats; a sub-1-sat fee rounds to 0 and the whole zap goes to
  the creator (no zero-amount platform send); fee is clamped 0–100 and a junk
  override falls back to the default. The earlier "fee landed in my own wallet"
  report is expected behavior (sender is the platform operator, or a self-zap),
  not a bug.
- Cheat-resistant tests: testCreatorEqualsPlatformCollapsesToOnePayment and
  testSplitRoundingPreservesEverySat in tests/zap-split.test.mjs.
- Receipt validation (9735) and NWC budget/retry UX confirmed already solid;
  audit plan + TODO #3 closed.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-25 08:56:00 -04:00
thePR0M3TH3ANandClaude Opus 4.8 b52a1b3fea docs(zap): close #3 CORS/LNURL — accept graceful degradation, no proxy
Decision: keep the visual zappability flag as the honest fallback for
CORS-less LNURL hosts. A proxy breaks the static/no-custody rule and adds an
invoice-swap security surface; the platform-fee split is inherently incompatible
with a CORS-blocked recipient in a static client; and WebLN's only CORS-bypass
(webln.lnurl()) pays directly with no NIP-57 receipt, no split, and no
deterministic amount. Current behavior is the right answer.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-25 08:41:55 -04:00
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 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