Refactor discussion counts into dedicated service

This commit is contained in:
thePR0M3TH3AN committed 2025-10-09 12:51:34 -04:00
1 parent 32686c5ef0
commit 4ed5c97678
5 files changed
+404 -149

No files matched your search

+21 -146
View File
@@ -35,6 +35,7 @@ import PlaybackService from "./services/playbackService.js";
import AuthService from "./services/authService.js";
import NwcSettingsService from "./services/nwcSettingsService.js";
import nostrService from "./services/nostrService.js";
import DiscussionCountService from "./services/discussionCountService.js";
import { initQuickR2Upload } from "./r2-quick.js";
import { createWatchHistoryRenderer } from "./historyView.js";
import WatchHistoryController from "./ui/watchHistoryController.js";
@@ -105,7 +106,6 @@ const UNSUPPORTED_BTITH_MESSAGE =
"This magnet link is missing a compatible BitTorrent v1 info hash.";
const FALLBACK_THUMBNAIL_SRC = "assets/jpg/video-thumbnail-fallback.jpg";
const MAX_DISCUSSION_COUNT_VIDEOS = 24;
const VIDEO_EVENT_KIND = 30078;
const HEX64_REGEX = /^[0-9a-f]{64}$/i;
/**
@@ -162,8 +162,6 @@ class Application {
: mediaLoaderFactory();
this.loadedThumbnails = new Map();
this.relayManager = relayManager;
this.videoDiscussionCountCache = new Map();
this.inFlightDiscussionCounts = new Map();
this.activeIntervals = [];
this.watchHistoryTelemetry = null;
this.authEventUnsubscribes = [];
@@ -194,6 +192,9 @@ class Application {
(services.payments && services.payments.splitAndZap) ||
splitAndZapDefault;
this.discussionCountService =
services.discussionCountService || new DiscussionCountService();
this.nwcSettingsService =
services.nwcSettingsService ||
new NwcSettingsService({
@@ -791,7 +792,10 @@ class Application {
batchFetchProfiles: (authorSet) => this.batchFetchProfiles(authorSet),
bindThumbnailFallbacks: (container) => this.bindThumbnailFallbacks(container),
handleUrlHealthBadge: (payload) => this.handleUrlHealthBadge(payload),
refreshDiscussionCounts: (videos) => this.refreshVideoDiscussionCounts(videos),
refreshDiscussionCounts: (videos, { container } = {}) =>
this.refreshVideoDiscussionCounts(videos, {
videoListRoot: container || this.videoList || null,
}),
ensureGlobalMoreMenuHandlers: () => this.ensureGlobalMoreMenuHandlers(),
closeAllMenus: () => this.closeAllMoreMenus(),
},
@@ -4120,112 +4124,27 @@ class Application {
this.videoListView.render(videos, metadata);
}
refreshVideoDiscussionCounts(videos = []) {
if (
!Array.isArray(videos) ||
!videos.length ||
!this.videoList ||
!nostrClient?.pool
) {
refreshVideoDiscussionCounts(videos = [], options = {}) {
if (!this.discussionCountService) {
return;
}
const eligible = videos
.filter(
(video) =>
video &&
typeof video.id === "string" &&
video.id &&
video.enableComments !== false
)
.slice(0, MAX_DISCUSSION_COUNT_VIDEOS);
const { videoListRoot = this.videoList || null } = options;
eligible.forEach((video) => {
const container = this.videoList.querySelector(
`[data-discussion-count="${video.id}"]`
);
if (!container) {
return;
}
const cached = this.videoDiscussionCountCache.get(video.id);
if (typeof cached === "number") {
this.updateDiscussionCountElement(container, cached);
return;
}
const filters = this.buildDiscussionCountFilters(video);
if (!filters.length) {
this.markDiscussionCountError(container, { unsupported: true });
return;
}
const existingPromise = this.inFlightDiscussionCounts.get(video.id);
if (existingPromise) {
this.setDiscussionCountPending(container);
return;
}
this.setDiscussionCountPending(container);
const request = nostrClient
.countEventsAcrossRelays(filters)
.then((result) => {
const perRelay = Array.isArray(result?.perRelay)
? result.perRelay.filter((entry) => entry && entry.ok)
: [];
if (!perRelay.length) {
this.markDiscussionCountError(container, { unsupported: true });
return result;
}
const total = Number(result?.total);
const normalized =
Number.isFinite(total) && total >= 0 ? total : 0;
this.videoDiscussionCountCache.set(video.id, normalized);
this.updateDiscussionCountElement(container, normalized);
return result;
})
.catch((error) => {
if (isDevMode) {
console.warn(
`[counts] Failed to fetch discussion count for ${video.id}:`,
error
);
}
this.markDiscussionCountError(container);
throw error;
})
.finally(() => {
this.inFlightDiscussionCounts.delete(video.id);
});
this.inFlightDiscussionCounts.set(video.id, request);
this.discussionCountService.refreshCounts(videos, {
videoListRoot,
nostrClient,
});
}
buildDiscussionCountFilters(video) {
if (!video || typeof video !== "object") {
return [];
}
const filters = [];
const eventId =
typeof video.id === "string" ? video.id.trim() : "";
if (eventId) {
filters.push({ kinds: [1], "#e": [eventId] });
}
const address = this.getVideoAddressPointer(video);
if (address) {
filters.push({ kinds: [1], "#a": [address] });
}
return filters;
}
getVideoAddressPointer(video) {
if (
this.discussionCountService &&
typeof this.discussionCountService.getVideoAddressPointer === "function"
) {
return this.discussionCountService.getVideoAddressPointer(video);
}
if (!video || typeof video !== "object") {
return "";
}
@@ -4263,50 +4182,6 @@ class Application {
return `${kind}:${pubkey}:${identifier}`;
}
setDiscussionCountPending(element) {
if (!element) {
return;
}
element.dataset.countState = "pending";
const valueEl = element.querySelector("[data-discussion-count-value]");
if (valueEl) {
valueEl.textContent = "…";
}
element.removeAttribute("title");
}
updateDiscussionCountElement(element, count) {
if (!element) {
return;
}
const valueEl = element.querySelector("[data-discussion-count-value]");
if (!valueEl) {
return;
}
const numeric = Number(count);
const safeValue =
Number.isFinite(numeric) && numeric >= 0 ? Math.floor(numeric) : 0;
element.dataset.countState = "ready";
valueEl.textContent = safeValue.toLocaleString();
element.removeAttribute("title");
}
markDiscussionCountError(element, { unsupported = false } = {}) {
if (!element) {
return;
}
const valueEl = element.querySelector("[data-discussion-count-value]");
if (valueEl) {
valueEl.textContent = "—";
}
element.dataset.countState = unsupported ? "unsupported" : "error";
if (unsupported) {
element.title = "Relay does not support NIP-45 COUNT queries.";
} else {
element.removeAttribute("title");
}
}
bindThumbnailFallbacks(container) {
if (!container || typeof container.querySelectorAll !== "function") {
return;
+231
View File
@@ -0,0 +1,231 @@
import { isDevMode } from "../config.js";
const DEFAULT_MAX_DISCUSSION_COUNT_VIDEOS = 24;
const COUNT_UNSUPPORTED_TITLE = "Relay does not support NIP-45 COUNT queries.";
function toPositiveInteger(value, fallback) {
const numeric = Number(value);
if (!Number.isFinite(numeric) || numeric <= 0) {
return fallback;
}
return Math.floor(numeric);
}
export default class DiscussionCountService {
constructor({
maxVideos = DEFAULT_MAX_DISCUSSION_COUNT_VIDEOS,
logger = typeof console !== "undefined" ? console : null,
} = {}) {
this.maxVideos = toPositiveInteger(maxVideos, DEFAULT_MAX_DISCUSSION_COUNT_VIDEOS);
this.logger = logger;
this.videoDiscussionCountCache = new Map();
this.inFlightDiscussionCounts = new Map();
}
clearCache() {
this.videoDiscussionCountCache.clear();
this.inFlightDiscussionCounts.clear();
}
getCachedCount(videoId) {
if (!videoId) {
return undefined;
}
return this.videoDiscussionCountCache.get(videoId);
}
refreshCounts(videos = [], options = {}) {
const { videoListRoot = null, nostrClient = null } = options;
if (!Array.isArray(videos) || !videos.length) {
return;
}
const root =
videoListRoot && typeof videoListRoot.querySelector === "function"
? videoListRoot
: null;
if (!root || !nostrClient?.pool || typeof nostrClient.countEventsAcrossRelays !== "function") {
return;
}
const eligibleVideos = videos
.filter(
(video) =>
video &&
typeof video.id === "string" &&
video.id &&
video.enableComments !== false
)
.slice(0, this.maxVideos);
eligibleVideos.forEach((video) => {
const container = root.querySelector(`[data-discussion-count="${video.id}"]`);
if (!container) {
return;
}
const cachedCount = this.getCachedCount(video.id);
if (typeof cachedCount === "number") {
this.updateDiscussionCountElement(container, cachedCount);
return;
}
const filters = this.buildDiscussionCountFilters(video);
if (!filters.length) {
this.markDiscussionCountError(container, { unsupported: true });
return;
}
if (this.inFlightDiscussionCounts.has(video.id)) {
this.setDiscussionCountPending(container);
return;
}
this.setDiscussionCountPending(container);
const request = Promise.resolve()
.then(() => nostrClient.countEventsAcrossRelays(filters))
.then((result) => {
const perRelay = Array.isArray(result?.perRelay)
? result.perRelay.filter((entry) => entry && entry.ok)
: [];
if (!perRelay.length) {
this.markDiscussionCountError(container, { unsupported: true });
return result;
}
const total = Number(result?.total);
const normalized =
Number.isFinite(total) && total >= 0 ? Math.floor(total) : 0;
this.videoDiscussionCountCache.set(video.id, normalized);
this.updateDiscussionCountElement(container, normalized);
return result;
})
.catch((error) => {
if (isDevMode && this.logger?.warn) {
this.logger.warn(
`[counts] Failed to fetch discussion count for ${video.id}:`,
error
);
}
this.markDiscussionCountError(container);
throw error;
})
.finally(() => {
this.inFlightDiscussionCounts.delete(video.id);
});
this.inFlightDiscussionCounts.set(video.id, request);
});
}
buildDiscussionCountFilters(video) {
if (!video || typeof video !== "object") {
return [];
}
const filters = [];
const eventId = typeof video.id === "string" ? video.id.trim() : "";
if (eventId) {
filters.push({ kinds: [1], "#e": [eventId] });
}
const address = this.getVideoAddressPointer(video);
if (address) {
filters.push({ kinds: [1], "#a": [address] });
}
return filters;
}
getVideoAddressPointer(video) {
if (!video || typeof video !== "object") {
return "";
}
const tags = Array.isArray(video.tags) ? video.tags : [];
const dTag = tags.find(
(tag) =>
Array.isArray(tag) &&
tag.length >= 2 &&
tag[0] === "d" &&
typeof tag[1] === "string" &&
tag[1].trim()
);
if (!dTag) {
return "";
}
const pubkey = typeof video.pubkey === "string" ? video.pubkey.trim() : "";
if (!pubkey) {
return "";
}
const identifier = dTag[1].trim();
if (!identifier) {
return "";
}
const kind =
Number.isFinite(video.kind) && video.kind > 0
? Math.floor(video.kind)
: 30078;
return `${kind}:${pubkey}:${identifier}`;
}
setDiscussionCountPending(element) {
if (!element) {
return;
}
element.dataset.countState = "pending";
const valueEl = element.querySelector("[data-discussion-count-value]");
if (valueEl) {
valueEl.textContent = "…";
}
element.removeAttribute("title");
}
updateDiscussionCountElement(element, count) {
if (!element) {
return;
}
const valueEl = element.querySelector("[data-discussion-count-value]");
if (!valueEl) {
return;
}
const numeric = Number(count);
const safeValue =
Number.isFinite(numeric) && numeric >= 0 ? Math.floor(numeric) : 0;
element.dataset.countState = "ready";
valueEl.textContent = safeValue.toLocaleString();
element.removeAttribute("title");
}
markDiscussionCountError(element, { unsupported = false } = {}) {
if (!element) {
return;
}
const valueEl = element.querySelector("[data-discussion-count-value]");
if (valueEl) {
valueEl.textContent = "—";
}
element.dataset.countState = unsupported ? "unsupported" : "error";
if (unsupported) {
element.title = COUNT_UNSUPPORTED_TITLE;
} else {
element.removeAttribute("title");
}
}
}
export { COUNT_UNSUPPORTED_TITLE };
+4 -2
View File
@@ -563,8 +563,10 @@ class SubscriptionsManager {
app?.bindThumbnailFallbacks?.(target),
handleUrlHealthBadge: (payload) =>
app?.handleUrlHealthBadge?.(payload),
refreshDiscussionCounts: (videosList) =>
app?.refreshVideoDiscussionCounts?.(videosList),
refreshDiscussionCounts: (videosList, { container: root } = {}) =>
app?.refreshVideoDiscussionCounts?.(videosList, {
videoListRoot: root || container || null,
}),
ensureGlobalMoreMenuHandlers: () =>
app?.ensureGlobalMoreMenuHandlers?.(),
closeAllMenus: () => app?.closeAllMoreMenus?.(),
+3 -1
View File
@@ -509,7 +509,9 @@ export class VideoListView {
if (authorSet.size) {
this.utils.batchFetchProfiles(authorSet);
}
this.utils.refreshDiscussionCounts(displayVideos);
this.utils.refreshDiscussionCounts(displayVideos, {
container: this.container,
});
this.pruneDetachedViewCountElements();
return displayVideos;
+145
View File
@@ -0,0 +1,145 @@
// Run with: node tests/discussion-count-service.test.mjs
import assert from "node:assert/strict";
import { JSDOM } from "jsdom";
import DiscussionCountService, {
COUNT_UNSUPPORTED_TITLE,
} from "../js/services/discussionCountService.js";
function createDomWithCountElement(videoId) {
const dom = new JSDOM(`
<div id="root">
<div data-discussion-count="${videoId}" data-count-state="idle">
<span data-discussion-count-value>0</span>
</div>
</div>
`);
const document = dom.window.document;
const container = document.getElementById("root");
const target = container.querySelector(
`[data-discussion-count="${videoId}"]`
);
return { dom, document, container, target };
}
async function testRefreshCountsCachesResults() {
const video = {
id: "count-video-1",
tags: [["d", "identifier-1"]],
pubkey: "pubkey-1",
kind: 30078,
};
const { container, target } = createDomWithCountElement(video.id);
const service = new DiscussionCountService();
const nostrClient = {
pool: {},
countEventsAcrossRelays: async (filters) => {
assert.equal(filters.length, 2, "should request both id and address filters");
return {
total: 5,
perRelay: [{ ok: true }],
};
},
};
service.refreshCounts([video], {
videoListRoot: container,
nostrClient,
});
const inFlight = service.inFlightDiscussionCounts.get(video.id);
assert.ok(inFlight, "refresh should record an in-flight request");
await inFlight;
assert.equal(
service.getCachedCount(video.id),
5,
"successful responses should be cached"
);
assert.equal(
target.dataset.countState,
"ready",
"DOM should transition to ready after a successful fetch"
);
assert.equal(
target.querySelector("[data-discussion-count-value]").textContent,
"5",
"count value should reflect the fetched total"
);
let callCount = 0;
nostrClient.countEventsAcrossRelays = async () => {
callCount += 1;
return { total: 99, perRelay: [{ ok: true }] };
};
service.refreshCounts([video], {
videoListRoot: container,
nostrClient,
});
assert.equal(
callCount,
0,
"cached videos should not trigger additional COUNT queries"
);
assert.equal(
target.querySelector("[data-discussion-count-value]").textContent,
"5",
"cached values should remain visible without pending states"
);
}
async function testUnsupportedRelaysUpdateDomState() {
const video = {
id: "unsupported-video",
tags: [["d", "identifier-2"]],
pubkey: "pubkey-2",
kind: 30078,
};
const { container, target } = createDomWithCountElement(video.id);
const service = new DiscussionCountService();
const nostrClient = {
pool: {},
countEventsAcrossRelays: async () => ({
total: 7,
perRelay: [{ ok: false }],
}),
};
service.refreshCounts([video], {
videoListRoot: container,
nostrClient,
});
const inFlight = service.inFlightDiscussionCounts.get(video.id);
assert.ok(inFlight, "unsupported relays should still trigger in-flight tracking");
await inFlight;
assert.equal(
service.getCachedCount(video.id),
undefined,
"unsupported relay responses should not populate the cache"
);
assert.equal(
target.dataset.countState,
"unsupported",
"DOM should flag unsupported relays for COUNT queries"
);
assert.equal(
target.querySelector("[data-discussion-count-value]").textContent,
"—",
"unsupported states should render an em dash for the count value"
);
assert.equal(
target.getAttribute("title"),
COUNT_UNSUPPORTED_TITLE,
"unsupported states should expose a descriptive tooltip"
);
}
await testRefreshCountsCachesResults();
await testUnsupportedRelaysUpdateDomState();
console.log("discussion-count-service tests passed");