// js/services/commentThreadService.js 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; const COMMENT_CACHE_VERSION = 2; function toPositiveInteger(value, fallback) { const numeric = Number(value); if (!Number.isFinite(numeric) || numeric <= 0) { return fallback; } return Math.floor(numeric); } function normalizeString(value) { return typeof value === "string" ? value.trim() : ""; } function normalizePubkey(pubkey) { const normalized = normalizeString(pubkey); return normalized ? normalized.toLowerCase() : ""; } function normalizeKind(value) { if (Number.isFinite(value)) { return String(Math.floor(value)); } if (typeof value === "string" && value.trim()) { return value.trim(); } return ""; } function toParentKey(parentId) { return parentId ? parentId : ROOT_PARENT_KEY; } function fromParentKey(parentKey) { return parentKey === ROOT_PARENT_KEY ? null : parentKey; } function safeCall(handler, payload) { if (typeof handler !== "function") { return; } try { handler(payload); } catch (error) { devLogger.warn("[commentThread] callback threw", error); } } export default class CommentThreadService { constructor({ nostrClient = null, app = null, fetchVideoComments = null, subscribeVideoComments = null, getProfileCacheEntry = null, batchFetchProfiles = null, limit = DEFAULT_INITIAL_LIMIT, hydrationDebounceMs = DEFAULT_HYDRATION_DEBOUNCE_MS, logger = devLogger, } = {}) { this.nostrClient = nostrClient; this.fetchVideoComments = fetchVideoComments; this.subscribeVideoComments = subscribeVideoComments; if (!this.fetchVideoComments && this.nostrClient) { const clientFetcher = this.nostrClient.fetchVideoComments; if (typeof clientFetcher === "function") { this.fetchVideoComments = (...args) => clientFetcher.apply(this.nostrClient, args); } } if (!this.subscribeVideoComments && this.nostrClient) { const clientSubscriber = this.nostrClient.subscribeVideoComments; if (typeof clientSubscriber === "function") { this.subscribeVideoComments = (...args) => clientSubscriber.apply(this.nostrClient, args); } } this.getProfileCacheEntry = typeof getProfileCacheEntry === "function" ? getProfileCacheEntry : this.app && typeof this.app.getProfileCacheEntry === "function" ? (pubkey) => this.app.getProfileCacheEntry(pubkey) : null; this.batchFetchProfiles = typeof batchFetchProfiles === "function" ? batchFetchProfiles : this.app && typeof this.app.batchFetchProfiles === "function" ? (pubkeys) => this.app.batchFetchProfiles(pubkeys) : null; this.defaultLimit = toPositiveInteger(limit, DEFAULT_INITIAL_LIMIT); this.hydrationDelay = Math.max( 0, toPositiveInteger(hydrationDebounceMs, DEFAULT_HYDRATION_DEBOUNCE_MS) ); this.logger = logger && typeof logger.warn === "function" ? logger : devLogger; this.callbacks = { onThreadReady: null, onCommentsAppended: null, onError: null, }; this.resetInternalState(); } resetInternalState() { this.eventsById = new Map(); this.metaById = new Map(); this.childrenByParent = new Map(); this.childrenByParent.set(ROOT_PARENT_KEY, []); this.profileCache = new Map(); this.profileQueue = new Set(); this.profileHydrationTimer = null; this.profileHydrationPromise = null; this.subscriptionCleanup = null; this.videoEventId = ""; this.videoAddressPointer = ""; this.videoKind = ""; this.videoAuthorPubkeyRaw = ""; this.videoAuthorPubkey = ""; this.parentCommentId = ""; this.parentCommentKind = ""; this.parentCommentPubkey = ""; this.activeRelays = null; } setCallbacks({ onThreadReady = this.callbacks.onThreadReady, onCommentsAppended = this.callbacks.onCommentsAppended, onError = this.callbacks.onError, } = {}) { this.callbacks.onThreadReady = typeof onThreadReady === "function" ? onThreadReady : null; this.callbacks.onCommentsAppended = typeof onCommentsAppended === "function" ? onCommentsAppended : null; this.callbacks.onError = typeof onError === "function" ? onError : null; } async loadThread({ video = null, parentCommentId = "", limit = this.defaultLimit, relays = null, } = {}) { this.teardown(); if (typeof this.nostrClient?.ensurePool === "function") { try { await this.nostrClient.ensurePool(); } catch (error) { this.emitError( new Error("Unable to initialize Nostr pool before loading comments."), ); return { success: false, error }; } } const rawVideoAuthorPubkey = normalizeString(video?.pubkey); this.videoEventId = normalizeString(video?.id); this.videoAddressPointer = buildVideoAddressPointer(video); this.videoKind = normalizeKind(video?.kind); this.videoAuthorPubkeyRaw = rawVideoAuthorPubkey; this.videoAuthorPubkey = normalizePubkey(rawVideoAuthorPubkey); this.parentCommentId = normalizeString(parentCommentId); this.parentCommentKind = this.parentCommentId ? String(COMMENT_EVENT_KIND) : ""; this.parentCommentPubkey = ""; this.activeRelays = Array.isArray(relays) ? [...relays] : null; if (!this.videoEventId) { this.emitError( new Error("Unable to resolve video pointer for comment thread."), ); return { success: false }; } if (typeof this.fetchVideoComments !== "function") { this.emitError( new Error("Nostr client is missing fetchVideoComments implementation."), ); return { success: false }; } const fetchLimit = toPositiveInteger(limit, this.defaultLimit); const target = { videoEventId: this.videoEventId, parentCommentId: this.parentCommentId, }; if (this.videoAddressPointer) { target.videoDefinitionAddress = this.videoAddressPointer; } if (this.videoKind) { target.videoKind = this.videoKind; } const targetVideoAuthorPubkey = this.getVideoAuthorPubkeyForOutput(); if (targetVideoAuthorPubkey) { target.videoAuthorPubkey = targetVideoAuthorPubkey; } if (this.parentCommentId) { if (this.parentCommentKind) { target.parentCommentKind = this.parentCommentKind; } if (this.parentCommentPubkey) { target.parentCommentPubkey = this.parentCommentPubkey; } } let events = []; try { events = await this.fetchThread(target, { limit: fetchLimit, relays: this.activeRelays, }); } catch (error) { this.emitError(error); return { success: false, error }; } if (Array.isArray(events)) { events.forEach((event) => { this.processIncomingEvent(event, { isInitial: true }); }); } this.emitThreadReady(); this.startSubscription(target); if (this.profileHydrationTimer) { clearTimeout(this.profileHydrationTimer); this.profileHydrationTimer = null; } if (this.profileQueue.size) { await this.flushProfileQueue(); } 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 cacheVersion = Number.isFinite(parsed.version) ? Number(parsed.version) : null; if (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 ) { 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({ version: COMMENT_CACHE_VERSION, 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; } const options = { relays: this.activeRelays, onEvent: (event) => this.processIncomingEvent(event), }; try { const cleanup = this.subscribeVideoComments(target, options); if (typeof cleanup === "function") { this.subscriptionCleanup = () => { try { cleanup(); } catch (error) { if (this.logger?.warn) { this.logger.warn( "[commentThread] Failed to unsubscribe from comment feed:", error, ); } } }; } else { this.subscriptionCleanup = null; } } catch (error) { this.emitError(error); } } processIncomingEvent(event, { isInitial = false } = {}) { const result = this.applyEvent(event); if (!result) { return; } if (!isInitial && result.type === "insert") { this.emitAppend(result); return; } if (!isInitial && result.type === "update") { this.persistCommentCache(); } } applyEvent(event) { if (!event || typeof event !== "object") { return null; } const eventId = normalizeString(event.id); if (!eventId) { return null; } const parentId = this.extractParentId(event); const parentKey = toParentKey(parentId); const createdAt = Number.isFinite(event.created_at) ? event.created_at : 0; const pubkey = normalizePubkey(event.pubkey); if (this.parentCommentId && eventId === this.parentCommentId) { const kindValue = normalizeKind(event.kind); if (kindValue) { this.parentCommentKind = kindValue; } if (pubkey) { this.parentCommentPubkey = pubkey; } } const existingMeta = this.metaById.get(eventId); this.eventsById.set(eventId, event); this.metaById.set(eventId, { parentKey, createdAt }); if (!existingMeta) { this.insertIntoParentList(parentKey, eventId, createdAt); if (pubkey) { this.queueProfileForHydration(pubkey); } return { type: "insert", parentId, parentKey, eventId, event }; } if (existingMeta.parentKey !== parentKey) { this.removeFromParentList(existingMeta.parentKey, eventId); this.insertIntoParentList(parentKey, eventId, createdAt); } else if (existingMeta.createdAt !== createdAt) { this.reorderParentList(parentKey, eventId, createdAt); } if (pubkey) { this.queueProfileForHydration(pubkey); } return { type: "update", parentId, parentKey, eventId, event }; } extractParentId(event) { const tags = Array.isArray(event.tags) ? event.tags : []; const values = []; for (const tag of tags) { if (!Array.isArray(tag) || tag.length < 2) { continue; } const [name, value] = tag; if (name !== "e") { continue; } const normalizedValue = normalizeString(value); if (!normalizedValue) { continue; } values.push(normalizedValue); } for (let index = values.length - 1; index >= 0; index -= 1) { const candidate = values[index]; if (candidate && candidate !== this.videoEventId) { return candidate; } } return ""; } insertIntoParentList(parentKey, eventId, createdAt) { const list = this.getParentList(parentKey); if (list.includes(eventId)) { return; } const insertIndex = list.findIndex((existingId) => { const meta = this.metaById.get(existingId); const existingCreated = meta ? meta.createdAt : 0; if (createdAt === existingCreated) { return eventId.localeCompare(existingId) < 0; } return createdAt < existingCreated; }); if (insertIndex === -1) { list.push(eventId); } else { list.splice(insertIndex, 0, eventId); } } removeFromParentList(parentKey, eventId) { const list = this.childrenByParent.get(parentKey); if (!Array.isArray(list)) { return; } const index = list.indexOf(eventId); if (index >= 0) { list.splice(index, 1); } } reorderParentList(parentKey, eventId, createdAt) { this.removeFromParentList(parentKey, eventId); this.insertIntoParentList(parentKey, eventId, createdAt); } getParentList(parentKey) { if (!this.childrenByParent.has(parentKey)) { this.childrenByParent.set(parentKey, []); } return this.childrenByParent.get(parentKey); } queueProfileForHydration(pubkey) { if (!pubkey) { return; } if (this.profileCache.has(pubkey)) { return; } const cachedProfile = this.getProfileFromCache(pubkey); if (cachedProfile) { this.profileCache.set(pubkey, cachedProfile); return; } this.profileQueue.add(pubkey); this.scheduleProfileHydration(); } getProfileFromCache(pubkey) { if (typeof this.getProfileCacheEntry !== "function") { return null; } try { const entry = this.getProfileCacheEntry(pubkey); if (!entry) { return null; } if (entry && typeof entry === "object" && entry.profile) { return entry.profile; } return entry; } catch (error) { if (this.logger?.warn) { this.logger.warn( "[commentThread] Failed to read profile cache entry:", error, ); } } return null; } scheduleProfileHydration() { if (this.profileQueue.size === 0) { return; } if (this.profileHydrationTimer) { return; } this.profileHydrationTimer = setTimeout(() => { this.profileHydrationTimer = null; this.flushProfileQueue(); }, this.hydrationDelay); } async flushProfileQueue() { if (!this.profileQueue.size) { return; } const pubkeys = Array.from(this.profileQueue); this.profileQueue.clear(); if (typeof this.batchFetchProfiles !== "function") { return; } const hydrationPromise = Promise.resolve( this.batchFetchProfiles(pubkeys), ) .catch((error) => { if (this.logger?.warn) { this.logger.warn( "[commentThread] Profile hydration failed:", error, ); } this.emitError(error); }) .then(() => { pubkeys.forEach((pubkey) => { const profile = this.getProfileFromCache(pubkey); if (profile) { this.profileCache.set(pubkey, profile); } }); }) .finally(() => { if (this.profileHydrationPromise === hydrationPromise) { this.profileHydrationPromise = null; } }); this.profileHydrationPromise = hydrationPromise; await hydrationPromise; } getVideoAuthorPubkeyForOutput() { return this.videoAuthorPubkeyRaw || this.videoAuthorPubkey || ""; } emitThreadReady() { const payload = { videoEventId: this.videoEventId, parentCommentId: this.parentCommentId || null, videoDefinitionAddress: this.videoAddressPointer || null, videoKind: this.videoKind || null, videoAuthorPubkey: this.getVideoAuthorPubkeyForOutput() || null, parentCommentKind: this.parentCommentKind || null, parentCommentPubkey: this.parentCommentPubkey || null, topLevelIds: this.getCommentIdsForParent(null), commentsById: this.cloneCommentsMap(), childrenByParent: this.cloneTreeMap(), profiles: this.getProfilesSnapshot(), }; safeCall(this.callbacks.onThreadReady, payload); this.persistCommentCache(); } emitAppend({ parentId, eventId }) { const payload = { videoEventId: this.videoEventId, parentCommentId: parentId || null, videoDefinitionAddress: this.videoAddressPointer || null, videoKind: this.videoKind || null, videoAuthorPubkey: this.getVideoAuthorPubkeyForOutput() || null, parentCommentKind: this.parentCommentKind || null, parentCommentPubkey: this.parentCommentPubkey || null, commentIds: [eventId], commentsById: this.cloneCommentsMap(), childrenByParent: this.cloneTreeMap(), profiles: this.getProfilesSnapshot(), }; safeCall(this.callbacks.onCommentsAppended, payload); this.persistCommentCache(); } emitError(error) { if (this.logger?.warn && error) { this.logger.warn("[commentThread]", error); } safeCall(this.callbacks.onError, error); } getSnapshot() { return { videoEventId: this.videoEventId, parentCommentId: this.parentCommentId || null, videoDefinitionAddress: this.videoAddressPointer || null, videoKind: this.videoKind || null, videoAuthorPubkey: this.getVideoAuthorPubkeyForOutput() || null, parentCommentKind: this.parentCommentKind || null, parentCommentPubkey: this.parentCommentPubkey || null, commentsById: this.cloneCommentsMap(), childrenByParent: this.cloneTreeMap(), profiles: this.getProfilesSnapshot(), }; } cloneCommentsMap() { return new Map(this.eventsById); } cloneTreeMap() { const tree = new Map(); this.childrenByParent.forEach((ids, parentKey) => { tree.set(fromParentKey(parentKey), [...ids]); }); return tree; } getProfilesSnapshot() { return new Map(this.profileCache); } getProfile(pubkey) { const normalized = normalizePubkey(pubkey); if (!normalized) { return null; } if (this.profileCache.has(normalized)) { return this.profileCache.get(normalized); } const profile = this.getProfileFromCache(normalized); if (profile) { this.profileCache.set(normalized, profile); return profile; } return null; } getCommentIdsForParent(parentId) { const key = toParentKey(normalizeString(parentId)); const list = this.childrenByParent.get(key); return Array.isArray(list) ? [...list] : []; } getCommentEvent(commentId) { const normalized = normalizeString(commentId); if (!normalized) { return null; } return this.eventsById.get(normalized) || null; } waitForProfileHydration() { return this.profileHydrationPromise || Promise.resolve(); } teardown() { if (this.subscriptionCleanup) { try { this.subscriptionCleanup(); } catch (error) { if (this.logger?.warn) { this.logger.warn( "[commentThread] Failed during teardown unsubscribe:", error, ); } } } this.subscriptionCleanup = null; if (this.profileHydrationTimer) { clearTimeout(this.profileHydrationTimer); this.profileHydrationTimer = null; } this.profileQueue.clear(); this.profileHydrationPromise = null; this.resetInternalState(); } }