mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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 <[email protected]>
1101 lines
31 KiB
JavaScript
1101 lines
31 KiB
JavaScript
// js/services/commentThreadService.js
|
|
|
|
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 } 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;
|
|
|
|
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 normalizeRelay(value) {
|
|
if (typeof value === "string") {
|
|
return value.trim();
|
|
}
|
|
return "";
|
|
}
|
|
|
|
const normalizePubkey = normalizeHexPubkey;
|
|
|
|
function normalizeKind(value) {
|
|
if (Number.isFinite(value)) {
|
|
return String(Math.floor(value));
|
|
}
|
|
if (typeof value === "string" && value.trim()) {
|
|
return value.trim();
|
|
}
|
|
return "";
|
|
}
|
|
|
|
// NIP-09 deletion request.
|
|
const DELETION_EVENT_KIND = 5;
|
|
|
|
function isDeletionEvent(event) {
|
|
return normalizeKind(event?.kind) === String(DELETION_EVENT_KIND);
|
|
}
|
|
|
|
function toParentKey(parentId) {
|
|
return parentId ? parentId : ROOT_PARENT_KEY;
|
|
}
|
|
|
|
function fromParentKey(parentKey) {
|
|
return parentKey === ROOT_PARENT_KEY ? null : parentKey;
|
|
}
|
|
|
|
function logDev(loggerCandidate, ...args) {
|
|
if (loggerCandidate?.info) {
|
|
loggerCandidate.info(...args);
|
|
}
|
|
}
|
|
|
|
function safeCall(handler, payload) {
|
|
if (typeof handler !== "function") {
|
|
return;
|
|
}
|
|
try {
|
|
handler(payload);
|
|
} catch (error) {
|
|
devLogger.warn("[commentThread] callback threw", error);
|
|
}
|
|
}
|
|
|
|
function normalizeLogger(loggerCandidate) {
|
|
const normalized =
|
|
loggerCandidate && typeof loggerCandidate === "object"
|
|
? loggerCandidate
|
|
: null;
|
|
|
|
const devChannel =
|
|
normalized?.dev && typeof normalized.dev.warn === "function"
|
|
? normalized.dev
|
|
: devLogger;
|
|
|
|
const userChannel =
|
|
normalized?.user && typeof normalized.user.warn === "function"
|
|
? normalized.user
|
|
: userLogger;
|
|
|
|
const warn =
|
|
typeof normalized?.warn === "function"
|
|
? (...args) => normalized.warn(...args)
|
|
: (...args) => devChannel.warn(...args);
|
|
|
|
return {
|
|
warn,
|
|
dev: devChannel,
|
|
user: userChannel,
|
|
};
|
|
}
|
|
|
|
export default class CommentThreadService {
|
|
constructor({
|
|
nostrClient = null,
|
|
app = null,
|
|
fetchVideoComments = null,
|
|
subscribeVideoComments = null,
|
|
fetchDeletionEvents = null,
|
|
getProfileCacheEntry = null,
|
|
batchFetchProfiles = null,
|
|
limit = DEFAULT_INITIAL_LIMIT,
|
|
hydrationDebounceMs = DEFAULT_HYDRATION_DEBOUNCE_MS,
|
|
logger: loggerCandidate = logger,
|
|
} = {}) {
|
|
this.nostrClient = nostrClient;
|
|
this.fetchVideoComments = fetchVideoComments;
|
|
this.subscribeVideoComments = subscribeVideoComments;
|
|
this.fetchDeletionEvents =
|
|
typeof fetchDeletionEvents === "function"
|
|
? fetchDeletionEvents
|
|
: (ids, opts) => this._defaultFetchDeletions(ids, opts);
|
|
|
|
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 = normalizeLogger(loggerCandidate);
|
|
|
|
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, []);
|
|
// NIP-09: target comment id -> Set of pubkeys that requested its deletion.
|
|
// A comment is treated as deleted only when its own author appears here.
|
|
this.deletionsByTarget = new Map();
|
|
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.videoRootIdentifier = "";
|
|
this.videoRootRelay = "";
|
|
this.parentCommentId = "";
|
|
this.parentCommentKind = "";
|
|
this.parentCommentPubkey = "";
|
|
this.activeRelays = null;
|
|
this.commentCacheDiagnostics = { storageUnavailable: false };
|
|
}
|
|
|
|
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();
|
|
|
|
// Always ensure the pool is ready before proceeding when supported.
|
|
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 = normalizeHexId(video?.id);
|
|
this.videoAddressPointer = buildVideoAddressPointer(video);
|
|
this.videoKind = normalizeKind(video?.kind);
|
|
this.videoAuthorPubkeyRaw = rawVideoAuthorPubkey;
|
|
this.videoAuthorPubkey = normalizePubkey(rawVideoAuthorPubkey);
|
|
const pointerRootIdentifier = normalizeHexId(
|
|
video?.pointerIdentifiers?.videoRootId,
|
|
);
|
|
this.videoRootIdentifier =
|
|
normalizeHexId(video?.videoRootId) || pointerRootIdentifier;
|
|
this.videoRootRelay = normalizeRelay(
|
|
video?.videoRootRelay || video?.rootIdentifierRelay,
|
|
);
|
|
this.parentCommentId = normalizeHexId(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;
|
|
}
|
|
if (this.videoRootIdentifier) {
|
|
target.rootIdentifier = this.videoRootIdentifier;
|
|
}
|
|
if (this.videoRootRelay) {
|
|
target.rootIdentifierRelay = this.videoRootRelay;
|
|
}
|
|
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;
|
|
}
|
|
}
|
|
|
|
this.startSubscription(target);
|
|
|
|
const cached = this.getCachedComments(this.videoEventId);
|
|
if (Array.isArray(cached)) {
|
|
cached.forEach((event) => {
|
|
this.processIncomingEvent(event, { isInitial: true });
|
|
});
|
|
}
|
|
|
|
this.emitThreadReady();
|
|
|
|
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, { emitReorder: true });
|
|
});
|
|
}
|
|
|
|
// Honor NIP-09 deletions: drop comments whose author has since requested
|
|
// their removal. Applied during the (silent) initial pass; the emit below
|
|
// renders the final, deletions-applied thread.
|
|
await this.fetchAndApplyDeletions();
|
|
|
|
this.emitThreadReady();
|
|
|
|
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) {
|
|
logDev(
|
|
this.logger?.dev,
|
|
"[commentThread] Improved fetching fallback: feature disabled.",
|
|
);
|
|
return fallbackFetch(options);
|
|
}
|
|
|
|
const targetCandidate =
|
|
target && typeof target === "object" ? target : {};
|
|
const videoEventId = normalizeHexId(
|
|
targetCandidate.videoEventId || targetCandidate.eventId,
|
|
);
|
|
|
|
if (!videoEventId) {
|
|
logDev(
|
|
this.logger?.dev,
|
|
"[commentThread] Improved fetching fallback: missing video event id.",
|
|
);
|
|
return fallbackFetch(options);
|
|
}
|
|
|
|
const cached = this.getCachedComments(videoEventId);
|
|
if (Array.isArray(cached)) {
|
|
return cached;
|
|
}
|
|
|
|
logDev(
|
|
this.logger?.dev,
|
|
`[commentThread] Comment cache miss for ${videoEventId}, fetching fresh thread.`,
|
|
);
|
|
|
|
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) {
|
|
return cacheGetCommentCacheKey(videoEventId);
|
|
}
|
|
|
|
handleCommentCacheError(context, videoEventId, error) {
|
|
return cacheHandleCommentCacheError(this, context, videoEventId, error);
|
|
}
|
|
|
|
getCachedComments(videoEventId) {
|
|
return cacheGetCachedComments(this, videoEventId);
|
|
}
|
|
|
|
cacheComments(videoEventId, comments) {
|
|
return cacheCacheComments(this, videoEventId, comments);
|
|
}
|
|
|
|
removeCommentCache(cacheKey) {
|
|
return cacheRemoveCommentCache(this, cacheKey);
|
|
}
|
|
|
|
clearCommentCache(videoEventId = null) {
|
|
return cacheClearCommentCache(this, videoEventId);
|
|
}
|
|
|
|
persistCommentCache() {
|
|
return cachePersistCommentCache(this);
|
|
}
|
|
|
|
serializeCommentsForCache() {
|
|
return cacheSerializeCommentsForCache(this);
|
|
}
|
|
|
|
startSubscription(target, options = {}) {
|
|
if (typeof this.subscribeVideoComments !== "function") {
|
|
return;
|
|
}
|
|
|
|
const subOptions = {
|
|
...options,
|
|
relays: this.activeRelays,
|
|
onEvent: (event) => this.processIncomingEvent(event, { isInitial: false }),
|
|
};
|
|
|
|
try {
|
|
const cleanup = this.subscribeVideoComments(target, subOptions);
|
|
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, emitReorder = false } = {},
|
|
) {
|
|
const result = this.applyEvent(event);
|
|
if (!result) {
|
|
return;
|
|
}
|
|
|
|
if (result.type === "skipped-deleted") {
|
|
return;
|
|
}
|
|
|
|
if (result.type === "deletion") {
|
|
// A removal can't be expressed as an append; re-render the thread when a
|
|
// comment actually disappeared (skip during the initial cache replay /
|
|
// bulk load, which emits once at the end).
|
|
if (!isInitial && result.removed) {
|
|
this.persistCommentCache();
|
|
this.emitThreadReady();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!isInitial && result.type === "insert") {
|
|
this.emitAppend(result);
|
|
return;
|
|
}
|
|
|
|
if (!isInitial && result.type === "update") {
|
|
this.persistCommentCache();
|
|
if (emitReorder && result.changed) {
|
|
this.emitThreadReady();
|
|
}
|
|
}
|
|
}
|
|
|
|
applyEvent(event) {
|
|
if (!event || typeof event !== "object") {
|
|
return null;
|
|
}
|
|
|
|
const eventId = normalizeHexId(event.id);
|
|
if (!eventId) {
|
|
return null;
|
|
}
|
|
|
|
if (isDeletionEvent(event)) {
|
|
return this.applyDeletion(event);
|
|
}
|
|
|
|
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);
|
|
const { identifier: rootIdentifier, relay: rootRelay } =
|
|
this.extractRootIdentifier(event);
|
|
if (rootIdentifier) {
|
|
this.videoRootIdentifier = rootIdentifier;
|
|
if (rootRelay) {
|
|
this.videoRootRelay = rootRelay;
|
|
}
|
|
}
|
|
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);
|
|
|
|
// The comment may arrive after we already saw its author's deletion
|
|
// request (out-of-order relays). Honor the deletion and don't surface it.
|
|
if (!existingMeta && this.isCommentDeleted(eventId, pubkey)) {
|
|
return { type: "skipped-deleted", 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 };
|
|
}
|
|
|
|
let changed = false;
|
|
|
|
if (existingMeta.parentKey !== parentKey) {
|
|
changed = true;
|
|
this.removeFromParentList(existingMeta.parentKey, eventId);
|
|
this.insertIntoParentList(parentKey, eventId, createdAt);
|
|
} else if (existingMeta.createdAt !== createdAt) {
|
|
changed = true;
|
|
this.reorderParentList(parentKey, eventId, createdAt);
|
|
}
|
|
|
|
if (pubkey) {
|
|
this.queueProfileForHydration(pubkey);
|
|
}
|
|
|
|
return { type: "update", parentId, parentKey, eventId, event, changed };
|
|
}
|
|
|
|
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 = normalizeHexId(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 "";
|
|
}
|
|
|
|
// The comment ids a NIP-09 kind-5 request asks to delete (its `e` tags).
|
|
extractDeletionTargets(event) {
|
|
const tags = Array.isArray(event?.tags) ? event.tags : [];
|
|
const ids = [];
|
|
for (const tag of tags) {
|
|
if (!Array.isArray(tag) || tag.length < 2 || tag[0] !== "e") {
|
|
continue;
|
|
}
|
|
const id = normalizeHexId(tag[1]);
|
|
if (id && !ids.includes(id)) {
|
|
ids.push(id);
|
|
}
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
isCommentDeleted(eventId, authorPubkey) {
|
|
if (!eventId || !authorPubkey) {
|
|
return false;
|
|
}
|
|
const deleters = this.deletionsByTarget.get(eventId);
|
|
return Boolean(deleters && deleters.has(authorPubkey));
|
|
}
|
|
|
|
// Apply a NIP-09 deletion request. A comment is only removed when the
|
|
// deletion is signed by the comment's own author (you can't delete someone
|
|
// else's comment). Deletions are also remembered so a comment that arrives
|
|
// afterwards (out-of-order relays) is suppressed on insert.
|
|
applyDeletion(event) {
|
|
const deleter = normalizePubkey(event.pubkey);
|
|
if (!deleter) {
|
|
return { type: "deletion", removed: false };
|
|
}
|
|
const targets = this.extractDeletionTargets(event);
|
|
let removed = false;
|
|
for (const targetId of targets) {
|
|
let deleters = this.deletionsByTarget.get(targetId);
|
|
if (!deleters) {
|
|
deleters = new Set();
|
|
this.deletionsByTarget.set(targetId, deleters);
|
|
}
|
|
deleters.add(deleter);
|
|
|
|
const existing = this.eventsById.get(targetId);
|
|
if (existing && normalizePubkey(existing.pubkey) === deleter) {
|
|
this.removeComment(targetId);
|
|
removed = true;
|
|
}
|
|
}
|
|
return { type: "deletion", removed };
|
|
}
|
|
|
|
removeComment(eventId) {
|
|
const meta = this.metaById.get(eventId);
|
|
if (meta) {
|
|
this.removeFromParentList(meta.parentKey, eventId);
|
|
this.metaById.delete(eventId);
|
|
}
|
|
this.eventsById.delete(eventId);
|
|
// Replies to a removed comment become unreachable from the root and so are
|
|
// hidden along with their deleted parent; their child list is left in place
|
|
// but never walked by getSnapshot().
|
|
}
|
|
|
|
async fetchAndApplyDeletions() {
|
|
const commentIds = Array.from(this.eventsById.keys());
|
|
if (!commentIds.length || typeof this.fetchDeletionEvents !== "function") {
|
|
return;
|
|
}
|
|
let deletionEvents = [];
|
|
try {
|
|
deletionEvents = await this.fetchDeletionEvents(commentIds, {
|
|
relays: this.activeRelays,
|
|
});
|
|
} catch (error) {
|
|
logDev(
|
|
this.logger?.dev,
|
|
"[commentThread] Failed to fetch comment deletions:",
|
|
error,
|
|
);
|
|
return;
|
|
}
|
|
if (Array.isArray(deletionEvents)) {
|
|
for (const deletionEvent of deletionEvents) {
|
|
this.processIncomingEvent(deletionEvent, { isInitial: true });
|
|
}
|
|
}
|
|
}
|
|
|
|
async _defaultFetchDeletions(commentIds, { relays } = {}) {
|
|
const pool = this.nostrClient?.pool;
|
|
if (
|
|
!pool ||
|
|
typeof pool.list !== "function" ||
|
|
!Array.isArray(commentIds) ||
|
|
!commentIds.length
|
|
) {
|
|
return [];
|
|
}
|
|
const relayList =
|
|
Array.isArray(relays) && relays.length
|
|
? relays
|
|
: Array.isArray(this.nostrClient?.relays)
|
|
? this.nostrClient.relays
|
|
: [];
|
|
if (!relayList.length) {
|
|
return [];
|
|
}
|
|
try {
|
|
const results = await pool.list(relayList, [
|
|
{ kinds: [DELETION_EVENT_KIND], "#e": commentIds },
|
|
]);
|
|
if (!Array.isArray(results)) {
|
|
return [];
|
|
}
|
|
return results
|
|
.flat()
|
|
.filter((event) => event && typeof event === "object");
|
|
} catch (error) {
|
|
logDev(
|
|
this.logger?.dev,
|
|
"[commentThread] Default deletion fetch failed:",
|
|
error,
|
|
);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
extractRootIdentifier(event) {
|
|
const tags = Array.isArray(event?.tags) ? event.tags : [];
|
|
for (let index = tags.length - 1; index >= 0; index -= 1) {
|
|
const tag = tags[index];
|
|
if (!Array.isArray(tag) || tag.length < 2) {
|
|
continue;
|
|
}
|
|
const name =
|
|
typeof tag[0] === "string" ? tag[0].trim().toLowerCase() : "";
|
|
if (name !== "i") {
|
|
continue;
|
|
}
|
|
const value = normalizeString(tag[1]);
|
|
if (!value) {
|
|
continue;
|
|
}
|
|
return {
|
|
identifier: value,
|
|
relay: normalizeRelay(tag[2]),
|
|
};
|
|
}
|
|
return { identifier: "", relay: "" };
|
|
}
|
|
|
|
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 pubkeyListLog = pubkeys.join(", ") || "(empty profile batch)";
|
|
const hydrationPromise = (async () => {
|
|
try {
|
|
let attempt = 0;
|
|
let lastError = null;
|
|
|
|
while (attempt < PROFILE_FETCH_MAX_ATTEMPTS) {
|
|
try {
|
|
await Promise.resolve(this.batchFetchProfiles(pubkeys));
|
|
break;
|
|
} catch (error) {
|
|
lastError = error;
|
|
const attemptLabel = `${attempt + 1}/${PROFILE_FETCH_MAX_ATTEMPTS}`;
|
|
if (this.logger?.dev?.warn) {
|
|
this.logger.dev.warn(
|
|
`[commentThread] Profile hydration attempt ${attemptLabel} failed for pubkeys: ${pubkeyListLog}.`,
|
|
error,
|
|
);
|
|
}
|
|
|
|
attempt += 1;
|
|
if (attempt >= PROFILE_FETCH_MAX_ATTEMPTS) {
|
|
throw lastError;
|
|
}
|
|
|
|
const backoffMs = PROFILE_FETCH_BACKOFF_MS * attempt;
|
|
logDev(
|
|
this.logger?.dev,
|
|
`[commentThread] Retrying profile hydration in ${backoffMs}ms for pubkeys: ${pubkeyListLog}.`,
|
|
);
|
|
await new Promise((resolve) => setTimeout(resolve, backoffMs));
|
|
}
|
|
}
|
|
} catch (error) {
|
|
const errorMessage = `[commentThread] Profile hydration failed for pubkeys: ${pubkeyListLog}.`;
|
|
if (this.logger?.user?.warn) {
|
|
this.logger.user.warn(errorMessage, error);
|
|
} else if (this.logger?.warn) {
|
|
this.logger.warn(errorMessage, error);
|
|
}
|
|
|
|
if (this.logger?.dev?.warn && this.logger.dev !== this.logger.user) {
|
|
this.logger.dev.warn(errorMessage, error);
|
|
}
|
|
|
|
this.emitError(error);
|
|
return;
|
|
}
|
|
|
|
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,
|
|
rootIdentifier: this.videoRootIdentifier || null,
|
|
rootIdentifierRelay: this.videoRootRelay || null,
|
|
parentCommentKind: this.parentCommentKind || null,
|
|
parentCommentPubkey: this.parentCommentPubkey || null,
|
|
topLevelIds: this.getCommentIdsForParent(null),
|
|
commentsById: this.cloneCommentsMap(),
|
|
childrenByParent: this.cloneTreeMap(),
|
|
profiles: this.getProfilesSnapshot(),
|
|
commentCacheDiagnostics: { ...this.commentCacheDiagnostics },
|
|
};
|
|
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,
|
|
rootIdentifier: this.videoRootIdentifier || null,
|
|
rootIdentifierRelay: this.videoRootRelay || null,
|
|
parentCommentKind: this.parentCommentKind || null,
|
|
parentCommentPubkey: this.parentCommentPubkey || null,
|
|
commentIds: [eventId],
|
|
commentsById: this.cloneCommentsMap(),
|
|
childrenByParent: this.cloneTreeMap(),
|
|
profiles: this.getProfilesSnapshot(),
|
|
commentCacheDiagnostics: { ...this.commentCacheDiagnostics },
|
|
};
|
|
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,
|
|
rootIdentifier: this.videoRootIdentifier || null,
|
|
rootIdentifierRelay: this.videoRootRelay || null,
|
|
parentCommentKind: this.parentCommentKind || null,
|
|
parentCommentPubkey: this.parentCommentPubkey || null,
|
|
commentsById: this.cloneCommentsMap(),
|
|
childrenByParent: this.cloneTreeMap(),
|
|
profiles: this.getProfilesSnapshot(),
|
|
commentCacheDiagnostics: { ...this.commentCacheDiagnostics },
|
|
};
|
|
}
|
|
|
|
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(normalizeHexId(parentId));
|
|
const list = this.childrenByParent.get(key);
|
|
return Array.isArray(list) ? [...list] : [];
|
|
}
|
|
|
|
getCommentEvent(commentId) {
|
|
const normalized = normalizeHexId(commentId);
|
|
if (!normalized) {
|
|
return null;
|
|
}
|
|
return this.eventsById.get(normalized) || null;
|
|
}
|
|
|
|
waitForProfileHydration() {
|
|
return this.profileHydrationPromise || Promise.resolve();
|
|
}
|
|
|
|
teardown() {
|
|
try {
|
|
this.persistCommentCache();
|
|
} catch (error) {
|
|
if (this.logger?.warn) {
|
|
this.logger.warn("[commentThread] Failed to persist comment cache:", error);
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|