diff --git a/js/nostr/client.js b/js/nostr/client.js index 1aeafe4b..c5e0ddce 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -117,6 +117,7 @@ import { isDmDecryptWorkerSupported, } from "./dmDecryptWorkerClient.js"; import { devLogger, userLogger } from "../utils/logger.js"; +import { LRUCache } from "../utils/lruCache.js"; import { updateConversationFromMessage, writeMessages } from "../storage/dmDb.js"; import { DM_RELAY_WARNING_FALLBACK, @@ -908,45 +909,6 @@ function cloneEventForCache(event) { const DM_DECRYPT_CACHE_LIMIT = 256; const DM_EVENT_KINDS = Object.freeze([4, 1059]); -class SimpleLruCache { - constructor(limit = 100) { - const resolvedLimit = Number.isFinite(limit) && limit > 0 ? limit : 100; - this.limit = Math.floor(resolvedLimit); - this.map = new Map(); - } - - get(key) { - if (!this.map.has(key)) { - return undefined; - } - - const value = this.map.get(key); - this.map.delete(key); - this.map.set(key, value); - return value; - } - - set(key, value) { - if (key === undefined || key === null) { - return; - } - - if (this.map.has(key)) { - this.map.delete(key); - } - this.map.set(key, value); - - while (this.map.size > this.limit) { - const oldestKey = this.map.keys().next().value; - this.map.delete(oldestKey); - } - } - - clear() { - this.map.clear(); - } -} - function buildDmFilters(actorPubkey, { since, until, limit } = {}) { const normalizedActor = normalizeActorKey(actorPubkey); const filters = []; @@ -1217,7 +1179,7 @@ export class NostrClient { }, }, }); - this.dmDecryptCache = new SimpleLruCache(DM_DECRYPT_CACHE_LIMIT); + this.dmDecryptCache = new LRUCache({ maxSize: DM_DECRYPT_CACHE_LIMIT }); this.dmDecryptor = null; this.dmDecryptorPromise = null; this.sessionActorCipherClosures = null; @@ -4009,6 +3971,13 @@ export class NostrClient { return DM_DECRYPT_CACHE_LIMIT; } + getDmDecryptCacheStats() { + if (!this.dmDecryptCache || typeof this.dmDecryptCache.getStats !== "function") { + return null; + } + return this.dmDecryptCache.getStats(); + } + clearDmDecryptCache() { if (this.dmDecryptCache) { this.dmDecryptCache.clear(); @@ -4231,6 +4200,11 @@ export class NostrClient { throw new Error("DM decryption helpers are unavailable."); } + const decryptLimit = + Number.isFinite(options?.decryptLimit) && options.decryptLimit > 0 + ? Math.floor(options.decryptLimit) + : null; + const relayCandidates = Array.isArray(options.relays) ? options.relays : Array.isArray(this.readRelays) && this.readRelays.length @@ -4266,8 +4240,19 @@ export class NostrClient { } } + let decryptTargets = Array.from(deduped.values()); + decryptTargets.sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0)); + + if (decryptLimit && decryptTargets.length > decryptLimit) { + devLogger.debug("[nostr] Limiting DM decrypt workload", { + requested: decryptTargets.length, + decryptLimit, + }); + decryptTargets = decryptTargets.slice(0, decryptLimit); + } + const messages = []; - for (const event of deduped.values()) { + for (const event of decryptTargets) { try { const decrypted = await this.decryptDirectMessageEvent(event, { actorPubkey: context.actorPubkey || actorPubkeyInput, diff --git a/js/nostr/dmDecryptWorkerClient.js b/js/nostr/dmDecryptWorkerClient.js index ae2a70ca..30b4b2a0 100644 --- a/js/nostr/dmDecryptWorkerClient.js +++ b/js/nostr/dmDecryptWorkerClient.js @@ -77,6 +77,10 @@ export function isDmDecryptWorkerSupported() { return typeof Worker !== "undefined"; } +export function getDmDecryptWorkerQueueSize() { + return pending.size; +} + export function decryptDmInWorker({ scheme = "nip04", privateKey, diff --git a/js/services/nostrService.js b/js/services/nostrService.js index 5216e0d0..80a4ee79 100644 --- a/js/services/nostrService.js +++ b/js/services/nostrService.js @@ -3,6 +3,7 @@ import { convertEventToVideo } from "../nostr/index.js"; import { accessControl } from "../accessControl.js"; import { ALLOW_NSFW_CONTENT } from "../config.js"; import { devLogger, userLogger } from "../utils/logger.js"; +import { LRUCache } from "../utils/lruCache.js"; import { describeAttachment, extractAttachmentsFromMessage } from "../attachments/attachmentUtils.js"; import moderationService from "./moderationService.js"; import { @@ -16,6 +17,7 @@ import { } from "../directMessagesStore.js"; import { writeMessages } from "../storage/dmDb.js"; import { DmNotificationManager } from "./dmNotificationManager.js"; +import { getDmDecryptWorkerQueueSize } from "../nostr/dmDecryptWorkerClient.js"; import { getVideosMap as getStoredVideosMap, setVideosMap as setStoredVideosMap, @@ -26,6 +28,9 @@ import { const VIDEO_KIND = 30078; const STORAGE_KEY_FILTERED_VIDEOS = "bitvid:filtered-videos:v1"; const MAX_PERSISTED_VIDEOS = 100; +const DM_DEFAULT_PAGE_LIMIT = 200; +const DM_INITIAL_DECRYPT_LIMIT = 120; +const DM_PREVIEW_CACHE_LIMIT = 500; function loadPersistedFilteredVideos() { if ( @@ -219,6 +224,55 @@ function extractSnapshotPreview(message) { return ""; } +function resolvePreviewCacheKey(message) { + if (!message || typeof message !== "object") { + return ""; + } + + const eventId = typeof message?.event?.id === "string" ? message.event.id : ""; + if (eventId) { + return eventId; + } + + const remote = + typeof message?.snapshot?.remotePubkey === "string" + ? message.snapshot.remotePubkey.trim() + : typeof message?.remotePubkey === "string" + ? message.remotePubkey.trim() + : ""; + const timestamp = Number.isFinite(message?.timestamp) + ? Math.floor(message.timestamp) + : Number.isFinite(message?.message?.created_at) + ? Math.floor(message.message.created_at) + : 0; + + if (remote && timestamp) { + return `snapshot:${remote}:${timestamp}`; + } + + return ""; +} + +function toPositiveInteger(value, fallback) { + const numeric = Number(value); + if (!Number.isFinite(numeric) || numeric <= 0) { + return fallback; + } + return Math.floor(numeric); +} + +function getMemoryUsageSnapshot() { + if (typeof performance === "undefined" || !performance?.memory) { + return null; + } + + return { + usedJSHeapSize: performance.memory.usedJSHeapSize, + totalJSHeapSize: performance.memory.totalJSHeapSize, + jsHeapSizeLimit: performance.memory.jsHeapSizeLimit, + }; +} + function getActiveKey(video) { if (video.videoRootId) { return `ROOT:${video.videoRootId}`; @@ -391,7 +445,7 @@ function normalizeDirectMessageRecord(message, actorPubkey = "") { }; } -function buildSnapshotFromMessages(messages, actorPubkey = "") { +function buildSnapshotFromMessages(messages, actorPubkey = "", resolvePreview = null) { const normalizedActor = normalizeHexPubkey(actorPubkey); const threadMap = new Map(); @@ -406,7 +460,10 @@ function buildSnapshotFromMessages(messages, actorPubkey = "") { } const timestamp = sanitizeSnapshotTimestamp(message.timestamp); - const preview = extractSnapshotPreview(message); + const preview = + typeof resolvePreview === "function" + ? resolvePreview(message) + : extractSnapshotPreview(message); const existing = threadMap.get(remote); if (!existing || timestamp > existing.latestTimestamp) { @@ -506,6 +563,7 @@ export class NostrService { this.dmNotificationManager = new DmNotificationManager({ logger: userLogger, }); + this.dmPreviewCache = new LRUCache({ maxSize: DM_PREVIEW_CACHE_LIMIT }); try { if (this.moderationService && typeof this.moderationService.setNostrClient === "function") { this.moderationService.setNostrClient(this.nostrClient); @@ -523,6 +581,49 @@ export class NostrService { } } + getDirectMessagePreviewCacheStats() { + if (!this.dmPreviewCache || typeof this.dmPreviewCache.getStats !== "function") { + return null; + } + return this.dmPreviewCache.getStats(); + } + + resolveDirectMessagePreview(message) { + const cacheKey = resolvePreviewCacheKey(message); + if (cacheKey && this.dmPreviewCache) { + const cached = this.dmPreviewCache.get(cacheKey); + if (cached !== undefined) { + return cached; + } + } + + const preview = extractSnapshotPreview(message); + if (cacheKey && this.dmPreviewCache) { + this.dmPreviewCache.set(cacheKey, preview); + } + return preview; + } + + logDirectMessageDiagnostics({ context = "", limit, decryptLimit } = {}) { + const cacheStats = + typeof this.nostrClient?.getDmDecryptCacheStats === "function" + ? this.nostrClient.getDmDecryptCacheStats() + : null; + const previewStats = this.getDirectMessagePreviewCacheStats(); + const memory = getMemoryUsageSnapshot(); + + devLogger.debug("[nostrService] DM diagnostics", { + context, + limit, + decryptLimit, + workerQueueSize: getDmDecryptWorkerQueueSize(), + messageCount: Array.isArray(this.dmMessages) ? this.dmMessages.length : 0, + decryptCache: cacheStats, + previewCache: previewStats, + memory, + }); + } + on(eventName, handler) { return this.emitter.on(eventName, handler); } @@ -595,6 +696,9 @@ export class NostrService { this.dmMessages = []; this.dmMessageIndex = new Map(); this.dmHydratedFromSnapshot = false; + if (this.dmPreviewCache) { + this.dmPreviewCache.clear(); + } const activeActor = normalizeHexPubkey( typeof actorPubkey === "string" && actorPubkey ? actorPubkey @@ -632,7 +736,7 @@ export class NostrService { await writeMessages(record); const notificationResult = await this.dmNotificationManager.recordMessage({ record, - preview: extractSnapshotPreview(message), + preview: this.resolveDirectMessagePreview(message), direction: message?.direction || "", }); if (notificationResult?.conversation) { @@ -808,6 +912,28 @@ export class NostrService { return []; } + const initialLoad = options.initialLoad !== false; + const limitCandidate = Number(options.limit); + const resolvedLimit = toPositiveInteger(limitCandidate, DM_DEFAULT_PAGE_LIMIT); + const decryptLimitCandidate = Number(options.decryptLimit); + let resolvedDecryptLimit = initialLoad + ? toPositiveInteger(decryptLimitCandidate, DM_INITIAL_DECRYPT_LIMIT) + : Number.isFinite(decryptLimitCandidate) && decryptLimitCandidate > 0 + ? Math.floor(decryptLimitCandidate) + : null; + + if (resolvedDecryptLimit && resolvedDecryptLimit > resolvedLimit) { + resolvedDecryptLimit = resolvedLimit; + } + + if (!Number.isFinite(limitCandidate) || limitCandidate <= 0) { + devLogger.debug("[nostrService] Using default DM pagination limit", { + limit: resolvedLimit, + decryptLimit: resolvedDecryptLimit, + initialLoad, + }); + } + const forceHydration = this.dmActorPubkey && this.dmActorPubkey !== normalizedActor; @@ -824,6 +950,8 @@ export class NostrService { messages = await this.nostrClient.listDirectMessages(normalizedActor, { relays, ...options, + limit: resolvedLimit, + decryptLimit: resolvedDecryptLimit, }); } catch (error) { userLogger.warn("[nostrService] Failed to load direct messages", error); @@ -885,6 +1013,12 @@ export class NostrService { ); } + this.logDirectMessageDiagnostics({ + context: "load", + limit: resolvedLimit, + decryptLimit: resolvedDecryptLimit, + }); + return snapshot; } @@ -1047,6 +1181,7 @@ export class NostrService { const snapshotPayload = buildSnapshotFromMessages( sourceMessages, normalizedActor, + (message) => this.resolveDirectMessagePreview(message), ); if (!snapshotPayload.length) { diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index c48577ce..50b7eaa7 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -3975,6 +3975,7 @@ export class ProfileModalController { try { let snapshot = await this.nostrService.loadDirectMessages({ actorPubkey: actor, + initialLoad: true, }); if (!Array.isArray(snapshot)) { snapshot = [];