diff --git a/components/sidebar.html b/components/sidebar.html index d4788085..a6f02778 100644 --- a/components/sidebar.html +++ b/components/sidebar.html @@ -63,6 +63,28 @@ /> Recently Added + + + + + + Trending + 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, diff --git a/js/constants.js b/js/constants.js index 5cb050cc..2dfc4d5f 100644 --- a/js/constants.js +++ b/js/constants.js @@ -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", diff --git a/js/feedEngine/sorters.js b/js/feedEngine/sorters.js index f0fe060a..66b52878 100644 --- a/js/feedEngine/sorters.js +++ b/js/feedEngine/sorters.js @@ -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 — diff --git a/js/feedEngine/trendingFeed.js b/js/feedEngine/trendingFeed.js new file mode 100644 index 00000000..c85e3ae8 --- /dev/null +++ b/js/feedEngine/trendingFeed.js @@ -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; + } +} diff --git a/js/trendingView.js b/js/trendingView.js new file mode 100644 index 00000000..4a1014fd --- /dev/null +++ b/js/trendingView.js @@ -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); + }); +} diff --git a/js/ui/applicationBootstrap.js b/js/ui/applicationBootstrap.js index 2f04c9f1..95fc3fb6 100644 --- a/js/ui/applicationBootstrap.js +++ b/js/ui/applicationBootstrap.js @@ -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. diff --git a/js/viewCounter.js b/js/viewCounter.js index eb86b9c4..cda34ced 100644 --- a/js/viewCounter.js +++ b/js/viewCounter.js @@ -39,6 +39,29 @@ const countVideoViewEventsApi = (pointer, options) => const pointerStates = new Map(); /** @type {Map} */ 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); + }; +} diff --git a/js/viewManager.js b/js/viewManager.js index b3852bd7..8d9ef461 100644 --- a/js/viewManager.js +++ b/js/viewManager.js @@ -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(); }, diff --git a/scripts/check-file-size.mjs b/scripts/check-file-size.mjs index 4b9f507c..bdd32db2 100644 --- a/scripts/check-file-size.mjs +++ b/scripts/check-file-size.mjs @@ -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, diff --git a/tests/trending-sorter.test.mjs b/tests/trending-sorter.test.mjs new file mode 100644 index 00000000..8e17383a --- /dev/null +++ b/tests/trending-sorter.test.mjs @@ -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"]); +}); diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index e9c9be0c..23de6b5a 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -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 diff --git a/views/trending.html b/views/trending.html new file mode 100644 index 00000000..24b0bdf2 --- /dev/null +++ b/views/trending.html @@ -0,0 +1,40 @@ + + + + Trending + + + + + + + + + + + + + + Loading trending… + + + +