From c562a326ae935c8af82802463acc736778671654 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 19 Jun 2026 16:31:13 -0400 Subject: [PATCH] refactor(comments): extract comment-cache subsystem (7/10) File-size decomposition (no behavior change). Move the localStorage comment-cache methods (getCommentCacheKey, handleCommentCacheError, getCachedComments, cacheComments, removeCommentCache, clearCommentCache, persistCommentCache, serializeCommentsForCache) into js/services/commentThreadCache.js as functions that take the service instance; the service keeps thin delegators so all call sites are unchanged. Cache constants moved with them; FIVE_MINUTES_MS import dropped from the service. commentThreadService.js: 1296 -> 1100 lines (below its original 1122 baseline; re-recorded to 1100). Build + full unit suite green. Co-Authored-By: Claude Opus 4.8 --- js/services/commentThreadCache.js | 256 ++++++++++++++++++++++++++++ js/services/commentThreadService.js | 233 +++---------------------- scripts/check-file-size.mjs | 2 +- 3 files changed, 276 insertions(+), 215 deletions(-) create mode 100644 js/services/commentThreadCache.js diff --git a/js/services/commentThreadCache.js b/js/services/commentThreadCache.js new file mode 100644 index 00000000..daf59716 --- /dev/null +++ b/js/services/commentThreadCache.js @@ -0,0 +1,256 @@ +// js/services/commentThreadCache.js +// +// localStorage comment-cache subsystem extracted from commentThreadService.js to +// keep that service under the file-size budget. Each function takes the service +// instance (`ctx`) so behavior is identical to the former methods; the service +// keeps thin delegators so existing call sites are unchanged. No behavior change. + +import { + FEATURE_IMPROVED_COMMENT_FETCHING, + FIVE_MINUTES_MS, +} from "../constants.js"; +import { normalizeHexId } from "../utils/hex.js"; + +const COMMENT_CACHE_PREFIX = "bitvid:comments:"; +const COMMENT_CACHE_TTL_MS = FIVE_MINUTES_MS; +const COMMENT_CACHE_VERSION = 2; + +function logDev(loggerCandidate, ...args) { + if (loggerCandidate?.info) { + loggerCandidate.info(...args); + } +} + +export function getCommentCacheKey(videoEventId) { + const normalized = normalizeHexId(videoEventId); + if (!normalized) { + return ""; + } + + return `${COMMENT_CACHE_PREFIX}${normalized.toLowerCase()}`; +} + +export function handleCommentCacheError(ctx, context, videoEventId, error) { + ctx.commentCacheDiagnostics = { + ...ctx.commentCacheDiagnostics, + storageUnavailable: true, + }; + + const message = + typeof videoEventId === "string" && videoEventId.trim() + ? `[commentThread] Failed to ${context} comment cache for ${videoEventId}.` + : `[commentThread] Failed to ${context} comment cache.`; + + if (ctx.logger?.user?.warn) { + ctx.logger.user.warn(message, error); + } else if (ctx.logger?.warn) { + ctx.logger.warn(message, error); + } + + if (ctx.logger?.dev?.warn && ctx.logger.dev !== ctx.logger.user) { + ctx.logger.dev.warn(message, error); + } +} + +export function getCachedComments(ctx, videoEventId) { + if ( + !FEATURE_IMPROVED_COMMENT_FETCHING || + typeof localStorage === "undefined" + ) { + return null; + } + + const cacheKey = ctx.getCommentCacheKey(videoEventId); + if (!cacheKey) { + logDev( + ctx.logger?.dev, + "[commentThread] Comment cache skipped: invalid video id.", + ); + return null; + } + + let raw = null; + try { + raw = localStorage.getItem(cacheKey); + } catch (error) { + ctx.handleCommentCacheError("read", videoEventId, error); + return null; + } + + if (raw === null) { + logDev( + ctx.logger?.dev, + `[commentThread] Comment cache miss for ${videoEventId}: no entry present.`, + ); + return null; + } + + try { + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object") { + logDev( + ctx.logger?.dev, + `[commentThread] Comment cache rejected for ${videoEventId}: malformed payload.`, + ); + ctx.removeCommentCache(cacheKey); + return null; + } + + const cacheVersion = Number.isFinite(parsed.version) + ? Number(parsed.version) + : null; + + if (cacheVersion !== COMMENT_CACHE_VERSION) { + logDev( + ctx.logger?.dev, + `[commentThread] Comment cache rejected for ${videoEventId}: version ${cacheVersion} != ${COMMENT_CACHE_VERSION}.`, + ); + ctx.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 + ) { + logDev( + ctx.logger?.dev, + `[commentThread] Loaded ${comments.length} cached comments for ${videoEventId}.`, + ); + return comments; + } + + logDev( + ctx.logger?.dev, + `[commentThread] Comment cache rejected for ${videoEventId}: entry expired.`, + ); + ctx.removeCommentCache(cacheKey); + } catch (error) { + if (ctx.logger?.warn) { + ctx.logger.warn( + `[commentThread] Failed to parse cached comments for ${videoEventId}:`, + error, + ); + } + logDev( + ctx.logger?.dev, + `[commentThread] Comment cache rejected for ${videoEventId}: parse error.`, + ); + ctx.removeCommentCache(cacheKey); + } + + return null; +} + +export function cacheComments(ctx, videoEventId, comments) { + if ( + !FEATURE_IMPROVED_COMMENT_FETCHING || + typeof localStorage === "undefined" || + !Array.isArray(comments) + ) { + return; + } + + const cacheKey = ctx.getCommentCacheKey(videoEventId); + if (!cacheKey) { + return; + } + + try { + localStorage.setItem( + cacheKey, + JSON.stringify({ + version: COMMENT_CACHE_VERSION, + comments, + timestamp: Date.now(), + }), + ); + logDev( + ctx.logger?.dev, + `[commentThread] Cached ${comments.length} comments for ${videoEventId}.`, + ); + } catch (error) { + ctx.handleCommentCacheError("write", videoEventId, error); + } +} + +export function removeCommentCache(ctx, cacheKey) { + if (!cacheKey || typeof localStorage === "undefined") { + return; + } + + try { + localStorage.removeItem(cacheKey); + } catch (error) { + if (ctx.logger?.warn) { + ctx.logger.warn( + `[commentThread] Failed to clear cached comments for ${cacheKey}:`, + error, + ); + } + } +} + +export function clearCommentCache(ctx, videoEventId = null) { + if (!FEATURE_IMPROVED_COMMENT_FETCHING || typeof localStorage === "undefined") { + return; + } + + if (videoEventId) { + ctx.removeCommentCache(ctx.getCommentCacheKey(videoEventId)); + return; + } + + try { + const keys = Object.keys(localStorage); + keys + .filter((key) => key.startsWith(COMMENT_CACHE_PREFIX)) + .forEach((key) => ctx.removeCommentCache(key)); + } catch (error) { + if (ctx.logger?.warn) { + ctx.logger.warn( + "[commentThread] Failed to clear comment cache:", + error, + ); + } + } +} + +export function persistCommentCache(ctx) { + if (!FEATURE_IMPROVED_COMMENT_FETCHING || !ctx.videoEventId) { + return; + } + + const comments = ctx.serializeCommentsForCache(); + ctx.cacheComments(ctx.videoEventId, comments); +} + +export function serializeCommentsForCache(ctx) { + const events = Array.from(ctx.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 = normalizeHexId(a?.id); + const bId = normalizeHexId(b?.id); + if (aId && bId) { + return aId.localeCompare(bId); + } + if (aId) { + return -1; + } + if (bId) { + return 1; + } + return 0; + }); +} diff --git a/js/services/commentThreadService.js b/js/services/commentThreadService.js index 31b98600..5b51ecec 100644 --- a/js/services/commentThreadService.js +++ b/js/services/commentThreadService.js @@ -4,16 +4,23 @@ import logger, { devLogger, userLogger } from "../utils/logger.js"; import { normalizeHexId, normalizeHexPubkey } from "../utils/hex.js"; import { buildVideoAddressPointer } from "../utils/videoPointer.js"; import { COMMENT_EVENT_KIND } from "../nostr/commentEvents.js"; -import { FEATURE_IMPROVED_COMMENT_FETCHING, FIVE_MINUTES_MS } from "../constants.js"; +import { FEATURE_IMPROVED_COMMENT_FETCHING } from "../constants.js"; +import { + getCommentCacheKey as cacheGetCommentCacheKey, + handleCommentCacheError as cacheHandleCommentCacheError, + getCachedComments as cacheGetCachedComments, + cacheComments as cacheCacheComments, + removeCommentCache as cacheRemoveCommentCache, + clearCommentCache as cacheClearCommentCache, + persistCommentCache as cachePersistCommentCache, + serializeCommentsForCache as cacheSerializeCommentsForCache, +} from "./commentThreadCache.js"; const ROOT_PARENT_KEY = "__root__"; const DEFAULT_INITIAL_LIMIT = 40; const DEFAULT_HYDRATION_DEBOUNCE_MS = 25; const PROFILE_FETCH_MAX_ATTEMPTS = 3; const PROFILE_FETCH_BACKOFF_MS = 50; -const COMMENT_CACHE_PREFIX = "bitvid:comments:"; -const COMMENT_CACHE_TTL_MS = FIVE_MINUTES_MS; -const COMMENT_CACHE_VERSION = 2; function toPositiveInteger(value, fallback) { const numeric = Number(value); @@ -400,237 +407,35 @@ export default class CommentThreadService { } getCommentCacheKey(videoEventId) { - const normalized = normalizeHexId(videoEventId); - if (!normalized) { - return ""; - } - - return `${COMMENT_CACHE_PREFIX}${normalized.toLowerCase()}`; + return cacheGetCommentCacheKey(videoEventId); } handleCommentCacheError(context, videoEventId, error) { - this.commentCacheDiagnostics = { - ...this.commentCacheDiagnostics, - storageUnavailable: true, - }; - - const message = - typeof videoEventId === "string" && videoEventId.trim() - ? `[commentThread] Failed to ${context} comment cache for ${videoEventId}.` - : `[commentThread] Failed to ${context} comment cache.`; - - if (this.logger?.user?.warn) { - this.logger.user.warn(message, error); - } else if (this.logger?.warn) { - this.logger.warn(message, error); - } - - if (this.logger?.dev?.warn && this.logger.dev !== this.logger.user) { - this.logger.dev.warn(message, error); - } + return cacheHandleCommentCacheError(this, context, videoEventId, error); } getCachedComments(videoEventId) { - if ( - !FEATURE_IMPROVED_COMMENT_FETCHING || - typeof localStorage === "undefined" - ) { - return null; - } - - const cacheKey = this.getCommentCacheKey(videoEventId); - if (!cacheKey) { - logDev( - this.logger?.dev, - "[commentThread] Comment cache skipped: invalid video id.", - ); - return null; - } - - let raw = null; - try { - raw = localStorage.getItem(cacheKey); - } catch (error) { - this.handleCommentCacheError("read", videoEventId, error); - return null; - } - - if (raw === null) { - logDev( - this.logger?.dev, - `[commentThread] Comment cache miss for ${videoEventId}: no entry present.`, - ); - return null; - } - - try { - const parsed = JSON.parse(raw); - if (!parsed || typeof parsed !== "object") { - logDev( - this.logger?.dev, - `[commentThread] Comment cache rejected for ${videoEventId}: malformed payload.`, - ); - this.removeCommentCache(cacheKey); - return null; - } - - const cacheVersion = Number.isFinite(parsed.version) - ? Number(parsed.version) - : null; - - if (cacheVersion !== COMMENT_CACHE_VERSION) { - logDev( - this.logger?.dev, - `[commentThread] Comment cache rejected for ${videoEventId}: version ${cacheVersion} != ${COMMENT_CACHE_VERSION}.`, - ); - 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 - ) { - logDev( - this.logger?.dev, - `[commentThread] Loaded ${comments.length} cached comments for ${videoEventId}.`, - ); - return comments; - } - - logDev( - this.logger?.dev, - `[commentThread] Comment cache rejected for ${videoEventId}: entry expired.`, - ); - this.removeCommentCache(cacheKey); - } catch (error) { - if (this.logger?.warn) { - this.logger.warn( - `[commentThread] Failed to parse cached comments for ${videoEventId}:`, - error, - ); - } - logDev( - this.logger?.dev, - `[commentThread] Comment cache rejected for ${videoEventId}: parse error.`, - ); - this.removeCommentCache(cacheKey); - } - - return null; + return cacheGetCachedComments(this, videoEventId); } 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({ - version: COMMENT_CACHE_VERSION, - comments, - timestamp: Date.now(), - }), - ); - logDev( - this.logger?.dev, - `[commentThread] Cached ${comments.length} comments for ${videoEventId}.`, - ); - } catch (error) { - this.handleCommentCacheError("write", videoEventId, error); - } + return cacheCacheComments(this, videoEventId, comments); } 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, - ); - } - } + return cacheRemoveCommentCache(this, cacheKey); } 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, - ); - } - } + return cacheClearCommentCache(this, videoEventId); } persistCommentCache() { - if (!FEATURE_IMPROVED_COMMENT_FETCHING || !this.videoEventId) { - return; - } - - const comments = this.serializeCommentsForCache(); - this.cacheComments(this.videoEventId, comments); + return cachePersistCommentCache(this); } 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 = normalizeHexId(a?.id); - const bId = normalizeHexId(b?.id); - if (aId && bId) { - return aId.localeCompare(bId); - } - if (aId) { - return -1; - } - if (bId) { - return 1; - } - return 0; - }); + return cacheSerializeCommentsForCache(this); } startSubscription(target, options = {}) { diff --git a/scripts/check-file-size.mjs b/scripts/check-file-size.mjs index 994b5440..435ff777 100644 --- a/scripts/check-file-size.mjs +++ b/scripts/check-file-size.mjs @@ -64,7 +64,7 @@ const GRANDFATHERED = { "js/services/r2Service.js": 1194, "js/ui/applicationBootstrap.js": 1163, "js/ui/components/EditModal.js": 1130, - "js/services/commentThreadService.js": 1122, + "js/services/commentThreadService.js": 1100, "js/nostr/managers/SignerManager.js": 1106, "js/nostr/nip46Connector.js": 1094, "js/ui/overlay/popoverEngine.js": 1070,