diff --git a/README.md b/README.md
index 4e8dee7c..efba1995 100644
--- a/README.md
+++ b/README.md
@@ -107,6 +107,13 @@ latest styles.
- **Magnet helpers**:
- Use `safeDecodeMagnet()` and `normalizeAndAugmentMagnet()` from `js/magnetUtils.js` to preserve hashes and add `ws=` / `xs=` hints safely.
+### Relay compatibility
+
+Bitvid now requests per-video discussion counts using the NIP-45 `COUNT` frame. The bundled client opens each relay via
+`this.pool.ensureRelay(url)` and streams a raw `COUNT` message, so your relay stack must understand that verb (nostr-tools ≥ 1.8
+or any relay advertising NIP-45 support). Relays that do not implement `COUNT` are skipped gracefully—the UI keeps the count
+placeholder at “—” and development builds log a warning—so mixed deployments remain usable while you phase in compatible relays.
+
### Adding Features
1. **Fork the repository** and create a new branch for your feature.
diff --git a/js/app.js b/js/app.js
index d286a7cc..55af3fef 100644
--- a/js/app.js
+++ b/js/app.js
@@ -97,6 +97,8 @@ const FALLBACK_THUMBNAIL_SRC = "assets/jpg/video-thumbnail-fallback.jpg";
const ADMIN_DM_IMAGE_URL =
"https://beta.bitvid.network/assets/jpg/video-thumbnail-fallback.jpg";
const BITVID_WEBSITE_URL = "https://bitvid.network/";
+const MAX_DISCUSSION_COUNT_VIDEOS = 24;
+const VIDEO_EVENT_KIND = 30078;
const TRACKING_SCRIPT_PATTERN = /(?:^|\/)tracking\.js(?:$|\?)/;
const EMPTY_VIDEO_LIST_SIGNATURE = "__EMPTY__";
/**
@@ -525,6 +527,8 @@ class bitvidApp {
// Lazy-loading helper for images
this.mediaLoader = new MediaLoader();
this.loadedThumbnails = new Map();
+ this.videoDiscussionCountCache = new Map();
+ this.inFlightDiscussionCounts = new Map();
this.activeIntervals = [];
this.urlPlaybackWatchdogCleanup = null;
@@ -7747,6 +7751,16 @@ class bitvidApp {
`
: "";
+ const showDiscussionCount = video.enableComments !== false;
+ const discussionCountHtml = showDiscussionCount
+ ? `
+
+ —
+ notes
+
+ `
+ : "";
+
const rawThumbnail =
typeof video.thumbnail === "string" ? video.thumbnail.trim() : "";
const escapedThumbnail = rawThumbnail
@@ -7833,6 +7847,7 @@ class bitvidApp {
${cardControls}
${connectionBadgesHtml}
+ ${discussionCountHtml}
${torrentWarningHtml}
@@ -8135,6 +8150,195 @@ class bitvidApp {
this.goToProfile(pubkey);
});
});
+
+ this.refreshVideoDiscussionCounts(dedupedVideos);
+ }
+
+ refreshVideoDiscussionCounts(videos = []) {
+ if (
+ !Array.isArray(videos) ||
+ !videos.length ||
+ !this.videoList ||
+ !nostrClient?.pool
+ ) {
+ return;
+ }
+
+ const eligible = videos
+ .filter(
+ (video) =>
+ video &&
+ typeof video.id === "string" &&
+ video.id &&
+ video.enableComments !== false
+ )
+ .slice(0, MAX_DISCUSSION_COUNT_VIDEOS);
+
+ 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);
+ });
+ }
+
+ 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)
+ : VIDEO_EVENT_KIND;
+
+ 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) {
diff --git a/js/nostr.js b/js/nostr.js
index b3111727..a12fc112 100644
--- a/js/nostr.js
+++ b/js/nostr.js
@@ -81,6 +81,58 @@ function withNip07Timeout(operation) {
});
}
+function withRequestTimeout(promise, timeoutMs, onTimeout, message = "Request timed out") {
+ const resolvedTimeout = Number(timeoutMs);
+ const effectiveTimeout =
+ Number.isFinite(resolvedTimeout) && resolvedTimeout > 0
+ ? Math.floor(resolvedTimeout)
+ : 4000;
+
+ let timeoutId = null;
+ let settled = false;
+
+ return new Promise((resolve, reject) => {
+ timeoutId = setTimeout(() => {
+ if (settled) {
+ return;
+ }
+ settled = true;
+ if (typeof onTimeout === "function") {
+ try {
+ onTimeout();
+ } catch (cleanupError) {
+ if (isDevMode) {
+ console.warn("[nostr] COUNT timeout cleanup failed:", cleanupError);
+ }
+ }
+ }
+ reject(new Error(message));
+ }, effectiveTimeout);
+
+ Promise.resolve(promise)
+ .then((value) => {
+ if (settled) {
+ return;
+ }
+ settled = true;
+ if (timeoutId) {
+ clearTimeout(timeoutId);
+ }
+ resolve(value);
+ })
+ .catch((error) => {
+ if (settled) {
+ return;
+ }
+ settled = true;
+ if (timeoutId) {
+ clearTimeout(timeoutId);
+ }
+ reject(error);
+ });
+ });
+}
+
function pointerKey(pointer) {
if (!pointer || typeof pointer !== "object") {
return "";
@@ -901,6 +953,7 @@ class NostrClient {
this.watchHistoryStorage = null;
this.watchHistoryRepublishTimers = new Map();
this.watchHistoryCacheTtlMs = WATCH_HISTORY_CACHE_TTL_MS;
+ this.countRequestCounter = 0;
}
restoreLocalData() {
@@ -4047,6 +4100,310 @@ class NostrClient {
return null;
}
+ getRequestTimeoutMs(timeoutMs) {
+ const candidate = Number(timeoutMs);
+ if (Number.isFinite(candidate) && candidate > 0) {
+ return Math.floor(candidate);
+ }
+ const poolTimeout = Number(this.pool?.getTimeout);
+ if (Number.isFinite(poolTimeout) && poolTimeout > 0) {
+ return Math.floor(poolTimeout);
+ }
+ return 3400;
+ }
+
+ normalizeCountFilter(filter) {
+ if (!filter || typeof filter !== "object") {
+ return null;
+ }
+
+ const normalized = {};
+
+ const toStringArray = (value) => {
+ if (value === undefined || value === null) {
+ return [];
+ }
+ const source = Array.isArray(value) ? value : [value];
+ const collected = [];
+ for (const item of source) {
+ if (typeof item !== "string") {
+ continue;
+ }
+ const trimmed = item.trim();
+ if (!trimmed || collected.includes(trimmed)) {
+ continue;
+ }
+ collected.push(trimmed);
+ }
+ return collected;
+ };
+
+ if (filter.kinds !== undefined) {
+ const kindsSource = Array.isArray(filter.kinds)
+ ? filter.kinds
+ : [filter.kinds];
+ const normalizedKinds = [];
+ const seenKinds = new Set();
+ for (const candidate of kindsSource) {
+ const parsed = Number(candidate);
+ if (!Number.isFinite(parsed)) {
+ continue;
+ }
+ const normalizedValue = Math.floor(parsed);
+ if (seenKinds.has(normalizedValue)) {
+ continue;
+ }
+ seenKinds.add(normalizedValue);
+ normalizedKinds.push(normalizedValue);
+ }
+ if (normalizedKinds.length) {
+ normalized.kinds = normalizedKinds;
+ }
+ }
+
+ const ids = toStringArray(filter.ids);
+ if (ids.length) {
+ normalized.ids = ids;
+ }
+
+ const authors = toStringArray(filter.authors);
+ if (authors.length) {
+ normalized.authors = authors;
+ }
+
+ for (const [key, value] of Object.entries(filter)) {
+ if (!key.startsWith("#")) {
+ continue;
+ }
+ const tagValues = toStringArray(value);
+ if (tagValues.length) {
+ normalized[key] = tagValues;
+ }
+ }
+
+ if (filter.since !== undefined) {
+ const parsedSince = Number(filter.since);
+ if (Number.isFinite(parsedSince)) {
+ normalized.since = Math.floor(parsedSince);
+ }
+ }
+
+ if (filter.until !== undefined) {
+ const parsedUntil = Number(filter.until);
+ if (Number.isFinite(parsedUntil)) {
+ normalized.until = Math.floor(parsedUntil);
+ }
+ }
+
+ if (filter.limit !== undefined) {
+ const parsedLimit = Number(filter.limit);
+ if (Number.isFinite(parsedLimit) && parsedLimit >= 0) {
+ normalized.limit = Math.floor(parsedLimit);
+ }
+ }
+
+ return Object.keys(normalized).length ? normalized : null;
+ }
+
+ normalizeCountFilters(filters) {
+ if (!filters) {
+ return [];
+ }
+
+ const list = Array.isArray(filters) ? filters : [filters];
+ const normalized = [];
+
+ for (const candidate of list) {
+ const normalizedFilter = this.normalizeCountFilter(candidate);
+ if (normalizedFilter) {
+ normalized.push(normalizedFilter);
+ }
+ }
+
+ return normalized;
+ }
+
+ generateCountRequestId(prefix = "count") {
+ this.countRequestCounter += 1;
+ if (this.countRequestCounter > Number.MAX_SAFE_INTEGER - 1) {
+ this.countRequestCounter = 1;
+ }
+ const normalizedPrefix =
+ typeof prefix === "string" && prefix.trim() ? prefix.trim() : "count";
+ const timestamp = Date.now().toString(36);
+ const counter = this.countRequestCounter.toString(36);
+ const random = Math.random().toString(36).slice(2, 8);
+ return `${normalizedPrefix}:${timestamp}:${counter}${random}`;
+ }
+
+ extractCountValue(payload) {
+ if (typeof payload === "number") {
+ const value = Math.floor(payload);
+ return value >= 0 ? value : 0;
+ }
+
+ if (payload && typeof payload === "object") {
+ const candidate =
+ typeof payload.count === "number"
+ ? payload.count
+ : Number(payload.count);
+ if (Number.isFinite(candidate)) {
+ const value = Math.floor(candidate);
+ return value >= 0 ? value : 0;
+ }
+ }
+
+ const parsed = Number(payload);
+ if (Number.isFinite(parsed)) {
+ const value = Math.floor(parsed);
+ return value >= 0 ? value : 0;
+ }
+
+ return 0;
+ }
+
+ async sendRawCountFrame(relayUrl, filters, options = {}) {
+ if (!this.pool) {
+ throw new Error(
+ "Nostr pool not initialized. Call nostrClient.init() before requesting counts."
+ );
+ }
+
+ const normalizedUrl =
+ typeof relayUrl === "string" ? relayUrl.trim() : "";
+ if (!normalizedUrl) {
+ throw new Error("Invalid relay URL for COUNT request.");
+ }
+
+ const normalizedFilters = this.normalizeCountFilters(filters);
+ if (!normalizedFilters.length) {
+ throw new Error("At least one filter is required for a COUNT request.");
+ }
+
+ const requestId =
+ typeof options.subId === "string" && options.subId.trim()
+ ? options.subId.trim()
+ : this.generateCountRequestId();
+
+ let relay;
+ try {
+ relay = await this.pool.ensureRelay(normalizedUrl);
+ } catch (error) {
+ throw new Error(`Failed to connect to relay ${normalizedUrl}`);
+ }
+
+ if (!relay) {
+ throw new Error(
+ `Relay ${normalizedUrl} is unavailable for COUNT requests.`
+ );
+ }
+
+ const frame = ["COUNT", requestId, ...normalizedFilters];
+ let countPromise;
+
+ if (
+ relay.openCountRequests instanceof Map &&
+ typeof relay.send === "function"
+ ) {
+ countPromise = new Promise((resolve, reject) => {
+ const cleanup = () => {
+ if (relay.openCountRequests instanceof Map) {
+ relay.openCountRequests.delete(requestId);
+ }
+ };
+
+ relay.openCountRequests.set(requestId, {
+ resolve: (value) => {
+ cleanup();
+ resolve(value);
+ },
+ reject: (error) => {
+ cleanup();
+ reject(error);
+ },
+ });
+
+ let sendResult;
+ try {
+ sendResult = relay.send(JSON.stringify(frame));
+ } catch (error) {
+ cleanup();
+ reject(error);
+ return;
+ }
+
+ if (sendResult && typeof sendResult.catch === "function") {
+ sendResult.catch((error) => {
+ cleanup();
+ reject(error);
+ });
+ }
+ });
+ } else if (typeof relay.count === "function") {
+ countPromise = relay.count(normalizedFilters, { id: requestId });
+ } else {
+ throw new Error(
+ `[nostr] Relay ${normalizedUrl} does not support COUNT frames.`
+ );
+ }
+
+ const timeoutMs = this.getRequestTimeoutMs(options.timeoutMs);
+ const rawResult = await withRequestTimeout(
+ countPromise,
+ timeoutMs,
+ () => {
+ if (relay?.openCountRequests instanceof Map) {
+ relay.openCountRequests.delete(requestId);
+ }
+ },
+ `COUNT request timed out after ${timeoutMs}ms`
+ );
+
+ const countValue = this.extractCountValue(rawResult);
+ return ["COUNT", requestId, { count: countValue }];
+ }
+
+ async countEventsAcrossRelays(filters, options = {}) {
+ const normalizedFilters = this.normalizeCountFilters(filters);
+ if (!normalizedFilters.length) {
+ return { total: 0, perRelay: [] };
+ }
+
+ const relayList =
+ Array.isArray(options.relays) && options.relays.length
+ ? options.relays
+ : Array.isArray(this.relays) && this.relays.length
+ ? this.relays
+ : RELAY_URLS;
+
+ const perRelay = await Promise.all(
+ relayList.map(async (url) => {
+ try {
+ const frame = await this.sendRawCountFrame(url, normalizedFilters, {
+ timeoutMs: options.timeoutMs,
+ });
+ const count = this.extractCountValue(frame?.[2]);
+ return { url, ok: true, frame, count };
+ } catch (error) {
+ if (isDevMode) {
+ console.warn(`[nostr] COUNT request failed on ${url}:`, error);
+ }
+ return { url, ok: false, error };
+ }
+ })
+ );
+
+ const total = perRelay.reduce((sum, entry) => {
+ if (!entry || !entry.ok) {
+ return sum;
+ }
+ const value = Number(entry.count);
+ return Number.isFinite(value) && value > 0 ? sum + value : sum;
+ }, 0);
+
+ return { total, perRelay };
+ }
+
/**
* Ensure we have every historical revision for a given video in memory and
* return the complete set sorted newest-first. We primarily group revisions