diff --git a/js/channelProfile.js b/js/channelProfile.js index 1261facb..af3ff7dd 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -13,6 +13,10 @@ import { escapeHTML } from "./utils/domUtils.js"; import { formatShortNpub } from "./utils/formatters.js"; import createPopover from "./ui/overlay/popoverEngine.js"; import { VideoCard } from "./ui/components/VideoCard.js"; +import { + subscribeChannelCardViewCount, + clearChannelCardViewCounts, +} from "./channelViewCounts.js"; import { createChannelProfileMenuPanel } from "./ui/components/videoMenuRenderers.js"; import { ALLOW_NSFW_CONTENT } from "./config.js"; import { sanitizeProfileMediaUrl } from "./utils/profileMedia.js"; @@ -179,6 +183,7 @@ function updateChannelBannerBackground(el, url) { export function clearChannelVideoCardRegistry() { channelVideoCardsById.clear(); + clearChannelCardViewCounts(); } let currentChannelHex = null; @@ -5029,6 +5034,9 @@ export async function renderChannelVideosFromList({ const cardEl = videoCard.getRoot(); if (cardEl) { fragment.appendChild(cardEl); + // Subscribe the card to the shared view counter (the feed does this; the + // channel grid didn't, so its view counts rendered blank). + subscribeChannelCardViewCount(cardEl, pointerInfo); } } diff --git a/js/channelViewCounts.js b/js/channelViewCounts.js new file mode 100644 index 00000000..5c3b3563 --- /dev/null +++ b/js/channelViewCounts.js @@ -0,0 +1,77 @@ +// Wire channel-profile video cards to the shared view counter. The main feed does +// this in VideoListView (ensureViewCountSubscription / registerVideoViewCountElement); +// the channel-profile grid renders its own VideoCards and never subscribed them, so +// every card's view count rendered blank. This mirrors the feed's behavior against +// the SAME batched view-count transport (one shared kind-30079 subscription), so the +// counts are identical to what the feed shows — the channel just wasn't displaying +// them (the counting itself is unchanged and shared). + +import { + subscribeToVideoViewCount, + unsubscribeFromVideoViewCount, + formatViewCount, +} from "./viewCounter.js"; +import { devLogger } from "./utils/logger.js"; + +const activeSubscriptions = []; + +function formatViewCountLabel(total) { + const value = Number.isFinite(total) ? Number(total) : 0; + return `${formatViewCount(value)} ${value === 1 ? "view" : "views"}`; +} + +// Subscribe one channel card's [data-view-count] element to its pointer. +export function subscribeChannelCardViewCount(cardEl, pointerInfo) { + const pointer = pointerInfo?.pointer; + if (!cardEl || !pointer) { + return; + } + const el = cardEl.querySelector("[data-view-count]"); + if (!el) { + return; + } + if (!el.textContent || !el.textContent.trim()) { + el.textContent = "– views"; + } + + try { + const token = subscribeToVideoViewCount( + pointer, + ({ total, status, partial }) => { + let text; + if (Number.isFinite(total)) { + text = formatViewCountLabel(total); + if (partial) { + text = `${text} (partial)`; + } + } else if (status === "hydrating") { + text = "Loading views…"; + } else { + text = "– views"; + } + if (el.isConnected) { + el.textContent = text; + el.dataset.viewCountState = partial ? "partial" : status; + } + }, + ); + activeSubscriptions.push({ pointer, token }); + } catch (error) { + devLogger.warn( + "[ChannelProfile] Failed to subscribe card view count:", + error, + ); + } +} + +// Tear down every channel-card view-count subscription (on grid re-render / close). +export function clearChannelCardViewCounts() { + while (activeSubscriptions.length) { + const { pointer, token } = activeSubscriptions.pop(); + try { + unsubscribeFromVideoViewCount(pointer, token); + } catch (error) { + // best-effort teardown + } + } +} diff --git a/tests/channel-view-counts.test.mjs b/tests/channel-view-counts.test.mjs new file mode 100644 index 00000000..201ff312 --- /dev/null +++ b/tests/channel-view-counts.test.mjs @@ -0,0 +1,65 @@ +// Channel-profile cards showed BLANK view counts because the channel grid never +// subscribed its cards to the shared view counter (the main feed does). This wires +// each card's [data-view-count] element the same way. The count VALUES come from the +// same shared transport as the feed — the channel just wasn't displaying them. +// +// test_integrity_note: +// change_type: ["new_tests"] +// scenarios: +// - id: SCN-channel-card-view-count +// given: "a channel card element with a [data-view-count] span + a pointer" +// when: "subscribeChannelCardViewCount runs (and clearChannelCardViewCounts tears down)" +// then: "the element gets a non-blank view label; missing pointer/element/card are safe no-ops" +// observable_outcomes: +// - "a card with a pointer gets a non-blank view label (no longer blank)" +// - "no pointer / no [data-view-count] / no card -> no throw, no label forced" +// - "clearChannelCardViewCounts is idempotent and never throws" +// determinism_controls: +// - "JSDOM element; real module against the (uninitialised) shared view counter" +// anti_cheat_rationale: +// prevents: ["asserting the blank (broken) state", "hard-coded return value"] +// 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 { JSDOM } from "jsdom"; +import { + subscribeChannelCardViewCount, + clearChannelCardViewCounts, +} from "../js/channelViewCounts.js"; + +function makeCard(html) { + const dom = new JSDOM(`${html}`); + return dom.window.document.body.firstElementChild; +} + +test("a card with a pointer gets a non-blank view-count label (fixes the blank)", () => { + const card = makeCard('
'); + subscribeChannelCardViewCount(card, { pointer: ["a", 30078, "root-1"], key: "k1" }); + const el = card.querySelector("[data-view-count]"); + assert.ok(el.textContent && el.textContent.trim().length > 0, "label is not blank"); + assert.match(el.textContent, /view|Loading/i, "shows a view/loading label"); + clearChannelCardViewCounts(); +}); + +test("safe no-ops: missing pointer, missing [data-view-count], or missing card", () => { + const cardNoEl = makeCard('
'); + const cardWithEl = makeCard('
'); + assert.doesNotThrow(() => { + subscribeChannelCardViewCount(cardWithEl, {}); // no pointer + subscribeChannelCardViewCount(cardNoEl, { pointer: ["a", 30078, "x"] }); // no element + subscribeChannelCardViewCount(null, { pointer: ["a", 30078, "x"] }); // no card + }); + // A card with no pointer must not have its (absent) element forced to a label. + assert.equal(cardWithEl.querySelector("[data-view-count]").textContent, ""); + clearChannelCardViewCounts(); +}); + +test("clearChannelCardViewCounts is idempotent and never throws", () => { + assert.doesNotThrow(() => { + clearChannelCardViewCounts(); + clearChannelCardViewCounts(); + }); +});