Files
bitvid/js/nostr/client.js
T
thePR0M3TH3ANandClaude Opus 4.8 de515462e6 fix(feed): always reserve live default relays in the feed set
The video feed's getDefaultRelays only fell back to bitvid's default relays
when the healthy set was COMPLETELY empty. A user whose own relays are all dead
but not yet probed (so they still pass the liveness filter) would have the feed
subscribe to that fully-broken list and hang forever on "Fetching…" — the
default aggregators were never injected.

Route the healthy set through capReadRelays (via new buildFeedRelaySet), which
always reserves a couple of reliable aggregator slots. Now the feed can reach
live relays regardless of how broken the user's personal list is, while still
prioritizing the user's own relays and bounding REQ fan-out to MAX_SUBSCRIBE_RELAYS.

Tests (buildFeedRelaySet): an all-dead user list still yields a default
aggregator while keeping the user's relays; empty input falls back to defaults;
an empty healthy set uses the configured fallback yet still reserves defaults; a
huge list stays bounded. Mutation-verified — reverting to the old
healthy-or-defaults logic fails 3 of the 4 scenarios.

Build + full lint green.

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

4303 lines
139 KiB
JavaScript

// js/nostr/client.js
/**
* NostrClient
*
* The central controller for all Nostr network interactions and state management.
* For a high-level overview, see `docs/nostr-client-overview.md`.
*
* Responsibilities:
* - Connection Management: Maintains the connection pool to Relays.
* - Event Publishing: Handles signing and broadcasting events (Kind 0, 1, 30078, etc.).
* - State Management:
* - `allEvents`: A raw map of all fetched video events.
* - `activeMap`: A derived map containing only the *latest* version of each video (deduplicated by `videoRootId`).
* - `rawEvents`: A cache of the exact raw JSON events from relays (for signature verification).
* - Caching: Implements a dual-layer cache (IndexedDB + localStorage) to restore app state instantly on load.
* - Signer Management: Orchestrates NIP-07 (Extension), NIP-46 (Remote/Bunker), and NIP-01 (Local nsec) signers.
*/
import { isDevMode } from "../config.js";
import { infoHashFromMagnet } from "../magnets.js";
// 🔧 merged conflicting changes from codex/update-video-publishing-and-parsing-logic vs unstable
import {
buildNip71MetadataTags,
collectNip71PointerRequests,
convertEventToVideo,
extractNip71MetadataFromTags,
getDTagValueFromTags,
mergeNip71MetadataIntoVideo as mergeNip71MetadataIntoVideoHelper,
populateNip71MetadataForVideos as populateNip71MetadataForVideosHelper,
processNip71Events as processNip71EventsHelper,
} from "./nip71.js";
import {
handlePublishNip94,
handlePublishNip71,
} from "./videoPublisher.js";
import {
buildStoragePointerValue,
deriveStoragePointerFromUrl,
getStoragePointerFromTags,
normalizeStoragePointer,
resolveStoragePointerValue,
} from "../utils/storagePointer.js";
import { RelayBatchFetcher } from "./relayBatchFetcher.js";
import { createPersistedPlaintextCache } from "./persistedPlaintextCache.js";
import {
createWatchHistoryManager,
normalizePointerInput,
pointerKey,
buildWatchHistoryPayload,
normalizeActorKey,
getWatchHistoryCacheTtlMs as getWatchHistoryCacheTtlMsFromManager,
getWatchHistoryStorage as getWatchHistoryStorageFromManager,
persistWatchHistoryEntry as persistWatchHistoryEntryToManager,
cancelWatchHistoryRepublish as cancelWatchHistoryRepublishForManager,
scheduleWatchHistoryRepublish as scheduleWatchHistoryRepublishForManager,
getWatchHistoryFingerprint as getWatchHistoryFingerprintFromManager,
ensureWatchHistoryBackgroundRefresh as ensureWatchHistoryBackgroundRefreshForManager,
publishWatchHistorySnapshot as publishWatchHistorySnapshotWithManager,
updateWatchHistoryList as updateWatchHistoryListWithManager,
removeWatchHistoryItem as removeWatchHistoryItemWithManager,
fetchWatchHistory as fetchWatchHistoryWithManager,
resolveWatchHistory as resolveWatchHistoryWithManager,
getWatchHistoryDeferredChunkCount as getWatchHistoryDeferredChunkCountFromManager,
} from "./watchHistory.js";
import {
buildVideoPostEvent,
buildRepostEvent,
buildWatchHistoryEvent,
buildDmAttachmentEvent,
buildDeletionEvent,
buildLegacyDirectMessageEvent,
buildGiftWrapEvent,
buildSealEvent,
buildChatMessageEvent,
getNostrEventSchema,
NOTE_TYPES,
} from "../nostrEventSchemas.js";
import { CACHE_POLICIES } from "./cachePolicies.js";
import {
SyncMetadataStore,
__testExports as syncMetaTestExports,
} from "./syncMetadataStore.js";
import {
listVideoViewEvents as listVideoViewEventsForClient,
subscribeVideoViewEvents as subscribeVideoViewEventsForClient,
countVideoViewEvents as countVideoViewEventsForClient,
publishViewEvent as publishViewEventForClient,
recordVideoView as recordVideoViewForClient,
} from "./viewEvents.js";
import { publishVideoReaction as publishVideoReactionForClient } from "./reactionEvents.js";
import {
publishComment as publishCommentForClient,
listVideoComments as listVideoCommentsForClient,
subscribeVideoComments as subscribeVideoCommentsForClient,
} from "./commentEvents.js";
import {
publishDmReadReceipt as publishDmReadReceiptForClient,
publishDmTypingIndicator as publishDmTypingIndicatorForClient,
} from "./dmSignalEvents.js";
import {
logCountTimeoutCleanupFailure,
logRelayCountFailure,
} from "./countDiagnostics.js";
import "./maxListenerDiagnostics.js";
import {
publishEventToRelay,
publishEventToRelays,
assertAnyRelayAccepted,
} from "../nostrPublish.js";
import {
signEventWithPrivateKey,
signAndPublishEvent as signAndPublishEventHelper,
repostEvent as repostEventHelper,
mirrorVideoEvent as mirrorVideoEventHelper,
rebroadcastEvent as rebroadcastEventHelper,
buildRevertVideoPayload,
summarizePublishResults,
} from "./publishHelpers.js";
import {
DEFAULT_RELAY_URLS,
RELAY_URLS,
capReadRelays,
buildFeedRelaySet,
ensureNostrTools,
getCachedNostrTools,
nostrToolsBootstrapFailure,
readToolkitFromScope,
resolveSimplePoolConstructor,
shimLegacySimplePoolMethods,
} from "./toolkit.js";
import { encryptNip04InWorker } from "./nip04WorkerClient.js";
import {
decryptDmInWorker,
isDmDecryptWorkerSupported,
} from "./dmDecryptWorkerClient.js";
import {
sanitizeDecryptError,
summarizeDmEventForLog,
} from "./dmDecryptDiagnostics.js";
import { devLogger, userLogger } from "../utils/logger.js";
import { withRequestTimeout, pMap } from "../utils/asyncUtils.js";
import { LRUCache } from "../utils/lruCache.js";
import { updateConversationFromMessage, writeMessages } from "../storage/dmDb.js";
import {
DM_RELAY_WARNING_FALLBACK,
resolveDmRelaySelection,
} from "../services/dmNostrService.js";
import {
DEFAULT_NIP07_CORE_METHODS,
DEFAULT_NIP07_ENCRYPTION_METHODS,
DEFAULT_NIP07_PERMISSION_METHODS,
NIP07_PRIORITY,
clearStoredNip07Permissions,
normalizePermissionMethod,
readStoredNip07Permissions,
requestEnablePermissions,
runNip07WithRetry,
writeStoredNip07Permissions,
waitForNip07Extension,
} from "./nip07Permissions.js";
import {
clearStoredSessionActor as clearStoredSessionActorEntry,
decryptSessionPrivateKey,
encryptSessionPrivateKey,
persistSessionActor as persistSessionActorEntry,
readStoredSessionActorEntry,
isSessionActor,
} from "./sessionActor.js";
import { HEX64_REGEX, normalizeHexHash } from "../utils/hex.js";
import {
NIP46_RPC_KIND,
NIP46_HANDSHAKE_TIMEOUT_MS,
NIP46_AUTH_CHALLENGE_MAX_ATTEMPTS,
sanitizeRelayList,
readStoredNip46Session,
writeStoredNip46Session,
decryptNip46Session,
clearStoredNip46Session,
parseNip46ConnectionString,
generateNip46Secret,
sanitizeNip46Metadata,
normalizeNip46EncryptionAlgorithm,
resolveNip46Relays,
normalizeNip46CiphertextPayload,
createNip46Cipher,
decryptNip46PayloadWithKeys,
attemptDecryptNip46HandshakePayload,
Nip46RpcClient,
createNip46RequestId,
decodeNpubToHex,
encodeHexToNpub,
normalizeNostrPubkey,
} from "./nip46Client.js";
import {
summarizeHexForLog,
summarizeSecretForLog,
summarizeMetadataForLog,
summarizeUrlForLog,
summarizePayloadPreviewForLog,
summarizeRpcParamsForLog,
summarizeRpcResultForLog,
summarizeRelayPublishResultsForLog,
} from "./nip46LoggingUtils.js";
import { profileCache } from "../state/profileCache.js";
import { createPrivateKeyCipherClosures } from "./signerHelpers.js";
import {
setActiveSigner as setActiveSignerInRegistry,
getActiveSigner as getActiveSignerFromRegistry,
clearActiveSigner as clearActiveSignerInRegistry,
logoutSigner as logoutSignerFromRegistry,
resolveActiveSigner as resolveActiveSignerFromRegistry,
} from "../nostrClientRegistry.js";
import { createNip07Adapter } from "./adapters/nip07Adapter.js";
import { createNsecAdapter } from "./adapters/nsecAdapter.js";
import { createNip46Adapter } from "./adapters/nip46Adapter.js";
import { queueSignEvent } from "./signRequestQueue.js";
import { EventsMap } from "./eventsMap.js";
import { PersistenceManager } from "./managers/PersistenceManager.js";
import { ConnectionManager } from "./managers/ConnectionManager.js";
import {
SignerManager,
resolveSignerCapabilities,
hydrateExtensionSignerCapabilities,
attachNipMethodAliases,
} from "./managers/SignerManager.js";
import {
prepareVideoPublishPayload,
prepareVideoEditPayload,
} from "./videoPayloadBuilder.js";
import { inferMimeTypeFromUrl } from "../utils/mime.js";
import { VideoEventBuffer } from "./videoEventBuffer.js";
import { createSubscriptionManager } from "./subscriptionManager.js";
import { RelaySubscriptionService } from "../services/relaySubscriptionService.js";
// capReadRelays (shared, in toolkit) bounds the read set AND always includes the
// reliable defaults — even when a user's NIP-65 list contains none of them — so
// reads aren't stuck on the user's (frequently dead) personal relays. Writes are
// not capped (see applyRelayPreferences).
const capSubscribeRelays = capReadRelays;
function normalizeProfileFromEvent(event) {
if (!event || !event.content) return null;
try {
return JSON.parse(event.content);
} catch (err) {
return null;
}
}
// Helper functions
// Ideally these are replaced by usages of signerManager instance, but they are exported.
// We keep them as pass-throughs to the registry or utility logic where appropriate.
function resolveActiveSigner(pubkey) {
const signer = resolveActiveSignerFromRegistry(pubkey);
hydrateExtensionSignerCapabilities(signer);
attachNipMethodAliases(signer);
return signer;
}
function setActiveSigner(signer) {
hydrateExtensionSignerCapabilities(signer);
attachNipMethodAliases(signer);
setActiveSignerInRegistry(signer);
}
function getActiveSigner() {
const signer = getActiveSignerFromRegistry();
hydrateExtensionSignerCapabilities(signer);
attachNipMethodAliases(signer);
return signer;
}
function clearActiveSigner() {
clearActiveSignerInRegistry();
}
function logoutSigner(pubkey) {
logoutSignerFromRegistry(pubkey);
}
function shouldRequestExtensionPermissions(signer) {
if (!signer || typeof signer !== "object") {
return false;
}
return signer.type === "extension" || signer.type === "nip07";
}
import {
RELAY_CONNECT_TIMEOUT_MS,
RELAY_RECONNECT_BASE_DELAY_MS,
RELAY_RECONNECT_MAX_DELAY_MS,
RELAY_RECONNECT_MAX_ATTEMPTS,
RELAY_BACKOFF_BASE_DELAY_MS,
RELAY_BACKOFF_MAX_DELAY_MS,
RELAY_CIRCUIT_BREAKER_THRESHOLD,
RELAY_CIRCUIT_BREAKER_COOLDOWN_MS,
RELAY_FAILURE_WINDOW_MS,
RELAY_FAILURE_WINDOW_THRESHOLD,
RELAY_SUMMARY_LOG_INTERVAL_MS,
RELAY_BACKGROUND_CONCURRENCY,
} from "./relayConstants.js";
import { STANDARD_TIMEOUT_MS } from "../constants.js";
const EVENTS_CACHE_STORAGE_KEY = "bitvid:eventsCache:v1";
// We use the policy TTL, but currently the storage backend is hardcoded to IDB (with localStorage fallback).
// Future refactors should make EventsCacheStore dynamic based on CACHE_POLICIES[NOTE_TYPES.VIDEO_POST].storage.
const EVENTS_CACHE_TTL_MS = CACHE_POLICIES[NOTE_TYPES.VIDEO_POST]?.ttl ?? (10 * 60 * 1000);
const EVENTS_CACHE_DB_NAME = "bitvid-events-cache";
const EVENTS_CACHE_DB_VERSION = 1;
const DEFAULT_VIDEO_REQUEST_LIMIT = 150;
const MAX_VIDEO_REQUEST_LIMIT = 500;
export const __testExports = {
runNip07WithRetry,
DEFAULT_NIP07_ENCRYPTION_METHODS,
decryptNip46PayloadWithKeys,
createNip46Cipher,
normalizeNip46CiphertextPayload,
parseNip46ConnectionString,
attemptDecryptNip46HandshakePayload,
};
function eventToAddressPointer(event) {
if (!event || typeof event !== "object") {
return "";
}
const kind = typeof event.kind === "number" ? event.kind : null;
const pubkey = typeof event.pubkey === "string" ? event.pubkey : "";
if (!kind || !pubkey) {
return "";
}
const tags = Array.isArray(event.tags) ? event.tags : [];
for (const tag of tags) {
if (Array.isArray(tag) && tag[0] === "d" && typeof tag[1] === "string") {
return `${kind}:${pubkey}:${tag[1]}`;
}
}
return "";
}
function cloneEventForCache(event) {
if (!event || typeof event !== "object") {
return null;
}
const cloned = { ...event };
if (Array.isArray(event.tags)) {
cloned.tags = event.tags.map((tag) =>
Array.isArray(tag) ? [...tag] : tag
);
}
return cloned;
}
const DM_DECRYPT_CACHE_LIMIT = 256;
const DM_EVENT_KINDS = Object.freeze([4, 1059]);
// Persisted (across reloads) cache of decrypted DM results keyed by the immutable
// DM event id. DM events never change, so caching the decrypted result means a
// reload re-decrypts only messages we haven't seen — skipping the (serialized,
// slow) nip-07 extension for the rest. Stored as JSON of the decrypt result;
// cleared on logout via clearDmDecryptCache().
const dmPlaintextCache = createPersistedPlaintextCache("bitvid:dmDecrypted:v1", 1000);
function buildDmFilters(actorPubkey, { since, until, limit } = {}) {
const normalizedActor = normalizeActorKey(actorPubkey);
const filters = [];
const normalizedLimit = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : undefined;
const normalizedSince = Number.isFinite(since) ? Math.floor(since) : undefined;
const normalizedUntil = Number.isFinite(until) ? Math.floor(until) : undefined;
const baseFilterPayload = (kinds) => {
const payload = { kinds: Array.isArray(kinds) ? kinds : [kinds] };
if (normalizedLimit !== undefined) {
payload.limit = normalizedLimit;
}
if (normalizedSince !== undefined) {
payload.since = normalizedSince;
}
if (normalizedUntil !== undefined) {
payload.until = normalizedUntil;
}
return payload;
};
if (normalizedActor) {
const authorFilter = baseFilterPayload(DM_EVENT_KINDS);
authorFilter.authors = [normalizedActor];
filters.push(authorFilter);
const directFilter = baseFilterPayload(DM_EVENT_KINDS);
directFilter["#p"] = [normalizedActor];
filters.push(directFilter);
} else {
const fallbackFilter = baseFilterPayload(DM_EVENT_KINDS);
filters.push(fallbackFilter);
}
return filters;
}
function getActiveKey(video) {
if (video.videoRootId) {
return `ROOT:${video.videoRootId}`;
}
const dTag = video.tags?.find((t) => t[0] === "d");
if (dTag) {
return `${video.pubkey}:${dTag[1]}`;
}
return `LEGACY:${video.id}`;
}
export {
convertEventToVideo,
Nip46RpcClient,
buildNip71MetadataTags,
extractNip71MetadataFromTags,
};
export class NostrClient {
/**
* Initializes the NostrClient.
* Sets up connection pools, state maps, and default relays.
*
* The client manages the full lifecycle of Nostr interactions:
* - Connection pooling via `SimplePool`.
* - State tracking (allEvents, activeMap).
* - Signer negotiation (NIP-07, NIP-46, local).
* - Background caching with IndexedDB.
*
* For a detailed architectural overview, see `docs/nostr-client-overview.md`.
*/
constructor() {
this.connectionManager = new ConnectionManager(this);
this.signerManager = new SignerManager(this);
this.relayBatchFetcher = new RelayBatchFetcher(this);
/**
* @type {Map<string, object>}
* Maps event ID to the converted Video object.
* Stores ALL fetched versions to ensure old links resolve.
* @public
*/
this.allEvents = new Map();
/**
* @type {Map<string, import("nostr-tools").Event>}
* Maps event ID to the raw Nostr event object.
* Kept for signature verification and republishing (e.g. NIP-94 mirror).
* @public
*/
this.rawEvents = new EventsMap();
/**
* @type {Map<string, object>}
* Maps `videoRootId` (or `pubkey:dTag`) to the latest valid Video object.
* This is the "materialized view" used by the UI.
* It automatically deduplicates versions, keeping only the most recent one.
* @public
*/
this.activeMap = new Map();
/**
* @type {Map<string, number>}
* Maps `activeKey` to the timestamp of its latest deletion.
* Prevents older events from reappearing after a delete.
* Critical for "Eventual Consistency" handling.
* @public
*/
this.tombstones = new Map();
this.dirtyEventIds = new Set();
this.dirtyTombstones = new Set();
this.rootCreatedAtByRoot = new Map();
this.persistenceManager = new PersistenceManager(this);
this.syncMetadataStore = new SyncMetadataStore();
this.nip71Cache = new Map();
this.watchHistory = createWatchHistoryManager({
getPool: () => this.pool,
getSubscriptionManager: () => this.getSubscriptionManager(),
getActivePubkey: () => this.pubkey,
getSessionActor: () => this.sessionActor,
ensureSessionActor: () => this.ensureSessionActor(),
ensureExtensionPermissions: (...args) => this.ensureExtensionPermissions(...args),
resolveActiveSigner: (pubkey) => this.signerManager.resolveActiveSigner(pubkey),
shouldRequestExtensionPermissions,
signEventWithPrivateKey,
getReadRelays: () => this.readRelays,
getWriteRelays: () => this.writeRelays,
getRelayFallback: () => this.relays,
eventToAddressPointer,
});
Object.defineProperties(this, {
watchHistoryCache: {
configurable: true,
enumerable: false,
get: () => this.watchHistory.cache,
set: (value) => {
const nextValue =
value instanceof Map
? value
: value && typeof value[Symbol.iterator] === "function"
? new Map(value)
: new Map();
this.watchHistory.cache = nextValue;
},
},
watchHistoryFingerprints: {
configurable: true,
enumerable: false,
get: () => this.watchHistory.fingerprints,
set: (value) => {
const nextValue =
value instanceof Map
? value
: value && typeof value[Symbol.iterator] === "function"
? new Map(value)
: new Map();
this.watchHistory.fingerprints = nextValue;
},
},
watchHistoryRepublishTimers: {
configurable: true,
enumerable: false,
get: () => this.watchHistory.republishTimers,
set: (value) => {
const nextValue =
value instanceof Map
? value
: value && typeof value[Symbol.iterator] === "function"
? new Map(value)
: new Map();
this.watchHistory.republishTimers = nextValue;
},
},
watchHistoryRefreshPromises: {
configurable: true,
enumerable: false,
get: () => this.watchHistory.refreshPromises,
set: (value) => {
const nextValue =
value instanceof Map
? value
: value && typeof value[Symbol.iterator] === "function"
? new Map(value)
: new Map();
this.watchHistory.refreshPromises = nextValue;
},
},
watchHistoryCacheTtlMs: {
configurable: true,
enumerable: false,
get: () => this.watchHistory.cacheTtlMs,
set: (value) => {
const numeric = Number(value);
this.watchHistory.cacheTtlMs = Number.isFinite(numeric)
? numeric
: 0;
},
},
watchHistoryLastCreatedAt: {
configurable: true,
enumerable: false,
get: () => this.watchHistory.lastCreatedAt,
set: (value) => {
const numeric = Number(value);
this.watchHistory.lastCreatedAt = Number.isFinite(numeric)
? numeric
: 0;
},
},
watchHistoryStorage: {
configurable: true,
enumerable: false,
get: () => this.watchHistory.storage,
set: (value) => {
this.watchHistory.storage =
value && typeof value === "object" ? value : null;
},
},
});
this.dmDecryptCache = new LRUCache({ maxSize: DM_DECRYPT_CACHE_LIMIT });
// event id -> in-flight decrypt promise (coalesces concurrent decrypts).
this.dmDecryptInFlight = new Map();
this.dmDecryptor = null;
this.dmDecryptorPromise = null;
this.isInitialized = false;
this.initPromise = null;
}
get pubkey() { return this.signerManager.pubkey; }
set pubkey(val) { this.signerManager.pubkey = val; }
get sessionActor() { return this.signerManager.sessionActor; }
set sessionActor(val) { this.signerManager.sessionActor = val; }
get lockedSessionActor() { return this.signerManager.lockedSessionActor; }
set lockedSessionActor(val) { this.signerManager.lockedSessionActor = val; }
get nip46Client() { return this.signerManager.nip46Client; }
set nip46Client(val) { this.signerManager.nip46Client = val; }
get sessionActorCipherClosures() { return this.signerManager.sessionActorCipherClosures; }
set sessionActorCipherClosures(val) { this.signerManager.sessionActorCipherClosures = val; }
get sessionActorCipherClosuresPrivateKey() { return this.signerManager.sessionActorCipherClosuresPrivateKey; }
set sessionActorCipherClosuresPrivateKey(val) { this.signerManager.sessionActorCipherClosuresPrivateKey = val; }
get extensionPermissionCache() { return this.signerManager.extensionPermissionCache; }
get pool() { return this.connectionManager.pool; }
set pool(val) { this.connectionManager.pool = val; }
get poolPromise() { return this.connectionManager.poolPromise; }
set poolPromise(val) { this.connectionManager.poolPromise = val; }
get relays() { return this.connectionManager.relays; }
set relays(val) { this.connectionManager.relays = val; }
get readRelays() { return this.connectionManager.readRelays; }
set readRelays(val) { this.connectionManager.readRelays = val; }
get writeRelays() { return this.connectionManager.writeRelays; }
set writeRelays(val) { this.connectionManager.writeRelays = val; }
get unreachableRelays() { return this.connectionManager.unreachableRelays; }
/**
* Records a deletion timestamp for a video identifier (Tombstoning).
*
* **Purpose:**
* Enforces "Eventual Consistency". If we receive an old version of a video
* *after* we've seen it was deleted, this tombstone ensures we ignore the zombie event.
*
* @param {string} activeKey - The unique key (root ID or pubkey:dTag).
* @param {number} createdAt - The timestamp of the deletion event.
*/
recordTombstone(activeKey, createdAt) {
const key = typeof activeKey === "string" ? activeKey.trim() : "";
if (!key) {
return;
}
let timestamp = Number(createdAt);
if (!Number.isFinite(timestamp)) {
return;
}
timestamp = Math.max(0, Math.floor(timestamp));
if (timestamp <= 0) {
return;
}
const previous = this.tombstones.get(key);
const previousTimestamp = Number.isFinite(previous)
? Math.max(0, Math.floor(previous))
: 0;
if (previousTimestamp >= timestamp) {
return;
}
this.tombstones.set(key, timestamp);
this.dirtyTombstones.add(key);
const activeVideo = this.activeMap.get(key);
if (activeVideo) {
const activeCreated = Number.isFinite(activeVideo?.created_at)
? Math.floor(activeVideo.created_at)
: 0;
if (activeCreated && activeCreated <= timestamp) {
activeVideo.deleted = true;
this.activeMap.delete(key);
}
}
for (const video of this.allEvents.values()) {
if (!video || typeof video !== "object") {
continue;
}
if (getActiveKey(video) !== key) {
continue;
}
const createdAtValue = Number.isFinite(video?.created_at)
? Math.floor(video.created_at)
: 0;
if (createdAtValue && createdAtValue <= timestamp) {
video.deleted = true;
}
}
}
isOlderThanTombstone(video) {
if (!video || typeof video !== "object") {
return false;
}
const activeKey = getActiveKey(video);
if (!activeKey) {
return false;
}
const tombstone = this.tombstones.get(activeKey);
if (!Number.isFinite(tombstone)) {
return false;
}
const normalizedTombstone = Math.max(0, Math.floor(tombstone));
const createdAtValue = Number.isFinite(video?.created_at)
? Math.floor(video.created_at)
: 0;
return createdAtValue > 0 && createdAtValue <= normalizedTombstone;
}
/**
* Checks if a video event is superseded by a known tombstone.
* If so, marks the video object as `deleted = true`.
*
* @param {object} video - The video object to check.
* @returns {boolean} True if the video was marked deleted by a tombstone.
*/
applyTombstoneGuard(video) {
if (!video || typeof video !== "object") {
return false;
}
const isGuarded = this.isOlderThanTombstone(video);
if (isGuarded) {
video.deleted = true;
}
return isGuarded;
}
makeCountUnsupportedError(relayUrl) {
return this.connectionManager.makeCountUnsupportedError(relayUrl);
}
resolveEventDTag(event, fallbackEvent = null) {
if (event && event.tags) {
const dTag = getDTagValueFromTags(event.tags);
if (dTag) return dTag;
}
if (fallbackEvent && fallbackEvent.tags) {
const dTag = getDTagValueFromTags(fallbackEvent.tags);
if (dTag) return dTag;
}
return "";
}
applyRootCreatedAt(video) {
if (!video || typeof video !== "object") return;
const rootId = video.videoRootId;
if (!rootId) return;
let currentMin = this.rootCreatedAtByRoot.get(rootId);
if (currentMin === undefined) {
currentMin = Number.MAX_SAFE_INTEGER;
}
const videoCreated = Number.isFinite(video.created_at) ? video.created_at : Number.MAX_SAFE_INTEGER;
if (videoCreated < currentMin) {
this.rootCreatedAtByRoot.set(rootId, videoCreated);
}
}
getActiveKey(video) {
return getActiveKey(video);
}
/**
* Restores application state from the best available local cache.
* Delegates to PersistenceManager.
*
* This method ensures the UI can render immediately (Stale-While-Revalidate)
* by loading `activeMap`, `allEvents`, and `tombstones` from IndexedDB.
*
* @returns {Promise<boolean>} True if data was successfully restored.
*/
async restoreLocalData() {
return this.persistenceManager.restoreLocalData();
}
getSyncLastSeen(kind, pubkey, dTag, relayUrl) {
const effectivePubkey = pubkey || this.pubkey;
return this.syncMetadataStore.getLastSeen(kind, effectivePubkey, dTag, relayUrl);
}
updateSyncLastSeen(kind, pubkey, dTag, relayUrl, createdAt) {
const effectivePubkey = pubkey || this.pubkey;
this.syncMetadataStore.updateLastSeen(kind, effectivePubkey, dTag, relayUrl, createdAt);
}
getPerRelaySyncLastSeen(kind, pubkey, dTag) {
const effectivePubkey = pubkey || this.pubkey;
return this.syncMetadataStore.getPerRelayLastSeen(kind, effectivePubkey, dTag);
}
/**
* Fetches a list of events incrementally from multiple relays, respecting `lastSeen` timestamps per relay.
*
* **Optimization Strategy:**
* - Checks `SyncMetadataStore` for the last known `created_at` timestamp for this query on each relay.
* - If a timestamp exists, it requests `since: lastSeen + 1` to fetch only new items.
* - If the incremental fetch fails (or returns nothing when we expected updates), it falls back to a full fetch.
* - Updates the `SyncMetadataStore` with the new max `created_at` on success.
*
* **Concurrency:**
* - Batches relay requests in chunks of 8 to avoid saturating network connections.
*
* @param {object} params
* @param {number} params.kind - The event kind to fetch (e.g. 10000 for mute list).
* @param {string} params.pubkey - The author's pubkey.
* @param {string} [params.dTag] - Optional d-tag for addressable events (NIP-33).
* @param {string[]} [params.relayUrls] - List of relays to query. Defaults to client's configured relays.
* @param {function} [params.fetchFn] - Custom fetch function (mocks or specialized logic). Defaults to `pool.list`.
* @param {number} [params.since] - Explicit start timestamp (overrides storage).
* @param {number} [params.timeoutMs] - Optional per-relay timeout for list fetches; defaults to a higher list-friendly baseline.
* @param {number} [params.maxRelays] - Maximum relay count to query for this fetch (defaults to 8).
* @returns {Promise<import("nostr-tools").Event[]>} Deduplicated list of events found across all relays.
*/
async fetchListIncrementally({
kind,
pubkey,
dTag,
relayUrls,
fetchFn,
since,
timeoutMs = STANDARD_TIMEOUT_MS,
maxRelays = 8,
} = {}) {
return this.relayBatchFetcher.fetchListIncrementally({
kind,
pubkey,
dTag,
relayUrls,
fetchFn,
since,
timeoutMs,
maxRelays,
});
}
applyRelayPreferences(preferences = {}) {
const normalizedPrefs =
preferences && typeof preferences === "object" ? preferences : {};
const sanitizedAll = sanitizeRelayList(
Array.isArray(normalizedPrefs.all)
? normalizedPrefs.all
: this.relays
);
const effectiveAll =
sanitizedAll.length > 0
? sanitizedAll
: sanitizeRelayList(Array.from(DEFAULT_RELAY_URLS));
const sanitizedRead = sanitizeRelayList(
Array.isArray(normalizedPrefs.read)
? normalizedPrefs.read
: effectiveAll
);
const sanitizedWrite = sanitizeRelayList(
Array.isArray(normalizedPrefs.write)
? normalizedPrefs.write
: effectiveAll
);
// Cap the SUBSCRIPTION/read set. A user's NIP-65 list can carry ~20 relays,
// most dead; subscribing on all of them is the dominant REQ-storm amplifier
// (every subsystem fans every query across all relays and re-fires on each
// flaky-relay reconnect). Reads go to a bounded core (known-good defaults
// prioritized); WRITES still fan out to the full set so publish reach is
// unaffected. This is the interim trim until RelayHealth ranks by liveness
// (see docs/architecture-refactor.md, P1).
const cappedRead = capSubscribeRelays(effectiveAll);
this.relays = cappedRead.length ? cappedRead : Array.from(RELAY_URLS);
this.readRelays = sanitizedRead.length
? capSubscribeRelays(sanitizedRead)
: Array.from(this.relays);
this.writeRelays = sanitizedWrite.length
? sanitizedWrite
: Array.from(effectiveAll.length ? effectiveAll : RELAY_URLS);
}
getWatchHistoryCacheTtlMs() {
return getWatchHistoryCacheTtlMsFromManager(this.watchHistory);
}
getWatchHistoryStorage() {
return getWatchHistoryStorageFromManager(this.watchHistory);
}
persistWatchHistoryEntry(actorInput, entry) {
persistWatchHistoryEntryToManager(this.watchHistory, actorInput, entry);
}
cancelWatchHistoryRepublish(taskId = null) {
cancelWatchHistoryRepublishForManager(this.watchHistory, taskId);
}
scheduleWatchHistoryRepublish(taskId, operation, options = {}) {
return scheduleWatchHistoryRepublishForManager(
this.watchHistory,
taskId,
operation,
options,
);
}
async getWatchHistoryFingerprint(actorInput, itemsOverride = null) {
return getWatchHistoryFingerprintFromManager(
this.watchHistory,
actorInput,
itemsOverride,
);
}
ensureWatchHistoryBackgroundRefresh(actorInput = null) {
return ensureWatchHistoryBackgroundRefreshForManager(
this.watchHistory,
actorInput,
);
}
async publishWatchHistorySnapshot(rawItems, options = {}) {
if (isSessionActor(this)) {
const error = new Error(
"Publishing watch history is not allowed for session actors."
);
error.code = "session-actor-publish-blocked";
throw error;
}
return publishWatchHistorySnapshotWithManager(
this.watchHistory,
rawItems,
options,
);
}
async updateWatchHistoryList(rawItems = [], options = {}) {
if (isSessionActor(this)) {
const error = new Error(
"Publishing watch history is not allowed for session actors."
);
error.code = "session-actor-publish-blocked";
throw error;
}
return updateWatchHistoryListWithManager(
this.watchHistory,
rawItems,
options,
);
}
async removeWatchHistoryItem(pointerInput, options = {}) {
if (isSessionActor(this)) {
const error = new Error(
"Publishing watch history is not allowed for session actors."
);
error.code = "session-actor-publish-blocked";
throw error;
}
return removeWatchHistoryItemWithManager(
this.watchHistory,
pointerInput,
options,
);
}
async fetchWatchHistory(actorInput, options = {}) {
return fetchWatchHistoryWithManager(this.watchHistory, actorInput, options);
}
async resolveWatchHistory(actorInput, options = {}) {
return resolveWatchHistoryWithManager(
this.watchHistory,
actorInput,
options,
);
}
getWatchHistoryDeferredChunkCount(actorInput) {
return getWatchHistoryDeferredChunkCountFromManager(
this.watchHistory,
actorInput,
);
}
async listVideoViewEvents(pointer, options = {}) {
return listVideoViewEventsForClient(this, pointer, options);
}
subscribeVideoViewEvents(pointer, options = {}) {
return subscribeVideoViewEventsForClient(this, pointer, options);
}
async countVideoViewEvents(pointer, options = {}) {
return countVideoViewEventsForClient(this, pointer, options);
}
async publishViewEvent(videoPointer, options = {}) {
return publishViewEventForClient(this, videoPointer, options, {
resolveActiveSigner: (p) => this.signerManager.resolveActiveSigner(p),
shouldRequestExtensionPermissions,
signEventWithPrivateKey,
DEFAULT_NIP07_PERMISSION_METHODS,
});
}
async publishVideoReaction(pointer, options = {}) {
return publishVideoReactionForClient(this, pointer, options, {
resolveActiveSigner: (p) => this.signerManager.resolveActiveSigner(p),
shouldRequestExtensionPermissions,
signEventWithPrivateKey,
DEFAULT_NIP07_PERMISSION_METHODS,
});
}
async publishVideoComment(target, options = {}) {
return publishCommentForClient(this, target, options, {
resolveActiveSigner: (p) => this.signerManager.resolveActiveSigner(p),
shouldRequestExtensionPermissions,
signEventWithPrivateKey,
DEFAULT_NIP07_PERMISSION_METHODS,
});
}
async publishDmReadReceipt(payload, options = {}) {
return publishDmReadReceiptForClient(this, payload, options, {
shouldRequestExtensionPermissions,
DEFAULT_NIP07_PERMISSION_METHODS,
});
}
async publishDmTypingIndicator(payload, options = {}) {
return publishDmTypingIndicatorForClient(this, payload, options, {
shouldRequestExtensionPermissions,
DEFAULT_NIP07_PERMISSION_METHODS,
});
}
async fetchVideoComments(target, options = {}) {
return listVideoCommentsForClient(this, target, options);
}
subscribeVideoComments(target, options = {}) {
return subscribeVideoCommentsForClient(this, target, options);
}
async recordVideoView(videoPointer, options = {}) {
return recordVideoViewForClient(this, videoPointer, options, {
resolveActiveSigner: (p) => this.signerManager.resolveActiveSigner(p),
shouldRequestExtensionPermissions,
signEventWithPrivateKey,
DEFAULT_NIP07_PERMISSION_METHODS,
});
}
/**
* Initializes the client and bootstraps the network layer.
*
* **Boot Sequence:**
* 1. **Offline Restore**: Loads cached events from IndexedDB/localStorage to render the UI immediately (Stale-While-Revalidate).
* 2. **Session Restore**: Attempts to reconnect to a stored NIP-46 remote signer (if any).
* 3. **Network Connect**: Initializes `SimplePool` and establishes WebSocket connections to relays.
*
* @returns {Promise<void>} Resolves when the relay pool is initialized and connections are attempted.
*/
async init() {
if (this.initPromise) {
return this.initPromise;
}
this.initPromise = (async () => {
devLogger.log("Connecting to relays...");
// 1. Restore cache for immediate UI render (Stale-While-Revalidate)
await this.restoreLocalData();
try {
this.scheduleStoredRemoteSignerRestore();
} catch (error) {
devLogger.warn(
"[nostr] Failed to schedule remote signer restoration:",
error
);
}
try {
await this.ensurePool();
const results = await this.connectToRelays();
const successfulRelays = results
.filter((r) => r.success)
.map((r) => r.url);
if (successfulRelays.length === 0) {
userLogger.warn(
"[nostr] No relays connected during init. Retrying in the background."
);
this.scheduleRelayReconnect({ reason: "initial-connect-failed" });
} else {
this.resetRelayReconnectState();
devLogger.log(`Connected to ${successfulRelays.length} relay(s)`);
}
this.isInitialized = true;
} catch (err) {
userLogger.error("Nostr init failed:", err);
this.initPromise = null;
throw err;
}
})();
return this.initPromise;
}
/**
* Ensures the `SimplePool` (from nostr-tools) is initialized and ready.
*
* @returns {Promise<import("nostr-tools").SimplePool>} The active pool instance.
*/
async ensurePool() {
return this.connectionManager.ensurePool();
}
/**
* Delegates to `ConnectionManager` to connect to all configured relays.
*
* @returns {Promise<Array<{url: string, success: boolean}>>} Connection results for each relay.
*/
async connectToRelays() {
return this.connectionManager.connectToRelays();
}
resolveRelayReconnectDelayMs(attempt) {
return this.connectionManager.resolveRelayReconnectDelayMs(attempt);
}
resetRelayReconnectState() {
this.connectionManager.resetRelayReconnectState();
}
scheduleRelayReconnect(options) {
return this.connectionManager.scheduleRelayReconnect(options);
}
logRelaySummary(options) {
return this.connectionManager.logRelaySummary(options);
}
resolveRelayBackoffMs(failureCount, ttlOverride) {
return this.connectionManager.resolveRelayBackoffMs(failureCount, ttlOverride);
}
clearRelayBackoff(url) {
return this.connectionManager.clearRelayBackoff(url);
}
recordRelayFailureWindow(url) {
return this.connectionManager.recordRelayFailureWindow(url);
}
markRelayUnreachable(url, ttlMs, options) {
return this.connectionManager.markRelayUnreachable(url, ttlMs, options);
}
getHealthyRelays(candidates) {
return this.connectionManager.getHealthyRelays(candidates);
}
getDmDecryptCacheLimit() {
return DM_DECRYPT_CACHE_LIMIT;
}
getDmDecryptCacheStats() {
if (!this.dmDecryptCache || typeof this.dmDecryptCache.getStats !== "function") {
return null;
}
return this.dmDecryptCache.getStats();
}
clearDmDecryptCache() {
if (this.dmDecryptCache) {
this.dmDecryptCache.clear();
}
dmPlaintextCache.clear();
}
async ensureDmDecryptor() {
if (this.dmDecryptor) {
return this.dmDecryptor;
}
if (!this.dmDecryptorPromise) {
this.dmDecryptorPromise = import("../dmDecryptor.js")
.then((module) => {
if (!module || typeof module.decryptDM !== "function") {
throw new Error("DM decryptor module is unavailable.");
}
this.dmDecryptor = module.decryptDM;
return this.dmDecryptor;
})
.catch((error) => {
this.dmDecryptorPromise = null;
throw error;
});
}
return this.dmDecryptorPromise;
}
async buildDmDecryptContext(actorPubkeyInput = null) {
let normalizedActor = normalizeActorKey(actorPubkeyInput);
if (!normalizedActor && typeof this.pubkey === "string" && this.pubkey) {
normalizedActor = normalizeActorKey(this.pubkey);
}
if (
!normalizedActor &&
this.sessionActor &&
typeof this.sessionActor.pubkey === "string"
) {
normalizedActor = normalizeActorKey(this.sessionActor.pubkey);
}
const decryptors = [];
const seen = new Set();
const addCandidate = (scheme, decrypt, options = {}) => {
if (typeof decrypt !== "function") {
return;
}
const normalizedScheme = typeof scheme === "string" ? scheme : "";
const key = `${normalizedScheme}:${options.source || ""}`;
if (seen.has(key)) {
return;
}
decryptors.push({
scheme: normalizedScheme,
decrypt,
priority: Number.isFinite(options.priority) ? options.priority : 0,
source: options.source || "",
supportsGiftWrap: options.supportsGiftWrap === true,
});
seen.add(key);
};
let activeSigner = null;
if (normalizedActor) {
activeSigner = this.signerManager.resolveActiveSigner(normalizedActor);
}
if (!activeSigner && this.pubkey) {
activeSigner = this.signerManager.resolveActiveSigner(this.pubkey);
}
if (!activeSigner) {
activeSigner = this.signerManager.getActiveSigner();
}
let extensionPermissionResult = null;
if (
(activeSigner?.type === "extension" || activeSigner?.type === "nip07") &&
typeof this.ensureExtensionPermissions === "function"
) {
extensionPermissionResult = await this.ensureExtensionPermissions(
DEFAULT_NIP07_ENCRYPTION_METHODS,
{ context: "dm" },
);
if (!extensionPermissionResult?.ok) {
devLogger.warn(
"[nostr] Extension encryption permissions missing for DM decryption.",
extensionPermissionResult?.error,
);
}
}
// Register the active signer's decryptors by METHOD EXISTENCE, not by the
// dynamic capabilities getter or the permission-request result. The getter
// probes window.nostr for injected nip04/nip44 modules and gives false
// negatives (lazy injection, or providers like nos2x that expose decrypt
// methods which validate at call time) — the list services already learned
// this. Hard-gating here returned ZERO decryptors and made the DM load throw
// "DM decryption helpers are unavailable" even though the signer decrypts
// everything else fine (KNOWN_BUGS #0). The permission request above still
// runs (it can grant), it just no longer blocks decryptor registration.
if (activeSigner) {
if (typeof activeSigner.nip44Decrypt === "function") {
addCandidate(
"nip44",
(pubkey, ciphertext, options) =>
activeSigner.nip44Decrypt(pubkey, ciphertext, {
...options,
// DM decryption is background-class ("load whenever"): it must
// never compete with the feed-driving lists (hashtags,
// subscriptions, watch history) or block/moderation lists for the
// serialized extension. LOW confines the DM backfill flood to the
// single unreserved queue slot. See nip07Permissions.js.
priority: NIP07_PRIORITY.LOW,
}),
{
priority: -20,
source: activeSigner.type || "signer",
supportsGiftWrap: true,
},
);
}
if (typeof activeSigner.nip04Decrypt === "function") {
addCandidate(
"nip04",
(pubkey, ciphertext, options) =>
activeSigner.nip04Decrypt(pubkey, ciphertext, {
...options,
// Background-class — see the nip44 candidate above.
priority: NIP07_PRIORITY.LOW,
}),
{
priority: -10,
source: activeSigner.type || "signer",
},
);
}
}
const sessionActor = this.sessionActor;
if (
sessionActor &&
typeof sessionActor.privateKey === "string" &&
sessionActor.privateKey
) {
const canUseWorker = isDmDecryptWorkerSupported();
const sessionPrivateKey = sessionActor.privateKey;
if (canUseWorker) {
addCandidate(
"nip44",
(targetPubkey, ciphertext, options = {}) =>
decryptDmInWorker({
scheme: "nip44",
privateKey: sessionPrivateKey,
targetPubkey,
ciphertext,
event: options?.event,
}),
{
priority: -6,
source: "worker",
supportsGiftWrap: true,
},
);
addCandidate(
"nip04",
(targetPubkey, ciphertext, options = {}) =>
decryptDmInWorker({
scheme: "nip04",
privateKey: sessionPrivateKey,
targetPubkey,
ciphertext,
event: options?.event,
}),
{
priority: -4,
source: "worker",
},
);
}
if (
!this.sessionActorCipherClosures ||
this.sessionActorCipherClosuresPrivateKey !== sessionActor.privateKey
) {
const closures = await createPrivateKeyCipherClosures(
sessionActor.privateKey,
);
this.sessionActorCipherClosures = closures || null;
this.sessionActorCipherClosuresPrivateKey = sessionActor.privateKey;
}
const closures = this.sessionActorCipherClosures || {};
if (typeof closures.nip44Decrypt === "function") {
addCandidate("nip44", closures.nip44Decrypt, {
priority: -5,
source: "session-actor",
supportsGiftWrap: true,
});
}
if (typeof closures.nip04Decrypt === "function") {
addCandidate("nip04", closures.nip04Decrypt, {
priority: 0,
source: "session-actor",
});
}
}
return { actorPubkey: normalizedActor, decryptors };
}
async decryptDirectMessageEvent(event, { actorPubkey } = {}) {
const eventId = typeof event?.id === "string" ? event.id : "";
if (eventId) {
const cached = this.dmDecryptCache.get(eventId);
if (cached) {
return cached;
}
// Persisted result from a prior session — avoids re-hitting the extension
// to decrypt the same immutable DM on every reload.
const persisted = dmPlaintextCache.get(eventId);
if (persisted) {
try {
const parsed = JSON.parse(persisted);
if (parsed && parsed.ok) {
this.dmDecryptCache.set(eventId, parsed);
return parsed;
}
} catch (_) {
// fall through to a fresh decrypt
}
}
// Coalesce concurrent decrypts of the SAME event. Multiple subscribers
// (history list, notifications) often process an event at once; without
// this they each hit the (serialized, slow) extension before the cache
// fills, multiplying extension round-trips per message.
const inFlight = this.dmDecryptInFlight.get(eventId);
if (inFlight) {
return inFlight;
}
}
const decryptPromise = (async () => {
const decryptDM = await this.ensureDmDecryptor();
const context = await this.buildDmDecryptContext(actorPubkey);
let result;
try {
result = await decryptDM(event, context);
} catch (error) {
devLogger.warn("[nostr] DM decryptor threw unexpectedly.", {
error: sanitizeDecryptError(error),
event: summarizeDmEventForLog(event),
});
throw error;
}
if (result?.ok && eventId) {
this.dmDecryptCache.set(eventId, result);
try {
dmPlaintextCache.set(eventId, JSON.stringify(result));
} catch (_) {
// result not serializable — skip persisting
}
}
return result;
})();
if (eventId) {
this.dmDecryptInFlight.set(eventId, decryptPromise);
}
try {
return await decryptPromise;
} finally {
if (eventId) {
this.dmDecryptInFlight.delete(eventId);
}
}
}
/**
* Fetches and decrypts Direct Messages (DMs) for the active user.
* Handles both NIP-04 (Legacy) and NIP-17 (Gift Wrap) events transparently.
*
* **Process:**
* 1. **Filter Construction**: Builds filters for Kind 4 and Kind 1059 events.
* 2. **Relay Query**: Fetches events from read relays (or specified candidates).
* 3. **Decryption**: Attempts to decrypt each event using available strategies:
* - NIP-04: Uses signer's `nip04.decrypt`.
* - NIP-44/17: Uses signer's `nip44.decrypt` (or private key if local).
* 4. **Caching**: Decrypted results are cached in memory (`dmDecryptCache`) to avoid repeated decryption overhead.
*
* @param {string|null} [actorPubkeyInput] - The public key of the user viewing the DMs (defaults to logged-in user).
* @param {object} [options] - Filter options.
* @param {number} [options.limit] - Max events to fetch.
* @param {number} [options.since] - Start timestamp.
* @param {string[]} [options.relays] - Custom relay list.
* @param {number} [options.decryptLimit] - Max number of messages to attempt decrypting (optimization).
* @returns {Promise<object[]>} A list of decrypted message objects (text, timestamp, sender, etc.).
*/
async listDirectMessages(actorPubkeyInput = null, options = {}) {
if (!this.pool) {
await this.ensurePool();
}
const context = await this.buildDmDecryptContext(actorPubkeyInput);
if (!context.decryptors.length) {
throw new Error("DM decryption helpers are unavailable.");
}
const decryptLimit =
Number.isFinite(options?.decryptLimit) && options.decryptLimit > 0
? Math.floor(options.decryptLimit)
: null;
const relayCandidates = Array.isArray(options.relays)
? options.relays
: Array.isArray(this.readRelays) && this.readRelays.length
? this.readRelays
: this.relays;
const relays = sanitizeRelayList(this.getHealthyRelays(relayCandidates));
const relaysToUse = relays.length ? relays : Array.from(DEFAULT_RELAY_URLS);
const filters = buildDmFilters(
context.actorPubkey || actorPubkeyInput,
options,
);
const dedupedIds = new Set();
const messages = [];
let decryptedCount = 0;
return new Promise((resolve) => {
const sub = this.pool.sub(relaysToUse, filters);
const timeoutMs = options.timeoutMs || STANDARD_TIMEOUT_MS;
let settled = false;
let timeoutId = null;
const finish = () => {
if (settled) return;
settled = true;
if (timeoutId) clearTimeout(timeoutId);
sub.unsub();
// Persist any newly-decrypted DM results once, after the batch.
dmPlaintextCache.flush();
messages.sort((a, b) => (b?.timestamp || 0) - (a?.timestamp || 0));
resolve(messages);
};
timeoutId = setTimeout(() => {
devLogger.warn("[nostr] listDirectMessages timed out.");
finish();
}, timeoutMs);
sub.on("event", async (event) => {
if (!event || typeof event !== "object") return;
const eventId = typeof event.id === "string" ? event.id : "";
if (eventId && dedupedIds.has(eventId)) return;
if (eventId) dedupedIds.add(eventId);
if (decryptLimit && decryptedCount >= decryptLimit) {
// We reached the limit, but since relays may send out of order,
// strictly stopping here might miss newer messages if they arrive late.
// However, to respect the limit and avoid over-decrypting massive histories,
// we stop processing new decrypts.
return;
}
try {
const decrypted = await this.decryptDirectMessageEvent(event, {
actorPubkey: context.actorPubkey || actorPubkeyInput,
});
if (decrypted?.ok) {
decryptedCount++;
messages.push(decrypted);
if (typeof options.onMessage === "function") {
try {
options.onMessage(decrypted);
} catch (error) {
devLogger.warn("[nostr] onMessage callback threw:", error);
}
}
}
} catch (error) {
devLogger.warn("[nostr] Failed to decrypt DM event during list.", {
error: sanitizeDecryptError(error),
event: summarizeDmEventForLog(event),
});
}
});
sub.on("eose", () => {
finish();
});
});
}
subscribeDirectMessages(actorPubkeyInput = null, options = {}) {
if (!this.pool) {
throw new Error("nostr pool is not initialized");
}
const relayCandidates = Array.isArray(options.relays)
? options.relays
: Array.isArray(this.readRelays) && this.readRelays.length
? this.readRelays
: this.relays;
const relays = sanitizeRelayList(this.getHealthyRelays(relayCandidates));
const relaysToUse = relays.length ? relays : Array.from(DEFAULT_RELAY_URLS);
const filters = buildDmFilters(actorPubkeyInput, options);
const subscription = this.pool.sub(relaysToUse, filters);
const seenIds = new Set();
subscription.on("event", (event) => {
if (!event || typeof event !== "object") {
return;
}
const eventId = typeof event.id === "string" ? event.id : null;
if (eventId) {
if (seenIds.has(eventId)) {
return;
}
seenIds.add(eventId);
}
if (typeof options.onEvent === "function") {
try {
options.onEvent(event);
} catch (error) {
devLogger.warn("[nostr] DM onEvent handler threw.", error);
}
}
if (options.skipDecrypt === true) {
return;
}
(async () => {
try {
const contextPromise = this.getCurrentDmDecryptContextPromise(actorPubkeyInput);
const context = await contextPromise;
if (!context.decryptors.length) {
this.refreshDmDecryptContext(actorPubkeyInput);
if (typeof options.onFailure === "function") {
options.onFailure(
{ ok: false, event },
{ event, reason: "no-decryptors" },
);
}
return;
}
const result = await this.decryptDirectMessageEvent(event, {
actorPubkey: context.actorPubkey || actorPubkeyInput,
});
if (result?.ok) {
if (typeof options.onMessage === "function") {
options.onMessage(result, { event });
}
} else if (typeof options.onFailure === "function") {
options.onFailure(result, { event });
}
} catch (error) {
if (typeof options.onError === "function") {
options.onError(error, { event });
} else {
devLogger.warn(
"[nostr] Failed to decrypt DM event from subscription.",
{
error: sanitizeDecryptError(error),
event: summarizeDmEventForLog(event),
},
);
}
}
})();
});
if (typeof options.onEose === "function") {
subscription.on("eose", () => {
try {
options.onEose();
} catch (error) {
devLogger.warn("[nostr] DM onEose handler threw.", error);
}
});
}
const originalUnsub =
typeof subscription.unsub === "function"
? subscription.unsub.bind(subscription)
: () => {};
subscription.unsub = () => {
seenIds.clear();
return originalUnsub();
};
return subscription;
}
getCurrentDmDecryptContextPromise(actorPubkeyInput = null) {
const normalizedActor = normalizeActorKey(actorPubkeyInput);
const cacheKey = normalizedActor || 'default';
if (!this.dmDecryptContextCache) {
this.dmDecryptContextCache = new Map();
}
let promise = this.dmDecryptContextCache.get(cacheKey);
if (!promise) {
promise = this.buildDmDecryptContext(actorPubkeyInput);
this.dmDecryptContextCache.set(cacheKey, promise);
}
return promise;
}
refreshDmDecryptContext(actorPubkeyInput = null) {
const normalizedActor = normalizeActorKey(actorPubkeyInput);
const cacheKey = normalizedActor || 'default';
if (!this.dmDecryptContextCache) {
this.dmDecryptContextCache = new Map();
}
if (this.dmDecryptContextCache.has(cacheKey)) {
this.dmDecryptContextCache.delete(cacheKey);
}
const newPromise = this.buildDmDecryptContext(actorPubkeyInput);
this.dmDecryptContextCache.set(cacheKey, newPromise);
return newPromise;
}
async _sendNip17DirectMessage({
signer,
signingAdapter,
actorHex,
targetHex,
message,
attachments,
resolvedOptions,
recipientSelection,
senderSelection,
relayWarning,
}) {
const tools = await ensureNostrTools();
const getPublicKey =
tools && typeof tools.getPublicKey === "function" ? tools.getPublicKey : null;
if (!getPublicKey) {
return { ok: false, error: "nip17-keygen-failed" };
}
const senderRelayTargets = senderSelection.relays;
const randomPastTimestamp = () => {
const now = Math.floor(Date.now() / 1000);
const offset = Math.floor(Math.random() * 172800);
return now - offset;
};
const generateEphemeralKeypair = () => {
let privateKey = "";
try {
if (typeof tools.generateSecretKey === "function") {
const secret = tools.generateSecretKey();
if (secret instanceof Uint8Array) {
privateKey = bytesToHex(secret);
}
}
if (!privateKey) {
if (typeof tools.generatePrivateKey === "function") {
privateKey = tools.generatePrivateKey();
} else if (window?.crypto?.getRandomValues) {
const randomBytes = new Uint8Array(32);
window.crypto.getRandomValues(randomBytes);
privateKey = Array.from(randomBytes)
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
}
} catch (error) {
devLogger.warn("[nostr] Failed to generate NIP-17 wrapper key.", error);
privateKey = "";
}
const normalizedPrivateKey =
typeof privateKey === "string" ? privateKey.trim() : "";
if (!normalizedPrivateKey) {
return null;
}
let pubkey = "";
try {
pubkey = getPublicKey(normalizedPrivateKey);
} catch (error) {
let retrySuccess = false;
try {
if (HEX64_REGEX.test(normalizedPrivateKey)) {
const bytes = new Uint8Array(normalizedPrivateKey.length / 2);
for (let i = 0; i < normalizedPrivateKey.length; i += 2) {
bytes[i / 2] = parseInt(
normalizedPrivateKey.substring(i, i + 2),
16,
);
}
pubkey = getPublicKey(bytes);
retrySuccess = true;
}
} catch (retryError) {
// Fall back to error handling below
}
if (!retrySuccess) {
devLogger.warn(
"[nostr] Failed to derive NIP-17 wrapper pubkey.",
error,
);
return null;
}
}
return { privateKey: normalizedPrivateKey, pubkey };
};
const rumorEvents = [];
const createdAt = Math.floor(Date.now() / 1000);
if (message) {
rumorEvents.push(buildChatMessageEvent({
pubkey: actorHex,
created_at: createdAt,
recipientPubkey: targetHex,
content: message,
}));
}
if (attachments.length > 0) {
attachments.forEach((attachment) => {
const normalizedAttachment = {
x:
typeof attachment.x === "string"
? attachment.x.trim().toLowerCase()
: "",
url: typeof attachment.url === "string" ? attachment.url.trim() : "",
name:
typeof attachment.name === "string" ? attachment.name.trim() : "",
type:
typeof attachment.type === "string" ? attachment.type.trim() : "",
size: Number.isFinite(attachment.size) ? Math.floor(attachment.size) : null,
key: typeof attachment.key === "string" ? attachment.key.trim() : "",
};
rumorEvents.push(
buildDmAttachmentEvent({
pubkey: actorHex,
created_at: createdAt,
recipientPubkey: targetHex,
attachment: normalizedAttachment,
}),
);
});
}
if (!rumorEvents.length) {
return { ok: false, error: "empty-message" };
}
const buildGiftWrapForRecipient = async (
recipientPubkey,
relayHint,
rumorEvent,
) => {
const sealPayload = buildSealEvent({
pubkey: actorHex,
created_at: randomPastTimestamp(),
ciphertext: await signer.nip44Encrypt(
recipientPubkey,
JSON.stringify(rumorEvent),
),
});
const signedSeal = await signingAdapter.signEvent(sealPayload);
if (!signedSeal || typeof signedSeal.id !== "string") {
throw new Error("seal-signature-failed");
}
const wrapperKeys = generateEphemeralKeypair();
if (!wrapperKeys) {
throw new Error("wrapper-keygen-failed");
}
const cipherClosures = await createPrivateKeyCipherClosures(
wrapperKeys.privateKey,
);
if (typeof cipherClosures.nip44Encrypt !== "function") {
throw new Error("wrapper-encryption-unavailable");
}
const wrapCiphertext = await cipherClosures.nip44Encrypt(
recipientPubkey,
JSON.stringify(signedSeal),
);
const wrapEvent = buildGiftWrapEvent({
pubkey: wrapperKeys.pubkey,
created_at: randomPastTimestamp(),
recipientPubkey,
relayHint,
ciphertext: wrapCiphertext,
});
return {
wrap: signEventWithPrivateKey(wrapEvent, wrapperKeys.privateKey),
seal: signedSeal,
};
};
const resolveRumorPreview = (rumorEvent) => {
const content =
typeof rumorEvent?.content === "string" ? rumorEvent.content.trim() : "";
if (content) {
return content;
}
const tags = Array.isArray(rumorEvent?.tags) ? rumorEvent.tags : [];
const nameTag = tags.find(
(tag) => Array.isArray(tag) && tag[0] === "name" && tag[1],
);
if (nameTag) {
return `Attachment: ${nameTag[1]}`;
}
return "Attachment";
};
const publishRumorEvent = async (rumorEvent) => {
let recipientGiftWrap;
try {
recipientGiftWrap = await buildGiftWrapForRecipient(
targetHex,
recipientSelection.relays[0] || "",
rumorEvent,
);
} catch (error) {
devLogger.warn("[nostr] Failed to build NIP-17 recipient wrap.", error);
const errorCode =
error?.message === "wrapper-keygen-failed"
? "nip17-keygen-failed"
: "encryption-failed";
return { ok: false, error: errorCode, details: error };
}
const conversationId = `dm:${[actorHex, targetHex].sort().join(":")}`;
const preview = resolveRumorPreview(rumorEvent);
const dmRecord = {
id: recipientGiftWrap.wrap.id,
conversation_id: conversationId,
sender_pubkey: actorHex,
receiver_pubkey: targetHex,
created_at: rumorEvent.created_at,
kind: rumorEvent.kind,
content: typeof rumorEvent.content === "string" ? rumorEvent.content : "",
tags: rumorEvent.tags,
status: "pending",
seen: true,
};
try {
await writeMessages(dmRecord);
await updateConversationFromMessage(dmRecord, {
preview,
unseenDelta: 0,
});
} catch (error) {
devLogger.warn("[nostr] Failed to persist outgoing DM.", error);
}
const recipientPublishResults = await pMap(
recipientSelection.relays,
(url) => publishEventToRelay(this.pool, url, recipientGiftWrap.wrap),
{ concurrency: RELAY_BACKGROUND_CONCURRENCY },
);
const recipientSuccess = recipientPublishResults.some(
(result) => result.success,
);
if (!recipientSuccess) {
try {
await writeMessages({ ...dmRecord, status: "failed" });
} catch (error) {
devLogger.warn("[nostr] Failed to persist DM failure state.", error);
}
return {
ok: false,
error: "publish-failed",
details: recipientPublishResults.filter((result) => !result.success),
};
}
let senderGiftWrap = null;
try {
senderGiftWrap = await buildGiftWrapForRecipient(
actorHex,
senderRelayTargets[0] || "",
rumorEvent,
);
} catch (error) {
devLogger.warn("[nostr] Failed to build NIP-17 sender copy.", error);
}
if (senderGiftWrap && senderRelayTargets.length) {
const senderPublishResults = await pMap(
senderRelayTargets,
(url) => publishEventToRelay(this.pool, url, senderGiftWrap.wrap),
{ concurrency: RELAY_BACKGROUND_CONCURRENCY },
);
if (!senderPublishResults.some((result) => result.success)) {
devLogger.warn("[nostr] Failed to publish sender copy of NIP-17 DM.", {
relays: senderRelayTargets,
});
}
}
try {
await writeMessages({ ...dmRecord, status: "published" });
} catch (error) {
devLogger.warn("[nostr] Failed to persist DM publish state.", error);
}
return { ok: true };
};
const failures = [];
for (const rumorEvent of rumorEvents) {
const result = await publishRumorEvent(rumorEvent);
if (!result?.ok) {
failures.push(result);
}
}
if (failures.length) {
return {
ok: false,
error: failures[0]?.error || "publish-failed",
details: failures,
};
}
return relayWarning ? { ok: true, warning: relayWarning } : { ok: true };
}
async _sendNip04DirectMessage({
signer,
signingAdapter,
actorHex,
targetHex,
message,
signerCapabilities,
}) {
const encryptionErrors = [];
let ciphertext = "";
const normalizedActorHex = normalizeActorKey(actorHex);
const sessionActor = this.sessionActor;
const sessionPrivateKey =
sessionActor &&
typeof sessionActor.pubkey === "string" &&
sessionActor.pubkey.toLowerCase() === normalizedActorHex &&
typeof sessionActor.privateKey === "string"
? sessionActor.privateKey
: "";
if (sessionPrivateKey) {
try {
ciphertext = await encryptNip04InWorker({
privateKey: sessionPrivateKey,
targetPubkey: targetHex,
plaintext: message,
});
} catch (error) {
encryptionErrors.push({ scheme: "nip04-worker", error });
}
}
if (
!ciphertext &&
signerCapabilities.nip04 &&
typeof signer.nip04Encrypt === "function"
) {
try {
const encrypted = await signer.nip04Encrypt(targetHex, message);
if (typeof encrypted === "string" && encrypted) {
ciphertext = encrypted;
}
} catch (error) {
encryptionErrors.push({ scheme: "nip04", error });
}
}
if (!ciphertext) {
if (!sessionPrivateKey && !signerCapabilities.nip04) {
const error = new Error(
"Your signer does not support NIP-04 encryption.",
);
userLogger.warn("[nostr] Encryption unsupported for DM send.", error);
return {
ok: false,
error: "encryption-unsupported",
details: error,
};
}
const details =
encryptionErrors.length === 1
? encryptionErrors[0].error
: encryptionErrors.map((entry) => ({
scheme: entry.scheme,
error: entry.error,
}));
return { ok: false, error: "encryption-failed", details };
}
const event = buildLegacyDirectMessageEvent({
pubkey: actorHex,
created_at: Math.floor(Date.now() / 1000),
recipientPubkey: targetHex,
ciphertext,
});
let signedEvent;
try {
signedEvent = await signingAdapter.signEvent(event);
} catch (error) {
return { ok: false, error: "signature-failed", details: error };
}
if (!signedEvent || typeof signedEvent.id !== "string") {
return { ok: false, error: "signature-failed" };
}
const conversationId = `dm:${[actorHex, targetHex].sort().join(":")}`;
const dmRecord = {
id: signedEvent.id,
conversation_id: conversationId,
sender_pubkey: actorHex,
receiver_pubkey: targetHex,
created_at: event.created_at,
kind: event.kind,
content: message,
tags: event.tags,
status: "pending",
seen: true,
};
try {
await writeMessages(dmRecord);
await updateConversationFromMessage(dmRecord, {
preview: message,
unseenDelta: 0,
});
} catch (error) {
devLogger.warn("[nostr] Failed to persist outgoing DM.", error);
}
const relayListCandidates = sanitizeRelayList(
Array.isArray(this.readRelays) && this.readRelays.length
? this.readRelays
: Array.isArray(this.relays) && this.relays.length
? this.relays
: RELAY_URLS,
);
const parseRecipientRelays = (relayEvent) => {
const tags = Array.isArray(relayEvent?.tags) ? relayEvent.tags : [];
const seen = new Set();
const candidates = [];
tags.forEach((tag) => {
if (!Array.isArray(tag) || tag[0] !== "r") {
return;
}
const url = typeof tag[1] === "string" ? tag[1].trim() : "";
if (!url) {
return;
}
const marker =
typeof tag[2] === "string" ? tag[2].trim().toLowerCase() : "";
if (marker === "write") {
return;
}
if (!seen.has(url)) {
seen.add(url);
candidates.push(url);
}
});
return sanitizeRelayList(candidates);
};
let recipientRelays = [];
if (relayListCandidates.length) {
try {
await this.ensurePool();
const events = await this.pool.list(relayListCandidates, [
{ kinds: [10002], authors: [targetHex], limit: 1 },
]);
const sorted = Array.isArray(events)
? events
.filter((entry) => entry && entry.pubkey === targetHex)
.sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0))
: [];
if (sorted.length) {
recipientRelays = parseRecipientRelays(sorted[0]);
}
} catch (error) {
devLogger.warn("[nostr] Failed to load recipient relay list.", error);
}
}
const fallbackRelays = sanitizeRelayList(
Array.isArray(this.writeRelays) && this.writeRelays.length
? this.writeRelays
: Array.isArray(this.relays) && this.relays.length
? this.relays
: RELAY_URLS,
);
const relays = recipientRelays.length ? recipientRelays : fallbackRelays;
const publishResults = await pMap(
relays,
(url) => publishEventToRelay(this.pool, url, signedEvent),
{ concurrency: RELAY_BACKGROUND_CONCURRENCY },
);
const success = publishResults.some((result) => result.success);
if (!success) {
try {
await writeMessages({ ...dmRecord, status: "failed" });
} catch (error) {
devLogger.warn("[nostr] Failed to persist DM failure state.", error);
}
return {
ok: false,
error: "publish-failed",
details: publishResults.filter((result) => !result.success),
};
}
try {
await writeMessages({ ...dmRecord, status: "published" });
} catch (error) {
devLogger.warn("[nostr] Failed to persist DM publish state.", error);
}
return { ok: true };
}
/**
* Sends a private Direct Message (DM) to a target user.
* Supports both legacy NIP-04 (simple encryption) and modern NIP-17 (sealed gift wraps).
*
* **Protocol Selection:**
* - Defaults to NIP-04 for maximum compatibility unless `options.useNip17` is explicitly true.
* - NIP-17 is required for sending rich attachments (images/videos) privately.
*
* **NIP-17 Flow (Sealed Rumor):**
* 1. **Rumor**: Create an unsigned Kind 14 (Chat Message) or Kind 1063 (Attachment).
* 2. **Seal**: Sign the rumor, encrypt it to the recipient, and wrap it in a Kind 13 (Seal).
* 3. **Wrap**: Generate an ephemeral keypair, encrypt the seal to the recipient, and wrap in a Kind 1059 (Gift Wrap).
* This creates an "onion" where the outer layer leaks no metadata about the sender or content.
* 4. **Publish**: Send to the recipient's inbox relays (discovered via NIP-65).
*
* **NIP-04 Flow (Legacy):**
* 1. **Encrypt**: Encrypt the message content using the shared secret (sender priv + recipient pub).
* 2. **Publish**: Send a Kind 4 event with the ciphertext.
* *Privacy Warning*: NIP-04 leaks metadata (sender, receiver, timestamp) to relays.
*
* @param {string} targetNpub - The recipient's public key (npub or hex).
* @param {string} message - The plaintext message to send.
* @param {string|null} [actorPubkeyOverride] - Optional sender pubkey (defaults to logged-in user).
* @param {object} [options] - Configuration options.
* @param {boolean} [options.useNip17=false] - If true, uses the NIP-17 sealing protocol.
* @param {Array<{url: string, type: string}>} [options.attachments] - File attachments (requires NIP-17).
* @returns {Promise<{ok: boolean, error?: string, details?: any}>} Result object. `ok: true` on success.
*/
async sendDirectMessage(targetNpub, message, actorPubkeyOverride = null, options = {}) {
let resolvedOptions = options;
let resolvedActorOverride = actorPubkeyOverride;
if (actorPubkeyOverride && typeof actorPubkeyOverride === "object") {
resolvedOptions = actorPubkeyOverride;
resolvedActorOverride = null;
}
if (!resolvedOptions || typeof resolvedOptions !== "object") {
resolvedOptions = {};
}
const trimmedTarget = typeof targetNpub === "string" ? targetNpub.trim() : "";
const trimmedMessage = typeof message === "string" ? message.trim() : "";
const attachments = Array.isArray(resolvedOptions.attachments)
? resolvedOptions.attachments.filter(
(attachment) =>
attachment &&
typeof attachment === "object" &&
(typeof attachment.url === "string" ||
typeof attachment.x === "string"),
)
: [];
const hasAttachments = attachments.length > 0;
if (!trimmedTarget) {
return { ok: false, error: "invalid-target" };
}
if (!trimmedMessage && !hasAttachments) {
return { ok: false, error: "empty-message" };
}
if (!this.pool) {
return { ok: false, error: "nostr-uninitialized" };
}
const activeSignerCandidate =
typeof resolvedActorOverride === "string" && resolvedActorOverride.trim()
? this.signerManager.resolveActiveSigner(resolvedActorOverride)
: this.signerManager.resolveActiveSigner(this.pubkey);
const baseActiveSigner = activeSignerCandidate || this.signerManager.getActiveSigner();
const signer =
typeof resolvedActorOverride === "string" && resolvedActorOverride.trim()
? activeSignerCandidate
: baseActiveSigner
? this.signerManager.resolveActiveSigner(baseActiveSigner.pubkey || this.pubkey)
: null;
if (!signer || typeof signer.signEvent !== "function") {
return { ok: false, error: "sign-event-unavailable" };
}
if (shouldRequestExtensionPermissions(signer)) {
const permissionResult = await this.ensureExtensionPermissions(
DEFAULT_NIP07_ENCRYPTION_METHODS,
{ context: "dm" },
);
if (!permissionResult.ok) {
userLogger.warn(
"[nostr] Cannot send direct message without encryption permissions.",
permissionResult.error,
);
return {
ok: false,
error: "extension-encryption-permission-denied",
details: permissionResult.error,
};
}
}
let actorHex =
typeof resolvedActorOverride === "string" && resolvedActorOverride.trim()
? resolvedActorOverride.trim()
: "";
if (!actorHex && typeof this.pubkey === "string") {
actorHex = this.pubkey.trim();
}
if (!actorHex && typeof signer?.pubkey === "string") {
actorHex = signer.pubkey;
}
const targetHex = decodeNpubToHex(trimmedTarget);
if (!targetHex) {
return { ok: false, error: "invalid-target" };
}
const signingAdapter =
resolvedOptions?.signingAdapter &&
typeof resolvedOptions.signingAdapter === "object"
? resolvedOptions.signingAdapter
: signer && typeof signer.signEvent === "function"
? {
signEvent: (event) => signer.signEvent(event),
getPubkey: async () => actorHex || signer.pubkey || "",
getDisplayName: async () => "",
}
: null;
if (!signingAdapter || typeof signingAdapter.signEvent !== "function") {
return { ok: false, error: "sign-event-unavailable" };
}
if (typeof signingAdapter.getPubkey === "function") {
try {
const adapterPubkey = await signingAdapter.getPubkey();
if (typeof adapterPubkey === "string" && adapterPubkey.trim()) {
actorHex = adapterPubkey.trim();
}
} catch (error) {
devLogger.warn("[nostr] Failed to resolve DM pubkey from adapter.", error);
}
}
if (!actorHex) {
return { ok: false, error: "missing-actor-pubkey" };
}
const signerCapabilities = resolveSignerCapabilities(signer);
const useNip17 = Boolean(resolvedOptions.useNip17);
// Heuristic: If attachments are present, we MUST use NIP-17 because
// NIP-04 does not support rich metadata or multiple recipients securely.
if (hasAttachments && !useNip17) {
return { ok: false, error: "attachments-unsupported" };
}
const resolveNip17RelaySelection = async (pubkey, relayHints) => {
const discoveryRelays = sanitizeRelayList(
Array.isArray(this.readRelays) && this.readRelays.length
? this.readRelays
: Array.isArray(this.relays) && this.relays.length
? this.relays
: RELAY_URLS,
);
const fallbackRelays = sanitizeRelayList(
Array.isArray(this.writeRelays) && this.writeRelays.length
? this.writeRelays
: Array.isArray(this.relays) && this.relays.length
? this.relays
: RELAY_URLS,
);
return resolveDmRelaySelection({
pubkey,
relayHints,
discoveryRelays,
fallbackRelays,
pool: this.pool,
log: { dev: devLogger, user: userLogger },
});
};
if (useNip17) {
// Protocol: NIP-17 (Sealed Rumor)
// Requires NIP-44 encryption and NIP-65 relay discovery.
if (!signerCapabilities.nip44 || typeof signer.nip44Encrypt !== "function") {
return { ok: false, error: "nip44-unsupported" };
}
const recipientSelection = await resolveNip17RelaySelection(
targetHex,
resolvedOptions.recipientRelayHints,
);
if (!recipientSelection.relays.length) {
return { ok: false, error: "nip17-relays-unavailable" };
}
const senderSelection = await resolveNip17RelaySelection(
actorHex,
resolvedOptions.senderRelayHints,
);
const relayWarning =
recipientSelection.warning === DM_RELAY_WARNING_FALLBACK ||
senderSelection.warning === DM_RELAY_WARNING_FALLBACK
? DM_RELAY_WARNING_FALLBACK
: null;
return this._sendNip17DirectMessage({
signer,
signingAdapter,
actorHex,
targetHex,
message: trimmedMessage,
attachments,
resolvedOptions,
recipientSelection,
senderSelection,
relayWarning,
});
}
// Protocol: NIP-04 (Legacy)
// Fallback for older clients. Simple ECDH encryption.
return this._sendNip04DirectMessage({
signer,
signingAdapter,
actorHex,
targetHex,
message: trimmedMessage,
signerCapabilities,
});
}
/**
* Publish a new video using the v3 content schema.
*/
async signAndPublishEvent(event, options = {}) {
return signAndPublishEventHelper({
client: this,
event,
options,
resolveActiveSigner: (p) => this.signerManager.resolveActiveSigner(p),
shouldRequestExtensionPermissions,
signEventWithPrivateKey,
});
}
/**
* Registers a local private key (nsec) as the active signer.
* Useful for tests or manual key management.
*
* @param {object} opts
* @param {string} opts.privateKey - The hex private key.
* @param {string} [opts.pubkey] - Optional hex public key.
* @returns {object} The created signer adapter.
*/
async registerPrivateKeySigner(opts = {}) {
const adapter = await createNsecAdapter(opts);
// Enforce access control (blacklist / whitelist) BEFORE activating the
// signer. The validator was previously accepted but ignored, so nsec login
// bypassed the access gate that NIP-07 and NIP-46 logins enforce.
if (typeof opts.validator === "function") {
try {
opts.validator(adapter.pubkey);
} catch (error) {
// Don't leave a since-blocked key persisted on this device.
try {
clearStoredSessionActorEntry();
} catch (cleanupError) {
devLogger.warn(
"[nostr] Failed to clear stored session after access denial:",
cleanupError,
);
}
throw error;
}
}
this.signerManager.setActiveSigner(adapter);
if (opts.privateKey && typeof opts.privateKey === "string") {
const normalizedPrivateKey = opts.privateKey.trim().toLowerCase();
this.sessionActor = {
pubkey: adapter.pubkey,
privateKey: normalizedPrivateKey,
source: "nsec",
};
// Persist the key, encrypted with the user's passphrase, when they opted
// to remember it. Uses the canonical { privateKeyEncrypted, encryption }
// shape that readStoredSessionActorEntry / decryptSessionPrivateKey
// expect — the previous shape was silently rejected, so "remember this
// key" never actually stored anything.
if (
opts.persist === true &&
typeof opts.passphrase === "string" &&
opts.passphrase.trim()
) {
try {
const encrypted = await encryptSessionPrivateKey(
normalizedPrivateKey,
opts.passphrase,
);
persistSessionActorEntry({
pubkey: adapter.pubkey,
privateKeyEncrypted: encrypted.ciphertext,
encryption: {
salt: encrypted.salt,
iv: encrypted.iv,
iterations: encrypted.iterations,
hash: encrypted.hash,
algorithm: encrypted.algorithm,
version: encrypted.version,
},
});
this.sessionActor.persisted = true;
} catch (error) {
devLogger.warn("[nostr] Failed to persist private key signer:", error);
}
}
}
return adapter;
}
// Metadata about a stored, passphrase-encrypted nsec session (no secrets).
// The login modal reads `hasEncryptedKey` to offer the "unlock saved key"
// flow; without this the unlock UI never appeared.
getStoredSessionActorMetadata() {
let entry = null;
try {
entry = readStoredSessionActorEntry();
} catch (error) {
devLogger.warn(
"[nostr] Failed to read stored session actor metadata:",
error,
);
return null;
}
if (!entry || !entry.privateKeyEncrypted || !entry.encryption) {
return null;
}
return {
hasEncryptedKey: true,
pubkey: typeof entry.pubkey === "string" ? entry.pubkey : "",
source: "nsec",
createdAt: Number.isFinite(entry.createdAt) ? entry.createdAt : null,
};
}
// Restore a persisted nsec session by decrypting it with the user's
// passphrase and re-activating the signer via the same path as fresh login.
// The access-control validator is enforced before activation, and a
// now-blocked key is forgotten.
async unlockStoredSessionActor(passphrase, { validator } = {}) {
if (typeof passphrase !== "string" || !passphrase.trim()) {
const error = new Error("A passphrase is required to unlock the saved key.");
error.code = "passphrase-required";
throw error;
}
const entry = readStoredSessionActorEntry();
if (!entry || !entry.privateKeyEncrypted || !entry.encryption) {
const error = new Error("There is no saved key to unlock.");
error.code = "no-stored-session";
throw error;
}
const storedPubkey =
typeof entry.pubkey === "string" && HEX64_REGEX.test(entry.pubkey.toLowerCase())
? entry.pubkey.toLowerCase()
: "";
if (storedPubkey && typeof validator === "function") {
try {
validator(storedPubkey);
} catch (error) {
try {
clearStoredSessionActorEntry();
} catch (cleanupError) {
devLogger.warn(
"[nostr] Failed to clear stored session after access denial:",
cleanupError,
);
}
throw error;
}
}
let privateKeyHex = "";
try {
privateKeyHex = await decryptSessionPrivateKey(
{
privateKeyEncrypted: entry.privateKeyEncrypted,
encryption: entry.encryption,
},
passphrase,
);
} catch (error) {
const failure = new Error(
"Failed to unlock the saved key. Check your passphrase and try again.",
);
failure.code =
error?.code === "decrypt-failed" ? "decrypt-failed" : "unlock-failed";
failure.cause = error;
throw failure;
}
const normalizedPrivateKey =
typeof privateKeyHex === "string" ? privateKeyHex.trim().toLowerCase() : "";
if (!HEX64_REGEX.test(normalizedPrivateKey)) {
const error = new Error("The saved key is corrupt and cannot be used.");
error.code = "stored-key-invalid";
throw error;
}
let pubkey = storedPubkey;
if (!pubkey) {
const tools = (await ensureNostrTools()) || getCachedNostrTools();
if (tools && typeof tools.getPublicKey === "function") {
pubkey = tools.getPublicKey(normalizedPrivateKey);
}
}
const adapter = await this.registerPrivateKeySigner({
privateKey: normalizedPrivateKey,
pubkey,
persist: false,
validator,
});
if (this.sessionActor && typeof this.sessionActor === "object") {
this.sessionActor.persisted = true;
}
const resolvedPubkey =
(adapter && typeof adapter.pubkey === "string" && adapter.pubkey) ||
pubkey ||
"";
return { pubkey: resolvedPubkey };
}
/**
* Publishes a fresh Video Series (Kind 30078) to the network.
* This is the entry point for creating new content.
*
* **Protocol (V3):**
* - The main event is a Kind 30078 (Application-Specific Data) with `t=video`.
* - It includes both a WebTorrent `magnet` and a direct `url` (if hosted).
* - A unique `d` tag is generated to make the series addressable (NIP-33).
* - A `videoRootId` is assigned to track future edits.
*
* **Side Effects (Mirroring):**
* 1. **Primary Event**: Signs and broadcasts the Kind 30078 event.
* 2. **NIP-94 Mirror**: If a `url` exists, we *also* publish a Kind 1063 (File Header).
* This allows clients that only support NIP-94 (like file browsers) to see the video.
* 3. **NIP-71 Metadata**: If the user added categories/tags, we publish a Kind 22 event
* referencing the main video. This keeps the main event lightweight.
*
* @param {object} videoPayload - The normalized form data (title, magnet, thumbnail, etc.).
* @param {string} pubkey - The active user's public key.
* @returns {Promise<import("nostr-tools").Event>} The signed Kind 30078 event.
* @throws {Error} If signing fails or the primary event is rejected by all relays.
*/
async publishVideo(videoPayload, pubkey) {
const context = await prepareVideoPublishPayload(videoPayload, pubkey);
devLogger.log("Publish event with series identifier:", context.videoRootId);
devLogger.log("Event content:", context.event.content);
try {
// 1. Publish the primary Video Note (Kind 30078)
const { signedEvent } = await this.signAndPublishEvent(context.event, {
context: "video note",
logName: "Video note",
devLogLabel: "video note",
resolveActiveSigner: (p) => this.signerManager.resolveActiveSigner(p),
});
// 2. Publish NIP-94 Mirror (Kind 1063) if a hosted URL is present.
await handlePublishNip94(this, signedEvent, context);
// 3. Publish NIP-71 Metadata (Kind 22) if categories/tags were added.
await handlePublishNip71(this, signedEvent, context);
return signedEvent;
} catch (err) {
devLogger.error("Failed to sign/publish:", err);
throw err;
}
}
/**
* Publishes a *new version* of an existing video series.
* Nostr events are immutable, so "editing" means creating a newer event that supersedes the old one.
*
* **Mechanism (NIP-33 Replacement):**
* 1. **Address Retention**: We reuse the original `d` tag. Relays will now serve *this* event
* instead of the old one when queried by address.
* 2. **Root Preservation**: We copy the `videoRootId` from the original event. This links the
* new event to the series history.
* 3. **Content Update**: The new payload (title, magnet, etc.) replaces the old content.
*
* **Constraints:**
* - The `userPubkey` must match the original event's author (only the creator can edit).
* - The original event must be V2 or newer (legacy V1 migration is not supported here).
*
* @param {object} originalEventStub - The video event being edited (requires `id` and `d` tag).
* @param {object} updatedData - The modified fields (title, thumbnail, etc.).
* @param {string} userPubkey - The active user's public key.
* @returns {Promise<import("nostr-tools").Event>} The newly published event.
* @throws {Error} If the user is not the owner or if the original event is too old.
*/
async editVideo(originalEventStub, updatedData, userPubkey) {
const { baseEvent, userPubkeyLower } = await this._validateEditPrerequisites(
originalEventStub,
userPubkey,
);
const context = prepareVideoEditPayload({
baseEvent,
originalEventStub,
updatedData,
userPubkey,
resolveEventDTag: (evt, stub) => this.resolveEventDTag(evt, stub),
});
devLogger.log("Creating edited event with root ID:", context.videoRootId);
devLogger.log("Event content:", context.event.content);
return this._signAndPublishEdit(context, userPubkeyLower);
}
async _validateEditPrerequisites(originalEventStub, userPubkey) {
if (!userPubkey) {
throw new Error("Not logged in to edit.");
}
// NOTE: Keep the Upload, Edit, and Revert flows synchronized when
// adjusting validation or persisted fields.
// Convert the provided pubkey to lowercase
const userPubkeyLower = userPubkey.toLowerCase();
// Use getEventById to fetch the full original event details
const baseEvent = await this.getEventById(originalEventStub.id);
if (!baseEvent) {
throw new Error("Could not retrieve the original event to edit.");
}
// Check that the original event is version 2 or higher
if (baseEvent.version < 2) {
throw new Error("This video is not in the supported version for editing.");
}
// Ownership check (compare lowercase hex public keys)
if (
!baseEvent.pubkey ||
baseEvent.pubkey.toLowerCase() !== userPubkeyLower
) {
throw new Error("You do not own this video (pubkey mismatch).");
}
return { baseEvent, userPubkeyLower };
}
async _signAndPublishEdit(context, userPubkeyLower) {
await this.ensureActiveSignerForPubkey(userPubkeyLower);
try {
const { signedEvent } = await this.signAndPublishEvent(context.event, {
context: "edited video note",
logName: "Edited video note",
devLogLabel: "edited video note",
resolveActiveSigner: (p) => this.signerManager.resolveActiveSigner(p),
});
await handlePublishNip94(this, signedEvent, context);
await handlePublishNip71(this, signedEvent, context);
return signedEvent;
} catch (err) {
userLogger.error("Edit failed:", err);
throw err;
}
}
async ensureActiveSignerForPubkey(pubkey) {
return this.signerManager.ensureActiveSignerForPubkey(pubkey);
}
async derivePrivateKeyFromSecret(secret) {
return this.signerManager.derivePrivateKeyFromSecret(secret);
}
async loginWithExtension(options) {
// Check if ensureExtensionPermissions was overridden on this instance (e.g. by tests).
// Only proxy when the own property differs from the prototype method to avoid recursion.
const ownDesc = Object.getOwnPropertyDescriptor(this, "ensureExtensionPermissions");
const isOverridden =
ownDesc &&
typeof ownDesc.value === "function" &&
ownDesc.value !== NostrClient.prototype.ensureExtensionPermissions;
if (isOverridden) {
const sm = this.signerManager;
const smOriginal = sm.ensureExtensionPermissions.bind(sm);
sm.ensureExtensionPermissions = (...args) => ownDesc.value.call(this, ...args);
try {
return await sm.loginWithExtension(options);
} finally {
sm.ensureExtensionPermissions = smOriginal;
}
}
return this.signerManager.loginWithExtension(options);
}
installNip46Client(client, options) {
return this.signerManager.installNip46Client(client, options);
}
async prepareRemoteSignerHandshake(params) {
return this.signerManager.prepareRemoteSignerHandshake(params);
}
async connectRemoteSigner(params) {
return this.signerManager.connectRemoteSigner(params);
}
async useStoredRemoteSigner(options) {
return this.signerManager.useStoredRemoteSigner(options);
}
getRemoteSignerStatus() {
return this.signerManager.getRemoteSignerStatus();
}
onRemoteSignerChange(listener) {
return this.signerManager.onRemoteSignerChange(listener);
}
getStoredNip46Metadata() {
return this.signerManager.getStoredNip46Metadata();
}
async ensureExtensionPermissions(
requiredMethods,
options,
) {
return this.signerManager.ensureExtensionPermissions(requiredMethods, options);
}
async scheduleStoredRemoteSignerRestore() {
return this.signerManager.scheduleStoredRemoteSignerRestore();
}
async disconnectRemoteSigner(options) {
return this.signerManager.disconnectRemoteSigner(options);
}
async ensureSessionActor(force) {
return this.signerManager.ensureSessionActor(force);
}
clearStoredSessionActor() {
return this.signerManager.clearStoredSessionActor();
}
/**
* Logs out the current user and clears all session state.
*
* **Cleanup:**
* - Clears `this.pubkey` and notifies the global signer registry.
* - Wipes session actor (ephemeral keys).
* - Disconnects NIP-46 remote signer (if active).
* - Clears Watch History cache.
* - Resets permissions cache.
*/
logout() {
return this.signerManager.logout();
}
/**
* Performs a "Soft Delete" by publishing a new version marked as deleted.
*
* **Mechanism:**
* - Creates a new Kind 30078 event with the same `d` tag as the original.
* - The content is scrubbed and replaced with `{ deleted: true, ...placeholder }`.
* - Relays will overwrite the old event with this tombstone when resolving the address.
* - This is reversible (by publishing a new valid version later) and preserves the history chain.
*
* @param {object} originalEvent - The video event object to revert.
* @param {string} pubkey - The active user's public key (must match owner).
* @returns {Promise<{event: object, publishResults: object[], summary: object}>} The published tombstone event and relay results.
* @throws {Error} If the user is not the owner or signing fails.
*/
async revertVideo(originalEvent, pubkey) {
if (!pubkey) {
throw new Error("Not logged in to revert.");
}
if (originalEvent.pubkey !== pubkey) {
throw new Error("Not your event (pubkey mismatch).");
}
let baseEvent = originalEvent;
if (!baseEvent.tags || !Array.isArray(baseEvent.tags)) {
const fetched = await this.getEventById(originalEvent.id);
if (!fetched) {
throw new Error("Could not fetch the original event for reverting.");
}
baseEvent = {
id: fetched.id,
pubkey: fetched.pubkey,
// Carry created_at so the tombstone can be made strictly newer than the
// version it replaces (see buildRevertVideoPayload monotonicity guard).
created_at: fetched.created_at,
content: JSON.stringify({
version: fetched.version,
deleted: fetched.deleted,
isPrivate: fetched.isPrivate,
isNsfw: fetched.isNsfw,
isForKids: fetched.isForKids,
title: fetched.title,
url: fetched.url,
magnet: fetched.magnet,
thumbnail: fetched.thumbnail,
description: fetched.description,
mode: fetched.mode,
}),
tags: fetched.tags,
};
}
const existingD = this.resolveEventDTag(baseEvent, originalEvent) || null;
const stableDTag =
existingD || baseEvent?.id || originalEvent?.id || null;
const event = buildRevertVideoPayload({
baseEvent,
originalEventId: originalEvent?.id,
pubkey,
existingD,
stableDTag,
});
await this.ensureActiveSignerForPubkey(pubkey);
const signer = this.signerManager.resolveActiveSigner(pubkey);
if (!signer || typeof signer.signEvent !== "function") {
const error = new Error(
"An active signer with signEvent support is required to revert videos.",
);
error.code = "nostr-extension-missing";
throw error;
}
if (shouldRequestExtensionPermissions(signer)) {
const permissionResult = await this.ensureExtensionPermissions(
DEFAULT_NIP07_CORE_METHODS,
);
if (!permissionResult.ok) {
userLogger.warn(
"[nostr] Signer permissions denied while reverting a video.",
permissionResult.error,
);
const error = new Error(
"The active signer must allow signing before reverting a video.",
);
error.code = "extension-permission-denied";
error.cause = permissionResult.error;
throw error;
}
}
const signedEvent = await queueSignEvent(signer, event);
const publishResults = await publishEventToRelays(
this.pool,
this.relays,
signedEvent
);
let publishSummary;
try {
publishSummary = assertAnyRelayAccepted(publishResults, {
context: "video revert",
});
} catch (publishError) {
if (publishError?.relayFailures?.length) {
publishError.relayFailures.forEach(
({ url, error: relayError, reason }) => {
userLogger.error(
`[nostr] Video revert rejected by ${url}: ${reason}`,
relayError || reason
);
}
);
}
throw publishError;
}
publishSummary.accepted.forEach(({ url }) =>
devLogger.log(`Revert event published to ${url}`)
);
if (publishSummary.failed.length) {
publishSummary.failed.forEach(({ url, error: relayError }) => {
const reason =
relayError instanceof Error
? relayError.message
: relayError
? String(relayError)
: "publish failed";
userLogger.warn(
`[nostr] Video revert not accepted by ${url}: ${reason}`,
relayError
);
});
}
return {
event: signedEvent,
publishResults,
summary: publishSummary,
};
}
async _hydrateHistoryForDelete(targetVideo) {
if (!targetVideo) return;
try {
await this.hydrateVideoHistory(targetVideo);
} catch (error) {
devLogger.warn(
"[nostr] Failed to hydrate video history before delete:",
error
);
}
}
_getEventsToDelete(videoRootId, pubkey, targetVideo) {
const normalizedPubkey = typeof pubkey === "string" ? pubkey.toLowerCase() : "";
const normalizedRootInput =
typeof videoRootId === "string" && videoRootId.trim().length
? videoRootId.trim()
: "";
const inferredRoot =
normalizedRootInput ||
(targetVideo && typeof targetVideo.videoRootId === "string"
? targetVideo.videoRootId.trim()
: "");
const targetDTag = this.resolveEventDTag(targetVideo);
const matchingEvents = new Map();
for (const candidate of this.allEvents.values()) {
if (!candidate || typeof candidate !== "object") {
continue;
}
const candidatePubkey =
typeof candidate.pubkey === "string" ? candidate.pubkey.toLowerCase() : "";
if (!candidatePubkey || candidatePubkey !== normalizedPubkey) {
continue;
}
if (candidate.deleted === true) {
continue;
}
const candidateRoot =
typeof candidate.videoRootId === "string" ? candidate.videoRootId : "";
const candidateDTag = this.resolveEventDTag(candidate);
const sameRoot = inferredRoot && candidateRoot === inferredRoot;
const sameD = targetDTag && candidateDTag === targetDTag;
const legacyMatch =
!inferredRoot &&
targetVideo &&
candidateRoot &&
candidateRoot === targetVideo.id;
const directMatch = targetVideo && candidate.id === targetVideo.id;
if (!sameRoot && !sameD && !legacyMatch && !directMatch) {
continue;
}
matchingEvents.set(candidate.id, candidate);
}
if (targetVideo && !matchingEvents.has(targetVideo.id)) {
matchingEvents.set(targetVideo.id, targetVideo);
}
if (!matchingEvents.size) {
throw new Error("No existing events found for that root.");
}
return { matchingEvents, inferredRoot };
}
/**
* Helper to perform a soft delete (revert) on a set of video events.
* Publishes a new version with `deleted: true` for each unique d-tag found.
*
* @param {Map} matchingEvents - The events to delete.
* @param {string} inferredRoot - The root ID of the series.
* @param {object} targetVideo - The main video object being deleted.
* @param {string} pubkey - The active user's pubkey.
* @returns {Promise<{revertSummaries: object[], revertEvents: object[]}>}
*/
async _softDeleteVersions(matchingEvents, inferredRoot, targetVideo, pubkey) {
const revertSummaries = [];
const revertEvents = [];
await Promise.all(
Array.from(matchingEvents.values()).map(async (vid) => {
const baseRoot =
(typeof vid.videoRootId === "string" && vid.videoRootId) ||
inferredRoot ||
(targetVideo && typeof targetVideo.videoRootId === "string"
? targetVideo.videoRootId
: "") ||
(targetVideo ? targetVideo.id : "") ||
vid.id;
const contentPayload = {
version: Number.isFinite(vid.version) ? vid.version : 3,
deleted: true,
isPrivate: vid.isPrivate === true,
isNsfw: vid.isNsfw === true,
isForKids: vid.isForKids === true && vid.isNsfw !== true,
title: typeof vid.title === "string" ? vid.title : "",
url: typeof vid.url === "string" ? vid.url : "",
magnet: typeof vid.magnet === "string" ? vid.magnet : "",
thumbnail: typeof vid.thumbnail === "string" ? vid.thumbnail : "",
description: typeof vid.description === "string" ? vid.description : "",
mode: typeof vid.mode === "string" ? vid.mode : "live",
videoRootId: baseRoot,
};
const revertResult = await this.revertVideo(
{
id: vid.id,
pubkey: vid.pubkey,
// Preserve the original timestamp so the tombstone is published with
// a strictly-newer created_at and actually replaces the live version.
created_at: vid.created_at,
content: JSON.stringify(contentPayload),
tags: Array.isArray(vid.tags) ? vid.tags : [],
},
pubkey
);
const revertEvent = revertResult?.event || null;
const revertSummary =
revertResult?.summary ||
summarizePublishResults(revertResult?.publishResults || []);
const revertPublishResults = Array.isArray(revertResult?.publishResults)
? revertResult.publishResults
: [];
revertSummaries.push({
targetId: vid.id || "",
event: revertEvent,
publishResults: revertPublishResults,
summary: revertSummary,
});
if (revertEvent?.id) {
revertEvents.push(revertEvent);
this.rawEvents.set(revertEvent.id, revertEvent);
}
const cached = this.allEvents.get(vid.id) || vid;
cached.deleted = true;
cached.url = "";
cached.magnet = "";
cached.thumbnail = "";
cached.description = "This version was deleted by the creator.";
cached.videoRootId = baseRoot;
this.allEvents.set(vid.id, cached);
this.dirtyEventIds.add(vid.id);
const activeKey = getActiveKey(cached);
if (activeKey) {
this.activeMap.delete(activeKey);
const revertCreatedAt = Number.isFinite(revertEvent?.created_at)
? Math.floor(revertEvent.created_at)
: Math.floor(Date.now() / 1000);
this.recordTombstone(activeKey, revertCreatedAt);
}
})
);
return { revertSummaries, revertEvents };
}
/**
* Helper to perform a hard delete (Kind 5) on a set of video events.
* Collects all event IDs (`e` tags) and addresses (`a` tags) and publishes a deletion request.
*
* @param {Map} matchingEvents - The original events to delete.
* @param {object[]} revertEvents - The soft-delete events created in the previous step (also deleted).
* @param {object} targetVideo - The main video object.
* @param {string} inferredRoot - The root ID.
* @param {string} pubkey - The active user's pubkey.
* @returns {Promise<object[]>} Summary of deletion results.
*/
async _hardDeleteVersions(matchingEvents, revertEvents, targetVideo, inferredRoot, pubkey) {
const eventIdSet = new Set();
const addressPointerSet = new Set();
const collectIdentifiersFromEvent = (eventLike) => {
if (!eventLike || typeof eventLike !== "object") {
return;
}
const eventId = typeof eventLike.id === "string" ? eventLike.id : "";
if (eventId) {
eventIdSet.add(eventId);
}
const pointerSources = [];
if (eventLike.kind && Array.isArray(eventLike.tags)) {
pointerSources.push(eventLike);
}
if (eventId) {
const raw = this.rawEvents.get(eventId);
if (raw && raw !== eventLike) {
pointerSources.push(raw);
}
}
for (const source of pointerSources) {
const pointer = eventToAddressPointer(source);
if (pointer) {
addressPointerSet.add(pointer);
}
}
};
matchingEvents.forEach((event) => collectIdentifiersFromEvent(event));
revertEvents.forEach((event) => collectIdentifiersFromEvent(event));
if (targetVideo) {
collectIdentifiersFromEvent(targetVideo);
}
const identifierRecords = [];
eventIdSet.forEach((value) => {
identifierRecords.push({ type: "e", value });
});
addressPointerSet.forEach((value) => {
identifierRecords.push({ type: "a", value });
});
const deleteSummaries = [];
if (identifierRecords.length) {
await this.ensureActiveSignerForPubkey(pubkey);
const signer = this.signerManager.resolveActiveSigner(pubkey);
if (!signer || typeof signer.signEvent !== "function") {
const error = new Error(
"An active signer with signEvent support is required to delete videos.",
);
error.code = "nostr-extension-missing";
throw error;
}
if (shouldRequestExtensionPermissions(signer)) {
const permissionResult = await this.ensureExtensionPermissions(
DEFAULT_NIP07_CORE_METHODS,
);
if (!permissionResult.ok) {
userLogger.warn(
"[nostr] Signer permissions denied while deleting videos.",
permissionResult.error,
);
const error = new Error(
"The active signer must allow signing before deleting a video.",
);
error.code = "extension-permission-denied";
error.cause = permissionResult.error;
throw error;
}
}
const chunkSize = 100;
for (let index = 0; index < identifierRecords.length; index += chunkSize) {
const chunk = identifierRecords.slice(index, index + chunkSize);
const eventIds = chunk
.filter((record) => record.type === "e")
.map((record) => record.value);
const addresses = chunk
.filter((record) => record.type === "a")
.map((record) => record.value);
const deleteEvent = buildDeletionEvent({
pubkey,
created_at: Math.floor(Date.now() / 1000),
eventIds,
addresses,
reason: inferredRoot
? `Delete video root ${inferredRoot}`
: "Delete published video events",
});
const signedDelete = await queueSignEvent(signer, deleteEvent);
const publishResults = await publishEventToRelays(
this.pool,
this.relays,
signedDelete,
{ waitForAll: true }
);
const publishSummary = summarizePublishResults(publishResults);
publishSummary.accepted.forEach(({ url }) =>
devLogger.log(`Delete event published to ${url}`),
);
if (publishSummary.failed.length) {
publishSummary.failed.forEach(({ url, error: relayError }) => {
const reason =
relayError instanceof Error
? relayError.message
: relayError
? String(relayError)
: "publish failed";
userLogger.warn(
`[nostr] Delete event not accepted by ${url}: ${reason}`,
relayError,
);
});
}
if (signedDelete?.id) {
this.rawEvents.set(signedDelete.id, signedDelete);
}
deleteSummaries.push({
event: signedDelete,
publishResults,
summary: publishSummary,
identifiers: {
events: chunk
.filter((record) => record.type === "e")
.map((record) => record.value),
addresses: chunk
.filter((record) => record.type === "a")
.map((record) => record.value),
},
});
}
}
return deleteSummaries;
}
/**
* Permanently removes a video series from the network.
* This is a "Nuclear Option" that attempts to wipe all traces of the video.
*
* **The Problem:**
* Because a video series might have 10 "edit" versions, deleting just the "latest" one isn't enough.
* Relays would just serve the *second-latest* version, resurrecting the video.
*
* **The Protocol (Soft + Hard Delete):**
* 1. **History Hydration**: We first fetch *every* historical version of this video (by `rootId` and `d` tag).
* 2. **Soft Delete (Tombstoning)**: For every unique `d` tag found in the history, we publish a *new* version
* with `content: { deleted: true }`. This ensures that even if NIP-09 fails, clients resolving "latest"
* will see a tombstone.
* 3. **Hard Delete (NIP-09)**: We construct a Kind 5 event referencing:
* - Every single Event ID (`e` tag) in the history.
* - Every NIP-33 address (`a` tag) found.
* - The video root ID.
* Relays that respect Kind 5 will physically delete these rows.
* 4. **Local Tombstone**: We update `this.tombstones` and `this.activeMap` immediately to hide the video
* from the UI without waiting for relay confirmation.
*
* @param {string} videoRootId - The unique root identifier of the video series.
* @param {string} pubkey - The owner's public key (must match event author).
* @param {object} [options] - Configuration.
* @param {boolean} [options.confirm=true] - Whether to show a browser confirmation dialog.
* @param {object} [options.video] - The current video object (optimization to skip initial fetch).
* @returns {Promise<{reverts: object[], deletes: object[]}|null>} A summary of the soft (revert) and hard (delete) events published. Returns null if user cancelled.
*/
async deleteAllVersions(videoRootId, pubkey, options = {}) {
if (!pubkey) {
throw new Error("Not logged in to delete all versions.");
}
const shouldConfirm = options?.confirm !== false;
const targetVideo =
options && typeof options.video === "object" ? options.video : null;
let confirmed = true;
if (shouldConfirm && typeof window?.confirm === "function") {
confirmed = window.confirm(
"Are you sure you want to delete all versions of this video? This action cannot be undone."
);
}
if (!confirmed) {
devLogger.log("Deletion cancelled by user.");
return null; // Cancel deletion if user clicks "Cancel"
}
await this._hydrateHistoryForDelete(targetVideo);
const { matchingEvents, inferredRoot } = this._getEventsToDelete(
videoRootId,
pubkey,
targetVideo
);
const { revertSummaries, revertEvents } = await this._softDeleteVersions(
matchingEvents,
inferredRoot,
targetVideo,
pubkey
);
const deleteSummaries = await this._hardDeleteVersions(
matchingEvents,
revertEvents,
targetVideo,
inferredRoot,
pubkey
);
this.saveLocalData("delete-events", { immediate: true });
return {
reverts: revertSummaries,
deletes: deleteSummaries,
};
}
/**
* Schedules a persistence operation to save current state to cache.
* Delegates to PersistenceManager.
*
* @param {string} [reason="unspecified"] - Debug label.
* @param {object} [options] - Configuration.
* @returns {Promise<boolean>|null} The persistence promise.
*/
saveLocalData(reason = "unspecified", options = {}) {
return this.persistenceManager.saveLocalData(reason, options);
}
clampVideoRequestLimit(limit, fallback = DEFAULT_VIDEO_REQUEST_LIMIT) {
const normalizedFallback =
Number.isFinite(fallback) && fallback > 0
? Math.floor(fallback)
: DEFAULT_VIDEO_REQUEST_LIMIT;
if (!Number.isFinite(limit) || limit <= 0) {
return normalizedFallback;
}
const floored = Math.floor(limit);
const clamped = Math.min(MAX_VIDEO_REQUEST_LIMIT, Math.max(1, floored));
if (clamped !== floored && isDevMode) {
devLogger.log(
`[nostr] Clamped video request limit from ${floored} to ${clamped}`,
);
}
return clamped;
}
getLatestCachedCreatedAt() {
let latest = 0;
const now = Math.floor(Date.now() / 1000);
for (const video of this.allEvents.values()) {
const createdAt = Number.isFinite(video?.created_at)
? Math.floor(video.created_at)
: 0;
if (createdAt > latest && createdAt <= now) {
latest = createdAt;
}
}
return latest;
}
/**
* Subscribes to Kind 30078 video events (Series/Clips) from relays using a buffered stream.
* This is the primary feed ingestion engine for the application.
*
* **Why Buffer?**
* Relays typically send events in high-speed bursts (historical dumps on connect).
* Processing every single event immediately (parsing -> hydrating -> React setState)
* causes massive layout thrashing and freezes the main thread.
*
* **The Algorithm (Buffering Strategy):**
* 1. **Ingest**: Push every incoming raw event into a temporary `eventBuffer`.
* 2. **Debounce**: A 75ms timer ensures we only process the buffer when the stream pauses briefly.
* 3. **Batch Process (Flush)**:
* - Validate & Parse: Convert raw JSON to internal `Video` objects.
* - Tombstone Check: Discard any video that is older than a known deletion timestamp.
* - **Latest-Wins Resolution**: Compare `created_at` against `activeMap`. Only update if newer.
* - **Hydration**: Bulk-fetch NIP-71 category tags for the new items.
* - **UI Update**: Fire `onVideo(batch)` *once* for the whole chunk.
* 4. **Persist**: Asynchronously save the new state to IndexedDB (Stale-While-Revalidate).
*
* **Invariants:**
* - `activeMap` will *only* ever contain the newest valid version of a video series.
* - Deleted videos (Tombstones) are filtered out before reaching the UI callback.
*
* @param {function(object[]): void} onVideo - Callback fired with a BATCH of new/updated `Video` objects.
* @param {object} [options] - Filter options.
* @param {number} [options.since] - Unix timestamp to fetch events after (usually `latestCachedCreatedAt`).
* @param {number} [options.until] - Unix timestamp to fetch events before.
* @param {number} [options.limit] - Max number of events to request from relays.
* @returns {import("nostr-tools").Sub} The subscription object. Call `unsub()` to stop listening.
*/
// Lazily create the L1 SubscriptionManager bound to this client's pool and a
// health-gated default relay set (matching subscribeVideos' historical
// healthy-or-default fallback). See docs/architecture-refactor.md.
getSubscriptionManager() {
if (!this._subscriptionManager) {
this._subscriptionManager = createSubscriptionManager({
getPool: () => this.pool,
getDefaultRelays: () => {
const healthy = sanitizeRelayList(this.getHealthyRelays(this.relays));
// Guarantee reliable default aggregators are always in the feed set,
// even when the user's own relays survive the health filter but are
// actually dead — otherwise the feed subscribes to a fully-broken
// list and hangs forever on "Fetching…".
return buildFeedRelaySet(healthy, this.relays);
},
// Own registry, isolated from the global singleton (which other
// subsystems use for their own keys) and from other client instances.
subscriptions: new RelaySubscriptionService(),
});
}
return this._subscriptionManager;
}
subscribeVideos(onVideo, options = {}) {
const { since, until, limit } = options;
const latestCachedCreatedAt = this.getLatestCachedCreatedAt();
const resolvedLimit = this.clampVideoRequestLimit(limit);
const resolvedSince = Number.isFinite(since)
? Math.floor(since)
: latestCachedCreatedAt > 0
? latestCachedCreatedAt
: undefined;
const resolvedUntil = Number.isFinite(until) ? Math.floor(until) : undefined;
const filter = {
kinds: [30078],
"#t": ["video"],
limit: resolvedLimit,
};
if (resolvedSince !== undefined) {
filter.since = resolvedSince;
}
if (resolvedUntil !== undefined) {
filter.until = resolvedUntil;
}
devLogger.log("[subscribeVideos] Subscribing with filter:", filter);
// BUFFERING STATE
// We collect events here instead of processing them instantly to avoid
// 1000s of React re-renders during the initial relay dump.
// The buffer acts as a pressure valve between the network and the UI.
// `videoEventVerifier` lets tests virtualize the off-thread signature worker;
// undefined in production so the buffer uses its default worker verifier.
const buffer = new VideoEventBuffer(this, onVideo, {
verifyEvents: this.videoEventVerifier,
});
// Route through the L1 SubscriptionManager: it resolves a health-gated
// relay set (healthy-or-default fallback, so the feed never subscribes to
// zero relays), dedups, and registers this sub for centralized re-issue on
// relay reconnect. Relays are NOT passed explicitly so reconnect re-resolves
// the currently-healthy set.
const handle = this.getSubscriptionManager().subscribe({
key: "feed:videos",
label: "subscribeVideos",
filters: [filter],
onEvent: (event) => buffer.push(event),
onEose: () => buffer.handleEose(),
});
// Preserve the legacy subscription contract: callers hold the return value
// and call .unsub() to tear down.
let unsubscribed = false;
return {
unsub: () => {
if (unsubscribed) {
return;
}
unsubscribed = true;
buffer.cleanup();
try {
handle.close();
} catch (err) {
userLogger.error("[subscribeVideos] Failed to close subscription:", err);
}
},
};
}
/**
* Delegates to `processNip71EventsHelper` to parse and cache NIP-71 metadata from events.
*
* @param {import("nostr-tools").Event[]} events - The raw events to process.
* @param {Map} [pointerMap] - Optional optimization map.
*/
processNip71Events(events, pointerMap = null) {
processNip71EventsHelper(events, {
nip71Cache: this.nip71Cache,
pointerMap,
});
}
/**
* Delegates to `mergeNip71MetadataIntoVideoHelper` to apply cached NIP-71 metadata to a video.
*
* @param {object} video - The video object to mutate.
* @returns {object} The mutated video object.
*/
mergeNip71MetadataIntoVideo(video) {
return mergeNip71MetadataIntoVideoHelper(video, {
nip71Cache: this.nip71Cache,
});
}
/**
* Delegates to `populateNip71MetadataForVideosHelper` to fetch missing NIP-71 metadata.
*
* @param {object[]} videos - The list of video objects to hydrate.
* @returns {Promise<void>}
*/
async populateNip71MetadataForVideos(videos = []) {
if (!Array.isArray(videos) || !videos.length) {
return;
}
const pointerMap = collectNip71PointerRequests(videos);
await populateNip71MetadataForVideosHelper(videos, {
nip71Cache: this.nip71Cache,
pointerMap,
fetchMetadata: (map, pointerValues) =>
this.fetchAndCacheNip71Metadata(map, pointerValues),
});
}
async fetchAndCacheNip71Metadata(pointerMap, pointerValues) {
if (!Array.isArray(pointerValues) || !pointerValues.length) {
return;
}
if (!this.pool || !Array.isArray(this.relays) || !this.relays.length) {
return;
}
const filter = {
kinds: [21, 22],
"#a": pointerValues,
};
try {
const responses = await pMap(
this.relays,
async (url) => {
try {
const events = await this.pool.list([url], [filter]);
return Array.isArray(events) ? events : [];
} catch (error) {
devLogger.warn(
`[nostr] NIP-71 fetch failed on ${url}:`,
error,
);
return [];
}
},
{ concurrency: RELAY_BACKGROUND_CONCURRENCY },
);
const deduped = new Map();
responses.flat().forEach((event) => {
if (event?.id && !deduped.has(event.id)) {
deduped.set(event.id, event);
}
});
processNip71EventsHelper(Array.from(deduped.values()), {
nip71Cache: this.nip71Cache,
pointerMap,
});
} catch (error) {
devLogger.warn("[nostr] Failed to fetch NIP-71 metadata:", error);
}
}
/**
* Fetches videos using a standard Request/Response model (legacy).
*
* Unlike `subscribeVideos` (which is streaming), this waits for all relays to EOSE
* before returning. It is useful for one-off fetches but less efficient for the main feed.
*
* @param {object} options - Filter options (limit, etc.).
* @returns {Promise<object[]>} A promise resolving to the list of active videos.
* @deprecated Use `subscribeVideos` for the main feed to support buffering and streaming.
*/
async fetchVideos(options = {}) {
return new Promise((resolve) => {
// Default to since: 0 to force full fetch if not specified
const effectiveOptions = { ...options };
if (effectiveOptions.since === undefined) {
effectiveOptions.since = 0;
}
const sub = this.subscribeVideos(() => {}, effectiveOptions);
sub.on("eose", async () => {
sub.unsub();
try {
const activeVideos = this.getActiveVideos();
await this.populateNip71MetadataForVideos(activeVideos);
activeVideos.forEach((video) => this.applyRootCreatedAt(video));
resolve(activeVideos);
} catch (err) {
userLogger.error("fetchVideos error:", err);
resolve([]);
}
});
});
}
/**
* Raw event helpers
*/
/**
* Fetch the unmodified Nostr event for a given id and cache the payload.
*
* @param {string} eventId
* @param {{ relays?: string[], filter?: import("nostr-tools").Filter }} options
* @returns {Promise<object|null>}
*/
async fetchRawEventById(eventId, options = {}) {
const id = typeof eventId === "string" ? eventId.trim() : "";
if (!id) {
return null;
}
const cached = this.rawEvents.get(id);
if (cached) {
return cached;
}
try {
await this.ensurePool();
} catch (error) {
devLogger.warn("fetchRawEventById ensurePool error:", error);
return null;
}
if (!this.pool) {
return null;
}
const relayCandidatesRaw =
Array.isArray(options?.relays) && options.relays.length
? options.relays
: this.relays;
const relays = sanitizeRelayList(this.getHealthyRelays(relayCandidatesRaw));
if (!relays.length) {
return null;
}
const baseFilter =
options && typeof options.filter === "object"
? { ...options.filter }
: { ids: [id] };
const normalizedIds = Array.isArray(baseFilter.ids)
? baseFilter.ids
.map((value) => (typeof value === "string" ? value.trim() : ""))
.filter(Boolean)
: [];
if (!normalizedIds.includes(id)) {
normalizedIds.push(id);
}
const filterTemplate = { ...baseFilter, ids: normalizedIds };
const makeFilter = () => ({
...filterTemplate,
ids: Array.from(filterTemplate.ids),
});
const remember = (evt) => {
if (evt && typeof evt === "object" && evt.id) {
this.rawEvents.set(evt.id, evt);
}
return evt || null;
};
if (typeof this.pool.get === "function") {
try {
const evt = await this.pool.get(relays, makeFilter());
if (evt && evt.id === id) {
return remember(evt);
}
} catch (error) {
devLogger.warn("fetchRawEventById pool.get error:", error);
}
}
if (typeof this.pool.list === "function") {
try {
const events = await this.pool.list(relays, [makeFilter()]);
if (Array.isArray(events)) {
for (const evt of events) {
if (!evt) {
continue;
}
const stored = remember(evt);
if (evt.id === id) {
return stored;
}
}
}
} catch (error) {
devLogger.warn("fetchRawEventById pool.list error:", error);
}
}
return null;
}
/**
* Resolve a single video from cache or the relay network.
*
* @param {string} eventId - The event id to look up.
* @param {{ includeRaw?: boolean }} options
* @returns {Promise<object|null|{video: object|null, rawEvent: object|null}>}
*/
async getEventById(eventId, options = {}) {
const includeRaw = options?.includeRaw === true;
const local = this.allEvents.get(eventId) || null;
const localRaw = this.rawEvents.get(eventId) || null;
if (local) {
this.applyRootCreatedAt(local);
const localKey = getActiveKey(local);
if (local.deleted) {
this.recordTombstone(localKey, local.created_at);
} else {
this.applyTombstoneGuard(local);
}
if (!includeRaw) {
return local;
}
if (localRaw) {
return { video: local, rawEvent: localRaw };
}
}
const rawEvent = await this.fetchRawEventById(eventId);
if (!rawEvent && !includeRaw) {
return local || null;
}
if (!rawEvent) {
return { video: local || null, rawEvent: null };
}
const video = convertEventToVideo(rawEvent);
this.applyRootCreatedAt(video);
const activeKey = getActiveKey(video);
if (video.deleted) {
this.recordTombstone(activeKey, video.created_at);
} else {
this.applyTombstoneGuard(video);
}
this.allEvents.set(eventId, video);
this.dirtyEventIds.add(eventId);
if (includeRaw) {
return { video, rawEvent };
}
return video;
}
async repostEvent(eventId, options = {}) {
return repostEventHelper({
client: this,
eventId,
options,
resolveActiveSigner: (p) => this.signerManager.resolveActiveSigner(p),
shouldRequestExtensionPermissions,
signEventWithPrivateKey,
eventToAddressPointer,
});
}
async mirrorVideoEvent(eventId, options = {}) {
return mirrorVideoEventHelper({
client: this,
eventId,
options,
resolveActiveSigner: (p) => this.signerManager.resolveActiveSigner(p),
shouldRequestExtensionPermissions,
signEventWithPrivateKey,
inferMimeTypeFromUrl,
});
}
async rebroadcastEvent(eventId, options = {}) {
return rebroadcastEventHelper({ client: this, eventId, options });
}
getRequestTimeoutMs(timeoutMs) {
return this.connectionManager.getRequestTimeoutMs(timeoutMs);
}
async sendRawCountFrame(relayUrl, filters, options = {}) {
return this.connectionManager.sendRawCountFrame(relayUrl, filters, options);
}
async countEventsAcrossRelays(filters, options = {}) {
return this.connectionManager.countEventsAcrossRelays(filters, options);
}
_processAndCacheEvent(evt) {
if (!evt || !evt.id) return null;
try {
this.rawEvents.set(evt.id, evt);
const parsed = convertEventToVideo(evt);
if (parsed.invalid) return null;
this.mergeNip71MetadataIntoVideo(parsed);
this.applyRootCreatedAt(parsed);
const activeKey = getActiveKey(parsed);
if (parsed.deleted) {
this.recordTombstone(activeKey, parsed.created_at);
} else {
this.applyTombstoneGuard(parsed);
}
this.allEvents.set(evt.id, parsed);
this.dirtyEventIds.add(evt.id);
return parsed;
} catch (err) {
devLogger.warn("[nostr] Failed to process incoming event:", err);
return null;
}
}
getLocalVideoHistory(video) {
if (!video || typeof video !== "object") return [];
const targetRoot = typeof video.videoRootId === "string" ? video.videoRootId : "";
const targetPubkey = typeof video.pubkey === "string" ? video.pubkey.toLowerCase() : "";
const targetDTag = this.resolveEventDTag(video);
const matches = [];
const seen = new Set();
for (const candidate of this.allEvents.values()) {
if (!candidate || typeof candidate !== "object") continue;
if (targetPubkey) {
const candidatePubkey = typeof candidate.pubkey === "string"
? candidate.pubkey.toLowerCase()
: "";
if (candidatePubkey !== targetPubkey) continue;
}
const candidateRoot = typeof candidate.videoRootId === "string" ? candidate.videoRootId : "";
const candidateDTag = this.resolveEventDTag(candidate, video);
const sameRoot = targetRoot && candidateRoot === targetRoot;
const sameD = targetDTag && candidateDTag === targetDTag;
const sameLegacyRoot = !targetRoot && candidateRoot && candidateRoot === video.id;
if (sameRoot || sameD || sameLegacyRoot || candidate.id === video.id) {
if (!seen.has(candidate.id)) {
seen.add(candidate.id);
if (!candidate.deleted) {
this.applyTombstoneGuard(candidate);
}
matches.push(candidate);
}
}
}
return matches;
}
/**
* Batch fetches and reconstructs the full edit history for multiple videos.
*
* @param {object[]} videos - The list of video objects to process.
* @returns {Promise<void>} Resolves when all histories have been hydrated into the cache.
*/
async hydrateVideoHistoryBatch(videos) {
if (!Array.isArray(videos) || videos.length === 0) return;
const missingRoots = new Set();
const dTagsToFetch = new Set();
const authorsForHistory = new Set();
// 1. Identify what to fetch
for (const video of videos) {
if (!video || typeof video !== "object") continue;
this.applyRootCreatedAt(video);
const targetRoot = typeof video.videoRootId === "string" ? video.videoRootId : "";
if (targetRoot && targetRoot !== video.id) {
if (!this.allEvents.has(targetRoot)) {
missingRoots.add(targetRoot);
}
}
const localMatches = this.getLocalVideoHistory(video);
const targetDTag = this.resolveEventDTag(video);
const shouldFetchHistory = localMatches.filter((entry) => !entry.deleted).length <= 1 && targetDTag;
if (shouldFetchHistory) {
dTagsToFetch.add(targetDTag);
if (video.pubkey) {
authorsForHistory.add(video.pubkey);
}
}
}
if (!this.pool || !Array.isArray(this.relays) || !this.relays.length) return;
const relays = this.getHealthyRelays(this.relays);
// 2. Fetch missing roots
if (missingRoots.size > 0) {
try {
const events = await this.pool.list(relays, [{ ids: Array.from(missingRoots) }]);
if (Array.isArray(events)) {
events.forEach(evt => this._processAndCacheEvent(evt));
}
} catch (err) {
devLogger.warn("[nostr] Batch root fetch failed:", err);
}
}
// 3. Fetch histories
if (dTagsToFetch.size > 0) {
const filter = {
kinds: [30078],
"#t": ["video"],
"#d": Array.from(dTagsToFetch),
limit: 500,
};
if (authorsForHistory.size > 0) {
filter.authors = Array.from(authorsForHistory);
}
try {
const events = await this.pool.list(relays, [filter]);
if (Array.isArray(events)) {
events.forEach(evt => this._processAndCacheEvent(evt));
}
} catch (err) {
devLogger.warn("[nostr] Batch history fetch failed:", err);
}
}
// 4. Update NIP-71 metadata for all touched videos
const allMatches = new Set();
for (const video of videos) {
if (!video || typeof video !== "object") continue;
const matches = this.getLocalVideoHistory(video);
matches.forEach(m => allMatches.add(m));
}
const uniqueMatches = Array.from(allMatches);
await this.populateNip71MetadataForVideos(uniqueMatches);
uniqueMatches.forEach(entry => this.applyRootCreatedAt(entry));
}
/**
* Fetches and reconstructs the full edit history of a video.
*
* **The Problem:**
* Nostr events are immutable. "Editing" a video creates a NEW event with a new ID.
* We need to find all previous versions to show a history log or allow reverting to an older state.
*
* **The Linking Logic (Version Stitching):**
* 1. **Modern (V3+)**: Versions share a `videoRootId` field in their content, pointing to the *first* event in the series.
* 2. **NIP-33 Addressability**: Versions share the same `d` tag, allowing clients to find them by `kind:pubkey:dTag`.
* 3. **Legacy Fallback**: Older versions might only be linked by the `d` tag or lack a root pointer entirely.
*
* **Algorithm:**
* 1. **Local Scan**: Linearly search `this.allEvents` (memory cache) for any event matching the target's `videoRootId` OR `d` tag.
* 2. **Root Recovery**: If the `videoRootId` refers to an event ID we don't have in memory, fetch it specifically from relays.
* This ensures we have the "genesis" block of the video history.
* 3. **Sparse History Check**: If we found <= 1 version locally, we assume our cache is incomplete.
* 4. **Relay Query**: If sparse, query relays for *all* events (Kind 30078) with the same `d` tag and author.
* 5. **Merge & Sort**: Combine local and network findings, deduplicate by ID, filter out unrelated events, and sort by `created_at` (Newest -> Oldest).
*
* @param {object} video - The target video object to find history for.
* @returns {Promise<object[]>} A promise resolving to an array of Video objects, sorted from newest to oldest.
*/
async hydrateVideoHistory(video) {
if (!video || typeof video !== "object") {
return [];
}
this.applyRootCreatedAt(video);
const targetRoot = typeof video.videoRootId === "string" ? video.videoRootId : "";
const targetPubkey = typeof video.pubkey === "string" ? video.pubkey.toLowerCase() : "";
const targetDTag = this.resolveEventDTag(video);
let localMatches = this.getLocalVideoHistory(video);
// Ensure we have the "Root" event (genesis block) even if we didn't start with it.
const ensureRootPresence = async () => {
const normalizedRoot = targetRoot && typeof targetRoot === "string"
? targetRoot
: "";
if (!normalizedRoot || normalizedRoot === video.id) {
return;
}
const alreadyPresent = localMatches.some((entry) => entry?.id === normalizedRoot);
if (alreadyPresent) {
return;
}
const cachedRoot = this.allEvents.get(normalizedRoot);
if (cachedRoot) {
this.applyRootCreatedAt(cachedRoot);
localMatches.push(cachedRoot);
return;
}
if (
!this.pool ||
typeof this.pool.get !== "function" ||
!Array.isArray(this.relays) ||
!this.relays.length
) {
return;
}
try {
const rootEvent = await this.pool.get(this.getHealthyRelays(this.relays), { ids: [normalizedRoot] });
if (rootEvent && rootEvent.id === normalizedRoot) {
const processed = this._processAndCacheEvent(rootEvent);
if (processed) localMatches.push(processed);
}
} catch (error) {
devLogger.warn(
`[nostr] Failed to fetch root event ${normalizedRoot} for history:`,
error
);
}
};
await ensureRootPresence();
// Heuristic: If we only have 1 version (the current one) and it's addressable (d-tag),
// we likely missed history. Query relays for all events with the same d-tag to fill gaps.
const shouldFetchFromRelays =
localMatches.filter((entry) => !entry.deleted).length <= 1 && targetDTag;
if (shouldFetchFromRelays && this.pool) {
const filter = {
kinds: [30078],
"#t": ["video"],
"#d": [targetDTag],
limit: 200,
};
if (targetPubkey) {
filter.authors = [video.pubkey];
}
try {
const perRelay = await pMap(
this.getHealthyRelays(this.relays),
async (url) => {
try {
const events = await this.pool.list([url], [filter]);
return events || [];
} catch (err) {
devLogger.warn(
`[nostr] History fetch failed on ${url}:`,
err,
);
return [];
}
},
{ concurrency: RELAY_BACKGROUND_CONCURRENCY },
);
const merged = perRelay.flat();
for (const evt of merged) {
this._processAndCacheEvent(evt);
}
} catch (err) {
devLogger.warn("[nostr] hydrateVideoHistory relay fetch error:", err);
}
localMatches = this.getLocalVideoHistory(video);
await ensureRootPresence();
}
localMatches.sort((a, b) => b.created_at - a.created_at);
await this.populateNip71MetadataForVideos(localMatches);
localMatches.forEach((entry) => this.applyRootCreatedAt(entry));
return localMatches;
}
getActiveVideos() {
return Array.from(this.activeMap.values()).sort(
(a, b) => b.created_at - a.created_at
);
}
handleEvent(event) {
if (!event || typeof event !== "object") return;
// Kind 0: Profile Metadata
if (event.kind === 0 && typeof event.pubkey === "string") {
const normalized = normalizeProfileFromEvent(event);
if (normalized) {
profileCache.setProfile(event.pubkey, normalized, { persist: true });
}
}
}
}
export {
getActiveSigner,
setActiveSigner,
clearActiveSigner,
resolveActiveSigner,
shouldRequestExtensionPermissions,
};
const a = 1