import { nostrClient } from "../nostrClientFacade.js"; 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 { 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; } 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 = 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}`; } 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 = sanitizeSnapshotTimestamp( message?.message?.created_at ?? message?.event?.created_at ?? message?.timestamp, ); 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 = sanitizeSnapshotTimestamp(message.timestamp); 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 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.dmHydratedFromSnapshot = false; 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); } } catch (error) { userLogger.warn("[nostrService] Failed to attach moderation service", error); } } 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] : []; } clearDirectMessages({ actorPubkey = null, emit = true } = {}) { this.dmMessages = []; this.dmMessageIndex = new Map(); this.dmHydratedFromSnapshot = false; if (this.dmPreviewCache) { this.dmPreviewCache.clear(); } const activeActor = normalizeHexPubkey( typeof actorPubkey === "string" && actorPubkey ? actorPubkey : this.dmActorPubkey || this.resolveActiveDmActor(), ); if (activeActor) { 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; } 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 = { ...message, timestamp: Number.isFinite(message?.timestamp) ? message.timestamp : Number.isFinite(message?.message?.created_at) ? message.message.created_at : Number.isFinite(message?.event?.created_at) ? message.event.created_at : Date.now() / 1000, }; 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) => (b?.timestamp || 0) - (a?.timestamp || 0)); 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(); } this.dmMessages = hydrated; this.dmMessageIndex = new Map(); 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; 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; let messages = []; try { messages = await this.nostrClient.listDirectMessages(normalizedActor, { relays, ...options, limit: resolvedLimit, decryptLimit: resolvedDecryptLimit, onMessage: (message) => { this.applyDirectMessage(message, { reason: "load-incremental" }); }, }); } catch (error) { userLogger.warn("[nostrService] Failed to load direct messages", error); return this.getDirectMessages(); } const collected = []; const index = new Map(); for (const message of Array.isArray(messages) ? messages : []) { if (!message || message.ok !== true) { continue; } const eventId = message?.event?.id; if (typeof eventId !== "string" || !eventId) { continue; } const normalized = { ...message, timestamp: Number.isFinite(message?.timestamp) ? message.timestamp : Number.isFinite(message?.message?.created_at) ? message.message.created_at : Number.isFinite(message?.event?.created_at) ? message.event.created_at : Date.now() / 1000, }; index.set(eventId, normalized); collected.push(normalized); } collected.sort((a, b) => (b?.timestamp || 0) - (a?.timestamp || 0)); this.dmMessageIndex = index; this.dmMessages = collected; const snapshot = this.getDirectMessages(); this.dmHydratedFromSnapshot = false; this.emit("directMessages:loaded", { messages: snapshot, actorPubkey: normalizedActor, }); 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 ensureDirectMessageSubscription({ actorPubkey, relays, ...handlers } = {}) { if (this.dmSubscription) { return this.dmSubscription; } const activeActor = actorPubkey || this.resolveActiveDmActor(); const normalizedActor = normalizeHexPubkey(activeActor); if (!normalizedActor) { return null; } let resolvedRelays = Array.isArray(relays) ? relays : null; let relaySelection = null; if (!resolvedRelays) { 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); } } relaySelection = await resolveDmRelaySelection({ pubkey: normalizedActor, discoveryRelays, fallbackRelays: discoveryRelays, pool, log: { dev: devLogger, user: userLogger }, }); 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.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; } 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; } const viewerIsAuthor = this.isViewerVideoAuthor(video); if (viewerIsAuthor) { return true; } if (ALLOW_NSFW_CONTENT !== true && video.isNsfw === true) { return false; } if (blacklistedEventIds.has(video.id)) { return false; } if (typeof video.pubkey === "string" && video.pubkey) { try { if (isAuthorBlocked(video.pubkey)) { return false; } } catch (error) { userLogger.warn("[nostrService] isAuthorBlocked handler threw", error); } } if (video.isPrivate === true) { return false; } if (this.accessControl && typeof this.accessControl.canAccess === "function") { try { if (!this.accessControl.canAccess(video)) { return false; } } catch (error) { userLogger.warn("[nostrService] access control check failed", error); return false; } } return true; } filterVideos(videos = [], options = {}) { const blacklist = ensureSet(options.blacklistedEventIds); const isAuthorBlocked = typeof options.isAuthorBlocked === "function" ? options.isAuthorBlocked : () => false; return videos.filter((video) => this.shouldIncludeVideo(video, { blacklistedEventIds: blacklist, isAuthorBlocked, }) ); } async ensureAccessControlReady() { if (!this.accessControl || typeof this.accessControl.waitForReady !== "function") { return; } try { await this.accessControl.waitForReady(); } catch (error) { userLogger.warn( "[nostrService] Failed to ensure access control lists are ready:", error ); } } getFilteredActiveVideos(options = {}) { const all = this.nostrClient.getActiveVideos(); return this.filterVideos(all, options); } getActiveVideosByAuthors(authors = [], options = {}) { const candidates = ensureSet(authors); const normalizedAuthors = new Set(); for (const candidate of candidates) { const normalized = normalizeHexPubkey(candidate); if (normalized) { normalizedAuthors.add(normalized); } } if (!normalizedAuthors.size) { return this.getFilteredActiveVideos(options); } const index = this.ensureVideosByAuthorIndex(); const collected = []; const seen = new Set(); const limitCandidate = Number(options?.limit); const limit = Number.isFinite(limitCandidate) && limitCandidate > 0 ? Math.floor(limitCandidate) : null; const perAuthorLimit = limit ? Math.max(limit * 2, limit + 5) : null; for (const author of normalizedAuthors) { const entries = index.get(author); if (!Array.isArray(entries) || entries.length === 0) { continue; } const sliceCount = perAuthorLimit ? Math.min(perAuthorLimit, entries.length) : entries.length; for (let idx = 0; idx < sliceCount; idx += 1) { const video = entries[idx]; if (!video || typeof video !== "object") { continue; } const id = typeof video.id === "string" ? video.id : ""; if (!id || seen.has(id)) { continue; } seen.add(id); collected.push(video); } } const filtered = this.filterVideos(collected, options); const sorted = filtered.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; }); if (limit) { return sorted.slice(0, limit); } return sorted; } async loadVideos({ forceFetch = false, blacklistedEventIds, isAuthorBlocked, onVideos, } = {}) { this.ensureInitialLoadDeferred(); try { // Ensure access control is ready before we attempt the optimistic re-filter // so checks like canAccess() are accurate. try { await this.ensureAccessControlReady(); } catch (error) { userLogger.warn( "[nostrService] Access control ready check failed (continuing):", error ); } if (forceFetch) { this.clearVideoSubscription(); } const applyAndNotify = (videos, reason) => { const filtered = this.filterVideos(videos, { blacklistedEventIds, isAuthorBlocked, }); this.cacheVideos(filtered); if (typeof onVideos === "function") { try { onVideos(filtered, { reason }); } catch (error) { userLogger.warn("[nostrService] onVideos handler threw", error); } } this.emit("videos:updated", { videos: filtered, reason }); // Persist the fresh filtered result for next time if (filtered.length > 0) { persistFilteredVideos(filtered); } return filtered; }; // Optimistic Step: Load from persistent cache let hasOptimisticData = false; let optimisticResult = []; try { const persisted = loadPersistedFilteredVideos(); if (persisted && persisted.length > 0) { // Re-filter synchronously using strict checks (same as normal flow) const optimisticFiltered = this.filterVideos(persisted, { blacklistedEventIds, isAuthorBlocked, }); if (optimisticFiltered.length > 0) { this.cacheVideos(optimisticFiltered); hasOptimisticData = true; optimisticResult = optimisticFiltered; if (typeof onVideos === "function") { try { onVideos(optimisticFiltered, { reason: "persistent-cache" }); } catch (error) { userLogger.warn("[nostrService] onVideos handler threw (optimistic)", error); } } this.emit("videos:updated", { videos: optimisticFiltered, reason: "persistent-cache" }); this.resolveInitialLoad({ videos: optimisticFiltered, reason: "persistent-cache" }); } } } catch (optimisticError) { userLogger.warn("[nostrService] Optimistic load failed", optimisticError); } const cached = this.nostrClient.getActiveVideos(); // If we loaded optimistically and the in-memory cache is empty, // skip emitting the empty list to prevent flashing empty content. // Otherwise, proceed as normal. const shouldSkipEmptyCache = hasOptimisticData && (!cached || cached.length === 0); let initial = []; if (!shouldSkipEmptyCache) { initial = applyAndNotify(cached, "cache"); this.resolveInitialLoad({ videos: initial, reason: "cache" }); } else { // If we skipped, return the optimistic data so the caller receives the currently visible videos initial = optimisticResult; } if (!getStoredVideoSubscription()) { const subscriptionOptions = { // Start from the freshest cached timestamp to avoid replaying the full history; // use loadOlderVideos when the user scrolls for backfill. since: this.nostrClient.getLatestCachedCreatedAt() || undefined, limit: this.nostrClient.clampVideoRequestLimit(200), }; const subscription = this.nostrClient.subscribeVideos( () => { const updated = this.nostrClient.getActiveVideos(); applyAndNotify(updated, "subscription"); }, subscriptionOptions, ); this.setVideoSubscription(subscription); this.emit("subscription:started", { subscription }); } return initial; } catch (error) { this.resolveInitialLoad({ videos: [], reason: "error", error }); throw error; } } async fetchVideos(options = {}) { try { const videos = await this.nostrClient.fetchVideos(); const filtered = this.filterVideos(videos, options); this.cacheVideos(filtered); this.emit("videos:fetched", { videos: filtered }); return filtered; } catch (error) { userLogger.error("[nostrService] Failed to fetch videos:", error); return []; } } async fetchVideosByAuthors(authors, options = {}) { const authorList = Array.isArray(authors) ? authors : []; if (!authorList.length) { return []; } this.log(`[nostrService] fetchVideosByAuthors START. Authors: ${authorList.length}`); const requestedLimit = Number(options?.limit); const resolvedLimit = this.nostrClient.clampVideoRequestLimit( requestedLimit ); const filter = { kinds: [VIDEO_KIND], "#t": ["video"], authors: authorList, limit: resolvedLimit, }; const localAll = new Map(); // Track invalid const invalidNotes = []; try { this.log(`[nostrService] Querying ${this.nostrClient.relays.length} relays...`); await Promise.all( this.nostrClient.relays.map(async (url) => { try { this.log(`[nostrService] Querying relay: ${url}`); const events = await this.nostrClient.pool.list([url], [filter]); this.log(`[nostrService] Relay ${url} returned ${events.length} events.`); for (const evt of events) { if (evt && evt.id) { this.nostrClient.rawEvents.set(evt.id, evt); } const vid = convertEventToVideo(evt); if (vid.invalid) { invalidNotes.push({ id: vid.id, reason: vid.reason }); } else { if ( this.nostrClient && typeof this.nostrClient.applyRootCreatedAt === "function" ) { this.nostrClient.applyRootCreatedAt(vid); } const activeKey = getActiveKey(vid); if (vid.deleted) { this.nostrClient.recordTombstone(activeKey, vid.created_at); } else { this.nostrClient.applyTombstoneGuard(vid); } localAll.set(evt.id, vid); } } } catch (relayErr) { this.log(`[nostrService] Relay ${url} failed:`, relayErr); } }) ); // Merge into allEvents for (const [id, vid] of localAll.entries()) { this.nostrClient.allEvents.set(id, vid); if ( this.nostrClient && typeof this.nostrClient.applyRootCreatedAt === "function" ) { this.nostrClient.applyRootCreatedAt(vid); } } // Update activeMap for affected keys for (const [id, video] of localAll.entries()) { if (video.deleted) continue; const activeKey = getActiveKey(video); const existing = this.nostrClient.activeMap.get(activeKey); if (!existing || video.created_at > existing.created_at) { this.nostrClient.activeMap.set(activeKey, video); if ( this.nostrClient && typeof this.nostrClient.applyRootCreatedAt === "function" ) { this.nostrClient.applyRootCreatedAt(video); } } } // Re-populate cache indexing this.cacheVideos(Array.from(localAll.values())); this.ensureVideosMap(); this.markAuthorIndexDirty(); // Collect the actual videos for the requested authors const collected = []; const seen = new Set(); // We look at localAll to ensure we only return what we fetched (or fresh stuff) // Actually, we should probably return the "best" active videos for these authors // now that we've refreshed the cache. const index = this.ensureVideosByAuthorIndex(); for (const author of authorList) { const entries = index.get(author); if (Array.isArray(entries)) { for (const video of entries) { if (video && video.id && !seen.has(video.id)) { seen.add(video.id); collected.push(video); } } } } const filtered = this.filterVideos(collected, options); // Sort newest first filtered.sort((a, b) => { const createdA = Number(a?.created_at) || 0; const createdB = Number(b?.created_at) || 0; return createdB - createdA; }); const finalLimit = resolvedLimit || filtered.length; const limited = filtered.slice(0, finalLimit); // Trigger metadata hydration await this.nostrClient.populateNip71MetadataForVideos(limited); limited.forEach((video) => { if ( this.nostrClient && typeof this.nostrClient.applyRootCreatedAt === "function" ) { this.nostrClient.applyRootCreatedAt(video); } }); this.cacheVideos(limited); this.emit("videos:fetched", { videos: limited, context: "authors" }); return limited; } catch (err) { userLogger.error("[nostrService] fetchVideosByAuthors error:", err); return []; } } async loadOlderVideos(lastTimestamp, { blacklistedEventIds, isAuthorBlocked, limit = 150, } = {}) { const until = normalizeUntil(lastTimestamp) - 1; if (until <= 0 || !this.nostrClient?.pool || !Array.isArray(this.nostrClient?.relays)) { return []; } const clampedLimit = this.nostrClient?.clampVideoRequestLimit(limit) ?? limit ?? 0; const filter = { kinds: [VIDEO_KIND], "#t": ["video"], until, limit: clampedLimit, }; const collected = new Map(); try { const events = await this.nostrClient.pool.list(this.nostrClient.relays, [filter]); for (const event of Array.isArray(events) ? events : []) { try { const video = convertEventToVideo(event); if (video.invalid) { continue; } if ( this.nostrClient && typeof this.nostrClient.applyRootCreatedAt === "function" ) { this.nostrClient.applyRootCreatedAt(video); } if (collected.has(video.id)) { continue; } collected.set(video.id, video); this.nostrClient.allEvents.set(video.id, video); } catch (error) { userLogger.warn("[nostrService] Failed to convert older event", error); } } } catch (error) { userLogger.error("[nostrService] Failed to load older videos:", error); return []; } const videos = Array.from(collected.values()); const filtered = this.filterVideos(videos, { blacklistedEventIds, isAuthorBlocked, }); this.cacheVideos(filtered); this.emit("videos:older", { videos: filtered, until }); return filtered; } async publishVideoNote(publishPayload, pubkey) { const detail = { pubkey }; if (publishPayload && typeof publishPayload === "object") { detail.payload = publishPayload; if (publishPayload.legacyFormData) { detail.legacyFormData = publishPayload.legacyFormData; } if (publishPayload.nip71) { detail.nip71 = publishPayload.nip71; } } else { detail.formData = publishPayload; } let nip71Result = null; let legacyResult = null; try { legacyResult = await this.nostrClient.publishVideo( publishPayload, pubkey ); } catch (error) { detail.error = error; throw error; } let shouldAttemptNip71 = true; const pointerIdentifiers = { eventId: legacyResult?.id, }; if (Array.isArray(legacyResult?.tags)) { const dTag = legacyResult.tags.find( (tag) => Array.isArray(tag) && tag[0] === "d" && typeof tag[1] === "string" ); if (dTag) { pointerIdentifiers.dTag = dTag[1]; } } if (legacyResult?.content) { try { const parsed = JSON.parse(legacyResult.content); if (parsed && typeof parsed.videoRootId === "string") { pointerIdentifiers.videoRootId = parsed.videoRootId; } if (parsed && parsed.isPrivate) { shouldAttemptNip71 = false; } } catch (parseError) { this.log("[nostrService] Failed to parse legacy publish payload", parseError); } } if (!pointerIdentifiers.videoRootId) { shouldAttemptNip71 = false; } if (shouldAttemptNip71) { try { nip71Result = await this.nostrClient.publishNip71Video( publishPayload, pubkey, pointerIdentifiers ); } catch (nip71Error) { detail.nip71Error = nip71Error; this.log("[nostrService] NIP-71 publish failed", nip71Error); } } const result = { legacy: legacyResult, nip71: nip71Result }; detail.result = result; this.emit("videos:published", detail); return result; } async handleEditVideoSubmit({ originalEvent, updatedData, pubkey }) { const result = await this.nostrClient.editVideo(originalEvent, updatedData, pubkey); this.emit("videos:edited", { originalEvent, updatedData, pubkey, result }); return result; } async handleFullDeleteVideo({ videoRootId, video, pubkey, confirm = true } = {}) { const result = await this.nostrClient.deleteAllVersions(videoRootId, pubkey, { confirm, video, }); if (!result) { return result; } const revertFailures = []; const deleteFailures = []; if (Array.isArray(result.reverts)) { for (const entry of result.reverts) { if (!entry || typeof entry !== "object") { continue; } const failed = Array.isArray(entry?.summary?.failed) ? entry.summary.failed.filter(Boolean) : []; if (failed.length) { revertFailures.push({ targetId: entry.targetId || "", eventId: entry.event?.id || "", failed, }); } } } if (Array.isArray(result.deletes)) { for (const entry of result.deletes) { if (!entry || typeof entry !== "object") { continue; } const failed = Array.isArray(entry?.summary?.failed) ? entry.summary.failed.filter(Boolean) : []; if (failed.length) { deleteFailures.push({ eventId: entry.event?.id || "", identifiers: entry.identifiers || { events: [], addresses: [] }, failed, }); } } } const detail = { videoRootId, video, pubkey, result, revertFailures, deleteFailures, }; this.emit("videos:deleted", detail); return detail; } async fetchVideoHistory(video) { if (!video || typeof video !== "object") { return []; } try { // Use the client's robust hydration logic which handles legacy roots and d-tags const history = await this.nostrClient.hydrateVideoHistory(video); // Ensure specific sort order: Newest First (Descending) return history.sort((a, b) => (b.created_at || 0) - (a.created_at || 0)); } catch (error) { this.log("[nostrService] Failed to fetch video history", error); // Fallback to returning the known video if fetch fails return [video]; } } async getOldEventById(eventId) { const map = this.ensureVideosMap(); const isBlockedNsfw = (video) => ALLOW_NSFW_CONTENT !== true && video?.isNsfw === true && !this.isViewerVideoAuthor(video); if (map.has(eventId)) { const existing = map.get(eventId); if (existing && !existing.deleted) { this.nostrClient.applyTombstoneGuard(existing); } if (!existing || existing.deleted) { map.delete(eventId); setStoredVideosMap(map); return null; } if (isBlockedNsfw(existing)) { map.delete(eventId); setStoredVideosMap(map); return null; } return existing; } const cached = this.nostrClient.allEvents.get(eventId); if (cached) { if (!cached.deleted) { this.nostrClient.applyTombstoneGuard(cached); } } if (cached && !cached.deleted) { if (isBlockedNsfw(cached)) { return null; } map.set(eventId, cached); setStoredVideosMap(map); return cached; } const fetched = await this.nostrClient.getEventById(eventId); if (fetched && !fetched.deleted) { if (isBlockedNsfw(fetched)) { return null; } map.set(eventId, fetched); setStoredVideosMap(map); return fetched; } return null; } isViewerVideoAuthor(video) { try { this.getModerationService(); } catch (error) { userLogger.warn("[nostrService] Failed to refresh moderation context", error); } if (!video || typeof video !== "object") { return false; } const viewerPubkey = normalizeHexPubkey(this.nostrClient?.pubkey); if (!viewerPubkey) { return false; } const videoPubkey = normalizeHexPubkey(video.pubkey); return !!videoPubkey && videoPubkey === viewerPubkey; } } const nostrService = new NostrService(); export default nostrService;