diff --git a/js/app.js b/js/app.js index 76439de1..1ada49cf 100644 --- a/js/app.js +++ b/js/app.js @@ -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; } }); diff --git a/js/nostr/client.js b/js/nostr/client.js index 88125a54..039d6e17 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -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, }; } diff --git a/js/nostr/commentEvents.js b/js/nostr/commentEvents.js index 8d930ed3..76f96a4b 100644 --- a/js/nostr/commentEvents.js +++ b/js/nostr/commentEvents.js @@ -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) { diff --git a/js/nostr/dmSignalEvents.js b/js/nostr/dmSignalEvents.js index b138d497..1deca098 100644 --- a/js/nostr/dmSignalEvents.js +++ b/js/nostr/dmSignalEvents.js @@ -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) { diff --git a/js/nostr/nip46Client.js b/js/nostr/nip46Client.js index e0832b57..9f31f957 100644 --- a/js/nostr/nip46Client.js +++ b/js/nostr/nip46Client.js @@ -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; } diff --git a/js/nostr/reactionEvents.js b/js/nostr/reactionEvents.js index d059f2cc..3311f9b3 100644 --- a/js/nostr/reactionEvents.js +++ b/js/nostr/reactionEvents.js @@ -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) { diff --git a/js/nostr/viewEvents.js b/js/nostr/viewEvents.js index 284e5d92..fc93c988 100644 --- a/js/nostr/viewEvents.js +++ b/js/nostr/viewEvents.js @@ -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, }; } diff --git a/js/relayManager.js b/js/relayManager.js index aa809074..a7d20bfe 100644 --- a/js/relayManager.js +++ b/js/relayManager.js @@ -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 || ""; diff --git a/js/services/discussionCountService.js b/js/services/discussionCountService.js index a47e3020..a519a7e0 100644 --- a/js/services/discussionCountService.js +++ b/js/services/discussionCountService.js @@ -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 } = {}) { diff --git a/js/ui/components/VideoModal.js b/js/ui/components/VideoModal.js index 8bf412dd..722e535f 100644 --- a/js/ui/components/VideoModal.js +++ b/js/ui/components/VideoModal.js @@ -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…"; diff --git a/js/ui/views/VideoListView.js b/js/ui/views/VideoListView.js index b6965f95..099a0b3d 100644 --- a/js/ui/views/VideoListView.js +++ b/js/ui/views/VideoListView.js @@ -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; } diff --git a/js/viewCounter.js b/js/viewCounter.js index f9bf799b..7013faa3 100644 --- a/js/viewCounter.js +++ b/js/viewCounter.js @@ -50,6 +50,7 @@ restoreCacheSnapshot(); * @property {Map} 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);