Files
bitvid/js/services/nostrService.js
T
thePR0M3TH3ANandClaude Opus 4.8 122d806ad2 perf(feed): don't block fresh load on remote admin-list fetch
loadVideos awaited ensureAccessControlReady -> waitForReady -> _performRefresh
-> loadAdminState, which awaits a remote admin-list fetch. On a cold load that
waits for relays to connect (~5s) plus the fetch, stalling the feed ~10s before
any video renders. The cached admin state IS hydrated synchronously at
construction, but with markLoaded:false, so waitForReady blocked on a full
remote refresh even when a usable cache existed ("refresh fixes it").

ensureAccessControlReady now blocks only on a first-ever cold load WITH whitelist
mode (where showing non-whitelisted content before the whitelist arrives would be
wrong). In every other case — admin state hydrated from the localStorage cache,
already loaded, or blacklist-only mode — it kicks the refresh off in the
background (ensureReady) and renders immediately; the reactive
onWhitelist/onBlacklistChange handlers re-filter the grid once fresh lists land.

Adds accessControl.isHydrated() and a branching test
(nostr-service-access-control-ready.test.mjs).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-12 14:31:38 -04:00

2277 lines
63 KiB
JavaScript

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;
}
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] : [];
}
clearDirectMessages({ actorPubkey = null, emit = true } = {}) {
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) {
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;
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,
...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,
});
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 } = {}) {
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();
}
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.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;
}
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() {
const ac = this.accessControl;
if (!ac) return;
// Block on the remote admin fetch only on a first-ever cold whitelist load;
// else the hydrated cache is accurate, so refresh in bg (handlers re-filter).
const hydrated = ac.isHydrated?.() ?? ac.hasLoaded === true;
const block = !hydrated && (ac.whitelistMode?.() ?? false);
const ready = block ? ac.waitForReady : ac.ensureReady || ac.waitForReady;
try {
if (typeof ready === "function") await ready.call(ac);
} catch (error) {
userLogger.warn("[nostrService] access control ready failed:", 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(
(updatedVideos) => {
if (!Array.isArray(updatedVideos) || !updatedVideos.length) {
return;
}
const activeUpdates = [];
const deletedUpdates = [];
for (const v of updatedVideos) {
if (v.deleted) {
deletedUpdates.push(v);
} else {
activeUpdates.push(v);
}
}
if (deletedUpdates.length > 0) {
const map = this.ensureVideosMap();
for (const v of deletedUpdates) {
if (v.id) {
map.delete(v.id);
}
}
this.markAuthorIndexDirty();
}
const filtered = this.filterVideos(activeUpdates, {
blacklistedEventIds,
isAuthorBlocked,
});
if (filtered.length > 0) {
this.cacheVideos(filtered);
}
if (filtered.length > 0 || deletedUpdates.length > 0) {
// Persist the full list (latest N) from the now-updated cache
const map = this.ensureVideosMap();
const fullList = Array.from(map.values());
fullList.sort((a, b) => {
const aCreated = Number(a?.created_at) || 0;
const bCreated = Number(b?.created_at) || 0;
return bCreated - aCreated;
});
this.emit("videos:updated", {
videos: fullList,
deleted: deletedUpdates,
reason: "subscription",
});
persistFilteredVideos(fullList);
}
},
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;