diff --git a/js/nostr.js b/js/nostr.js index 00c8dc1d..7651ae9c 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -2465,6 +2465,7 @@ class NostrClient { return { total: Array.isArray(events) ? events.length : 0, perRelay: [], + best: null, fallback: true, }; } @@ -2539,6 +2540,7 @@ class NostrClient { return { total: Array.isArray(events) ? events.length : 0, perRelay: [], + best: null, fallback: true, }; } @@ -4766,7 +4768,7 @@ class NostrClient { async countEventsAcrossRelays(filters, options = {}) { const normalizedFilters = this.normalizeCountFilters(filters); if (!normalizedFilters.length) { - return { total: 0, perRelay: [] }; + return { total: 0, best: null, perRelay: [] }; } const relayList = @@ -4776,7 +4778,7 @@ class NostrClient { ? this.relays : RELAY_URLS; - const perRelay = await Promise.all( + const perRelayResults = await Promise.all( relayList.map(async (url) => { try { const frame = await this.sendRawCountFrame(url, normalizedFilters, { @@ -4793,15 +4795,47 @@ class NostrClient { }) ); - const total = perRelay.reduce((sum, entry) => { + let bestEstimate = null; + const perRelay = perRelayResults.map((entry) => { if (!entry || !entry.ok) { - return sum; + return entry; } - const value = Number(entry.count); - return Number.isFinite(value) && value > 0 ? sum + value : sum; - }, 0); - return { total, perRelay }; + const numericValue = Number(entry.count); + const normalizedCount = + Number.isFinite(numericValue) && numericValue >= 0 ? numericValue : 0; + + const normalizedEntry = { + ...entry, + count: normalizedCount, + }; + + if (!Number.isFinite(numericValue) || numericValue < 0) { + normalizedEntry.rawCount = entry.count; + } + + if ( + !bestEstimate || + normalizedCount > bestEstimate.count || + (bestEstimate && normalizedCount === bestEstimate.count && !bestEstimate.frame) + ) { + bestEstimate = { + relay: normalizedEntry.url, + count: normalizedCount, + frame: normalizedEntry.frame, + }; + } + + return normalizedEntry; + }); + + const total = bestEstimate ? bestEstimate.count : 0; + + return { + total, + best: bestEstimate, + perRelay, + }; } /** diff --git a/js/viewCounter.js b/js/viewCounter.js index cd38164c..72ab3350 100644 --- a/js/viewCounter.js +++ b/js/viewCounter.js @@ -485,10 +485,18 @@ async function hydratePointer(key, listeners) { mutated = applyEventToState(key, event) || mutated; } } - if (countResult && Number.isFinite(countResult.total)) { + const bestCount = Number.isFinite(countResult?.best?.count) + ? Number(countResult.best.count) + : Number.isFinite(countResult?.total) + ? Number(countResult.total) + : null; + + if (bestCount !== null) { const state = ensurePointerState(key); - if (countResult.total > state.total) { - state.total = Number(countResult.total); + const shouldUpdate = + !countResult?.fallback || bestCount >= state.total || state.total === 0; + if (shouldUpdate && state.total !== bestCount) { + state.total = bestCount; state.lastSyncedAt = Date.now(); mutated = true; } diff --git a/tests/view-counter.test.mjs b/tests/view-counter.test.mjs index 043cb8be..897ccdda 100644 --- a/tests/view-counter.test.mjs +++ b/tests/view-counter.test.mjs @@ -46,7 +46,7 @@ const { nostrClient } = await import("../js/nostr.js"); function createMockNostrHarness() { const storedEvents = new Map(); - const customTotals = new Map(); + const customCountResults = new Map(); const subscribers = new Map(); const metrics = { list: 0, count: 0 }; @@ -108,11 +108,37 @@ function createMockNostrHarness() { const countVideoViewEvents = async (pointer) => { metrics.count += 1; const key = pointerKeyFromInput(pointer); - if (customTotals.has(key)) { - return { total: customTotals.get(key) }; + if (customCountResults.has(key)) { + const stored = customCountResults.get(key); + if (stored && typeof stored === "object" && !Array.isArray(stored)) { + const totalValue = Number(stored.total); + const normalizedTotal = + Number.isFinite(totalValue) && totalValue >= 0 ? totalValue : 0; + const perRelay = Array.isArray(stored.perRelay) + ? stored.perRelay.map((entry) => + entry && typeof entry === "object" ? { ...entry } : entry + ) + : []; + const result = { + total: normalizedTotal, + perRelay, + best: + stored.best && typeof stored.best === "object" + ? { ...stored.best } + : null, + }; + if (stored.fallback) { + result.fallback = true; + } + return result; + } + const numericTotal = Number(stored); + const normalizedTotal = + Number.isFinite(numericTotal) && numericTotal >= 0 ? numericTotal : 0; + return { total: normalizedTotal, perRelay: [], best: null }; } const events = storedEvents.get(key) || []; - return { total: events.length }; + return { total: events.length, perRelay: [], best: null }; }; const subscribeVideoViewEvents = (pointer, options = {}) => { @@ -152,16 +178,22 @@ function createMockNostrHarness() { }; const setCountTotal = (key, total) => { - if (Number.isFinite(total)) { - customTotals.set(key, Number(total)); + if ( + typeof total === "object" && + total !== null && + !Array.isArray(total) + ) { + customCountResults.set(key, JSON.parse(JSON.stringify(total))); + } else if (Number.isFinite(total)) { + customCountResults.set(key, Number(total)); } else { - customTotals.delete(key); + customCountResults.delete(key); } }; const reset = () => { storedEvents.clear(); - customTotals.clear(); + customCountResults.clear(); subscribers.clear(); resetMetrics(); }; @@ -319,6 +351,45 @@ async function testHydrationSkipsStaleEventsAndRollsOff() { } } +async function testRelayCountAggregationUsesBestEstimate() { + localStorage.clear(); + harness.reset(); + harness.resetMetrics(); + + const pointer = { type: "e", value: "view-counter-multi-relay" }; + const pointerKey = harness.pointerKeyFromInput(pointer); + + harness.setEvents(pointerKey, []); + harness.setCountTotal(pointerKey, { + total: 5, + best: { relay: "wss://relay.alpha", count: 5 }, + perRelay: [ + { url: "wss://relay.alpha", ok: true, count: 5 }, + { url: "wss://relay.beta", ok: true, count: 5 }, + ], + }); + + const updates = []; + const token = subscribeToVideoViewCount(pointer, (state) => { + updates.push({ ...state }); + }); + + try { + await flushPromises(); + await flushPromises(); + + const final = updates.at(-1); + assert.ok(final, "expected hydration update for aggregated relay counts"); + assert.equal( + final.total, + 5, + "identical relay COUNT responses should not be double-counted" + ); + } finally { + unsubscribeFromVideoViewCount(pointer, token); + } +} + async function testLocalIngestNotifiesImmediately() { localStorage.clear(); harness.reset(); @@ -458,5 +529,6 @@ await testDedupesWithinWindow(); await testHydrationSkipsStaleEventsAndRollsOff(); await testLocalIngestNotifiesImmediately(); await testUnsubscribeStopsCallbacks(); +await testRelayCountAggregationUsesBestEstimate(); console.log("View counter tests completed successfully.");