diff --git a/js/viewCountChart.js b/js/viewCountChart.js index c9c8cc2c..c7c337d3 100644 --- a/js/viewCountChart.js +++ b/js/viewCountChart.js @@ -319,13 +319,17 @@ export function openPopularityModal({ // Headline total: subscribe to the same counter the card uses (stable + cached). try { - countToken = subscribeToVideoViewCount(pointer, ({ total } = {}) => { - if (closed) return; - if (Number.isFinite(total)) { - counterTotal = Number(total); - renderHeadline(); - } - }); + countToken = subscribeToVideoViewCount( + pointer, + ({ total } = {}) => { + if (closed) return; + if (Number.isFinite(total)) { + counterTotal = Number(total); + renderHeadline(); + } + }, + { exact: true }, + ); } catch (error) { devLogger.warn("[popularity] Failed to subscribe to view count:", error); } diff --git a/js/viewCounter.js b/js/viewCounter.js index cda34ced..a1da08b3 100644 --- a/js/viewCounter.js +++ b/js/viewCounter.js @@ -249,35 +249,84 @@ async function runBatchedBackfill() { // Exact NIP-45 COUNT for a single pointer (watch page). Cheap — one pointer, // no storm — and accurate where a viewer might actually scrutinize the number. +const EXACT_LIST_LIMIT = 2000; + +// Accurate single-pointer count (watch page / popularity chart). Lists view +// events across ALL relays and dedupes by (viewer, window) — a true cross-relay +// UNION. This replaces relying on the NIP-45 COUNT, which returns the single +// relay with the most events (it can't union across relays) and therefore +// UNDER-counts whenever views are spread across relays. The deduped set accrues +// monotonically, so the number is accurate AND never spuriously decreases. For +// extremely popular videos where the list truncates, the per-relay COUNT is used +// as a lower-bound floor and the result is flagged partial. async function exactCountForPointer(key, pointer, options = {}) { + const sinceSeconds = + Math.floor(Date.now() / 1000) - VIEW_COUNT_BACKFILL_MAX_DAYS * SECONDS_PER_DAY; + + let events; try { - const result = await countVideoViewEventsApi(pointer, { + events = await listVideoViewEventsApi(pointer, { + since: sinceSeconds, + limit: EXACT_LIST_LIMIT, relays: options.relays, signal: options.signal, }); - const best = Number.isFinite(result?.best?.count) - ? Number(result.best.count) - : Number.isFinite(result?.total) - ? Number(result.total) - : null; - if (best === null) { + } catch (error) { + if (error?.name === "AbortError") { return; } - const state = ensurePointerState(key); - if (result && typeof result === "object") { - state.partial = Boolean(result.partial); + userLogger.warn("[viewCounter] Exact view count list failed:", error); + return; + } + + const eventList = Array.isArray(events) ? events : []; + const truncated = eventList.length >= EXACT_LIST_LIMIT; + + const state = ensurePointerState(key); + // Fold the union into the deduped bucket set (one entry per viewer-window). + for (const event of eventList) { + const bucketKey = deriveBucketKey(event) || deriveFallbackKey(event); + if (!bucketKey) { + continue; } - if (best > state.total) { - state.total = best; - state.lastSyncedAt = Date.now(); - } - schedulePersist(); - notifyHandlers(key); - } catch (error) { - if (error?.name !== "AbortError") { - userLogger.warn("[viewCounter] Exact view count failed:", error); + const createdAt = Number.isFinite(event?.created_at) + ? Number(event.created_at) + : Math.floor(Date.now() / 1000); + state.dedupeBuckets.set(bucketKey, createdAt); + } + + let total = state.dedupeBuckets.size; + let partial = truncated; + + if (truncated) { + try { + const result = await countVideoViewEventsApi(pointer, { + relays: options.relays, + signal: options.signal, + }); + const best = Number.isFinite(result?.best?.count) + ? Number(result.best.count) + : Number.isFinite(result?.total) + ? Number(result.total) + : null; + if (Number.isFinite(best) && best > total) { + total = best; + } + partial = partial || Boolean(result?.partial); + } catch (error) { + if (error?.name === "AbortError") { + return; + } } } + + state.partial = partial; + if (total !== state.total) { + state.total = total; + state.lastSyncedAt = Date.now(); + } + schedulePersist(); + notifyHandlers(key); } restoreCacheSnapshot(); diff --git a/scripts/check-file-size.mjs b/scripts/check-file-size.mjs index bdd32db2..02f9bfb8 100644 --- a/scripts/check-file-size.mjs +++ b/scripts/check-file-size.mjs @@ -43,6 +43,7 @@ const GRANDFATHERED = { "js/nostr/nip46Client.js": 2102, "js/state/cache.js": 1827, "js/app/feedCoordinator.js": 1857, + "js/viewCounter.js": 1023, "js/app/authSessionCoordinator.js": 1803, "js/ui/components/RevertModal.js": 1663, "js/payments/nwcClient.js": 1642, diff --git a/tests/view-counter.test.mjs b/tests/view-counter.test.mjs index 079e0d10..9b70fded 100644 --- a/tests/view-counter.test.mjs +++ b/tests/view-counter.test.mjs @@ -575,6 +575,66 @@ async function testRelayCountAggregationUsesBestEstimate() { } } +// The exact path (options.exact, watch page / popularity chart) must count the +// cross-relay UNION of view events deduped by (viewer, window) — NOT the per-relay +// NIP-45 COUNT, which returns the single relay with the most events and therefore +// under-counts when views are spread across relays. +async function testExactCountUnionsAcrossRelays() { + localStorage.clear(); + harness.reset(); + harness.resetMetrics(); + + const previousGetManager = nostrClient.getSubscriptionManager; + // exactCountForPointer only runs when a SubscriptionManager exists. + nostrClient.getSubscriptionManager = () => ({ + list: async () => [], + subscribe: () => ({ close() {} }), + handleReconnect() {}, + }); + + try { + const pointer = { type: "e", value: "view-counter-exact-union" }; + const pointerKey = harness.pointerKeyFromInput(pointer); + const dayStart = + Math.floor(Math.floor(Date.now() / 1000) / 86400) * 86400; + // alice has two views in the same day (dedupe to 1) → 3 distinct (viewer,day): + // alice@d0, bob@d0, alice@d1. + harness.setEvents(pointerKey, [ + { id: "e1", pubkey: "alice", created_at: dayStart + 10 }, + { id: "e2", pubkey: "alice", created_at: dayStart + 20 }, + { id: "e3", pubkey: "bob", created_at: dayStart + 30 }, + { id: "e4", pubkey: "alice", created_at: dayStart - 86400 + 10 }, + ]); + // A single relay's COUNT only saw 1 — the union must win. + harness.setCountTotal(pointerKey, { + total: 1, + best: { relay: "wss://relay.one", count: 1 }, + }); + + const updates = []; + const token = subscribeToVideoViewCount( + pointer, + (state) => updates.push({ ...state }), + { exact: true }, + ); + try { + await flushPromises(); + await flushPromises(); + const final = updates.at(-1); + assert.ok(final, "expected an exact-count update"); + assert.equal( + final.total, + 3, + "exact count = cross-relay union deduped by (viewer,day), not the per-relay COUNT (1)", + ); + } finally { + unsubscribeFromVideoViewCount(pointer, token); + } + } finally { + nostrClient.getSubscriptionManager = previousGetManager; + } +} + async function testLocalIngestNotifiesImmediately() { localStorage.clear(); harness.reset(); @@ -1136,6 +1196,7 @@ await testHydrationSkipsStaleEventsAndRollsOff(); await testLocalIngestNotifiesImmediately(); await testUnsubscribeStopsCallbacks(); await testRelayCountAggregationUsesBestEstimate(); +await testExactCountUnionsAcrossRelays(); await testRecordVideoViewEmitsJsonPayload(); await testSignAndPublishFallbackUsesSessionActor(); await testHydrateHistoryPrefersRootEvent(); diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index a1df816c..14ebaded 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -221,6 +221,22 @@ parameterized-replaceable event; (b) `exactCountForPointer` runs a NIP-45 COUNT boundary (map vs the `syncActiveVideoRootTimestamp` field step). Removed from the QUARANTINE map. Spec corrections logged in TEST_INTEGRITY.md (SCN-view-counter-legacy-path, SCN-hydrate-root-timestamp). +- [x] **Cross-relay UNDER-count fixed (2026-06-24).** The "exact" path (watch page + + popularity chart) relied on the NIP-45 COUNT, but `countEventsAcrossRelays` + returns `bestEstimate` = the SINGLE relay with the most events — it can't + union across relays, so it under-counts when views are spread across relays + (e.g. card showed 19 while the chart's uncapped list union showed ~63). + `exactCountForPointer` now lists view events across ALL relays (uncapped, one + on-demand list — not a storm) and folds the UNION into the deduped + `dedupeBuckets` (one per viewer-window); total = the set size, so it's + accurate AND monotonic (never spuriously decreases). Per-relay COUNT is kept + only as a lower-bound floor when the list truncates (>2000), flagged partial. + The popularity modal now passes `{ exact: true }`, and since the count flows + through the shared pointer state, opening a video corrects its count + everywhere (grid cards converge). Test: `testExactCountUnionsAcrossRelays` in + `tests/view-counter.test.mjs`. NOTE: grid cards (no `exact`) still use the + §17-capped batched backfill (approximate, storm-safe) until the video is + opened — acceptable accuracy/cost tradeoff. - [x] **`reactionCounter.js` (likes/kind-7) audited — 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), @@ -469,6 +485,13 @@ toward freshness and looked identical. Gave each a structural identity: windowed "trending" (views in the last N days) once view-over-time data (#26) lands, instead of all-time totals. +### 31. ⋯ menu "Event Details" button does nothing (BUG) +- [ ] The "Event Details" item in the video ⋯ (more) menu does not open / does + nothing when clicked. Trace its `data-action` → `handleMoreMenuAction` case + (vs `EventDetailsModal`): confirm the action name matches a case (likely a + missing/renamed case or a no-op like the embed/copy-magnet event-name + mismatches), wire it to open `EventDetailsModal`, and add a regression test. + ### 28. Beacon torrent app stuck — spinner never resolves (BUG) - [ ] The torrent beacon app (`scripts/build:beacon` / `torrent/` integration) shows a spinner that never goes away. Trace the beacon's init/connect path: what the