From 5d31366c79d2b29a7c12d3a7dca873b316ee9312 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 3 Nov 2025 20:19:45 -0500 Subject: [PATCH] Restore comment bootstrap and guard admin cache hydration --- js/accessControl.js | 10 +- js/adminListStore.js | 8 +- js/constants.js | 31 ++++ js/services/commentThreadService.js | 242 ++++++++++++++++++++++++++- js/ui/videoModalCommentController.js | 1 - 5 files changed, 284 insertions(+), 8 deletions(-) diff --git a/js/accessControl.js b/js/accessControl.js index 54360164..0ac932dc 100644 --- a/js/accessControl.js +++ b/js/accessControl.js @@ -147,7 +147,15 @@ class AccessControl { _hydrateFromCache() { this._hydratedFromCache = false; - const cachedState = readCachedAdminState(); + let cachedState = null; + try { + cachedState = readCachedAdminState(); + } catch (error) { + if (error && error.name === "ReferenceError") { + return; + } + throw error; + } if (cachedState) { this._applyState(cachedState, { markLoaded: false }); this._hydratedFromCache = true; diff --git a/js/adminListStore.js b/js/adminListStore.js index edec5d88..ef9c83fa 100644 --- a/js/adminListStore.js +++ b/js/adminListStore.js @@ -276,7 +276,7 @@ function loadLegacyAdminState() { return hasAnyEntries(sanitized) ? sanitized : null; } -export function readCachedAdminState() { +export const readCachedAdminState = () => { if (typeof localStorage === "undefined") { return null; } @@ -306,9 +306,9 @@ export function readCachedAdminState() { ); return null; } -} +}; -export function writeCachedAdminState(state) { +export const writeCachedAdminState = (state) => { if (typeof localStorage === "undefined") { return; } @@ -322,7 +322,7 @@ export function writeCachedAdminState(state) { error, ); } -} +}; function clearLegacyStorageFor(listKey) { if (typeof localStorage === "undefined") { diff --git a/js/constants.js b/js/constants.js index a3765e49..0ef92f3c 100644 --- a/js/constants.js +++ b/js/constants.js @@ -172,6 +172,8 @@ const runtimeFlags = (() => { FEATURE_HASHTAG_PREFERENCES: DEFAULT_FLAGS.FEATURE_HASHTAG_PREFERENCES, FEATURE_TRUST_SEEDS: DEFAULT_FLAGS.FEATURE_TRUST_SEEDS, FEATURE_TRUSTED_HIDE_CONTROLS: DEFAULT_FLAGS.FEATURE_TRUSTED_HIDE_CONTROLS, + FEATURE_IMPROVED_COMMENT_FETCHING: + DEFAULT_FLAGS.FEATURE_IMPROVED_COMMENT_FETCHING, TRUSTED_MUTE_HIDE_THRESHOLD: DEFAULT_FLAGS.TRUSTED_MUTE_HIDE_THRESHOLD, TRUSTED_SPAM_HIDE_THRESHOLD: DEFAULT_FLAGS.TRUSTED_SPAM_HIDE_THRESHOLD, WSS_TRACKERS: [...DEFAULT_FLAGS.WSS_TRACKERS], @@ -222,6 +224,11 @@ export let FEATURE_TRUSTED_HIDE_CONTROLS = coerceBoolean( DEFAULT_FLAGS.FEATURE_TRUSTED_HIDE_CONTROLS ); +export let FEATURE_IMPROVED_COMMENT_FETCHING = coerceBoolean( + runtimeFlags.FEATURE_IMPROVED_COMMENT_FETCHING, + DEFAULT_FLAGS.FEATURE_IMPROVED_COMMENT_FETCHING +); + export let WSS_TRACKERS = freezeTrackers( sanitizeTrackerList(runtimeFlags.WSS_TRACKERS, DEFAULT_FLAGS.WSS_TRACKERS) ); @@ -342,6 +349,20 @@ Object.defineProperty(runtimeFlags, "FEATURE_TRUSTED_HIDE_CONTROLS", { }, }); +Object.defineProperty(runtimeFlags, "FEATURE_IMPROVED_COMMENT_FETCHING", { + configurable: true, + enumerable: true, + get() { + return FEATURE_IMPROVED_COMMENT_FETCHING; + }, + set(next) { + FEATURE_IMPROVED_COMMENT_FETCHING = coerceBoolean( + next, + DEFAULT_FLAGS.FEATURE_IMPROVED_COMMENT_FETCHING + ); + }, +}); + Object.defineProperty(runtimeFlags, "WSS_TRACKERS", { configurable: true, enumerable: true, @@ -392,6 +413,8 @@ runtimeFlags.FEATURE_PUBLISH_NIP71 = FEATURE_PUBLISH_NIP71; runtimeFlags.FEATURE_HASHTAG_PREFERENCES = FEATURE_HASHTAG_PREFERENCES; runtimeFlags.FEATURE_TRUST_SEEDS = FEATURE_TRUST_SEEDS; runtimeFlags.FEATURE_TRUSTED_HIDE_CONTROLS = FEATURE_TRUSTED_HIDE_CONTROLS; +runtimeFlags.FEATURE_IMPROVED_COMMENT_FETCHING = + FEATURE_IMPROVED_COMMENT_FETCHING; runtimeFlags.WSS_TRACKERS = WSS_TRACKERS; runtimeFlags.TRUSTED_MUTE_HIDE_THRESHOLD = TRUSTED_MUTE_HIDE_THRESHOLD; runtimeFlags.TRUSTED_SPAM_HIDE_THRESHOLD = TRUSTED_SPAM_HIDE_THRESHOLD; @@ -430,6 +453,11 @@ export function setTrustedHideControlsEnabled(next) { return FEATURE_TRUSTED_HIDE_CONTROLS; } +export function setImprovedCommentFetchingEnabled(next) { + runtimeFlags.FEATURE_IMPROVED_COMMENT_FETCHING = next; + return FEATURE_IMPROVED_COMMENT_FETCHING; +} + export function setHashtagPreferencesEnabled(next) { runtimeFlags.FEATURE_HASHTAG_PREFERENCES = next; return FEATURE_HASHTAG_PREFERENCES; @@ -445,6 +473,9 @@ export function resetRuntimeFlags() { setHashtagPreferencesEnabled(DEFAULT_FLAGS.FEATURE_HASHTAG_PREFERENCES); setTrustSeedsEnabled(DEFAULT_FLAGS.FEATURE_TRUST_SEEDS); setTrustedHideControlsEnabled(DEFAULT_FLAGS.FEATURE_TRUSTED_HIDE_CONTROLS); + setImprovedCommentFetchingEnabled( + DEFAULT_FLAGS.FEATURE_IMPROVED_COMMENT_FETCHING + ); setTrustedMuteHideThreshold(DEFAULT_FLAGS.TRUSTED_MUTE_HIDE_THRESHOLD); setTrustedSpamHideThreshold(DEFAULT_FLAGS.TRUSTED_SPAM_HIDE_THRESHOLD); setWssTrackers(DEFAULT_FLAGS.WSS_TRACKERS); diff --git a/js/services/commentThreadService.js b/js/services/commentThreadService.js index eee83334..1a97d759 100644 --- a/js/services/commentThreadService.js +++ b/js/services/commentThreadService.js @@ -3,10 +3,13 @@ import { devLogger } from "../utils/logger.js"; import { buildVideoAddressPointer } from "../utils/videoPointer.js"; import { COMMENT_EVENT_KIND } from "../nostr/commentEvents.js"; +import { FEATURE_IMPROVED_COMMENT_FETCHING } from "../constants.js"; const ROOT_PARENT_KEY = "__root__"; const DEFAULT_INITIAL_LIMIT = 40; const DEFAULT_HYDRATION_DEBOUNCE_MS = 25; +const COMMENT_CACHE_PREFIX = "bitvid:comments:"; +const COMMENT_CACHE_TTL_MS = 5 * 60 * 1000; function toPositiveInteger(value, fallback) { const numeric = Number(value); @@ -200,7 +203,7 @@ export default class CommentThreadService { let events = []; try { - events = await this.fetchVideoComments(target, { + events = await this.fetchThread(target, { limit: fetchLimit, relays: this.activeRelays, }); @@ -230,6 +233,235 @@ export default class CommentThreadService { return this.getSnapshot(); } + async fetchThread(target, options = {}) { + const fallbackFetch = async (overrideOptions = options) => { + if (typeof this.fetchVideoComments !== "function") { + return []; + } + return await this.fetchVideoComments(target, overrideOptions); + }; + + if (!FEATURE_IMPROVED_COMMENT_FETCHING) { + return fallbackFetch(options); + } + + const targetCandidate = + target && typeof target === "object" ? target : {}; + const videoEventId = normalizeString( + targetCandidate.videoEventId || targetCandidate.eventId, + ); + + if (!videoEventId) { + return fallbackFetch(options); + } + + const cached = this.getCachedComments(videoEventId); + if (Array.isArray(cached)) { + return cached; + } + + const fetchOptions = { + ...options, + since: 0, + }; + + if (Number.isFinite(options?.limit)) { + fetchOptions.limit = Math.max( + toPositiveInteger(options.limit, this.defaultLimit), + this.defaultLimit, + ); + } else if (!Number.isFinite(fetchOptions.limit)) { + fetchOptions.limit = Math.max(this.defaultLimit, 100); + } + + const comments = await fallbackFetch(fetchOptions); + this.cacheComments(videoEventId, comments); + return comments; + } + + getCommentCacheKey(videoEventId) { + const normalized = normalizeString(videoEventId); + return normalized ? `${COMMENT_CACHE_PREFIX}${normalized}` : ""; + } + + getCachedComments(videoEventId) { + if ( + !FEATURE_IMPROVED_COMMENT_FETCHING || + typeof localStorage === "undefined" + ) { + return null; + } + + const cacheKey = this.getCommentCacheKey(videoEventId); + if (!cacheKey) { + return null; + } + + let raw = null; + try { + raw = localStorage.getItem(cacheKey); + } catch (error) { + if (this.logger?.warn) { + this.logger.warn( + `[commentThread] Failed to read cached comments for ${videoEventId}:`, + error, + ); + } + return null; + } + + if (raw === null) { + return null; + } + + try { + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object") { + this.removeCommentCache(cacheKey); + return null; + } + + const comments = Array.isArray(parsed.comments) + ? parsed.comments + : null; + const timestamp = Number(parsed.timestamp); + + if ( + Array.isArray(comments) && + Number.isFinite(timestamp) && + Date.now() - timestamp <= COMMENT_CACHE_TTL_MS + ) { + if (devLogger?.info) { + devLogger.info( + `[commentThread] Loaded ${comments.length} cached comments for ${videoEventId}.`, + ); + } + return comments; + } + + this.removeCommentCache(cacheKey); + } catch (error) { + if (this.logger?.warn) { + this.logger.warn( + `[commentThread] Failed to parse cached comments for ${videoEventId}:`, + error, + ); + } + this.removeCommentCache(cacheKey); + } + + return null; + } + + cacheComments(videoEventId, comments) { + if ( + !FEATURE_IMPROVED_COMMENT_FETCHING || + typeof localStorage === "undefined" || + !Array.isArray(comments) + ) { + return; + } + + const cacheKey = this.getCommentCacheKey(videoEventId); + if (!cacheKey) { + return; + } + + try { + localStorage.setItem( + cacheKey, + JSON.stringify({ comments, timestamp: Date.now() }), + ); + if (devLogger?.info) { + devLogger.info( + `[commentThread] Cached ${comments.length} comments for ${videoEventId}.`, + ); + } + } catch (error) { + if (this.logger?.warn) { + this.logger.warn( + `[commentThread] Failed to cache comments for ${videoEventId}:`, + error, + ); + } + } + } + + removeCommentCache(cacheKey) { + if (!cacheKey || typeof localStorage === "undefined") { + return; + } + + try { + localStorage.removeItem(cacheKey); + } catch (error) { + if (this.logger?.warn) { + this.logger.warn( + `[commentThread] Failed to clear cached comments for ${cacheKey}:`, + error, + ); + } + } + } + + clearCommentCache(videoEventId = null) { + if (!FEATURE_IMPROVED_COMMENT_FETCHING || typeof localStorage === "undefined") { + return; + } + + if (videoEventId) { + this.removeCommentCache(this.getCommentCacheKey(videoEventId)); + return; + } + + try { + const keys = Object.keys(localStorage); + keys + .filter((key) => key.startsWith(COMMENT_CACHE_PREFIX)) + .forEach((key) => this.removeCommentCache(key)); + } catch (error) { + if (this.logger?.warn) { + this.logger.warn( + "[commentThread] Failed to clear comment cache:", + error, + ); + } + } + } + + persistCommentCache() { + if (!FEATURE_IMPROVED_COMMENT_FETCHING || !this.videoEventId) { + return; + } + + const comments = this.serializeCommentsForCache(); + this.cacheComments(this.videoEventId, comments); + } + + serializeCommentsForCache() { + const events = Array.from(this.eventsById.values()); + return events.sort((a, b) => { + const aTime = Number.isFinite(a?.created_at) ? a.created_at : 0; + const bTime = Number.isFinite(b?.created_at) ? b.created_at : 0; + if (aTime !== bTime) { + return aTime - bTime; + } + + const aId = normalizeString(a?.id); + const bId = normalizeString(b?.id); + if (aId && bId) { + return aId.localeCompare(bId); + } + if (aId) { + return -1; + } + if (bId) { + return 1; + } + return 0; + }); + } + startSubscription(target) { if (typeof this.subscribeVideoComments !== "function") { return; @@ -271,6 +503,11 @@ export default class CommentThreadService { if (!isInitial && result.type === "insert") { this.emitAppend(result); + return; + } + + if (!isInitial && result.type === "update") { + this.persistCommentCache(); } } @@ -511,6 +748,7 @@ export default class CommentThreadService { profiles: this.getProfilesSnapshot(), }; safeCall(this.callbacks.onThreadReady, payload); + this.persistCommentCache(); } emitAppend({ parentId, eventId }) { @@ -528,6 +766,7 @@ export default class CommentThreadService { profiles: this.getProfilesSnapshot(), }; safeCall(this.callbacks.onCommentsAppended, payload); + this.persistCommentCache(); } emitError(error) { @@ -630,4 +869,3 @@ export default class CommentThreadService { this.resetInternalState(); } } -// New fetchThread with caching for improved persistence\n async fetchThread(target, options = {}) {\n if (!FEATURE_IMPROVED_COMMENT_FETCHING) {\n return await this.fetchVideoComments(target, options);\n }\n\n const videoEventId = target.videoEventId || target.eventId;\n if (!videoEventId) return [];\n\n let comments = this.getCachedComments(videoEventId);\n if (!comments) {\n comments = await this.fetchVideoComments(target, { ...options, since: 0 });\n this.cacheComments(videoEventId, comments);\n }\n\n devLogger.info(`Fetched ${comments.length} comments for ${videoEventId} (cached: ${!!this.getCachedComments(videoEventId)})`);\n return comments;\n }\n\n getCachedComments(videoEventId) {\n const cacheKey = `comments:${videoEventId}`;\n const cached = localStorage.getItem(cacheKey);\n if (!cached) return null;\n try {\n const { comments, timestamp } = JSON.parse(cached);\n if (Date.now() - timestamp < 300000) { // 5min TTL\n devLogger.info(`Loaded cached comments for ${videoEventId}`);\n return comments;\n } else {\n localStorage.removeItem(cacheKey);\n devLogger.info(`Expired cache for ${videoEventId}`);\n }\n } catch (error) {\n devLogger.warn('[cache] Invalid cached comments:', error);\n localStorage.removeItem(cacheKey);\n }\n return null;\n }\n\n cacheComments(videoEventId, comments) {\n const cacheKey = `comments:${videoEventId}`;\n try {\n localStorage.setItem(cacheKey, JSON.stringify({\n comments,\n timestamp: Date.now(),\n }));\n devLogger.info(`Cached ${comments.length} comments for ${videoEventId}`);\n } catch (error) {\n devLogger.warn('[cache] Failed to cache comments:', error);\n }\n }\n\n // Flush cache on teardown (optional, for privacy)\n clearCommentCache(videoEventId) {\n if (videoEventId) {\n localStorage.removeItem(`comments:${videoEventId}`);\n } else {\n Object.keys(localStorage).filter(key => key.startsWith('comments:')).forEach(key => localStorage.removeItem(key));\n }\n devLogger.info('Cleared comment cache');\n } diff --git a/js/ui/videoModalCommentController.js b/js/ui/videoModalCommentController.js index 8c4f7520..a021a3ab 100644 --- a/js/ui/videoModalCommentController.js +++ b/js/ui/videoModalCommentController.js @@ -48,7 +48,6 @@ export default class VideoModalCommentController { }; this.callbacks = { -async loadComments(retryCount = 0) {\n if (!FEATURE_IMPROVED_COMMENT_FETCHING) {\n // Fallback to existing sub-only\n this.subscription = subscribeVideoComments(this.client, this.videoTarget, {\n onEvent: (event) => this.addComment(event),\n });\n return;\n }\n\n try {\n const options = { since: 0, limit: 100 }; // Full history, cap for perf\n const comments = await listVideoComments(this.client, this.videoTarget, options);\n devLogger.info(`Loaded ${comments.length} comments for video; filters applied.`, options);\n this.renderCommentThread(comments);\n\n // Subscribe for live updates\n this.subscription = subscribeVideoComments(this.client, this.videoTarget, {\n onEvent: (event) => this.addComment(event),\n });\n\n // Retry if low count (e.g., async propagation)\n if (comments.length < 5 && retryCount < 3) { // Threshold heuristic\n setTimeout(() => this.loadComments(retryCount + 1), 2000);\n }\n } catch (error) {\n devLogger.warn('[comments] Failed to load comments:', error);\n // Fallback sub\n this.subscription = subscribeVideoComments(this.client, this.videoTarget, { onEvent: (event) => this.addComment(event) });\n }\n } showError: typeof callbacks.showError === "function" ? (message) => callbacks.showError(message)