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