mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-06 21:02:28 +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
@@ -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 { 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 —
|
||||
|
||||
Reference in new issue
Block a user