mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Refactor discussion counts into dedicated service
This commit is contained in:
1 parent
32686c5ef0
commit
4ed5c97678
5 files changed
+404
-149
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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
@@ -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?.(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
Reference in new issue
Block a user