import { nostrClient } from "../nostrClientFacade.js"; import { convertEventToVideo, isAudioOnlyVideoObject } 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 { DM_RELAY_WARNING_FALLBACK, resolveDmRelaySelection, } from "./dmNostrService.js"; import { loadDirectMessageSnapshot, saveDirectMessageSnapshot, clearDirectMessageSnapshot, } 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, getVideoSubscription as getStoredVideoSubscription, setVideoSubscription as setStoredVideoSubscription, } from "../state/appState.js"; 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 ( typeof window === "undefined" || !window.localStorage || typeof window.localStorage.getItem !== "function" ) { return []; } try { const raw = window.localStorage.getItem(STORAGE_KEY_FILTERED_VIDEOS); if (!raw) { return []; } const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed : []; } catch (error) { userLogger.warn("[nostrService] Failed to load persisted filtered videos", error); return []; } } function persistFilteredVideos(videos) { if ( typeof window === "undefined" || !window.localStorage || typeof window.localStorage.setItem !== "function" ) { return; } if (!Array.isArray(videos)) { return; } try { const sliced = videos.slice(0, MAX_PERSISTED_VIDEOS); const serialized = JSON.stringify(sliced); window.localStorage.setItem(STORAGE_KEY_FILTERED_VIDEOS, serialized); } catch (error) { userLogger.warn("[nostrService] Failed to persist filtered videos", error); } } class SimpleEventEmitter { constructor(logger = null) { this.logger = typeof logger === "function" ? logger : null; this.listeners = new Map(); } on(eventName, handler) { if (typeof handler !== "function") { return () => {}; } if (!this.listeners.has(eventName)) { this.listeners.set(eventName, new Set()); } const handlers = this.listeners.get(eventName); handlers.add(handler); return () => { handlers.delete(handler); if (!handlers.size) { this.listeners.delete(eventName); } }; } emit(eventName, detail) { const handlers = this.listeners.get(eventName); if (!handlers || !handlers.size) { return; } for (const handler of Array.from(handlers)) { try { handler(detail); } catch (error) { if (this.logger) { try { this.logger(`nostrService listener for "${eventName}" threw`, error); } catch (logError) { userLogger.warn("[nostrService] listener logger threw", logError); } } } } } } function normalizeLogger(logger) { if (typeof logger === "function") { return logger; } if (logger && typeof logger.log === "function") { return (...args) => logger.log(...args); } return () => {}; } function ensureSet(value) { if (value instanceof Set) { return value; } if (Array.isArray(value)) { return new Set(value); } return new Set(); } function normalizeHexPubkey(pubkey) { if (typeof pubkey !== "string") { return ""; } const trimmed = pubkey.trim(); return trimmed ? trimmed.toLowerCase() : ""; } function normalizeUntil(timestamp) { const value = Number(timestamp); if (!Number.isFinite(value) || value <= 0) { return 0; } const seconds = Math.floor(value); return seconds > 0 ? seconds : 0; } function sanitizeSnapshotTimestamp(value) { const numeric = Number(value); if (!Number.isFinite(numeric) || numeric <= 0) { return 0; } const floored = Math.floor(numeric); if (!Number.isFinite(floored) || floored <= 0) { return 0; } return floored; } function resolveDirectMessageCreatedAt(message) { const createdAt = sanitizeSnapshotTimestamp( message?.message?.created_at ?? message?.event?.created_at ?? message?.created_at ?? message?.timestamp, ); if (createdAt) { return createdAt; } return Math.floor(Date.now() / 1000); } function resolveDirectMessageSortValue(message) { return sanitizeSnapshotTimestamp( message?.created_at ?? message?.timestamp ?? message?.message?.created_at ?? message?.event?.created_at, ); } const SNAPSHOT_PREVIEW_LIMIT = 160; function sanitizeSnapshotPreview(value) { if (typeof value !== "string") { return ""; } let preview = value.replace(/\s+/g, " ").trim(); if (!preview) { return ""; } if (preview.length > SNAPSHOT_PREVIEW_LIMIT) { preview = `${preview.slice(0, SNAPSHOT_PREVIEW_LIMIT).trimEnd()}\u2026`; } return preview; } function extractSnapshotPreview(message) { if (!message || typeof message !== "object") { return ""; } const candidates = []; if (typeof message.plaintext === "string") { candidates.push(message.plaintext); } if (typeof message.preview === "string") { candidates.push(message.preview); } if (message.message && typeof message.message.content === "string") { candidates.push(message.message.content); } const selected = candidates.find((candidate) => candidate && candidate.trim()); if (selected) { return sanitizeSnapshotPreview(selected); } const attachments = extractAttachmentsFromMessage(message); if (attachments.length) { return sanitizeSnapshotPreview(describeAttachment(attachments[0])); } 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 = resolveDirectMessageSortValue(message); 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}`; } const dTag = video.tags?.find((t) => Array.isArray(t) && t[0] === "d"); if (dTag && typeof dTag[1] === "string") { return `${video.pubkey}:${dTag[1]}`; } return `LEGACY:${video.id}`; } function resolveDirectMessageRemotePubkey(message, actorPubkey = "") { const normalizedActor = normalizeHexPubkey(actorPubkey); const directRemote = typeof message?.remotePubkey === "string" ? normalizeHexPubkey(message.remotePubkey) : ""; if (directRemote && directRemote !== normalizedActor) { return directRemote; } const snapshotRemote = typeof message?.snapshot?.remotePubkey === "string" ? normalizeHexPubkey(message.snapshot.remotePubkey) : ""; if (snapshotRemote && snapshotRemote !== normalizedActor) { return snapshotRemote; } const direction = typeof message?.direction === "string" ? message.direction.toLowerCase() : ""; const senderHex = typeof message?.sender?.pubkey === "string" ? normalizeHexPubkey(message.sender.pubkey) : ""; if (direction === "incoming" && senderHex && senderHex !== normalizedActor) { return senderHex; } if (Array.isArray(message?.recipients)) { for (const recipient of message.recipients) { const candidate = recipient && typeof recipient.pubkey === "string" ? normalizeHexPubkey(recipient.pubkey) : ""; if (candidate && candidate !== normalizedActor) { return candidate; } } } if (direction === "outgoing" && senderHex && senderHex !== normalizedActor) { return senderHex; } const messagePubkey = typeof message?.message?.pubkey === "string" ? normalizeHexPubkey(message.message.pubkey) : ""; if (messagePubkey && messagePubkey !== normalizedActor) { return messagePubkey; } const eventPubkey = typeof message?.event?.pubkey === "string" ? normalizeHexPubkey(message.event.pubkey) : ""; if (eventPubkey && eventPubkey !== normalizedActor) { return eventPubkey; } if (senderHex && senderHex !== normalizedActor) { return senderHex; } return ""; } function buildConversationId(actorPubkey, remotePubkey) { const normalizedActor = normalizeHexPubkey(actorPubkey); const normalizedRemote = normalizeHexPubkey(remotePubkey); if (!normalizedActor || !normalizedRemote) { return ""; } return `dm:${[normalizedActor, normalizedRemote].sort().join(":")}`; } function normalizeDirectMessageRecord(message, actorPubkey = "") { if (!message || message.ok !== true) { return null; } const actor = normalizeHexPubkey( actorPubkey || message.actorPubkey || "", ); const remote = resolveDirectMessageRemotePubkey(message, actor); const conversationId = buildConversationId(actor, remote); const eventId = typeof message?.event?.id === "string" ? message.event.id : ""; if (!conversationId || !eventId) { return null; } const senderPubkey = normalizeHexPubkey( message?.sender?.pubkey || message?.message?.pubkey || message?.event?.pubkey || "", ); const direction = message.direction; let receiverPubkey = ""; if (direction === "incoming") { receiverPubkey = actor; } else if (direction === "outgoing") { receiverPubkey = remote; } else if (senderPubkey) { receiverPubkey = senderPubkey === actor ? remote : actor; } const createdAt = resolveDirectMessageCreatedAt(message); const kind = Number.isFinite(message?.message?.kind) ? message.message.kind : Number.isFinite(message?.event?.kind) ? message.event.kind : 0; const tags = Array.isArray(message?.message?.tags) ? message.message.tags : Array.isArray(message?.event?.tags) ? message.event.tags : []; const encryptionScheme = typeof message?.encryption_scheme === "string" ? message.encryption_scheme.trim() : typeof message?.scheme === "string" ? message.scheme.trim() : typeof message?.decryptor?.scheme === "string" ? message.decryptor.scheme.trim() : ""; return { id: eventId, conversation_id: conversationId, sender_pubkey: senderPubkey || actor, receiver_pubkey: receiverPubkey, created_at: createdAt, kind, content: typeof message?.plaintext === "string" ? message.plaintext : "", tags, relay: typeof message?.event?.relay === "string" ? message.event.relay : "", status: "published", seen: direction !== "incoming", encryption_scheme: encryptionScheme, }; } function buildSnapshotFromMessages(messages, actorPubkey = "", resolvePreview = null) { const normalizedActor = normalizeHexPubkey(actorPubkey); const threadMap = new Map(); for (const message of Array.isArray(messages) ? messages : []) { if (!message || message.ok !== true) { continue; } const remote = resolveDirectMessageRemotePubkey(message, normalizedActor); if (!remote) { continue; } const timestamp = resolveDirectMessageSortValue(message); const preview = typeof resolvePreview === "function" ? resolvePreview(message) : extractSnapshotPreview(message); const existing = threadMap.get(remote); if (!existing || timestamp > existing.latestTimestamp) { threadMap.set(remote, { remotePubkey: remote, latestTimestamp: timestamp, preview, }); } } return Array.from(threadMap.values()).sort( (a, b) => (b.latestTimestamp || 0) - (a.latestTimestamp || 0), ); } function normalizeDirectMessagePayload(message) { if (!message || message.ok !== true) { return null; } const eventId = typeof message?.event?.id === "string" ? message.event.id : ""; if (!eventId) { return null; } const createdAt = resolveDirectMessageCreatedAt(message); return { ...message, created_at: createdAt, timestamp: createdAt, }; } function mergeDirectMessageCache(existingMessages, incomingMessages) { const mergedIndex = new Map(); const upsert = (message) => { const normalized = normalizeDirectMessagePayload(message); if (!normalized) { return; } const eventId = normalized.event.id; const existing = mergedIndex.get(eventId); if ( !existing || normalized.created_at >= resolveDirectMessageSortValue(existing) ) { mergedIndex.set(eventId, normalized); } }; for (const message of Array.isArray(existingMessages) ? existingMessages : []) { upsert(message); } for (const message of Array.isArray(incomingMessages) ? incomingMessages : []) { upsert(message); } const mergedMessages = Array.from(mergedIndex.values()); mergedMessages.sort( (a, b) => resolveDirectMessageSortValue(b) - resolveDirectMessageSortValue(a), ); return { messages: mergedMessages, index: mergedIndex }; } function hydrateMessagesFromSnapshot(snapshot, actorPubkey = "") { const normalizedActor = normalizeHexPubkey(actorPubkey); const hydrated = []; const entries = Array.isArray(snapshot) ? snapshot : []; entries.sort( (a, b) => (b?.latestTimestamp || 0) - (a?.latestTimestamp || 0), ); entries.forEach((entry, index) => { const remote = normalizeHexPubkey(entry?.remotePubkey || entry?.remote); if (!remote) { return; } const timestamp = sanitizeSnapshotTimestamp(entry?.latestTimestamp); const preview = sanitizeSnapshotPreview(entry?.preview || ""); hydrated.push({ ok: true, timestamp, plaintext: preview || null, preview: preview || "", actorPubkey: normalizedActor, remotePubkey: remote, sender: remote ? { pubkey: remote, relayHints: [], role: "snapshot", } : null, recipients: [], direction: "unknown", scheme: "", encryption_scheme: "", decryptor: { scheme: "", source: "snapshot" }, event: { id: `dm-snapshot:${normalizedActor || "actor"}:${remote}:${timestamp}:${index}`, }, message: preview ? { content: preview, } : null, snapshot: { remotePubkey: remote, timestamp, preview, }, }); }); return hydrated; } export class NostrService { constructor({ logger } = {}) { this.nostrClient = nostrClient; this.accessControl = accessControl; this.logger = normalizeLogger(logger); this.emitter = new SimpleEventEmitter((message, error) => { try { this.logger(message, error); } catch (logError) { userLogger.warn("[nostrService] logger threw", logError); } }); this.videosMap = null; this.videosByAuthorIndex = null; this.authorIndexDirty = false; this.moderationService = moderationService || null; this.initialLoadPromise = null; this.initialLoadResolved = false; this.initialLoadResolve = null; this.dmMessages = []; this.dmMessageIndex = new Map(); this.dmSubscription = null; this.dmActorPubkey = null; this.dmSubscriptionRelays = null; this.dmHydratedFromSnapshot = false; this.dmNotificationManager = new DmNotificationManager({ logger: userLogger, }); this.dmPreviewCache = new LRUCache({ maxSize: DM_PREVIEW_CACHE_LIMIT }); this._dmBlockChecker = null; try { if (this.moderationService && typeof this.moderationService.setNostrClient === "function") { this.moderationService.setNostrClient(this.nostrClient); } } catch (error) { userLogger.warn("[nostrService] Failed to attach moderation service", error); } } setDmBlockChecker(checker) { this._dmBlockChecker = typeof checker === "function" ? checker : null; } _isDmRemoteBlocked(message, actorPubkey) { if (typeof this._dmBlockChecker !== "function") { return false; } try { const actor = normalizeHexPubkey(actorPubkey || this.dmActorPubkey || ""); const remote = resolveDirectMessageRemotePubkey(message, actor); if (!remote) { return false; } return this._dmBlockChecker(remote); } catch (error) { devLogger.warn("[nostrService] Block check failed for DM", error); return false; } } log(...args) { try { this.logger(...args); } catch (error) { userLogger.warn("[nostrService] logger threw", error); } } 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); } emit(eventName, detail) { this.emitter.emit(eventName, detail); } ensureInitialLoadDeferred() { if (!this.initialLoadPromise) { this.initialLoadResolved = false; this.initialLoadPromise = new Promise((resolve) => { this.initialLoadResolve = (value) => { if (this.initialLoadResolved) { return; } this.initialLoadResolved = true; this.initialLoadResolve = null; resolve(value); }; }); } return this.initialLoadPromise; } resolveInitialLoad(value) { if (this.initialLoadResolved) { return; } if (typeof this.initialLoadResolve === "function") { try { this.initialLoadResolve(value); } finally { this.initialLoadResolve = null; this.initialLoadResolved = true; } } } awaitInitialLoad() { return this.ensureInitialLoadDeferred(); } resolveActiveDmActor() { if ( this.nostrClient && typeof this.nostrClient.pubkey === "string" && this.nostrClient.pubkey ) { return this.nostrClient.pubkey; } if ( this.nostrClient?.sessionActor && typeof this.nostrClient.sessionActor.pubkey === "string" ) { return this.nostrClient.sessionActor.pubkey; } return ""; } getDirectMessages() { return Array.isArray(this.dmMessages) ? [...this.dmMessages] : []; } // keepSnapshot: clear the IN-MEMORY DM store but preserve each account's // persisted per-actor snapshot. Used on account SWITCH so the new account // doesn't show the previous account's messages, while switching back stays fast // (its cached DMs survive). Logout still fully clears (keepSnapshot defaults off). clearDirectMessages({ actorPubkey = null, emit = true, keepSnapshot = false } = {}) { this.dmMessages = []; this.dmMessageIndex = new Map(); this.dmHydratedFromSnapshot = false; if (this.dmPreviewCache) { this.dmPreviewCache.clear(); } if (this.dmNotificationManager?.reset) { try { this.dmNotificationManager.reset(); } catch (error) { userLogger.warn("[nostrService] Failed to reset DM notifications cache", error); } } const activeActor = normalizeHexPubkey( typeof actorPubkey === "string" && actorPubkey ? actorPubkey : this.dmActorPubkey || this.resolveActiveDmActor(), ); if (activeActor && !keepSnapshot) { clearDirectMessageSnapshot(activeActor).catch((error) => { userLogger.warn("[nostrService] Failed to clear DM snapshot", error); }); } this.dmActorPubkey = null; if (typeof this.nostrClient?.clearDmDecryptCache === "function") { try { this.nostrClient.clearDmDecryptCache(); } catch (error) { userLogger.warn("[nostrService] Failed to clear DM decrypt cache", error); } } if (emit) { this.emit("directMessages:cleared", {}); this.emit("directMessages:updated", { messages: [] }); } } async persistDirectMessageRecord(message, { actorPubkey = null } = {}) { const record = normalizeDirectMessageRecord( message, actorPubkey || message?.actorPubkey || this.dmActorPubkey, ); if (!record) { return null; } if (this._isDmRemoteBlocked(message, actorPubkey)) { devLogger.log("[nostrService] Skipping persist for blocked DM sender", { id: record.id, }); return null; } try { await writeMessages(record); const notificationResult = await this.dmNotificationManager.recordMessage({ record, preview: this.resolveDirectMessagePreview(message), direction: message?.direction || "", }); if (notificationResult?.conversation) { this.emit("directMessages:conversationUpdated", { conversation: notificationResult.conversation, }); } if (notificationResult?.shouldNotify) { this.emit("directMessages:notification", { conversationId: record.conversation_id, message: record, unseenCount: notificationResult.conversation?.unseen_count || 0, }); } return record; } catch (error) { userLogger.warn("[nostrService] Failed to persist DM record", error); return null; } } setFocusedDirectMessageConversation(conversationId, isFocused = true) { this.dmNotificationManager.setFocusedConversation(conversationId, isFocused); } async acknowledgeRenderedDirectMessages(conversationId, renderedUntil) { const conversation = await this.dmNotificationManager.acknowledgeRenderedMessages({ conversationId, renderedUntil, }); if (conversation) { this.emit("directMessages:conversationUpdated", { conversation, }); } return conversation; } getDirectMessageUnseenCount(conversationId) { return this.dmNotificationManager.getUnseenCount(conversationId); } async listDirectMessageConversationSummaries() { return this.dmNotificationManager.listConversationSummaries(); } applyDirectMessage(message, { reason = "update", event = null } = {}) { if (!message || message.ok !== true) { return; } const eventId = message?.event?.id; if (typeof eventId !== "string" || !eventId) { return; } const normalized = normalizeDirectMessagePayload(message); if (!normalized) { return; } if (this._isDmRemoteBlocked(normalized)) { devLogger.log("[nostrService] Dropping DM from blocked user", { eventId }); return; } this.dmMessageIndex.set(eventId, normalized); const actor = normalizeHexPubkey( normalized?.actorPubkey || this.dmActorPubkey || this.resolveActiveDmActor(), ); const remote = resolveDirectMessageRemotePubkey(normalized, actor); if (remote) { this.dmMessages = this.dmMessages.filter( (entry) => !entry?.snapshot || entry.snapshot.remotePubkey !== remote, ); } const existingIndex = this.dmMessages.findIndex( (entry) => entry?.event?.id === eventId, ); if (existingIndex >= 0) { this.dmMessages[existingIndex] = normalized; } else { this.dmMessages.push(normalized); } this.dmMessages.sort( (a, b) => resolveDirectMessageSortValue(b) - resolveDirectMessageSortValue(a), ); if (actor) { this.dmActorPubkey = actor; } this.dmHydratedFromSnapshot = false; this.emit("directMessages:message", { message: normalized, reason, event }); this.emit("directMessages:updated", { messages: this.getDirectMessages(), reason, }); if (actor) { this.persistDirectMessageSnapshot(actor).catch((error) => { userLogger.warn( "[nostrService] Failed to persist DM snapshot after update", error, ); }); } if (!normalized.snapshot) { void this.persistDirectMessageRecord(normalized, { actorPubkey: actor }); } } async hydrateDirectMessagesFromStore({ actorPubkey = null, emit = false, force = false, } = {}) { const activeActor = normalizeHexPubkey( typeof actorPubkey === "string" && actorPubkey ? actorPubkey : this.resolveActiveDmActor(), ); if (!activeActor) { return []; } let stored = []; try { stored = await loadDirectMessageSnapshot(activeActor); } catch (error) { userLogger.warn("[nostrService] Failed to load DM snapshot", error); return []; } const hydrated = hydrateMessagesFromSnapshot(stored, activeActor); const shouldReplace = force || !Array.isArray(this.dmMessages) || !this.dmMessages.length || this.dmHydratedFromSnapshot; this.dmActorPubkey = activeActor; if (!shouldReplace) { return this.getDirectMessages(); } if (force) { this.dmMessages = []; this.dmMessageIndex = new Map(); } const merged = mergeDirectMessageCache(this.dmMessages, hydrated); this.dmMessageIndex = merged.index; this.dmMessages = merged.messages; this.dmHydratedFromSnapshot = hydrated.length > 0; const snapshot = this.getDirectMessages(); if (emit) { this.emit("directMessages:hydrated", { messages: snapshot, actorPubkey: activeActor, }); this.emit("directMessages:updated", { messages: snapshot, reason: "snapshot", }); } return snapshot; } async loadDirectMessages({ actorPubkey, relays, ...options } = {}) { const activeActor = actorPubkey || this.resolveActiveDmActor(); const normalizedActor = normalizeHexPubkey(activeActor); if (!normalizedActor) { 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; const sinceCandidate = Number(options.since); const untilCandidate = Number(options.until); 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; await this.hydrateDirectMessagesFromStore({ actorPubkey: normalizedActor, emit: true, force: forceHydration, }); this.dmActorPubkey = normalizedActor; const relaySelection = await this.resolveDirectMessageRelaySelection( normalizedActor, relays, ); const resolvedRelays = relaySelection.relays; let resolvedSince = Number.isFinite(sinceCandidate) && sinceCandidate >= 0 ? Math.floor(sinceCandidate) : null; const resolvedUntil = Number.isFinite(untilCandidate) && untilCandidate >= 0 ? Math.floor(untilCandidate) : null; if (resolvedSince === null && initialLoad) { let latestTimestamp = 0; for (const message of Array.isArray(this.dmMessages) ? this.dmMessages : []) { const timestamp = resolveDirectMessageSortValue(message); if (timestamp > latestTimestamp) { latestTimestamp = timestamp; } } if (latestTimestamp > 0) { resolvedSince = Math.max(latestTimestamp - 60, 0); } } let messages = []; try { messages = await this.nostrClient.listDirectMessages(normalizedActor, { relays: resolvedRelays, ...options, limit: resolvedLimit, decryptLimit: resolvedDecryptLimit, ...(resolvedSince !== null ? { since: resolvedSince } : {}), ...(resolvedUntil !== null ? { until: resolvedUntil } : {}), onMessage: (message) => { this.applyDirectMessage(message, { reason: "load-incremental" }); }, }); } catch (error) { userLogger.warn("[nostrService] Failed to load direct messages", error); return this.getDirectMessages(); } const collected = []; for (const message of Array.isArray(messages) ? messages : []) { const normalized = normalizeDirectMessagePayload(message); if (!normalized) { continue; } collected.push(normalized); } const merged = mergeDirectMessageCache(this.dmMessages, collected); this.dmMessageIndex = merged.index; this.dmMessages = merged.messages; const snapshot = this.getDirectMessages(); this.dmHydratedFromSnapshot = false; this.emit("directMessages:loaded", { messages: snapshot, actorPubkey: normalizedActor, }); if (relaySelection.warning === DM_RELAY_WARNING_FALLBACK) { this.emit("directMessages:relayWarning", { actorPubkey: normalizedActor, warning: relaySelection.warning, relays: resolvedRelays, source: relaySelection.source, }); } this.emit("directMessages:updated", { messages: snapshot, reason: "load", }); try { await this.persistDirectMessageSnapshot(normalizedActor, snapshot); } catch (error) { userLogger.warn( "[nostrService] Failed to persist DM snapshot after load", error, ); } this.logDirectMessageDiagnostics({ context: "load", limit: resolvedLimit, decryptLimit: resolvedDecryptLimit, }); return snapshot; } async resolveDirectMessageRelaySelection(actorPubkey, relays = null) { if (Array.isArray(relays)) { return { relays, source: "explicit", warning: null }; } const discoveryRelays = Array.isArray(this.nostrClient?.readRelays) ? this.nostrClient.readRelays : Array.isArray(this.nostrClient?.relays) ? this.nostrClient.relays : []; let pool = this.nostrClient?.pool || null; if (!pool && typeof this.nostrClient?.ensurePool === "function") { try { pool = await this.nostrClient.ensurePool(); } catch (error) { devLogger.warn("[nostrService] Failed to initialize DM relay pool.", error); } } return resolveDmRelaySelection({ pubkey: actorPubkey, discoveryRelays, fallbackRelays: discoveryRelays, pool, log: { dev: devLogger, user: userLogger }, }); } async ensureDirectMessageSubscription({ actorPubkey, relays, ...handlers } = {}) { const activeActor = actorPubkey || this.resolveActiveDmActor(); const normalizedActor = normalizeHexPubkey(activeActor); if (!normalizedActor) { return null; } if (this.dmSubscription) { if (this.dmActorPubkey === normalizedActor) { devLogger.log( "[nostrService] Reusing existing DM subscription for active actor.", { actorPubkey: normalizedActor, relays: Array.isArray(this.dmSubscriptionRelays) ? this.dmSubscriptionRelays : [], }, ); return this.dmSubscription; } devLogger.log( "[nostrService] Replacing existing DM subscription for new actor.", { previous: this.dmActorPubkey, next: normalizedActor, previousRelays: Array.isArray(this.dmSubscriptionRelays) ? this.dmSubscriptionRelays : [], }, ); this.stopDirectMessageSubscription(); } const relaySelection = await this.resolveDirectMessageRelaySelection( normalizedActor, relays, ); const resolvedRelays = relaySelection.relays; try { const subscription = this.nostrClient.subscribeDirectMessages( normalizedActor, { relays: resolvedRelays, onEvent: handlers.onEvent, onMessage: (message, context = {}) => { this.applyDirectMessage(message, { reason: "subscription", event: context.event || null, }); if (typeof handlers.onMessage === "function") { try { handlers.onMessage(message, context); } catch (error) { userLogger.warn( "[nostrService] DM onMessage handler threw", error, ); } } }, onFailure: (payload, context) => { this.emit("directMessages:failure", { failure: payload, context }); if (typeof handlers.onFailure === "function") { try { handlers.onFailure(payload, context); } catch (error) { userLogger.warn( "[nostrService] DM onFailure handler threw", error, ); } } }, onError: (error, context) => { this.emit("directMessages:error", { error, context }); if (typeof handlers.onError === "function") { try { handlers.onError(error, context); } catch (handlerError) { userLogger.warn( "[nostrService] DM onError handler threw", handlerError, ); } } else { userLogger.warn( "[nostrService] Direct message subscription error", error, ); } }, onEose: () => { this.emit("directMessages:eose", { actorPubkey: normalizedActor }); if (typeof handlers.onEose === "function") { try { handlers.onEose(); } catch (error) { userLogger.warn( "[nostrService] DM onEose handler threw", error, ); } } }, }, ); this.dmSubscription = subscription; this.dmActorPubkey = normalizedActor; this.dmSubscriptionRelays = Array.isArray(resolvedRelays) ? resolvedRelays.slice() : []; devLogger.log("[nostrService] DM subscription activated.", { actorPubkey: normalizedActor, relays: resolvedRelays, }); this.emit("directMessages:subscribed", { subscription }); if (relaySelection?.warning === DM_RELAY_WARNING_FALLBACK) { this.emit("directMessages:relayWarning", { actorPubkey: normalizedActor, warning: relaySelection.warning, relays: resolvedRelays, source: relaySelection.source, }); } return subscription; } catch (error) { userLogger.warn( "[nostrService] Failed to subscribe to direct messages", error, ); return null; } } stopDirectMessageSubscription() { if (this.dmSubscription && typeof this.dmSubscription.unsub === "function") { try { this.dmSubscription.unsub(); } catch (error) { userLogger.warn( "[nostrService] Failed to unsubscribe from direct messages", error, ); } } this.dmSubscription = null; this.dmSubscriptionRelays = null; } async persistDirectMessageSnapshot(actorPubkey = null, messages = null) { const normalizedActor = normalizeHexPubkey( typeof actorPubkey === "string" && actorPubkey ? actorPubkey : this.dmActorPubkey || this.resolveActiveDmActor(), ); if (!normalizedActor) { return []; } const sourceMessages = Array.isArray(messages) ? messages : this.dmMessages; const snapshotPayload = buildSnapshotFromMessages( sourceMessages, normalizedActor, (message) => this.resolveDirectMessagePreview(message), ); if (!snapshotPayload.length) { await clearDirectMessageSnapshot(normalizedActor); return snapshotPayload; } return saveDirectMessageSnapshot(normalizedActor, snapshotPayload); } getModerationService() { if (!this.moderationService) { return null; } try { if ( typeof this.moderationService.setNostrClient === "function" && this.moderationService.nostrClient !== this.nostrClient ) { this.moderationService.setNostrClient(this.nostrClient); } if (typeof this.moderationService.refreshViewerFromClient === "function") { this.moderationService.refreshViewerFromClient(); } } catch (error) { userLogger.warn("[nostrService] Failed to synchronize moderation service", error); } return this.moderationService; } ensureVideosMap() { if (this.videosMap instanceof Map) { return this.videosMap; } let map = getStoredVideosMap(); if (!(map instanceof Map)) { map = new Map(); setStoredVideosMap(map); } this.videosMap = map; this.markAuthorIndexDirty(); return this.videosMap; } getVideosMap() { return this.ensureVideosMap(); } getVideoSubscription() { return getStoredVideoSubscription(); } setVideoSubscription(subscription) { setStoredVideoSubscription(subscription || null); this.emit("subscription:changed", { subscription: subscription || null }); } clearVideoSubscription() { const current = getStoredVideoSubscription(); if (current && typeof current.unsub === "function") { try { current.unsub(); } catch (error) { userLogger.warn("[nostrService] Failed to unsubscribe from video feed:", error); } } this.setVideoSubscription(null); } cacheVideos(videos = []) { if (!Array.isArray(videos) || !videos.length) { return; } const map = this.ensureVideosMap(); for (const video of videos) { if (video && typeof video.id === "string" && video.id) { map.set(video.id, video); } } setStoredVideosMap(map); this.markAuthorIndexDirty(); this.emit("videos:cache", { size: map.size }); } resetVideosCache() { const map = new Map(); this.videosMap = map; this.videosByAuthorIndex = new Map(); this.authorIndexDirty = false; setStoredVideosMap(map); this.emit("videos:cache", { size: 0 }); } markAuthorIndexDirty() { this.authorIndexDirty = true; } ensureVideosByAuthorIndex() { const videosMap = this.ensureVideosMap(); if (this.videosByAuthorIndex instanceof Map && !this.authorIndexDirty) { return this.videosByAuthorIndex; } const index = new Map(); for (const video of videosMap.values()) { if (!video || typeof video !== "object") { continue; } const author = normalizeHexPubkey(video.pubkey); if (!author) { continue; } if (!index.has(author)) { index.set(author, []); } index.get(author).push(video); } for (const [author, videos] of index.entries()) { if (!Array.isArray(videos) || videos.length <= 1) { continue; } videos.sort((a, b) => { const aCreatedCandidate = Number(a?.created_at); const bCreatedCandidate = Number(b?.created_at); const aCreated = Number.isFinite(aCreatedCandidate) ? aCreatedCandidate : 0; const bCreated = Number.isFinite(bCreatedCandidate) ? bCreatedCandidate : 0; return bCreated - aCreated; }); index.set(author, videos); } this.videosByAuthorIndex = index; this.authorIndexDirty = false; return this.videosByAuthorIndex; } shouldIncludeVideo(video, { blacklistedEventIds = new Set(), isAuthorBlocked = () => false, } = {}) { if (!video || typeof video !== "object") { return false; } // Audio-only notes (podcasts/music) have no