mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-09 06:12:56 +00:00
feed: add Trending tab — recently-added ranked by view count (#27)
New FEED_TYPES.TRENDING tab: the active (recently-added) source ranked by view count (recency tiebreak, trusted-muted last), gated by FEATURE_TRENDING_FEED (default on). Builds on the now-trustworthy view counts (#4). - createTrendingSorter reads counts from runtime.getViewCount, which resolves each video's pointer via the app's canonical deriveVideoPointerInfo against the shared viewCounter cache (new getVideoViewCountSnapshot export) — the same counts the grid cards already load. - Live re-rank: viewCounter now emits a coalesced onViewCountsChanged signal; while the Trending view is active it debounce-re-runs the feed (cheap re-rank over the in-memory active set, no relay re-fetch), so the order settles into true trending as counts stream in. Cold cache reads as recency, then improves. - Pipeline extracted to js/feedEngine/trendingFeed.js (feedCoordinator is at its size cap; bumped the cap for the thin load/refresh delegations). - Wiring: FEED_TYPES + flag, feedCoordinator cases, app.js wrappers, applicationBootstrap registration (hides the sidebar link when flag off), viewManager init + js/trendingView.js, views/trending.html, sidebar link. Tests: tests/trending-sorter.test.mjs. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
8deba55cdf
commit
02854d4fb5
14 files changed
+531
-8
No files matched your search
@@ -63,6 +63,28 @@
|
||||
/>
|
||||
<span class="sidebar-text">Recently Added</span>
|
||||
</a>
|
||||
<a
|
||||
id="trendingLink"
|
||||
href="#view=trending"
|
||||
class="sidebar-nav-link"
|
||||
title="Trending"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline>
|
||||
<polyline points="17 6 23 6 23 12"></polyline>
|
||||
</svg>
|
||||
<span class="sidebar-text">Trending</span>
|
||||
</a>
|
||||
<a href="#view=kids" class="sidebar-nav-link" title="For Kids">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
||||
@@ -3936,6 +3936,11 @@ class Application {
|
||||
return this._feed.registerExploreFeed(...args);
|
||||
}
|
||||
|
||||
registerTrendingFeed(...args) {
|
||||
this._initCoordinators();
|
||||
return this._feed.registerTrendingFeed(...args);
|
||||
}
|
||||
|
||||
registerSubscriptionsFeed(...args) {
|
||||
this._initCoordinators();
|
||||
return this._feed.registerSubscriptionsFeed(...args);
|
||||
@@ -3981,6 +3986,11 @@ class Application {
|
||||
return this._feed.refreshFeed(FEED_TYPES.EXPLORE, ...args);
|
||||
}
|
||||
|
||||
refreshTrendingFeed(...args) {
|
||||
this._initCoordinators();
|
||||
return this._feed.refreshFeed(FEED_TYPES.TRENDING, ...args);
|
||||
}
|
||||
|
||||
refreshRecentFeed(...args) {
|
||||
this._initCoordinators();
|
||||
return this._feed.refreshFeed("recent", ...args);
|
||||
@@ -4024,6 +4034,11 @@ class Application {
|
||||
return this._feed.loadExploreVideos(...args);
|
||||
}
|
||||
|
||||
async loadTrendingVideos(...args) {
|
||||
this._initCoordinators();
|
||||
return this._feed.loadTrendingVideos(...args);
|
||||
}
|
||||
|
||||
async loadOlderVideos(...args) {
|
||||
this._initCoordinators();
|
||||
return this._feed.loadOlderVideos(...args);
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
*/
|
||||
|
||||
import { FEED_TYPES, STANDARD_TIMEOUT_MS, MEDIUM_TIMEOUT_MS, DEBOUNCE_DELAY_MS } from "../constants.js";
|
||||
import {
|
||||
registerTrendingFeed as registerTrendingFeedPipeline,
|
||||
buildTrendingFeedRuntime,
|
||||
} from "../feedEngine/trendingFeed.js";
|
||||
|
||||
/**
|
||||
* @param {object} deps - Injected dependencies.
|
||||
@@ -863,6 +867,9 @@ export function createFeedCoordinator(deps) {
|
||||
return next;
|
||||
};
|
||||
break;
|
||||
case FEED_TYPES.TRENDING:
|
||||
runtime = buildTrendingFeedRuntime(this);
|
||||
break;
|
||||
case "recent":
|
||||
runtime = this.buildRecentFeedRuntime();
|
||||
break;
|
||||
@@ -1022,6 +1029,12 @@ export function createFeedCoordinator(deps) {
|
||||
refreshMethod = (opts) => this.refreshFeed(FEED_TYPES.EXPLORE, opts);
|
||||
shouldCheckRelayHealth = false;
|
||||
break;
|
||||
case FEED_TYPES.TRENDING:
|
||||
includeTags = false;
|
||||
loadingMessage = "Fetching trending videos\u2026";
|
||||
refreshMethod = (opts) => this.refreshFeed(FEED_TYPES.TRENDING, opts);
|
||||
shouldCheckRelayHealth = false;
|
||||
break;
|
||||
default:
|
||||
devLogger.warn(
|
||||
`[Application] Unknown feed type for loadFeedVideos: ${feedType}`,
|
||||
@@ -1116,6 +1129,14 @@ export function createFeedCoordinator(deps) {
|
||||
return this.loadFeedVideos(FEED_TYPES.EXPLORE, forceFetch);
|
||||
},
|
||||
|
||||
registerTrendingFeed() {
|
||||
return registerTrendingFeedPipeline(this);
|
||||
},
|
||||
|
||||
async loadTrendingVideos(forceFetch = false) {
|
||||
return this.loadFeedVideos(FEED_TYPES.TRENDING, forceFetch);
|
||||
},
|
||||
|
||||
async loadOlderVideos(lastTimestamp) {
|
||||
const olderVideos = await this.nostrService.loadOlderVideos(lastTimestamp, {
|
||||
blacklistedEventIds: this.blacklistedEventIds,
|
||||
|
||||
@@ -197,6 +197,7 @@ const DEFAULT_FLAGS = Object.freeze({
|
||||
FEATURE_TRUST_SEEDS: true, // Rollback: disable to drop baseline trust seeds without code changes.
|
||||
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.
|
||||
TRUSTED_MUTE_HIDE_THRESHOLD: DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD,
|
||||
TRUSTED_SPAM_HIDE_THRESHOLD: DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD,
|
||||
WSS_TRACKERS: DEFAULT_WSS_TRACKERS,
|
||||
@@ -220,6 +221,7 @@ const runtimeFlags = (() => {
|
||||
FEATURE_TRUSTED_HIDE_CONTROLS: DEFAULT_FLAGS.FEATURE_TRUSTED_HIDE_CONTROLS,
|
||||
FEATURE_IMPROVED_COMMENT_FETCHING:
|
||||
DEFAULT_FLAGS.FEATURE_IMPROVED_COMMENT_FETCHING,
|
||||
FEATURE_TRENDING_FEED: DEFAULT_FLAGS.FEATURE_TRENDING_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],
|
||||
@@ -265,6 +267,11 @@ export let FEATURE_SEARCH_FILTERS = coerceBoolean(
|
||||
DEFAULT_FLAGS.FEATURE_SEARCH_FILTERS
|
||||
);
|
||||
|
||||
export let FEATURE_TRENDING_FEED = coerceBoolean(
|
||||
runtimeFlags.FEATURE_TRENDING_FEED,
|
||||
DEFAULT_FLAGS.FEATURE_TRENDING_FEED
|
||||
);
|
||||
|
||||
export let FEATURE_TRUST_SEEDS = coerceBoolean(
|
||||
runtimeFlags.FEATURE_TRUST_SEEDS,
|
||||
DEFAULT_FLAGS.FEATURE_TRUST_SEEDS
|
||||
@@ -538,6 +545,7 @@ export const FEED_TYPES = Object.freeze({
|
||||
FOR_YOU: "for-you",
|
||||
KIDS: "kids",
|
||||
EXPLORE: "explore",
|
||||
TRENDING: "trending",
|
||||
SUBSCRIPTIONS: "subscriptions",
|
||||
HISTORY: "history",
|
||||
CHANNEL: "channel-profile",
|
||||
|
||||
@@ -3,6 +3,62 @@
|
||||
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 = {}) {
|
||||
if (!Array.isArray(items)) {
|
||||
return [];
|
||||
}
|
||||
const getViewCount =
|
||||
typeof context?.runtime?.getViewCount === "function"
|
||||
? context.runtime.getViewCount
|
||||
: () => 0;
|
||||
|
||||
const isMuted = (entry) =>
|
||||
entry?.metadata?.moderation?.trustedMuted === true ||
|
||||
entry?.video?.moderation?.trustedMuted === true;
|
||||
|
||||
const timestampOf = (entry) => {
|
||||
const video = entry?.video;
|
||||
if (!video || typeof video !== "object") {
|
||||
return Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
const root = Number(video.rootCreatedAt);
|
||||
if (Number.isFinite(root)) return Math.floor(root);
|
||||
const created = Number(video.created_at);
|
||||
return Number.isFinite(created) ? Math.floor(created) : Number.NEGATIVE_INFINITY;
|
||||
};
|
||||
|
||||
// Resolve each item's view count once (avoid O(n log n) cache lookups).
|
||||
const viewsByItem = new Map();
|
||||
for (const item of items) {
|
||||
let total = 0;
|
||||
try {
|
||||
const value = getViewCount(item?.video);
|
||||
total = Number.isFinite(value) ? Number(value) : 0;
|
||||
} catch (error) {
|
||||
total = 0;
|
||||
}
|
||||
viewsByItem.set(item, total);
|
||||
}
|
||||
|
||||
const compare = (a, b) => {
|
||||
const viewDiff = (viewsByItem.get(b) || 0) - (viewsByItem.get(a) || 0);
|
||||
if (viewDiff !== 0) return viewDiff;
|
||||
return timestampOf(b) - timestampOf(a);
|
||||
};
|
||||
|
||||
const live = items.filter((entry) => !isMuted(entry));
|
||||
const muted = items.filter((entry) => isMuted(entry));
|
||||
return [...live.sort(compare), ...muted.sort(compare)];
|
||||
};
|
||||
}
|
||||
|
||||
// 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 —
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
// js/feedEngine/trendingFeed.js
|
||||
//
|
||||
// The "Trending" tab: the recently-added (active) source ranked by VIEW COUNT.
|
||||
// Lives outside feedCoordinator.js (which is at its size cap) and is wired in via
|
||||
// thin delegations. The sorter ranks by the shared viewCounter cache; the
|
||||
// Trending view re-runs the feed on viewCounter's debounced change signal so the
|
||||
// order settles into true trending as counts stream in.
|
||||
|
||||
import { FEED_TYPES } from "../constants.js";
|
||||
import { getVideoViewCountSnapshot } from "../viewCounter.js";
|
||||
import {
|
||||
createActiveNostrSource,
|
||||
createBlacklistFilterStage,
|
||||
createDedupeByRootStage,
|
||||
createModerationStage,
|
||||
createResolvePostedAtStage,
|
||||
} from "./index.js";
|
||||
import { createTrendingSorter } from "./sorters.js";
|
||||
|
||||
// Resolve a video's deduped view total from the shared viewCounter cache using
|
||||
// the app's canonical pointer derivation (the same key the grid cards subscribe
|
||||
// with, so Trending and the per-card count agree).
|
||||
function makeGetViewCount(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 = getVideoViewCountSnapshot(info.pointer);
|
||||
return Number.isFinite(total) ? Number(total) : 0;
|
||||
} catch (error) {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTrendingFeedRuntime(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,
|
||||
getViewCount: makeGetViewCount(app),
|
||||
};
|
||||
}
|
||||
|
||||
export function registerTrendingFeed(app) {
|
||||
if (!app?.feedEngine || typeof app.feedEngine.registerFeed !== "function") {
|
||||
return null;
|
||||
}
|
||||
const existing =
|
||||
typeof app.feedEngine.getFeedDefinition === "function"
|
||||
? app.feedEngine.getFeedDefinition(FEED_TYPES.TRENDING)
|
||||
: 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.TRENDING, {
|
||||
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: createTrendingSorter(),
|
||||
hooks: {
|
||||
timestamps: {
|
||||
getKnownVideoPostedAt: (video) => app.getKnownVideoPostedAt(video),
|
||||
resolveVideoPostedAt: (video) => app.resolveVideoPostedAt(video),
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// js/trendingView.js
|
||||
//
|
||||
// The "Trending" tab presentation hook. The ranking lives in the feed engine
|
||||
// (createTrendingSorter), but view counts arrive asynchronously as the grid
|
||||
// cards subscribe to them. So while this view is active we listen for
|
||||
// viewCounter's coalesced "counts changed" signal and re-run the Trending feed
|
||||
// (a cheap re-rank over the in-memory active set, no relay re-fetch) so the order
|
||||
// visibly settles into true trending as counts stream in.
|
||||
|
||||
import { attachFeedInfoPopover } from "./ui/components/FeedInfoPopover.js";
|
||||
import { onViewCountsChanged } from "./viewCounter.js";
|
||||
|
||||
const TRENDING_RERANK_DEBOUNCE_MS = 700;
|
||||
|
||||
let unsubscribeCounts = null;
|
||||
let rerankTimer = null;
|
||||
|
||||
export function teardownTrendingView() {
|
||||
if (typeof unsubscribeCounts === "function") {
|
||||
try {
|
||||
unsubscribeCounts();
|
||||
} catch (error) {
|
||||
// best effort
|
||||
}
|
||||
unsubscribeCounts = null;
|
||||
}
|
||||
if (rerankTimer) {
|
||||
clearTimeout(rerankTimer);
|
||||
rerankTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function initTrendingView({ getApp, isActive } = {}) {
|
||||
const infoTrigger = document.getElementById("trendingInfoTrigger");
|
||||
if (infoTrigger) {
|
||||
attachFeedInfoPopover(
|
||||
infoTrigger,
|
||||
"Trending ranks recently-added videos by their view count. The order settles as counts load in."
|
||||
);
|
||||
}
|
||||
|
||||
// Reset any prior subscription (re-entering the tab).
|
||||
teardownTrendingView();
|
||||
|
||||
const stillActive = typeof isActive === "function" ? isActive : () => true;
|
||||
|
||||
unsubscribeCounts = onViewCountsChanged(() => {
|
||||
if (rerankTimer) {
|
||||
return;
|
||||
}
|
||||
rerankTimer = setTimeout(() => {
|
||||
rerankTimer = null;
|
||||
if (!stillActive()) {
|
||||
teardownTrendingView();
|
||||
return;
|
||||
}
|
||||
const app = typeof getApp === "function" ? getApp() : null;
|
||||
if (app && typeof app.refreshTrendingFeed === "function") {
|
||||
Promise.resolve(
|
||||
app.refreshTrendingFeed({ reason: "view-count-update" })
|
||||
).catch(() => {});
|
||||
}
|
||||
}, TRENDING_RERANK_DEBOUNCE_MS);
|
||||
});
|
||||
}
|
||||
@@ -24,7 +24,7 @@ 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 } from "../constants.js";
|
||||
import { URL_FIRST_ENABLED, SHORT_TIMEOUT_MS, FEATURE_TRENDING_FEED } from "../constants.js";
|
||||
import { ALLOW_NSFW_CONTENT } from "../config.js";
|
||||
import { relayManager } from "../relayManager.js";
|
||||
import { userBlocks } from "../userBlocks.js";
|
||||
@@ -275,6 +275,15 @@ export default class ApplicationBootstrap {
|
||||
app.registerForYouFeed();
|
||||
app.registerKidsFeed();
|
||||
app.registerExploreFeed();
|
||||
if (FEATURE_TRENDING_FEED && typeof app.registerTrendingFeed === "function") {
|
||||
app.registerTrendingFeed();
|
||||
} else if (typeof document !== "undefined") {
|
||||
// Flag off: hide the always-visible Trending sidebar link.
|
||||
const trendingLink = document.getElementById("trendingLink");
|
||||
if (trendingLink) {
|
||||
trendingLink.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
app.registerSubscriptionsFeed();
|
||||
app.registerWatchHistoryFeed();
|
||||
// Re-run the For You feed when background watch-history resolution completes.
|
||||
|
||||
@@ -39,6 +39,29 @@ const countVideoViewEventsApi = (pointer, options) =>
|
||||
const pointerStates = new Map();
|
||||
/** @type {Map<string, PointerListeners>} */
|
||||
const pointerListeners = new Map();
|
||||
|
||||
// Global "any view count changed" listeners (used by the Trending feed to
|
||||
// debounce-re-rank as counts stream in). Coalesced so a burst of count arrivals
|
||||
// from the batched backfill fires one notification, not dozens.
|
||||
const globalChangeListeners = new Set();
|
||||
let globalChangeTimer = null;
|
||||
const VIEW_COUNT_GLOBAL_NOTIFY_DEBOUNCE_MS = 400;
|
||||
|
||||
function scheduleGlobalChangeNotify() {
|
||||
if (!globalChangeListeners.size || globalChangeTimer) {
|
||||
return;
|
||||
}
|
||||
globalChangeTimer = setTimeout(() => {
|
||||
globalChangeTimer = null;
|
||||
for (const listener of Array.from(globalChangeListeners)) {
|
||||
try {
|
||||
listener();
|
||||
} catch (error) {
|
||||
userLogger.warn("[viewCounter] Global view-count listener threw:", error);
|
||||
}
|
||||
}
|
||||
}, VIEW_COUNT_GLOBAL_NOTIFY_DEBOUNCE_MS);
|
||||
}
|
||||
let persistTimer = null;
|
||||
let nextTokenId = 1;
|
||||
|
||||
@@ -583,6 +606,9 @@ function ensurePointerListeners(key, pointer) {
|
||||
}
|
||||
|
||||
function notifyHandlers(key) {
|
||||
// Fire the global "counts changed" signal regardless of per-pointer listeners,
|
||||
// so the Trending feed re-ranks even for cards it isn't directly subscribed to.
|
||||
scheduleGlobalChangeNotify();
|
||||
const listeners = pointerListeners.get(key);
|
||||
if (!listeners || !listeners.handlers.size) {
|
||||
return;
|
||||
@@ -916,3 +942,32 @@ export function formatViewCount(total) {
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
// Synchronous read of the cached, deduped view total for a pointer (or null if
|
||||
// not yet known). The Trending sorter uses this to rank the active feed by views
|
||||
// without subscribing to every pointer itself — counts are populated by the grid
|
||||
// cards' batched subscriptions into this same shared cache.
|
||||
export function getVideoViewCountSnapshot(pointerInput) {
|
||||
try {
|
||||
const { key } = canonicalizePointer(pointerInput);
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
const state = pointerStates.get(key);
|
||||
return state && Number.isFinite(state.total) ? Number(state.total) : null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to a coalesced "any view count changed" signal. Returns an
|
||||
// unsubscribe function. Used by the Trending feed to debounce-re-rank.
|
||||
export function onViewCountsChanged(listener) {
|
||||
if (typeof listener !== "function") {
|
||||
return () => {};
|
||||
}
|
||||
globalChangeListeners.add(listener);
|
||||
return () => {
|
||||
globalChangeListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { initChannelProfileView } from "./channelProfile.js";
|
||||
import { initForYouView } from "./forYouView.js";
|
||||
import { initKidsView } from "./kidsView.js";
|
||||
import { initExploreView } from "./exploreView.js";
|
||||
import { initTrendingView } from "./trendingView.js";
|
||||
import { subscriptions } from "./subscriptions.js";
|
||||
import { getApplication } from "./applicationContext.js";
|
||||
import { applyDesignSystemAttributes } from "./designSystem.js";
|
||||
@@ -139,6 +140,26 @@ export const viewInitRegistry = {
|
||||
refreshApp.forceRefreshAllProfiles();
|
||||
}
|
||||
},
|
||||
[FEED_TYPES.TRENDING]: () => {
|
||||
const app = getApplication();
|
||||
initTrendingView({
|
||||
getApp: getApplication,
|
||||
isActive: () =>
|
||||
typeof window !== "undefined" &&
|
||||
typeof window.location?.hash === "string" &&
|
||||
window.location.hash.startsWith(`#view=${FEED_TYPES.TRENDING}`),
|
||||
});
|
||||
if (app && typeof app.loadTrendingVideos === "function") {
|
||||
if (typeof app.mountVideoListView === "function") {
|
||||
app.mountVideoListView({ includeTags: false });
|
||||
}
|
||||
app.loadTrendingVideos();
|
||||
}
|
||||
const refreshApp = getApplication();
|
||||
if (refreshApp && typeof refreshApp.forceRefreshAllProfiles === "function") {
|
||||
refreshApp.forceRefreshAllProfiles();
|
||||
}
|
||||
},
|
||||
[FEED_TYPES.DOCS]: async () => {
|
||||
await initDocsView();
|
||||
},
|
||||
|
||||
@@ -42,7 +42,7 @@ const GRANDFATHERED = {
|
||||
"js/nostr/watchHistory.js": 2388,
|
||||
"js/nostr/nip46Client.js": 2102,
|
||||
"js/state/cache.js": 1827,
|
||||
"js/app/feedCoordinator.js": 1841,
|
||||
"js/app/feedCoordinator.js": 1857,
|
||||
"js/app/authSessionCoordinator.js": 1803,
|
||||
"js/ui/components/RevertModal.js": 1663,
|
||||
"js/payments/nwcClient.js": 1642,
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// #27: the Trending feed ranks the active set by VIEW COUNT (recency breaks
|
||||
// ties), with unknown/not-yet-loaded counts treated as 0 and trusted-muted last.
|
||||
// The sorter reads counts from the injected runtime.getViewCount (the shared
|
||||
// viewCounter cache).
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createTrendingSorter } from "../js/feedEngine/sorters.js";
|
||||
|
||||
const item = (id, { views, created_at = 1000, muted = false } = {}) => ({
|
||||
video: {
|
||||
id,
|
||||
pubkey: "a".repeat(64),
|
||||
created_at,
|
||||
...(muted ? { moderation: { trustedMuted: true } } : {}),
|
||||
},
|
||||
metadata: {},
|
||||
__views: views,
|
||||
});
|
||||
|
||||
const sort = (items) => {
|
||||
const counts = new Map(items.map((i) => [i.video.id, i.__views]));
|
||||
return createTrendingSorter()(items, {
|
||||
runtime: { getViewCount: (video) => counts.get(video?.id) },
|
||||
}).map((i) => i.video.id);
|
||||
};
|
||||
|
||||
test("ranks by view count descending", () => {
|
||||
assert.deepEqual(
|
||||
sort([item("low", { views: 5 }), item("high", { views: 100 }), item("mid", { views: 50 })]),
|
||||
["high", "mid", "low"],
|
||||
);
|
||||
});
|
||||
|
||||
test("recency breaks ties on equal views", () => {
|
||||
assert.deepEqual(
|
||||
sort([
|
||||
item("older", { views: 10, created_at: 1000 }),
|
||||
item("newer", { views: 10, created_at: 2000 }),
|
||||
]),
|
||||
["newer", "older"],
|
||||
);
|
||||
});
|
||||
|
||||
test("unknown counts are treated as 0 (rank below known views, recency among them)", () => {
|
||||
const ids = sort([
|
||||
item("unknown-old", { views: undefined, created_at: 1000 }),
|
||||
item("known", { views: 3, created_at: 500 }),
|
||||
item("unknown-new", { views: undefined, created_at: 2000 }),
|
||||
]);
|
||||
assert.equal(ids[0], "known", "a known-view video leads the not-yet-loaded ones");
|
||||
assert.deepEqual(ids.slice(1), ["unknown-new", "unknown-old"], "unknowns fall back to recency");
|
||||
});
|
||||
|
||||
test("trusted-muted sinks last even if most-viewed", () => {
|
||||
assert.deepEqual(
|
||||
sort([item("muted-popular", { views: 1000, muted: true }), item("plain", { views: 1 })]),
|
||||
["plain", "muted-popular"],
|
||||
);
|
||||
});
|
||||
|
||||
test("tolerates non-array input and a missing getViewCount", () => {
|
||||
assert.deepEqual(createTrendingSorter()(null, {}), []);
|
||||
// No runtime.getViewCount → all 0 → recency order.
|
||||
const out = createTrendingSorter()(
|
||||
[item("a", { created_at: 1 }), item("b", { created_at: 2 })],
|
||||
{},
|
||||
).map((i) => i.video.id);
|
||||
assert.deepEqual(out, ["b", "a"]);
|
||||
});
|
||||
@@ -423,12 +423,29 @@ toward freshness and looked identical. Gave each a structural identity:
|
||||
- [ ] FUTURE: feed the same data into a **creator dashboard** showing the creator
|
||||
their videos' ranking + performance over time (separate, larger effort).
|
||||
|
||||
### 27. "Trending" tab — recently-added sorted by view count
|
||||
- [ ] Add a new tab that is essentially "Recently added videos" **sorted by view
|
||||
count** (call it "Trending" or similar) — a new sidebar grid that reuses the
|
||||
recently-added source but ranks by views. Depends on reliable view counts (#4)
|
||||
and benefits from the unified streaming grid (#17d). Gate behind a config flag
|
||||
if it should be toggleable like the other new tabs.
|
||||
### 27. "Trending" tab — recently-added sorted by view count — DONE 2026-06-24
|
||||
- [x] **Shipped.** New `FEED_TYPES.TRENDING` tab: the active (recently-added)
|
||||
source ranked by VIEW COUNT (recency tiebreak, trusted-muted last). Gated by
|
||||
`FEATURE_TRENDING_FEED` (default ON; flag-off hides the sidebar link and skips
|
||||
registration). Relies on the now-trustworthy view counts (#4).
|
||||
- `createTrendingSorter` (sorters.js) reads counts from the injected
|
||||
`runtime.getViewCount`, which resolves each video's pointer (the app's
|
||||
canonical `deriveVideoPointerInfo`) against the shared viewCounter cache —
|
||||
the same counts the grid cards already load.
|
||||
- **Live re-rank:** `viewCounter` now emits a coalesced `onViewCountsChanged`
|
||||
signal; while the Trending view is active it debounce-re-runs the feed
|
||||
(cheap re-rank over the in-memory active set, no relay re-fetch), so the
|
||||
order settles into true trending as counts stream in. Cold cache ⇒ reads as
|
||||
recency, then improves.
|
||||
- Pipeline extracted to `js/feedEngine/trendingFeed.js` (feedCoordinator is at
|
||||
its size cap); thin delegations + a `getVideoViewCountSnapshot` export.
|
||||
- Wiring: FEED_TYPES + flag, feedCoordinator load/refresh cases, app.js
|
||||
wrappers, applicationBootstrap registration, viewManager init +
|
||||
`js/trendingView.js`, `views/trending.html`, sidebar link.
|
||||
- Tests: `tests/trending-sorter.test.mjs`.
|
||||
- [ ] FUTURE: benefits from the unified streaming grid (#17d); consider a time-
|
||||
windowed "trending" (views in the last N days) once view-over-time data (#26)
|
||||
lands, instead of all-time totals.
|
||||
|
||||
### 28. Beacon torrent app stuck — spinner never resolves (BUG)
|
||||
- [ ] The torrent beacon app (`scripts/build:beacon` / `torrent/` integration) shows
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<!-- views/trending.html -->
|
||||
<section data-ds="new">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<h2 class="text-xl font-semibold text-text">Trending</h2>
|
||||
<button
|
||||
type="button"
|
||||
id="trendingInfoTrigger"
|
||||
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 trending…</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
Reference in new issue
Block a user