mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
feeds: "Most Zapped" sidebar tab ranked by zap totals (#47)
Trending-pattern clone ranked by sats from kind-9735 receipts: - js/zapTotals.js: per-pointer receipt aggregation. The feed's metric getter returns the cached total and schedules a batched fetch (#a/#e chunks via the L1 SubscriptionManager) for unknown/stale pointers; amounts parse via nip57 bolt11 with the zap request's amount tag as fallback; receipts are deduped by id so re-fetches never double-count; zero-receipt answers are cached (2-min TTL). - sorters.js: shared count-ranked sorter (metric desc, recency tie-break, author spread, trusted-muted sink); Trending delegates unchanged, Most Zapped ranks on runtime.getZapTotal. - Full wiring: feed definition, view + info popover, change-signal re-rank (order settles as receipts stream in), sidebar link, viewManager registry, feedCoordinator case/loader, FEED_TYPES.MOST_ZAPPED, and a FEATURE_MOST_ZAPPED_FEED flag (default on; off hides the tab). Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
1 parent
35a50611fe
commit
8dfd2034b0
13 files changed
+815
-20
No files matched your search
@@ -76,6 +76,27 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span class="sidebar-text">Trending</span>
|
<span class="sidebar-text">Trending</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a
|
||||||
|
id="mostZappedLink"
|
||||||
|
href="#view=most-zapped"
|
||||||
|
class="sidebar-nav-link"
|
||||||
|
title="Most Zapped"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="sidebar-icon"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon>
|
||||||
|
</svg>
|
||||||
|
<span class="sidebar-text">Most Zapped</span>
|
||||||
|
</a>
|
||||||
<a href="#view=most-recent-videos" class="sidebar-nav-link" title="Recently Added">
|
<a href="#view=most-recent-videos" class="sidebar-nav-link" title="Recently Added">
|
||||||
<img
|
<img
|
||||||
src="assets/svg/globe-icon.svg"
|
src="assets/svg/globe-icon.svg"
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ import {
|
|||||||
formatViewCount,
|
formatViewCount,
|
||||||
ingestLocalViewEvent,
|
ingestLocalViewEvent,
|
||||||
} from "./viewCounter.js";
|
} from "./viewCounter.js";
|
||||||
|
import { initZapTotals } from "./zapTotals.js";
|
||||||
import {
|
import {
|
||||||
formatAbsoluteTimestamp as formatAbsoluteTimestampUtil,
|
formatAbsoluteTimestamp as formatAbsoluteTimestampUtil,
|
||||||
formatAbsoluteDateWithOrdinal as formatAbsoluteDateWithOrdinalUtil,
|
formatAbsoluteDateWithOrdinal as formatAbsoluteDateWithOrdinalUtil,
|
||||||
@@ -982,6 +983,11 @@ class Application {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
devLogger.warn("Failed to initialize view counter:", error);
|
devLogger.warn("Failed to initialize view counter:", error);
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
initZapTotals({ nostrClient });
|
||||||
|
} catch (error) {
|
||||||
|
devLogger.warn("Failed to initialize zap totals:", error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async _initAccessControl() {
|
async _initAccessControl() {
|
||||||
@@ -4409,6 +4415,11 @@ class Application {
|
|||||||
return this._feed.refreshFeed(FEED_TYPES.TRENDING, ...args);
|
return this._feed.refreshFeed(FEED_TYPES.TRENDING, ...args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
refreshMostZappedFeed(...args) {
|
||||||
|
this._initCoordinators();
|
||||||
|
return this._feed.refreshFeed(FEED_TYPES.MOST_ZAPPED, ...args);
|
||||||
|
}
|
||||||
|
|
||||||
refreshRecentFeed(...args) {
|
refreshRecentFeed(...args) {
|
||||||
this._initCoordinators();
|
this._initCoordinators();
|
||||||
return this._feed.refreshFeed("recent", ...args);
|
return this._feed.refreshFeed("recent", ...args);
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ import {
|
|||||||
registerTrendingFeed as registerTrendingFeedPipeline,
|
registerTrendingFeed as registerTrendingFeedPipeline,
|
||||||
buildTrendingFeedRuntime,
|
buildTrendingFeedRuntime,
|
||||||
} from "../feedEngine/trendingFeed.js";
|
} from "../feedEngine/trendingFeed.js";
|
||||||
|
import {
|
||||||
|
registerMostZappedFeed as registerMostZappedFeedPipeline,
|
||||||
|
buildMostZappedFeedRuntime,
|
||||||
|
} from "../feedEngine/mostZappedFeed.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {object} deps - Injected dependencies.
|
* @param {object} deps - Injected dependencies.
|
||||||
@@ -876,6 +880,11 @@ export function createFeedCoordinator(deps) {
|
|||||||
// Preserve the view-count ranking (otherwise re-sorted by date).
|
// Preserve the view-count ranking (otherwise re-sorted by date).
|
||||||
metadataModifier = (m) => ({ ...m, preserveOrder: true });
|
metadataModifier = (m) => ({ ...m, preserveOrder: true });
|
||||||
break;
|
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":
|
case "recent":
|
||||||
runtime = this.buildRecentFeedRuntime();
|
runtime = this.buildRecentFeedRuntime();
|
||||||
break;
|
break;
|
||||||
@@ -1041,6 +1050,12 @@ export function createFeedCoordinator(deps) {
|
|||||||
refreshMethod = (opts) => this.refreshFeed(FEED_TYPES.TRENDING, opts);
|
refreshMethod = (opts) => this.refreshFeed(FEED_TYPES.TRENDING, opts);
|
||||||
shouldCheckRelayHealth = false;
|
shouldCheckRelayHealth = false;
|
||||||
break;
|
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:
|
default:
|
||||||
devLogger.warn(
|
devLogger.warn(
|
||||||
`[Application] Unknown feed type for loadFeedVideos: ${feedType}`,
|
`[Application] Unknown feed type for loadFeedVideos: ${feedType}`,
|
||||||
@@ -1143,6 +1158,14 @@ export function createFeedCoordinator(deps) {
|
|||||||
return this.loadFeedVideos(FEED_TYPES.TRENDING, forceFetch);
|
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) {
|
async loadOlderVideos(lastTimestamp) {
|
||||||
const olderVideos = await this.nostrService.loadOlderVideos(lastTimestamp, {
|
const olderVideos = await this.nostrService.loadOlderVideos(lastTimestamp, {
|
||||||
blacklistedEventIds: this.blacklistedEventIds,
|
blacklistedEventIds: this.blacklistedEventIds,
|
||||||
|
|||||||
@@ -199,6 +199,7 @@ const DEFAULT_FLAGS = Object.freeze({
|
|||||||
FEATURE_TRUSTED_HIDE_CONTROLS: true,
|
FEATURE_TRUSTED_HIDE_CONTROLS: true,
|
||||||
FEATURE_IMPROVED_COMMENT_FETCHING: true, // Default true for comment persistence fixes
|
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_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_MUTE_HIDE_THRESHOLD: DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD,
|
||||||
TRUSTED_SPAM_HIDE_THRESHOLD: DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD,
|
TRUSTED_SPAM_HIDE_THRESHOLD: DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD,
|
||||||
WSS_TRACKERS: DEFAULT_WSS_TRACKERS,
|
WSS_TRACKERS: DEFAULT_WSS_TRACKERS,
|
||||||
@@ -222,6 +223,7 @@ const runtimeFlags = (() => {
|
|||||||
FEATURE_IMPROVED_COMMENT_FETCHING:
|
FEATURE_IMPROVED_COMMENT_FETCHING:
|
||||||
DEFAULT_FLAGS.FEATURE_IMPROVED_COMMENT_FETCHING,
|
DEFAULT_FLAGS.FEATURE_IMPROVED_COMMENT_FETCHING,
|
||||||
FEATURE_TRENDING_FEED: DEFAULT_FLAGS.FEATURE_TRENDING_FEED,
|
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_MUTE_HIDE_THRESHOLD: DEFAULT_FLAGS.TRUSTED_MUTE_HIDE_THRESHOLD,
|
||||||
TRUSTED_SPAM_HIDE_THRESHOLD: DEFAULT_FLAGS.TRUSTED_SPAM_HIDE_THRESHOLD,
|
TRUSTED_SPAM_HIDE_THRESHOLD: DEFAULT_FLAGS.TRUSTED_SPAM_HIDE_THRESHOLD,
|
||||||
WSS_TRACKERS: [...DEFAULT_FLAGS.WSS_TRACKERS],
|
WSS_TRACKERS: [...DEFAULT_FLAGS.WSS_TRACKERS],
|
||||||
@@ -267,6 +269,11 @@ export let FEATURE_TRENDING_FEED = coerceBoolean(
|
|||||||
DEFAULT_FLAGS.FEATURE_TRENDING_FEED
|
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(
|
export let FEATURE_TRUST_SEEDS = coerceBoolean(
|
||||||
runtimeFlags.FEATURE_TRUST_SEEDS,
|
runtimeFlags.FEATURE_TRUST_SEEDS,
|
||||||
DEFAULT_FLAGS.FEATURE_TRUST_SEEDS
|
DEFAULT_FLAGS.FEATURE_TRUST_SEEDS
|
||||||
@@ -578,6 +585,7 @@ export const FEED_TYPES = Object.freeze({
|
|||||||
KIDS: "kids",
|
KIDS: "kids",
|
||||||
EXPLORE: "explore",
|
EXPLORE: "explore",
|
||||||
TRENDING: "trending",
|
TRENDING: "trending",
|
||||||
|
MOST_ZAPPED: "most-zapped",
|
||||||
SUBSCRIPTIONS: "subscriptions",
|
SUBSCRIPTIONS: "subscriptions",
|
||||||
HISTORY: "history",
|
HISTORY: "history",
|
||||||
CHANNEL: "channel-profile",
|
CHANNEL: "channel-profile",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
-14
@@ -3,21 +3,20 @@
|
|||||||
import { normalizeHashtag } from "../utils/hashtagNormalization.js";
|
import { normalizeHashtag } from "../utils/hashtagNormalization.js";
|
||||||
import { markAsNormalized } from "./utils.js";
|
import { markAsNormalized } from "./utils.js";
|
||||||
|
|
||||||
// Ranks the active feed by VIEW COUNT (the "Trending" tab). Counts come from the
|
// Shared count-ranked sorter: rank by an injected per-video metric (desc),
|
||||||
// injected runtime.getViewCount(video) — the shared viewCounter cache populated
|
// tie-break by recency, spread authors, sink trusted-muted last. Unknown /
|
||||||
// by the grid cards' per-card subscriptions. Unknown/not-yet-loaded counts are
|
// not-yet-loaded counts are treated as 0, so on a cold cache the feed reads
|
||||||
// treated as 0, so on a cold cache the feed reads as recency and settles into
|
// as recency and settles into the true ranking as counts stream in (the view
|
||||||
// true trending as counts stream in (the Trending view re-runs the feed on
|
// re-runs the feed on the metric cache's debounced change signal).
|
||||||
// viewCounter's debounced change signal). Trusted-muted always sinks last.
|
// - Trending (#27): runtime.getViewCount (viewCounter cache)
|
||||||
export function createTrendingSorter() {
|
// - Most Zapped (#47): runtime.getZapTotal (zapTotals cache)
|
||||||
return function trendingSorter(items = [], context = {}) {
|
function createCountRankedSorter(resolveMetric) {
|
||||||
|
return function countRankedSorter(items = [], context = {}) {
|
||||||
if (!Array.isArray(items)) {
|
if (!Array.isArray(items)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const getViewCount =
|
const metricFn = resolveMetric(context);
|
||||||
typeof context?.runtime?.getViewCount === "function"
|
const getViewCount = typeof metricFn === "function" ? metricFn : () => 0;
|
||||||
? context.runtime.getViewCount
|
|
||||||
: () => 0;
|
|
||||||
|
|
||||||
const isMuted = (entry) =>
|
const isMuted = (entry) =>
|
||||||
entry?.metadata?.moderation?.trustedMuted === true ||
|
entry?.metadata?.moderation?.trustedMuted === true ||
|
||||||
@@ -55,12 +54,21 @@ export function createTrendingSorter() {
|
|||||||
|
|
||||||
const live = items.filter((entry) => !isMuted(entry));
|
const live = items.filter((entry) => !isMuted(entry));
|
||||||
const muted = 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.
|
// Rank by the metric, then spread authors so the tab isn't walls of one
|
||||||
// The most-viewed item is still chosen first, so it stays topmost.
|
// creator. The top-ranked item is still chosen first, so it stays topmost.
|
||||||
return [...spreadAuthors(live.sort(compare)), ...muted.sort(compare)];
|
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
|
// 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,
|
// 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 —
|
// so a feed reads as a varied "discovery" mix instead of a chronological wall —
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -24,7 +24,12 @@ import r2Service from "../services/r2Service.js";
|
|||||||
import s3UploadService from "../services/s3UploadService.js";
|
import s3UploadService from "../services/s3UploadService.js";
|
||||||
import RelayHealthService from "../services/relayHealthService.js";
|
import RelayHealthService from "../services/relayHealthService.js";
|
||||||
import { createFeedEngine } from "../feedEngine/index.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 { ALLOW_NSFW_CONTENT } from "../config.js";
|
||||||
import { relayManager } from "../relayManager.js";
|
import { relayManager } from "../relayManager.js";
|
||||||
import { userBlocks } from "../userBlocks.js";
|
import { userBlocks } from "../userBlocks.js";
|
||||||
@@ -284,6 +289,18 @@ export default class ApplicationBootstrap {
|
|||||||
trendingLink.classList.add("hidden");
|
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.registerSubscriptionsFeed();
|
||||||
app.registerWatchHistoryFeed();
|
app.registerWatchHistoryFeed();
|
||||||
// Re-run the For You feed when background watch-history resolution completes.
|
// Re-run the For You feed when background watch-history resolution completes.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { initForYouView } from "./forYouView.js";
|
|||||||
import { initKidsView } from "./kidsView.js";
|
import { initKidsView } from "./kidsView.js";
|
||||||
import { initExploreView } from "./exploreView.js";
|
import { initExploreView } from "./exploreView.js";
|
||||||
import { initTrendingView } from "./trendingView.js";
|
import { initTrendingView } from "./trendingView.js";
|
||||||
|
import { initMostZappedView } from "./mostZappedView.js";
|
||||||
import { subscriptions } from "./subscriptions.js";
|
import { subscriptions } from "./subscriptions.js";
|
||||||
import { getApplication } from "./applicationContext.js";
|
import { getApplication } from "./applicationContext.js";
|
||||||
import { applyDesignSystemAttributes } from "./designSystem.js";
|
import { applyDesignSystemAttributes } from "./designSystem.js";
|
||||||
@@ -160,6 +161,26 @@ export const viewInitRegistry = {
|
|||||||
refreshApp.forceRefreshAllProfiles();
|
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 () => {
|
[FEED_TYPES.DOCS]: async () => {
|
||||||
await initDocsView();
|
await initDocsView();
|
||||||
},
|
},
|
||||||
|
|||||||
+268
@@ -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);
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -615,11 +615,27 @@ toward freshness and looked identical. Gave each a structural identity:
|
|||||||
handled).
|
handled).
|
||||||
|
|
||||||
### 47. "Most Zapped" sidebar tab (trending-by-zaps)
|
### 47. "Most Zapped" sidebar tab (trending-by-zaps)
|
||||||
- [ ] Add a **Most Zapped** sidebar tab: like Trending (#27, view-count) but ranked by
|
- [x] **DONE 2026-07-03.** Full Trending-pattern clone ranked by SATS:
|
||||||
**total zaps** (sats / zap count) per video. Reuse the zap accounting (kind-9735
|
- `js/zapTotals.js` — per-pointer kind-9735 aggregation. The feed's
|
||||||
receipts) already used for the zap system; build a feed source/ranker that sorts by
|
getZapTotal reads the cached total AND schedules a BATCHED one-shot
|
||||||
zap total over a window, wired into the feed engine like Trending. Depends on
|
receipt fetch (`#a`/`#e` chunks via the L1 SubscriptionManager — the
|
||||||
reliable zap data (couples with the zap system already shipped in #3).
|
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)
|
### 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
|
- [x] **nsec switching FIXED 2026-06-30.** Switching to a saved nsec profile used to fail
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<!-- views/most-zapped.html -->
|
||||||
|
<section data-ds="new">
|
||||||
|
<div class="flex items-center gap-2 mb-4">
|
||||||
|
<h2 class="text-xl font-semibold text-text">Most Zapped</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="mostZappedInfoTrigger"
|
||||||
|
class="text-muted hover:text-text focus:outline-none transition-colors"
|
||||||
|
aria-label="How is this feed sorted?"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="w-5 h-5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
|
<line x1="12" y1="16" x2="12" y2="12"></line>
|
||||||
|
<line x1="12" y1="8" x2="12.01" y2="8"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="videoListTags" class="mb-4" hidden></div>
|
||||||
|
<div id="videoList" class="bv-grid-video" data-testid="video-list">
|
||||||
|
<div class="sidebar-loading-wrapper" role="status" aria-live="polite">
|
||||||
|
<div class="sidebar-loading-indicator">
|
||||||
|
<span
|
||||||
|
class="status-spinner status-spinner--inline"
|
||||||
|
aria-hidden="true"
|
||||||
|
></span>
|
||||||
|
<span class="sidebar-loading-text">Loading most zapped…</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
Reference in new issue
Block a user