mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Merge pull request #1924 from PR0M3TH3AN/codex/audit-relay-connection-logic-and-timeouts
Harden relay hygiene, add relay backoff, and surface partial COUNT status
This commit is contained in:
12 files changed
+286
-59
No files matched your search
@@ -1488,7 +1488,7 @@ class Application {
|
||||
this.videoModal.setViewCountPointer(pointerKey);
|
||||
}
|
||||
try {
|
||||
const token = subscribeToVideoViewCount(pointer, ({ total, status }) => {
|
||||
const token = subscribeToVideoViewCount(pointer, ({ total, status, partial }) => {
|
||||
const latestViewEl = this.videoModal?.getViewCountElement() || null;
|
||||
if (!latestViewEl) {
|
||||
return;
|
||||
@@ -1497,10 +1497,12 @@ class Application {
|
||||
if (Number.isFinite(total)) {
|
||||
const numeric = Number(total);
|
||||
if (this.videoModal) {
|
||||
const label = this.formatViewCountLabel(numeric);
|
||||
this.videoModal.updateViewCountLabel(
|
||||
this.formatViewCountLabel(numeric)
|
||||
partial ? `${label} (partial)` : label
|
||||
);
|
||||
}
|
||||
latestViewEl.dataset.viewCountState = partial ? "partial" : "ready";
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1508,10 +1510,12 @@ class Application {
|
||||
if (this.videoModal) {
|
||||
this.videoModal.updateViewCountLabel("Loading views…");
|
||||
}
|
||||
latestViewEl.dataset.viewCountState = "hydrating";
|
||||
} else {
|
||||
if (this.videoModal) {
|
||||
this.videoModal.updateViewCountLabel("– views");
|
||||
}
|
||||
latestViewEl.dataset.viewCountState = status;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+135
-26
@@ -1261,6 +1261,8 @@ export class NostrClient {
|
||||
this.sessionActorCipherClosuresPrivateKey = null;
|
||||
this.countUnsupportedRelays = new Set();
|
||||
this.unreachableRelays = new Set();
|
||||
this.relayBackoff = new Map();
|
||||
this.relayFailureCounts = new Map();
|
||||
this.isInitialized = false;
|
||||
this.nip46Client = null;
|
||||
this.pendingHandshakeCancel = null;
|
||||
@@ -4303,8 +4305,9 @@ export class NostrClient {
|
||||
// `unsub` to avoid leaking subscriptions. Note: any future change must still
|
||||
// provide a lightweight readiness check with similar timeout semantics.
|
||||
async connectToRelays() {
|
||||
const relayTargets = this.getHealthyRelays(this.relays);
|
||||
const results = await Promise.all(
|
||||
this.relays.map(
|
||||
relayTargets.map(
|
||||
(url) =>
|
||||
new Promise((resolve) => {
|
||||
const sub = this.pool.sub([url], [{ kinds: [0], limit: 1 }]);
|
||||
@@ -4326,9 +4329,11 @@ export class NostrClient {
|
||||
|
||||
for (const result of results) {
|
||||
if (result.success) {
|
||||
this.unreachableRelays.delete(result.url);
|
||||
this.clearRelayBackoff(result.url);
|
||||
} else {
|
||||
this.unreachableRelays.add(result.url);
|
||||
this.markRelayUnreachable(result.url, 60000, {
|
||||
reason: "connect-timeout",
|
||||
});
|
||||
if (isDevMode) {
|
||||
devLogger.warn(`[nostr] Marked relay as unreachable: ${result.url}`);
|
||||
}
|
||||
@@ -4338,7 +4343,21 @@ export class NostrClient {
|
||||
return results;
|
||||
}
|
||||
|
||||
markRelayUnreachable(url, ttlMs = 60000) {
|
||||
resolveRelayBackoffMs(failureCount, ttlOverride) {
|
||||
const baseMs = 15000;
|
||||
const maxMs = 5 * 60 * 1000;
|
||||
const computed = Math.min(
|
||||
maxMs,
|
||||
baseMs * Math.pow(2, Math.max(0, failureCount - 1))
|
||||
);
|
||||
const override = Number(ttlOverride);
|
||||
if (Number.isFinite(override) && override > 0) {
|
||||
return Math.max(computed, Math.floor(override));
|
||||
}
|
||||
return computed;
|
||||
}
|
||||
|
||||
clearRelayBackoff(url) {
|
||||
if (!url || typeof url !== "string") {
|
||||
return;
|
||||
}
|
||||
@@ -4346,14 +4365,34 @@ export class NostrClient {
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
this.relayBackoff.delete(normalized);
|
||||
this.relayFailureCounts.delete(normalized);
|
||||
this.unreachableRelays.delete(normalized);
|
||||
}
|
||||
|
||||
markRelayUnreachable(url, ttlMs = 60000, { reason = null } = {}) {
|
||||
if (!url || typeof url !== "string") {
|
||||
return;
|
||||
}
|
||||
const normalized = url.trim();
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
const nextFailureCount = (this.relayFailureCounts.get(normalized) || 0) + 1;
|
||||
this.relayFailureCounts.set(normalized, nextFailureCount);
|
||||
const backoffMs = this.resolveRelayBackoffMs(nextFailureCount, ttlMs);
|
||||
const retryAt = Date.now() + backoffMs;
|
||||
this.relayBackoff.set(normalized, {
|
||||
retryAt,
|
||||
backoffMs,
|
||||
failureCount: nextFailureCount,
|
||||
reason,
|
||||
});
|
||||
this.unreachableRelays.add(normalized);
|
||||
if (ttlMs > 0) {
|
||||
setTimeout(() => {
|
||||
this.unreachableRelays.delete(normalized);
|
||||
if (isDevMode) {
|
||||
devLogger.debug(`[nostr] Cleared temporary failure mark for ${normalized}`);
|
||||
}
|
||||
}, ttlMs);
|
||||
if (isDevMode) {
|
||||
devLogger.debug(
|
||||
`[nostr] Relay backoff set for ${normalized} (${backoffMs}ms).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4362,7 +4401,26 @@ export class NostrClient {
|
||||
if (!this.unreachableRelays.size) {
|
||||
return source;
|
||||
}
|
||||
return source.filter((url) => !this.unreachableRelays.has(url));
|
||||
const now = Date.now();
|
||||
return source.filter((url) => {
|
||||
if (!this.unreachableRelays.has(url)) {
|
||||
return true;
|
||||
}
|
||||
const entry = this.relayBackoff.get(url);
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
if (Number.isFinite(entry.retryAt) && entry.retryAt > now) {
|
||||
return false;
|
||||
}
|
||||
this.relayBackoff.delete(url);
|
||||
this.relayFailureCounts.delete(url);
|
||||
this.unreachableRelays.delete(url);
|
||||
if (isDevMode) {
|
||||
devLogger.debug(`[nostr] Relay backoff expired for ${url}.`);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -7764,13 +7822,25 @@ export class NostrClient {
|
||||
try {
|
||||
relay = await this.pool.ensureRelay(normalizedUrl);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to connect to relay ${normalizedUrl}`);
|
||||
const connectError = new Error(
|
||||
`Failed to connect to relay ${normalizedUrl}`
|
||||
);
|
||||
connectError.code = "relay-connect-failed";
|
||||
this.markRelayUnreachable(normalizedUrl, 60000, {
|
||||
reason: "connect-failed",
|
||||
});
|
||||
throw connectError;
|
||||
}
|
||||
|
||||
if (!relay) {
|
||||
throw new Error(
|
||||
const relayError = new Error(
|
||||
`Relay ${normalizedUrl} is unavailable for COUNT requests.`
|
||||
);
|
||||
relayError.code = "relay-unavailable";
|
||||
this.markRelayUnreachable(normalizedUrl, 60000, {
|
||||
reason: "relay-unavailable",
|
||||
});
|
||||
throw relayError;
|
||||
}
|
||||
|
||||
const frame = ["COUNT", requestId, ...normalizedFilters];
|
||||
@@ -7822,16 +7892,27 @@ export class NostrClient {
|
||||
}
|
||||
|
||||
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`
|
||||
);
|
||||
let rawResult;
|
||||
try {
|
||||
rawResult = await withRequestTimeout(
|
||||
countPromise,
|
||||
timeoutMs,
|
||||
() => {
|
||||
if (relay?.openCountRequests instanceof Map) {
|
||||
relay.openCountRequests.delete(requestId);
|
||||
}
|
||||
},
|
||||
`COUNT request timed out after ${timeoutMs}ms`
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message.includes("COUNT request timed out")
|
||||
) {
|
||||
error.code = "count-timeout";
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const countValue = this.extractCountValue(rawResult);
|
||||
return ["COUNT", requestId, { count: countValue }];
|
||||
@@ -7840,7 +7921,7 @@ export class NostrClient {
|
||||
async countEventsAcrossRelays(filters, options = {}) {
|
||||
const normalizedFilters = this.normalizeCountFilters(filters);
|
||||
if (!normalizedFilters.length) {
|
||||
return { total: 0, best: null, perRelay: [] };
|
||||
return { total: 0, best: null, perRelay: [], partial: false };
|
||||
}
|
||||
|
||||
const relayList =
|
||||
@@ -7854,6 +7935,8 @@ export class NostrClient {
|
||||
.map((url) => (typeof url === "string" ? url.trim() : ""))
|
||||
.filter(Boolean);
|
||||
|
||||
const eligibleRelays = this.getHealthyRelays(normalizedRelayList);
|
||||
const eligibleRelaySet = new Set(eligibleRelays);
|
||||
const activeRelays = [];
|
||||
const precomputedEntries = [];
|
||||
|
||||
@@ -7868,6 +7951,15 @@ export class NostrClient {
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!eligibleRelaySet.has(url)) {
|
||||
precomputedEntries.push({
|
||||
url,
|
||||
ok: false,
|
||||
skipped: true,
|
||||
reason: "backoff",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
activeRelays.push(url);
|
||||
}
|
||||
|
||||
@@ -7878,15 +7970,28 @@ export class NostrClient {
|
||||
timeoutMs: options.timeoutMs,
|
||||
});
|
||||
const count = this.extractCountValue(frame?.[2]);
|
||||
this.clearRelayBackoff(url);
|
||||
return { url, ok: true, frame, count };
|
||||
} catch (error) {
|
||||
const isUnsupported = error?.code === "count-unsupported";
|
||||
const isTimeout =
|
||||
error?.code === "count-timeout" || error?.code === "timeout";
|
||||
if (isUnsupported) {
|
||||
this.countUnsupportedRelays.add(url);
|
||||
} else {
|
||||
logRelayCountFailure(url, error);
|
||||
this.markRelayUnreachable(url, 60000, {
|
||||
reason: isTimeout ? "count-timeout" : "count-error",
|
||||
});
|
||||
}
|
||||
return { url, ok: false, error, unsupported: isUnsupported };
|
||||
return {
|
||||
url,
|
||||
ok: false,
|
||||
error,
|
||||
unsupported: isUnsupported,
|
||||
timedOut: isTimeout,
|
||||
errorCode: error?.code || null,
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -7940,11 +8045,15 @@ export class NostrClient {
|
||||
});
|
||||
|
||||
const total = bestEstimate ? bestEstimate.count : 0;
|
||||
const partial = perRelayResults.some(
|
||||
(entry) => entry?.timedOut || entry?.skipped
|
||||
);
|
||||
|
||||
return {
|
||||
total,
|
||||
best: bestEstimate,
|
||||
perRelay,
|
||||
partial,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { CACHE_POLICIES } from "./cachePolicies.js";
|
||||
import { isSessionActor } from "./sessionActor.js";
|
||||
import { queueSignEvent } from "./signRequestQueue.js";
|
||||
import { getActiveSigner } from "../nostrClientRegistry.js";
|
||||
import { sanitizeRelayList as sanitizeRelayUrls } from "./nip46Client.js";
|
||||
|
||||
const COMMENT_EVENT_SCHEMA = getNostrEventSchema(NOTE_TYPES.VIDEO_COMMENT);
|
||||
const CACHE_POLICY = CACHE_POLICIES[NOTE_TYPES.VIDEO_COMMENT];
|
||||
@@ -32,13 +33,15 @@ function getAllowedCommentKinds() {
|
||||
return ALLOWED_COMMENT_KINDS.slice();
|
||||
}
|
||||
function sanitizeRelayList(primary, fallback) {
|
||||
if (Array.isArray(primary) && primary.length) {
|
||||
return primary;
|
||||
const primaryList = sanitizeRelayUrls(Array.isArray(primary) ? primary : []);
|
||||
if (primaryList.length) {
|
||||
return primaryList;
|
||||
}
|
||||
if (Array.isArray(fallback) && fallback.length) {
|
||||
return fallback;
|
||||
const fallbackList = sanitizeRelayUrls(Array.isArray(fallback) ? fallback : []);
|
||||
if (fallbackList.length) {
|
||||
return fallbackList;
|
||||
}
|
||||
return RELAY_URLS;
|
||||
return sanitizeRelayUrls(RELAY_URLS);
|
||||
}
|
||||
|
||||
function normalizeRelay(candidate) {
|
||||
|
||||
@@ -9,17 +9,20 @@ import { devLogger, userLogger } from "../utils/logger.js";
|
||||
import { queueSignEvent } from "./signRequestQueue.js";
|
||||
import { getActiveSigner } from "../nostrClientRegistry.js";
|
||||
import { isSessionActor } from "./sessionActor.js";
|
||||
import { sanitizeRelayList as sanitizeRelayUrls } from "./nip46Client.js";
|
||||
|
||||
const DEFAULT_TYPING_EXPIRY_SECONDS = 15;
|
||||
|
||||
function sanitizeRelayList(primary, fallback) {
|
||||
if (Array.isArray(primary) && primary.length) {
|
||||
return primary;
|
||||
const primaryList = sanitizeRelayUrls(Array.isArray(primary) ? primary : []);
|
||||
if (primaryList.length) {
|
||||
return primaryList;
|
||||
}
|
||||
if (Array.isArray(fallback) && fallback.length) {
|
||||
return fallback;
|
||||
const fallbackList = sanitizeRelayUrls(Array.isArray(fallback) ? fallback : []);
|
||||
if (fallbackList.length) {
|
||||
return fallbackList;
|
||||
}
|
||||
return RELAY_URLS;
|
||||
return sanitizeRelayUrls(RELAY_URLS);
|
||||
}
|
||||
|
||||
function normalizeString(value) {
|
||||
|
||||
+37
-1
@@ -43,6 +43,28 @@ export const NIP46_ENCRYPTION_ALGORITHMS = Object.freeze([
|
||||
"nip04",
|
||||
]);
|
||||
|
||||
function isSecureRelayContext() {
|
||||
if (typeof window === "undefined") {
|
||||
return false;
|
||||
}
|
||||
const protocol = window?.location?.protocol;
|
||||
return protocol === "https:";
|
||||
}
|
||||
|
||||
function resolveRelayProtocol(protocol, { enforceTls = false } = {}) {
|
||||
if (!protocol) {
|
||||
return null;
|
||||
}
|
||||
const normalized = protocol.toLowerCase();
|
||||
if (normalized === "wss:") {
|
||||
return "wss:";
|
||||
}
|
||||
if (normalized === "ws:") {
|
||||
return enforceTls ? "wss:" : "ws:";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* resolves the appropriate storage mechanism (localStorage or polyfill) for NIP-46 sessions.
|
||||
* @returns {Storage|null} The storage interface or null if unavailable.
|
||||
@@ -72,6 +94,7 @@ export function sanitizeRelayList(list) {
|
||||
if (!Array.isArray(list)) {
|
||||
return sanitized;
|
||||
}
|
||||
const enforceTls = isSecureRelayContext();
|
||||
|
||||
list.forEach((value) => {
|
||||
if (typeof value !== "string") {
|
||||
@@ -90,16 +113,29 @@ export function sanitizeRelayList(list) {
|
||||
|
||||
let normalized = trimmed.replace(/\/+$/, "");
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
const parsed = new URL(normalized);
|
||||
if (!parsed.hostname) {
|
||||
return;
|
||||
}
|
||||
const resolvedProtocol = resolveRelayProtocol(parsed.protocol, {
|
||||
enforceTls,
|
||||
});
|
||||
if (!resolvedProtocol) {
|
||||
return;
|
||||
}
|
||||
if (parsed.protocol !== resolvedProtocol) {
|
||||
parsed.protocol = resolvedProtocol;
|
||||
}
|
||||
const pathname = parsed.pathname.replace(/\/+$/, "");
|
||||
normalized = `${parsed.protocol}//${parsed.host}${pathname}${parsed.search || ""}`;
|
||||
} catch (error) {
|
||||
// Ignore URL parsing failures and fall back to the trimmed string.
|
||||
}
|
||||
|
||||
if (enforceTls && normalized.startsWith("ws://")) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (seen.has(normalized)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -7,18 +7,21 @@ import { LRUCache } from "../utils/lruCache.js";
|
||||
import { CACHE_POLICIES } from "./cachePolicies.js";
|
||||
import { NOTE_TYPES } from "../nostrEventSchemas.js";
|
||||
import { queueSignEvent } from "./signRequestQueue.js";
|
||||
import { sanitizeRelayList as sanitizeRelayUrls } from "./nip46Client.js";
|
||||
|
||||
const CACHE_POLICY = CACHE_POLICIES[NOTE_TYPES.VIDEO_REACTION];
|
||||
const reactionCache = new LRUCache({ maxSize: 100 });
|
||||
|
||||
function sanitizeRelayList(primary, fallback) {
|
||||
if (Array.isArray(primary) && primary.length) {
|
||||
return primary;
|
||||
const primaryList = sanitizeRelayUrls(Array.isArray(primary) ? primary : []);
|
||||
if (primaryList.length) {
|
||||
return primaryList;
|
||||
}
|
||||
if (Array.isArray(fallback) && fallback.length) {
|
||||
return fallback;
|
||||
const fallbackList = sanitizeRelayUrls(Array.isArray(fallback) ? fallback : []);
|
||||
if (fallbackList.length) {
|
||||
return fallbackList;
|
||||
}
|
||||
return RELAY_URLS;
|
||||
return sanitizeRelayUrls(RELAY_URLS);
|
||||
}
|
||||
|
||||
function normalizeString(value) {
|
||||
|
||||
+18
-6
@@ -14,6 +14,7 @@ import { normalizePointerInput } from "./watchHistory.js";
|
||||
import { devLogger, userLogger } from "../utils/logger.js";
|
||||
import { logViewCountFailure } from "./countDiagnostics.js";
|
||||
import { queueSignEvent } from "./signRequestQueue.js";
|
||||
import { sanitizeRelayList as sanitizeRelayUrls } from "./nip46Client.js";
|
||||
|
||||
const VIEW_EVENT_GUARD_PREFIX = "bitvid:viewed";
|
||||
|
||||
@@ -340,13 +341,15 @@ function flattenListResults(input) {
|
||||
}
|
||||
|
||||
function sanitizeRelayList(primary, fallback) {
|
||||
if (Array.isArray(primary) && primary.length) {
|
||||
return primary;
|
||||
const primaryList = sanitizeRelayUrls(Array.isArray(primary) ? primary : []);
|
||||
if (primaryList.length) {
|
||||
return primaryList;
|
||||
}
|
||||
if (Array.isArray(fallback) && fallback.length) {
|
||||
return fallback;
|
||||
const fallbackList = sanitizeRelayUrls(Array.isArray(fallback) ? fallback : []);
|
||||
if (fallbackList.length) {
|
||||
return fallbackList;
|
||||
}
|
||||
return RELAY_URLS;
|
||||
return sanitizeRelayUrls(RELAY_URLS);
|
||||
}
|
||||
|
||||
export async function listVideoViewEvents(client, pointer, options = {}) {
|
||||
@@ -563,6 +566,7 @@ export async function countVideoViewEvents(client, pointer, options = {}) {
|
||||
perRelay: [],
|
||||
best: null,
|
||||
fallback: true,
|
||||
partial: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -588,19 +592,26 @@ export async function countVideoViewEvents(client, pointer, options = {}) {
|
||||
throw normalizeAbortError();
|
||||
}
|
||||
|
||||
let countAttemptPartial = false;
|
||||
|
||||
try {
|
||||
const result = await client.countEventsAcrossRelays([pointerFilter], {
|
||||
relays: sanitizeRelayList(relayList, client.relays),
|
||||
timeoutMs: options?.timeoutMs,
|
||||
});
|
||||
|
||||
countAttemptPartial = Boolean(result?.partial);
|
||||
|
||||
if (result?.perRelay?.some((entry) => entry && entry.ok)) {
|
||||
return { ...result, fallback: false };
|
||||
return { ...result, fallback: false, partial: countAttemptPartial };
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.code !== "count-unsupported") {
|
||||
logViewCountFailure(error);
|
||||
}
|
||||
if (error?.code === "count-timeout" || error?.code === "timeout") {
|
||||
countAttemptPartial = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (signal?.aborted) {
|
||||
@@ -647,6 +658,7 @@ export async function countVideoViewEvents(client, pointer, options = {}) {
|
||||
perRelay: [],
|
||||
best: null,
|
||||
fallback: true,
|
||||
partial: countAttemptPartial,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,10 @@ function normalizeRelayUrl(input) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const enforceTls =
|
||||
typeof window !== "undefined" &&
|
||||
window?.location?.protocol === "https:";
|
||||
|
||||
let candidate = input.trim();
|
||||
if (!candidate) {
|
||||
return null;
|
||||
@@ -59,6 +63,18 @@ function normalizeRelayUrl(input) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (enforceTls && parsed.protocol === "ws:") {
|
||||
try {
|
||||
parsed = new URL(candidate.replace(/^ws:/i, "wss:"));
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (enforceTls && parsed.protocol !== "wss:") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hostname = parsed.hostname.toLowerCase();
|
||||
const port = parsed.port ? `:${parsed.port}` : "";
|
||||
let pathname = parsed.pathname || "";
|
||||
|
||||
@@ -4,6 +4,8 @@ import { COMMENT_EVENT_KIND } from "../nostr/commentEvents.js";
|
||||
|
||||
const DEFAULT_MAX_DISCUSSION_COUNT_VIDEOS = 24;
|
||||
const COUNT_UNSUPPORTED_TITLE = "Relay does not support NIP-45 COUNT queries.";
|
||||
const COUNT_PARTIAL_TITLE =
|
||||
"Some relays timed out. Comment counts may be partial.";
|
||||
|
||||
function toPositiveInteger(value, fallback) {
|
||||
const numeric = Number(value);
|
||||
@@ -99,10 +101,11 @@ export default class DiscussionCountService {
|
||||
}
|
||||
|
||||
const total = Number(result?.total);
|
||||
const isPartial = Boolean(result?.partial);
|
||||
const normalized =
|
||||
Number.isFinite(total) && total >= 0 ? Math.floor(total) : 0;
|
||||
this.videoDiscussionCountCache.set(video.id, normalized);
|
||||
this.updateDiscussionCountElement(container, normalized);
|
||||
this.updateDiscussionCountElement(container, normalized, { partial: isPartial });
|
||||
return result;
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -204,7 +207,7 @@ export default class DiscussionCountService {
|
||||
element.removeAttribute("title");
|
||||
}
|
||||
|
||||
updateDiscussionCountElement(element, count) {
|
||||
updateDiscussionCountElement(element, count, { partial = false } = {}) {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
@@ -217,9 +220,13 @@ export default class DiscussionCountService {
|
||||
const numeric = Number(count);
|
||||
const safeValue =
|
||||
Number.isFinite(numeric) && numeric >= 0 ? Math.floor(numeric) : 0;
|
||||
element.dataset.countState = "ready";
|
||||
element.dataset.countState = partial ? "partial" : "ready";
|
||||
valueEl.textContent = safeValue.toLocaleString();
|
||||
element.removeAttribute("title");
|
||||
if (partial) {
|
||||
element.title = COUNT_PARTIAL_TITLE;
|
||||
} else {
|
||||
element.removeAttribute("title");
|
||||
}
|
||||
}
|
||||
|
||||
markDiscussionCountError(element, { unsupported = false } = {}) {
|
||||
|
||||
@@ -6201,11 +6201,16 @@ export class VideoModal {
|
||||
try {
|
||||
const token = subscribeToVideoViewCount(
|
||||
pointerInfo.pointer,
|
||||
({ total, status }) => {
|
||||
({ total, status, partial }) => {
|
||||
if (!viewEl || !viewEl.isConnected) {
|
||||
return;
|
||||
}
|
||||
viewEl.textContent = this.getViewCountLabel(total, status);
|
||||
viewEl.textContent = this.getViewCountLabel(total, status, partial);
|
||||
if (partial) {
|
||||
viewEl.dataset.viewCountState = "partial";
|
||||
} else {
|
||||
viewEl.dataset.viewCountState = status;
|
||||
}
|
||||
}
|
||||
);
|
||||
return { pointer: pointerInfo.pointer, token };
|
||||
@@ -6216,9 +6221,10 @@ export class VideoModal {
|
||||
}
|
||||
}
|
||||
|
||||
getViewCountLabel(total, status) {
|
||||
getViewCountLabel(total, status, partial) {
|
||||
if (Number.isFinite(total)) {
|
||||
return this.formatViewCountLabel(Number(total));
|
||||
const label = this.formatViewCountLabel(Number(total));
|
||||
return partial ? `${label} (partial)` : label;
|
||||
}
|
||||
if (status === "hydrating") {
|
||||
return "Loading…";
|
||||
|
||||
@@ -1134,16 +1134,20 @@ export class VideoListView {
|
||||
elements: new Set(),
|
||||
lastTotal: null,
|
||||
lastStatus: "idle",
|
||||
lastPartial: false,
|
||||
lastText: "– views",
|
||||
};
|
||||
|
||||
try {
|
||||
const token = subscribeToVideoViewCount(pointerInfo.pointer, ({ total, status }) => {
|
||||
const token = subscribeToVideoViewCount(pointerInfo.pointer, ({ total, status, partial }) => {
|
||||
let text;
|
||||
if (Number.isFinite(total)) {
|
||||
const numeric = Number(total);
|
||||
entry.lastTotal = numeric;
|
||||
text = this.formatters.formatViewCountLabel(numeric);
|
||||
if (partial) {
|
||||
text = `${text} (partial)`;
|
||||
}
|
||||
} else if (status === "hydrating") {
|
||||
text = "Loading views…";
|
||||
} else {
|
||||
@@ -1151,6 +1155,7 @@ export class VideoListView {
|
||||
}
|
||||
|
||||
entry.lastStatus = status;
|
||||
entry.lastPartial = Boolean(partial);
|
||||
entry.lastText = text;
|
||||
|
||||
for (const el of Array.from(entry.elements)) {
|
||||
@@ -1159,6 +1164,11 @@ export class VideoListView {
|
||||
continue;
|
||||
}
|
||||
el.textContent = text;
|
||||
if (entry.lastPartial) {
|
||||
el.dataset.viewCountState = "partial";
|
||||
} else {
|
||||
el.dataset.viewCountState = status;
|
||||
}
|
||||
}
|
||||
});
|
||||
entry.token = token;
|
||||
@@ -1193,6 +1203,9 @@ export class VideoListView {
|
||||
if (typeof pointerInfo.key === "string") {
|
||||
viewCountEl.dataset.viewPointer = pointerInfo.key;
|
||||
}
|
||||
viewCountEl.dataset.viewCountState = entry.lastPartial
|
||||
? "partial"
|
||||
: entry.lastStatus;
|
||||
viewCountEl.textContent = entry.lastText;
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ restoreCacheSnapshot();
|
||||
* @property {Map<string, number>} dedupeBuckets
|
||||
* @property {number} lastSyncedAt
|
||||
* @property {"idle"|"hydrating"|"live"|"stale"} status
|
||||
* @property {boolean} partial
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -278,6 +279,7 @@ function restoreCacheSnapshot() {
|
||||
dedupeBuckets,
|
||||
lastSyncedAt,
|
||||
status: status === "hydrating" || status === "live" ? "idle" : status,
|
||||
partial: Boolean(state.partial),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -299,6 +301,7 @@ function persistCacheSnapshot() {
|
||||
dedupeBuckets: Array.from(state.dedupeBuckets.entries()),
|
||||
lastSyncedAt: state.lastSyncedAt,
|
||||
status: state.status,
|
||||
partial: Boolean(state.partial),
|
||||
},
|
||||
]);
|
||||
}
|
||||
@@ -321,6 +324,7 @@ function ensurePointerState(key) {
|
||||
dedupeBuckets: new Map(),
|
||||
lastSyncedAt: 0,
|
||||
status: "idle",
|
||||
partial: false,
|
||||
};
|
||||
pointerStates.set(key, state);
|
||||
} else if (!(state.dedupeBuckets instanceof Map)) {
|
||||
@@ -373,6 +377,7 @@ function notifyHandlers(key) {
|
||||
total: state.total,
|
||||
lastSyncedAt: state.lastSyncedAt,
|
||||
status: state.status,
|
||||
partial: Boolean(state.partial),
|
||||
};
|
||||
for (const handler of listeners.handlers.values()) {
|
||||
try {
|
||||
@@ -494,6 +499,15 @@ async function hydratePointer(key, listeners) {
|
||||
? Number(countResult.total)
|
||||
: null;
|
||||
|
||||
if (countResult && typeof countResult === "object") {
|
||||
const state = ensurePointerState(key);
|
||||
const nextPartial = Boolean(countResult.partial);
|
||||
if (state.partial !== nextPartial) {
|
||||
state.partial = nextPartial;
|
||||
mutated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestCount !== null) {
|
||||
const state = ensurePointerState(key);
|
||||
const shouldUpdate = bestCount > state.total || state.total === 0;
|
||||
@@ -597,6 +611,7 @@ export function subscribeToVideoViewCount(pointerInput, handler, options = {}) {
|
||||
total: state.total,
|
||||
lastSyncedAt: state.lastSyncedAt,
|
||||
status: state.status,
|
||||
partial: Boolean(state.partial),
|
||||
});
|
||||
} catch (error) {
|
||||
userLogger.warn("[viewCounter] Initial handler invocation threw:", error);
|
||||
|
||||
Reference in new issue
Block a user