[channel] Show view counts on channel-profile cards (were blank)

Channel-profile video cards rendered blank view counts. The main feed subscribes
each card's [data-view-count] element to the shared view counter
(VideoListView.ensureViewCountSubscription / registerVideoViewCountElement), but the
channel-profile grid renders its own VideoCards and never did — so nothing ever
populated the element.

Add js/channelViewCounts.js (subscribeChannelCardViewCount /
clearChannelCardViewCounts) and call it from channelProfile.js after each card's
getRoot(); teardown runs in clearChannelVideoCardRegistry (already invoked on grid
re-render / channel close). Logic lives in a new module so channelProfile.js stays
under its size cap.

On "are they counted correctly?": yes — this subscribes to the SAME batched
kind-30079 view-count transport the feed uses (one shared subscription + the deduped
cache), so the channel now shows the exact same counts the feed does. The counting
itself was never the issue; the channel grid just wasn't displaying it.

Tests: tests/channel-view-counts.test.mjs (3 — non-blank label, safe no-ops,
idempotent teardown). Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-07-02 10:44:09 -04:00
1 parent 6b251dbda5
commit 9e5cb9f57b
3 files changed
+150

No files matched your search

+8
View File
@@ -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);
}
}
+77
View File
@@ -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
}
}
}
+65
View File
@@ -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(`<!DOCTYPE html><body>${html}</body>`);
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('<div class="card"><span data-view-count></span></div>');
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('<div class="card"></div>');
const cardWithEl = makeCard('<div class="card"><span data-view-count></span></div>');
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();
});
});