diff --git a/js/app.js b/js/app.js index 43505d19..b26b6b3e 100644 --- a/js/app.js +++ b/js/app.js @@ -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; diff --git a/js/services/discussionCountService.js b/js/services/discussionCountService.js new file mode 100644 index 00000000..0673901a --- /dev/null +++ b/js/services/discussionCountService.js @@ -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 }; diff --git a/js/subscriptions.js b/js/subscriptions.js index 63ba063f..8b07c1e7 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -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?.(), diff --git a/js/ui/views/VideoListView.js b/js/ui/views/VideoListView.js index d5170cab..68afa1f3 100644 --- a/js/ui/views/VideoListView.js +++ b/js/ui/views/VideoListView.js @@ -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; diff --git a/tests/discussion-count-service.test.mjs b/tests/discussion-count-service.test.mjs new file mode 100644 index 00000000..75652af2 --- /dev/null +++ b/tests/discussion-count-service.test.mjs @@ -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(` +