diff --git a/components/sidebar.html b/components/sidebar.html index 76be8484..19e018be 100644 --- a/components/sidebar.html +++ b/components/sidebar.html @@ -76,6 +76,27 @@ Trending + + + + + Most Zapped + ({ ...m, preserveOrder: true }); break; + case FEED_TYPES.MOST_ZAPPED: + runtime = buildMostZappedFeedRuntime(this); + // Preserve the zap-total ranking (otherwise re-sorted by date). + metadataModifier = (m) => ({ ...m, preserveOrder: true }); + break; case "recent": runtime = this.buildRecentFeedRuntime(); break; @@ -1041,6 +1050,12 @@ export function createFeedCoordinator(deps) { refreshMethod = (opts) => this.refreshFeed(FEED_TYPES.TRENDING, opts); shouldCheckRelayHealth = false; break; + case FEED_TYPES.MOST_ZAPPED: + includeTags = false; + loadingMessage = "Fetching most-zapped videos\u2026"; + refreshMethod = (opts) => this.refreshFeed(FEED_TYPES.MOST_ZAPPED, opts); + shouldCheckRelayHealth = false; + break; default: devLogger.warn( `[Application] Unknown feed type for loadFeedVideos: ${feedType}`, @@ -1143,6 +1158,14 @@ export function createFeedCoordinator(deps) { return this.loadFeedVideos(FEED_TYPES.TRENDING, forceFetch); }, + registerMostZappedFeed() { + return registerMostZappedFeedPipeline(this); + }, + + async loadMostZappedVideos(forceFetch = false) { + return this.loadFeedVideos(FEED_TYPES.MOST_ZAPPED, forceFetch); + }, + async loadOlderVideos(lastTimestamp) { const olderVideos = await this.nostrService.loadOlderVideos(lastTimestamp, { blacklistedEventIds: this.blacklistedEventIds, diff --git a/js/constants.js b/js/constants.js index b0bb8663..9e43bcb4 100644 --- a/js/constants.js +++ b/js/constants.js @@ -199,6 +199,7 @@ const DEFAULT_FLAGS = Object.freeze({ FEATURE_TRUSTED_HIDE_CONTROLS: true, FEATURE_IMPROVED_COMMENT_FETCHING: true, // Default true for comment persistence fixes FEATURE_TRENDING_FEED: true, // "Trending" tab: recently-added ranked by view count. Disable to hide the tab. + FEATURE_MOST_ZAPPED_FEED: true, // "Most Zapped" tab: recently-added ranked by zap total. Disable to hide the tab. TRUSTED_MUTE_HIDE_THRESHOLD: DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD, TRUSTED_SPAM_HIDE_THRESHOLD: DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD, WSS_TRACKERS: DEFAULT_WSS_TRACKERS, @@ -222,6 +223,7 @@ const runtimeFlags = (() => { FEATURE_IMPROVED_COMMENT_FETCHING: DEFAULT_FLAGS.FEATURE_IMPROVED_COMMENT_FETCHING, FEATURE_TRENDING_FEED: DEFAULT_FLAGS.FEATURE_TRENDING_FEED, + FEATURE_MOST_ZAPPED_FEED: DEFAULT_FLAGS.FEATURE_MOST_ZAPPED_FEED, TRUSTED_MUTE_HIDE_THRESHOLD: DEFAULT_FLAGS.TRUSTED_MUTE_HIDE_THRESHOLD, TRUSTED_SPAM_HIDE_THRESHOLD: DEFAULT_FLAGS.TRUSTED_SPAM_HIDE_THRESHOLD, WSS_TRACKERS: [...DEFAULT_FLAGS.WSS_TRACKERS], @@ -267,6 +269,11 @@ export let FEATURE_TRENDING_FEED = coerceBoolean( DEFAULT_FLAGS.FEATURE_TRENDING_FEED ); +export let FEATURE_MOST_ZAPPED_FEED = coerceBoolean( + runtimeFlags.FEATURE_MOST_ZAPPED_FEED, + DEFAULT_FLAGS.FEATURE_MOST_ZAPPED_FEED +); + export let FEATURE_TRUST_SEEDS = coerceBoolean( runtimeFlags.FEATURE_TRUST_SEEDS, DEFAULT_FLAGS.FEATURE_TRUST_SEEDS @@ -578,6 +585,7 @@ export const FEED_TYPES = Object.freeze({ KIDS: "kids", EXPLORE: "explore", TRENDING: "trending", + MOST_ZAPPED: "most-zapped", SUBSCRIPTIONS: "subscriptions", HISTORY: "history", CHANNEL: "channel-profile", diff --git a/js/feedEngine/mostZappedFeed.js b/js/feedEngine/mostZappedFeed.js new file mode 100644 index 00000000..7fcde6ec --- /dev/null +++ b/js/feedEngine/mostZappedFeed.js @@ -0,0 +1,126 @@ +// js/feedEngine/mostZappedFeed.js +// +// The "Most Zapped" tab (#47): the recently-added (active) source ranked by +// ZAP TOTAL (sats from kind-9735 receipts). Mirrors trendingFeed.js — lives +// outside feedCoordinator.js (size cap) and is wired in via thin delegations. +// The metric getter uses requestVideoZapTotal, which returns the cached total +// AND schedules a batched receipt fetch for unknown pointers; the Most Zapped +// view re-runs the feed on zapTotals' change signal so the order settles as +// totals stream in. + +import { FEED_TYPES } from "../constants.js"; +import { requestVideoZapTotal } from "../zapTotals.js"; +import { + createActiveNostrSource, + createBlacklistFilterStage, + createDedupeByRootStage, + createModerationStage, + createResolvePostedAtStage, +} from "./index.js"; +import { createMostZappedSorter } from "./sorters.js"; + +// Resolve a video's zap total via the app's canonical pointer derivation (the +// same pointer keys the zap flow tags receipts with, so ranking and receipts +// agree). +function makeGetZapTotal(app) { + return (video) => { + if (!video || typeof video !== "object") { + return 0; + } + try { + const info = + typeof app.deriveVideoPointerInfo === "function" + ? app.deriveVideoPointerInfo(video) + : null; + if (!info?.pointer) { + return 0; + } + const total = requestVideoZapTotal(info.pointer); + return Number.isFinite(total) ? Number(total) : 0; + } catch (error) { + return 0; + } + }; +} + +export function buildMostZappedFeedRuntime(app) { + const blacklist = + app.blacklistedEventIds instanceof Set + ? new Set(app.blacklistedEventIds) + : new Set(); + const moderationThresholds = + typeof app.getActiveModerationThresholds === "function" + ? app.getActiveModerationThresholds() + : null; + return { + blacklistedEventIds: blacklist, + isAuthorBlocked: (pubkey) => app.isAuthorBlocked(pubkey), + moderationThresholds: moderationThresholds + ? { ...moderationThresholds } + : undefined, + getZapTotal: makeGetZapTotal(app), + }; +} + +export function registerMostZappedFeed(app) { + if (!app?.feedEngine || typeof app.feedEngine.registerFeed !== "function") { + return null; + } + const existing = + typeof app.feedEngine.getFeedDefinition === "function" + ? app.feedEngine.getFeedDefinition(FEED_TYPES.MOST_ZAPPED) + : null; + if (existing) { + return existing; + } + + const resolveThreshold = (key) => ({ runtimeValue, defaultValue }) => { + if ( + Number.isFinite(runtimeValue) || + runtimeValue === Number.POSITIVE_INFINITY + ) { + return runtimeValue; + } + if (typeof app.getActiveModerationThresholds === "function") { + const active = app.getActiveModerationThresholds(); + const candidate = + active && typeof active === "object" ? active[key] : undefined; + if (Number.isFinite(candidate) || candidate === Number.POSITIVE_INFINITY) { + return candidate; + } + } + return defaultValue; + }; + + try { + return app.feedEngine.registerFeed(FEED_TYPES.MOST_ZAPPED, { + source: createActiveNostrSource({ service: app.nostrService }), + stages: [ + createBlacklistFilterStage({ + shouldIncludeVideo: (video, options) => + app.nostrService.shouldIncludeVideo(video, options), + }), + createDedupeByRootStage({ + dedupe: (videos) => app.dedupeVideosByRoot(videos), + }), + createModerationStage({ + getService: () => app.nostrService.getModerationService(), + autoplayThreshold: resolveThreshold("autoplayBlockThreshold"), + blurThreshold: resolveThreshold("blurThreshold"), + trustedMuteHideThreshold: resolveThreshold("trustedMuteHideThreshold"), + trustedReportHideThreshold: resolveThreshold("trustedSpamHideThreshold"), + }), + createResolvePostedAtStage(), + ], + sorter: createMostZappedSorter(), + hooks: { + timestamps: { + getKnownVideoPostedAt: (video) => app.getKnownVideoPostedAt(video), + resolveVideoPostedAt: (video) => app.resolveVideoPostedAt(video), + }, + }, + }); + } catch (error) { + return null; + } +} diff --git a/js/feedEngine/sorters.js b/js/feedEngine/sorters.js index f23bd52c..042c2dc6 100644 --- a/js/feedEngine/sorters.js +++ b/js/feedEngine/sorters.js @@ -3,21 +3,20 @@ import { normalizeHashtag } from "../utils/hashtagNormalization.js"; import { markAsNormalized } from "./utils.js"; -// Ranks the active feed by VIEW COUNT (the "Trending" tab). Counts come from the -// injected runtime.getViewCount(video) — the shared viewCounter cache populated -// by the grid cards' per-card subscriptions. Unknown/not-yet-loaded counts are -// treated as 0, so on a cold cache the feed reads as recency and settles into -// true trending as counts stream in (the Trending view re-runs the feed on -// viewCounter's debounced change signal). Trusted-muted always sinks last. -export function createTrendingSorter() { - return function trendingSorter(items = [], context = {}) { +// Shared count-ranked sorter: rank by an injected per-video metric (desc), +// tie-break by recency, spread authors, sink trusted-muted last. Unknown / +// not-yet-loaded counts are treated as 0, so on a cold cache the feed reads +// as recency and settles into the true ranking as counts stream in (the view +// re-runs the feed on the metric cache's debounced change signal). +// - Trending (#27): runtime.getViewCount (viewCounter cache) +// - Most Zapped (#47): runtime.getZapTotal (zapTotals cache) +function createCountRankedSorter(resolveMetric) { + return function countRankedSorter(items = [], context = {}) { if (!Array.isArray(items)) { return []; } - const getViewCount = - typeof context?.runtime?.getViewCount === "function" - ? context.runtime.getViewCount - : () => 0; + const metricFn = resolveMetric(context); + const getViewCount = typeof metricFn === "function" ? metricFn : () => 0; const isMuted = (entry) => entry?.metadata?.moderation?.trustedMuted === true || @@ -55,12 +54,21 @@ export function createTrendingSorter() { const live = items.filter((entry) => !isMuted(entry)); const muted = items.filter((entry) => isMuted(entry)); - // Rank by views, then spread authors so the tab isn't walls of one creator. - // The most-viewed item is still chosen first, so it stays topmost. + // Rank by the metric, then spread authors so the tab isn't walls of one + // creator. The top-ranked item is still chosen first, so it stays topmost. return [...spreadAuthors(live.sort(compare)), ...muted.sort(compare)]; }; } +export function createTrendingSorter() { + return createCountRankedSorter((context) => context?.runtime?.getViewCount); +} + +// "Most Zapped" (#47): same ranking mechanics over sats totals. +export function createMostZappedSorter() { + return createCountRankedSorter((context) => context?.runtime?.getZapTotal); +} + // Round-robin interleave by author: one video per creator per pass, each pass in // the caller's pre-sorted order. Consecutive cards are from different creators, // so a feed reads as a varied "discovery" mix instead of a chronological wall — diff --git a/js/mostZappedView.js b/js/mostZappedView.js new file mode 100644 index 00000000..19dc0ce1 --- /dev/null +++ b/js/mostZappedView.js @@ -0,0 +1,65 @@ +// js/mostZappedView.js +// +// The "Most Zapped" tab presentation hook (#47). The ranking lives in the feed +// engine (createMostZappedSorter); zap totals arrive asynchronously as the +// feed's metric getter schedules batched receipt fetches. While this view is +// active we listen for zapTotals' change signal and re-run the feed (a cheap +// re-rank over the in-memory active set, no relay re-fetch) so the order +// visibly settles as totals stream in — same pattern as trendingView. + +import { attachFeedInfoPopover } from "./ui/components/FeedInfoPopover.js"; +import { onZapTotalsChanged } from "./zapTotals.js"; + +const MOST_ZAPPED_RERANK_DEBOUNCE_MS = 700; + +let unsubscribeTotals = null; +let rerankTimer = null; + +export function teardownMostZappedView() { + if (typeof unsubscribeTotals === "function") { + try { + unsubscribeTotals(); + } catch (error) { + // best effort + } + unsubscribeTotals = null; + } + if (rerankTimer) { + clearTimeout(rerankTimer); + rerankTimer = null; + } +} + +export function initMostZappedView({ getApp, isActive } = {}) { + const infoTrigger = document.getElementById("mostZappedInfoTrigger"); + if (infoTrigger) { + attachFeedInfoPopover( + infoTrigger, + "Most Zapped ranks recently-added videos by their zap total (sats). The order settles as zap receipts load in." + ); + } + + // Reset any prior subscription (re-entering the tab). + teardownMostZappedView(); + + const stillActive = typeof isActive === "function" ? isActive : () => true; + + unsubscribeTotals = onZapTotalsChanged(() => { + if (rerankTimer) { + return; + } + rerankTimer = setTimeout(() => { + rerankTimer = null; + if (!stillActive()) { + teardownMostZappedView(); + return; + } + const app = typeof getApp === "function" ? getApp() : null; + if (app && typeof app.refreshMostZappedFeed === "function") { + Promise.resolve( + app.refreshMostZappedFeed({ reason: "zap-total-update" }) + ).catch(() => {}); + } + }, MOST_ZAPPED_RERANK_DEBOUNCE_MS); + }); +} diff --git a/js/ui/applicationBootstrap.js b/js/ui/applicationBootstrap.js index 330ac6d5..89f67f60 100644 --- a/js/ui/applicationBootstrap.js +++ b/js/ui/applicationBootstrap.js @@ -24,7 +24,12 @@ import r2Service from "../services/r2Service.js"; import s3UploadService from "../services/s3UploadService.js"; import RelayHealthService from "../services/relayHealthService.js"; import { createFeedEngine } from "../feedEngine/index.js"; -import { URL_FIRST_ENABLED, SHORT_TIMEOUT_MS, FEATURE_TRENDING_FEED } from "../constants.js"; +import { + URL_FIRST_ENABLED, + SHORT_TIMEOUT_MS, + FEATURE_TRENDING_FEED, + FEATURE_MOST_ZAPPED_FEED, +} from "../constants.js"; import { ALLOW_NSFW_CONTENT } from "../config.js"; import { relayManager } from "../relayManager.js"; import { userBlocks } from "../userBlocks.js"; @@ -284,6 +289,18 @@ export default class ApplicationBootstrap { trendingLink.classList.add("hidden"); } } + if ( + FEATURE_MOST_ZAPPED_FEED && + typeof app.registerMostZappedFeed === "function" + ) { + app.registerMostZappedFeed(); + } else if (typeof document !== "undefined") { + // Flag off: hide the always-visible Most Zapped sidebar link. + const mostZappedLink = document.getElementById("mostZappedLink"); + if (mostZappedLink) { + mostZappedLink.classList.add("hidden"); + } + } app.registerSubscriptionsFeed(); app.registerWatchHistoryFeed(); // Re-run the For You feed when background watch-history resolution completes. diff --git a/js/viewManager.js b/js/viewManager.js index 8d9ef461..5c706cfe 100644 --- a/js/viewManager.js +++ b/js/viewManager.js @@ -4,6 +4,7 @@ import { initForYouView } from "./forYouView.js"; import { initKidsView } from "./kidsView.js"; import { initExploreView } from "./exploreView.js"; import { initTrendingView } from "./trendingView.js"; +import { initMostZappedView } from "./mostZappedView.js"; import { subscriptions } from "./subscriptions.js"; import { getApplication } from "./applicationContext.js"; import { applyDesignSystemAttributes } from "./designSystem.js"; @@ -160,6 +161,26 @@ export const viewInitRegistry = { refreshApp.forceRefreshAllProfiles(); } }, + [FEED_TYPES.MOST_ZAPPED]: () => { + const app = getApplication(); + initMostZappedView({ + getApp: getApplication, + isActive: () => + typeof window !== "undefined" && + typeof window.location?.hash === "string" && + window.location.hash.startsWith(`#view=${FEED_TYPES.MOST_ZAPPED}`), + }); + if (app && typeof app.loadMostZappedVideos === "function") { + if (typeof app.mountVideoListView === "function") { + app.mountVideoListView({ includeTags: false }); + } + app.loadMostZappedVideos(); + } + const refreshApp = getApplication(); + if (refreshApp && typeof refreshApp.forceRefreshAllProfiles === "function") { + refreshApp.forceRefreshAllProfiles(); + } + }, [FEED_TYPES.DOCS]: async () => { await initDocsView(); }, diff --git a/js/zapTotals.js b/js/zapTotals.js new file mode 100644 index 00000000..85f93f59 --- /dev/null +++ b/js/zapTotals.js @@ -0,0 +1,268 @@ +// js/zapTotals.js +// +// Per-video ZAP TOTAL aggregation (kind-9735 receipts) for the "Most Zapped" +// tab (#47). Mirrors viewCounter's surface at feed granularity: the feed's +// getZapTotal(video) reads the cached sats total AND schedules a batched +// one-shot fetch for unknown/stale pointers; when a batch lands, +// onZapTotalsChanged fires and the Most Zapped view re-runs the feed (a cheap +// in-memory re-rank), so the order settles as totals stream in. +// +// Amounts: nostr-tools nip57.getSatoshisAmountFromBolt11 on the receipt's +// bolt11 tag, falling back to the embedded zap request's amount tag (msats). +// Receipts are deduped by event id per pointer, so re-fetches never +// double-count. + +import { devLogger } from "./utils/logger.js"; + +export const ZAP_RECEIPT_KIND = 9735; +const FETCH_TTL_MS = 120000; +const BATCH_DELAY_MS = 150; +const MAX_POINTERS_PER_FILTER = 100; +const RECEIPTS_PER_FILTER_LIMIT = 500; + +function tagValues(event, name) { + if (!Array.isArray(event?.tags)) { + return []; + } + return event.tags + .filter((tag) => Array.isArray(tag) && tag[0] === name && typeof tag[1] === "string") + .map((tag) => tag[1]); +} + +export function pointerKey(pointer) { + const type = pointer?.type === "e" ? "e" : pointer?.type === "a" ? "a" : ""; + const value = typeof pointer?.value === "string" ? pointer.value.trim() : ""; + if (!type || !value) { + return ""; + } + return `${type}:${value}`; +} + +// Sats carried by one receipt: bolt11 first (the actual paid invoice), then +// the zap request's amount tag (msats) embedded in the description. +export function extractReceiptAmountSats(event, tools) { + const bolt11 = tagValues(event, "bolt11")[0] || ""; + const fromBolt11 = tools?.nip57?.getSatoshisAmountFromBolt11; + if (bolt11 && typeof fromBolt11 === "function") { + try { + const sats = Number(fromBolt11(bolt11)); + if (Number.isFinite(sats) && sats > 0) { + return Math.round(sats); + } + } catch (error) { + // fall through to the description amount + } + } + const description = tagValues(event, "description")[0] || ""; + if (description) { + try { + const request = JSON.parse(description); + const amountTag = Array.isArray(request?.tags) + ? request.tags.find((tag) => Array.isArray(tag) && tag[0] === "amount") + : null; + const msats = Number(amountTag?.[1]); + if (Number.isFinite(msats) && msats > 0) { + return Math.round(msats / 1000); + } + } catch (error) { + // unparseable description — count the receipt as zero + } + } + return 0; +} + +export function createZapTotalsStore({ + now = () => Date.now(), + batchDelayMs = BATCH_DELAY_MS, + fetchTtlMs = FETCH_TTL_MS, + getClient = () => null, + getTools = () => null, + schedule = (fn, ms) => setTimeout(fn, ms), +} = {}) { + const totals = new Map(); // key → { sats, receiptIds:Set, fetchedAt } + const pending = new Map(); // key → pointer + const listeners = new Set(); + let batchTimer = null; + + const entryFor = (key) => { + let entry = totals.get(key); + if (!entry) { + entry = { sats: 0, receiptIds: new Set(), fetchedAt: 0 }; + totals.set(key, entry); + } + return entry; + }; + + const emitChange = () => { + for (const listener of listeners) { + try { + listener(); + } catch (error) { + devLogger.warn("[zapTotals] Change listener threw:", error); + } + } + }; + + async function runBatch() { + batchTimer = null; + if (!pending.size) { + return; + } + const batch = new Map(pending); + pending.clear(); + + const client = getClient(); + const manager = + typeof client?.getSubscriptionManager === "function" + ? client.getSubscriptionManager() + : null; + const relays = Array.isArray(client?.relays) ? client.relays : []; + if (!manager || typeof manager.list !== "function" || !relays.length) { + // Leave fetchedAt untouched so a later attempt retries. + return; + } + + const aValues = []; + const eValues = []; + for (const pointer of batch.values()) { + if (pointer.type === "a") aValues.push(pointer.value); + else if (pointer.type === "e") eValues.push(pointer.value); + } + + const filters = []; + for (let i = 0; i < aValues.length; i += MAX_POINTERS_PER_FILTER) { + filters.push({ + kinds: [ZAP_RECEIPT_KIND], + "#a": aValues.slice(i, i + MAX_POINTERS_PER_FILTER), + limit: RECEIPTS_PER_FILTER_LIMIT, + }); + } + for (let i = 0; i < eValues.length; i += MAX_POINTERS_PER_FILTER) { + filters.push({ + kinds: [ZAP_RECEIPT_KIND], + "#e": eValues.slice(i, i + MAX_POINTERS_PER_FILTER), + limit: RECEIPTS_PER_FILTER_LIMIT, + }); + } + + let events = []; + try { + // Routed through the L1 SubscriptionManager (relay caps + in-flight + // dedupe) — the direct-pool-access lint gate forbids raw pool.list here. + events = await manager.list({ relays, filters }); + } catch (error) { + devLogger.warn("[zapTotals] Receipt fetch failed:", error); + return; // retry on the next request + } + + const fetchedAt = now(); + // Mark every requested pointer fresh (a pointer with no receipts is a + // real answer: zero sats), THEN fold receipts in. + for (const key of batch.keys()) { + entryFor(key).fetchedAt = fetchedAt; + } + + let changed = false; + for (const event of Array.isArray(events) ? events : []) { + if (!event || event.kind !== ZAP_RECEIPT_KIND || !event.id) { + continue; + } + const keys = [ + ...tagValues(event, "a").map((value) => `a:${value}`), + ...tagValues(event, "e").map((value) => `e:${value}`), + ].filter((key) => batch.has(key) || totals.has(key)); + if (!keys.length) { + continue; + } + const sats = extractReceiptAmountSats(event, getTools()); + for (const key of keys) { + const entry = entryFor(key); + if (entry.receiptIds.has(event.id)) { + continue; + } + entry.receiptIds.add(event.id); + if (sats > 0) { + entry.sats += sats; + } + changed = true; + } + } + if (changed || batch.size) { + emitChange(); + } + } + + return { + // Cached total only — never triggers a fetch. + getSnapshot(pointer) { + const key = pointerKey(pointer); + return key ? totals.get(key)?.sats || 0 : 0; + }, + // Cached total now, plus a scheduled batched fetch when unknown/stale. + request(pointer) { + const key = pointerKey(pointer); + if (!key) { + return 0; + } + const entry = totals.get(key); + const fresh = entry && now() - entry.fetchedAt < fetchTtlMs; + if (!fresh && !pending.has(key)) { + pending.set(key, { type: key[0], value: key.slice(2) }); + if (!batchTimer) { + batchTimer = schedule(() => { + void runBatch(); + }, batchDelayMs); + } + } + return entry?.sats || 0; + }, + onChange(listener) { + if (typeof listener !== "function") { + return () => {}; + } + listeners.add(listener); + return () => listeners.delete(listener); + }, + // Test hook: run the pending batch immediately. + async flush() { + await runBatch(); + }, + }; +} + +// --- App singleton ----------------------------------------------------------- + +let clientRef = null; +let toolsRef = null; + +const store = createZapTotalsStore({ + getClient: () => clientRef, + // Lazily resolve the canonical nostr-tools (nip57 bolt11 amount helper); + // the toolkit bootstrap stamps it on the global before feeds run. + getTools: () => + toolsRef || + globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ || + globalThis.NostrTools || + null, +}); + +export function initZapTotals({ nostrClient, tools } = {}) { + if (nostrClient) { + clientRef = nostrClient; + } + if (tools) { + toolsRef = tools; + } +} + +export function getVideoZapTotalSnapshot(pointer) { + return store.getSnapshot(pointer); +} + +export function requestVideoZapTotal(pointer) { + return store.request(pointer); +} + +export function onZapTotalsChanged(listener) { + return store.onChange(listener); +} diff --git a/tests/zap-totals.test.mjs b/tests/zap-totals.test.mjs new file mode 100644 index 00000000..acbc5744 --- /dev/null +++ b/tests/zap-totals.test.mjs @@ -0,0 +1,171 @@ +// #47 "Most Zapped": per-video zap totals aggregated from kind-9735 receipts. +// Covers the amount extraction (bolt11 first, zap-request amount fallback), +// the batched store (request → scheduled fetch → totals; receipt dedupe; +// zero-receipt answers cached; change signal), and the feed sorter that ranks +// by the injected zap total. +// +// test_integrity_note: +// change_type: ["new_tests"] +// scenarios: +// - id: SCN-zap-total-aggregation +// given: "a fake relay pool returning scripted 9735 receipts for requested #a/#e pointers" +// when: "pointers are requested and the batch flushes" +// then: "totals sum per pointer, duplicate receipt ids never double-count, empty answers cache as 0, listeners fire" +// - id: SCN-most-zapped-sorter +// given: "feed items with differing zap totals / authors / mute states" +// when: "createMostZappedSorter runs" +// then: "sats desc, recency tie-break, trusted-muted sink last" +// observable_outcomes: +// - "getSnapshot/request return the summed sats" +// - "sorted order of feed items" +// determinism_controls: +// - "injected now/schedule; manual flush(); no timers or sockets" +// anti_cheat_rationale: +// prevents: ["hard-coded return value", "over-mocking internal logic"] +// relaxation: +// did_relax_any_assertion: false + +import "./test-helpers/setup-localstorage.mjs"; +import test from "node:test"; +import { strict as assert } from "node:assert"; +import { + createZapTotalsStore, + extractReceiptAmountSats, + pointerKey, + ZAP_RECEIPT_KIND, +} from "../js/zapTotals.js"; +import { createMostZappedSorter } from "../js/feedEngine/sorters.js"; + +const A1 = "30078:aaaa:video-1"; +const A2 = "30078:bbbb:video-2"; + +const receipt = (id, tags) => ({ id, kind: ZAP_RECEIPT_KIND, tags }); + +test("amount extraction: bolt11 first, zap-request amount fallback, garbage → 0", () => { + const tools = { + nip57: { getSatoshisAmountFromBolt11: (b) => (b === "lnbc210n1..." ? 21 : NaN) }, + }; + assert.equal( + extractReceiptAmountSats(receipt("r1", [["bolt11", "lnbc210n1..."]]), tools), + 21, + ); + // bolt11 unparseable → embedded zap request amount (msats → sats) + const description = JSON.stringify({ tags: [["amount", "50000"]] }); + assert.equal( + extractReceiptAmountSats( + receipt("r2", [["bolt11", "garbage"], ["description", description]]), + tools, + ), + 50, + ); + assert.equal(extractReceiptAmountSats(receipt("r3", []), tools), 0); + assert.equal( + extractReceiptAmountSats(receipt("r4", [["description", "not json"]]), tools), + 0, + ); +}); + +test("pointerKey normalizes and rejects unusable pointers", () => { + assert.equal(pointerKey({ type: "a", value: A1 }), `a:${A1}`); + assert.equal(pointerKey({ type: "e", value: "evt1" }), "e:evt1"); + assert.equal(pointerKey({ type: "x", value: "nope" }), ""); + assert.equal(pointerKey(null), ""); +}); + +function makeStore({ receipts = [], now = () => 1000 } = {}) { + const listCalls = []; + const scheduled = []; + const store = createZapTotalsStore({ + now, + getTools: () => ({ nip57: { getSatoshisAmountFromBolt11: () => NaN } }), + getClient: () => ({ + relays: ["wss://relay.example"], + getSubscriptionManager: () => ({ + list: async ({ relays, filters }) => { + listCalls.push({ relays, filters }); + return receipts; + }, + }), + }), + schedule: (fn) => { + scheduled.push(fn); + return 1; + }, + }); + return { store, listCalls, scheduled }; +} + +const amountTag = (msats) => [ + "description", + JSON.stringify({ tags: [["amount", String(msats)]] }), +]; + +test("batched fetch sums receipts per pointer and dedupes by receipt id", async () => { + const { store, listCalls } = makeStore({ + receipts: [ + receipt("r1", [["a", A1], amountTag(21000)]), + receipt("r2", [["a", A1], amountTag(100000)]), + receipt("r3", [["a", A2], amountTag(5000)]), + receipt("r3", [["a", A2], amountTag(5000)]), // duplicate id — ignored + ], + }); + + assert.equal(store.request({ type: "a", value: A1 }), 0, "cold cache reads 0"); + store.request({ type: "a", value: A2 }); + await store.flush(); + + assert.equal(store.getSnapshot({ type: "a", value: A1 }), 121); + assert.equal(store.getSnapshot({ type: "a", value: A2 }), 5); + assert.equal(listCalls.length, 1, "one batched fetch for both pointers"); + assert.equal( + listCalls[0].filters[0]["#a"].length, + 2, + "both pointers in one #a filter", + ); +}); + +test("zero-receipt answers are cached (no immediate refetch) and listeners fire", async () => { + let t = 1000; + const { store, listCalls, scheduled } = makeStore({ receipts: [], now: () => t }); + let changes = 0; + store.onChange(() => { + changes += 1; + }); + + store.request({ type: "e", value: "evt1" }); + await store.flush(); + assert.equal(changes, 1, "an answered batch notifies even when empty"); + assert.equal(store.getSnapshot({ type: "e", value: "evt1" }), 0); + + const schedulesBefore = scheduled.length; + store.request({ type: "e", value: "evt1" }); + assert.equal(scheduled.length, schedulesBefore, "fresh zero answer is not refetched"); + assert.equal(listCalls.length, 1); + + // After the TTL, the same pointer refetches. + t += 10 * 60 * 1000; + store.request({ type: "e", value: "evt1" }); + assert.equal(scheduled.length, schedulesBefore + 1, "stale entry schedules again"); +}); + +test("most-zapped sorter: sats desc, recency tie-break, muted sinks", () => { + const item = (id, author, sats, createdAt, muted = false) => ({ + video: { id, pubkey: author, created_at: createdAt }, + metadata: muted ? { moderation: { trustedMuted: true } } : {}, + sats, + }); + const items = [ + item("low", "a1", 5, 300), + item("high", "a2", 500, 100), + item("tie-new", "a3", 50, 200), + item("tie-old", "a4", 50, 100), + item("muted-whale", "a5", 9999, 400, true), + ]; + const sorter = createMostZappedSorter(); + const sorted = sorter(items, { runtime: { getZapTotal: (video) => items.find((i) => i.video === video)?.sats || 0 } }); + assert.deepEqual( + sorted.map((entry) => entry.video.id), + ["high", "tie-new", "tie-old", "low", "muted-whale"], + "sats desc, newer wins ties, trusted-muted last regardless of sats", + ); +}); diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index 85353db3..84ffe82f 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -615,11 +615,27 @@ toward freshness and looked identical. Gave each a structural identity: handled). ### 47. "Most Zapped" sidebar tab (trending-by-zaps) -- [ ] Add a **Most Zapped** sidebar tab: like Trending (#27, view-count) but ranked by - **total zaps** (sats / zap count) per video. Reuse the zap accounting (kind-9735 - receipts) already used for the zap system; build a feed source/ranker that sorts by - zap total over a window, wired into the feed engine like Trending. Depends on - reliable zap data (couples with the zap system already shipped in #3). +- [x] **DONE 2026-07-03.** Full Trending-pattern clone ranked by SATS: + - `js/zapTotals.js` — per-pointer kind-9735 aggregation. The feed's + getZapTotal reads the cached total AND schedules a BATCHED one-shot + receipt fetch (`#a`/`#e` chunks via the L1 SubscriptionManager — the + direct-pool lint gate applies) for unknown/stale pointers (2-min TTL; + zero-receipt answers cached too). Amounts: nip57 bolt11 parse, falling + back to the zap request's amount tag (msats); receipts deduped by id + so re-fetches never double-count. + - Feed: `js/feedEngine/mostZappedFeed.js` + `createMostZappedSorter` + (sorters.js refactored to a shared count-ranked sorter — Trending + behavior unchanged: sats/views desc, recency tie-break, author + spread, trusted-muted sink). + - View: `views/most-zapped.html` + `js/mostZappedView.js` (re-runs the + feed on zapTotals' change signal, same settle-in pattern), sidebar + link, viewManager registry, feedCoordinator case/loader, + `FEED_TYPES.MOST_ZAPPED`, `FEATURE_MOST_ZAPPED_FEED` flag (default + on; off hides the tab like Trending's flag). + Tests: `tests/zap-totals.test.mjs` (5). Lint + build green. +- [ ] **VERIFY on unstable:** open Most Zapped → zapped videos rise as receipts + load (unzapped feed reads as Recent at first); zap a video → within ~2min + of tab re-entry its rank reflects the new total. ### 48. Fluent profile switching across signing methods (nsec / NIP-46 / NIP-07) - [x] **nsec switching FIXED 2026-06-30.** Switching to a saved nsec profile used to fail diff --git a/views/most-zapped.html b/views/most-zapped.html new file mode 100644 index 00000000..527d0df5 --- /dev/null +++ b/views/most-zapped.html @@ -0,0 +1,40 @@ + + + + Most Zapped + + + + + + + + + + + + + + Loading most zapped… + + + +