mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-06 12:52:26 +00:00
Add Nostr COUNT helper and surface discussion counts
This commit is contained in:
1 parent
6b2c052899
commit
9682875ed9
3 files changed
+568
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
|
||||
? `
|
||||
<div class="flex items-center text-xs text-gray-500 mt-3" data-discussion-count="${video.id}" data-count-state="idle">
|
||||
<span data-discussion-count-value>—</span>
|
||||
<span class="ml-1">notes</span>
|
||||
</div>
|
||||
`
|
||||
: "";
|
||||
|
||||
const rawThumbnail =
|
||||
typeof video.thumbnail === "string" ? video.thumbnail.trim() : "";
|
||||
const escapedThumbnail = rawThumbnail
|
||||
@@ -7833,6 +7847,7 @@ class bitvidApp {
|
||||
${cardControls}
|
||||
</div>
|
||||
${connectionBadgesHtml}
|
||||
${discussionCountHtml}
|
||||
${torrentWarningHtml}
|
||||
</div>
|
||||
</div>
|
||||
@@ -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) {
|
||||
|
||||
+357
@@ -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
|
||||
|
||||
Reference in new issue
Block a user