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:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-24 13:06:34 -04:00
1 parent 8deba55cdf
commit 02854d4fb5
14 files changed
+531 -8

No files matched your search

+22
View File
@@ -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"
+15
View File
@@ -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);
+21
View File
@@ -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,
+8
View File
@@ -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",
+56
View File
@@ -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 —
+124
View File
@@ -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;
}
}
+65
View File
@@ -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);
});
}
+10 -1
View File
@@ -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.
+55
View File
@@ -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);
};
}
+21
View File
@@ -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();
},
+1 -1
View File
@@ -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,
+70
View File
@@ -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"]);
});
+23 -6
View File
@@ -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
+40
View File
@@ -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>