Files
client/www/js/init-ndk.mjs
T

1779 lines
60 KiB
JavaScript

/**
* NDK Initialization Module
* Shared by all pages for authentication and worker connection
*/
import {
configureMuteList,
loadMuteList as loadMuteListCore,
isEventMuted,
addMute as addMuteCore,
removeMute as removeMuteCore,
getMuteList,
getMuteCount,
onMuteListChanged,
isMuteListLoaded
} from './mute-list.mjs';
let VERSION = "0.0.1";
let versionData = null;
let versionLoaded = false;
// Load version asynchronously
const versionPromise = (async () => {
try {
const versionResponse = await fetch('./js/version.json');
versionData = await versionResponse.json();
VERSION = versionData.VERSION;
versionLoaded = true;
console.log(`[init-ndk] Loaded version: ${VERSION}`);
} catch (error) {
console.warn('[init-ndk] Could not load version.json, using default:', error);
versionLoaded = true;
}
})();
let ndkWorker = null;
let requestCounter = 0;
let pendingRequests = new Map();
const ANON_WORKER_PUBKEY = '0'.repeat(64);
let muteListBootstrapStarted = false;
let muteListBootstrapPromise = null;
function normalizeHexPubkey(value) {
const normalized = String(value || '').trim().toLowerCase();
return /^[a-f0-9]{64}$/.test(normalized) ? normalized : '';
}
async function getPublicKeyWithTimeout(timeoutMs = 7000) {
if (!window.nostr || typeof window.nostr.getPublicKey !== 'function') {
throw new Error('window.nostr.getPublicKey unavailable');
}
return await Promise.race([
window.nostr.getPublicKey(),
new Promise((_, reject) => {
setTimeout(() => reject(new Error(`getPublicKey timeout after ${timeoutMs}ms`)), timeoutMs);
})
]);
}
async function getExistingPubkeyNoPrompt() {
if (!window.nostr || typeof window.nostr.getPublicKey !== 'function') return '';
try {
const pubkey = await getPublicKeyWithTimeout(2500);
return normalizeHexPubkey(pubkey);
} catch {
return '';
}
}
function buildMuteListEncryptor() {
return async (recipientPubkey, plaintext) => {
if (window?.nostr?.nip44?.encrypt) {
return await window.nostr.nip44.encrypt(recipientPubkey, plaintext);
}
if (window?.nostr?.nip04?.encrypt) {
return await window.nostr.nip04.encrypt(recipientPubkey, plaintext);
}
throw new Error('No encryption support available in signer');
};
}
function buildMuteListDecryptor() {
return async (senderPubkey, ciphertext) => {
const content = String(ciphertext || '');
if (content.includes('?iv=')) {
if (!window?.nostr?.nip04?.decrypt) throw new Error('NIP-04 decrypt unavailable');
return await window.nostr.nip04.decrypt(senderPubkey, content);
}
if (window?.nostr?.nip44?.decrypt) {
return await window.nostr.nip44.decrypt(senderPubkey, content);
}
if (window?.nostr?.nip04?.decrypt) {
return await window.nostr.nip04.decrypt(senderPubkey, content);
}
throw new Error('No decryption support available in signer');
};
}
async function syncWorkerMuteList() {
try {
if (!ndkWorker) return false;
await sendWorkerRequest('syncMuteList', {}, 10000, 'syncMuteList timeout');
return true;
} catch (error) {
console.warn('[init-ndk] Worker mute-list sync failed:', error?.message || error);
return false;
}
}
async function bootstrapMuteList({ forceReload = false } = {}) {
configureMuteList({
ndkFetchEvents,
queryCache,
publishEvent,
getPubkey,
nip44Encrypt: buildMuteListEncryptor(),
nip44Decrypt: buildMuteListDecryptor()
});
if (forceReload) {
muteListBootstrapStarted = false;
muteListBootstrapPromise = null;
}
if (muteListBootstrapStarted && muteListBootstrapPromise) {
return muteListBootstrapPromise;
}
muteListBootstrapStarted = true;
muteListBootstrapPromise = (async () => {
try {
await loadMuteListCore();
await syncWorkerMuteList();
return true;
} catch (error) {
console.warn('[init-ndk] Mute-list bootstrap failed:', error?.message || error);
return false;
}
})();
return muteListBootstrapPromise;
}
/**
* Initialize NDK for any page - handles authentication automatically
* This function can be called from any page (index.html, profile.html, etc.)
*
* Options:
* - allowUnauthenticated: initialize worker without forcing login modal
* - publicPubkey: optional fallback pubkey for unauthenticated worker init
*/
export async function initNDKPage(options = {}) {
await versionPromise;
console.log(`[init-ndk v${VERSION}] Initializing NDK page...`);
const {
allowUnauthenticated = false,
publicPubkey = ''
} = options || {};
let authenticated = false;
let initPubkey = '';
if (allowUnauthenticated) {
initPubkey = await getExistingPubkeyNoPrompt();
authenticated = Boolean(initPubkey);
if (!initPubkey) {
initPubkey = normalizeHexPubkey(publicPubkey) || ANON_WORKER_PUBKEY;
console.log('[init-ndk] Optional auth mode: continuing without login');
}
} else {
authenticated = await ensureAuthenticated();
if (!authenticated) {
console.log('[init-ndk] Not authenticated - launching nostr-login-lite');
throw new Error("Authentication required");
}
initPubkey = await getPublicKeyWithTimeout(10000);
authenticated = true;
}
console.log('[init-ndk] Initializing NDK worker with pubkey context');
// All pages in www/ directory, so worker path is always the same.
// Include explicit worker revision token so updated SharedWorker code is guaranteed to restart.
const WORKER_REVISION = 'nip60-lightweight-republish-1';
const workerPath = `./ndk-worker.js?v=${encodeURIComponent(VERSION)}&wr=${encodeURIComponent(WORKER_REVISION)}`;
// Initialize NDK SharedWorker (shared across all tabs/pages)
// Don't use type: 'module' - the worker uses importScripts()
ndkWorker = new SharedWorker(workerPath);
// Set up message handlers BEFORE sending init message to ensure we don't miss the ready response
ndkWorker.port.onmessage = handleWorkerMessage;
// Explicitly start the SharedWorker port to ensure message delivery across browsers.
ndkWorker.port.start();
// Send pubkey context to worker
const pubkey = initPubkey;
// Wait for the worker to signal it's ready before returning
const initPromise = new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
cleanup();
console.error('[init-ndk] Worker initialization timeout - did not receive ready signal');
reject(new Error('Worker initialization timeout'));
}, 30000); // 30 second timeout
const cleanup = () => {
clearTimeout(timeout);
window.removeEventListener('ndkReady', readyHandler);
window.removeEventListener('ndkInitError', errorHandler);
};
// Listen for the ndkReady event dispatched by handleWorkerMessage
const readyHandler = () => {
cleanup();
console.log('[init-ndk] Worker signaled ready, initialization complete');
tryRegisterActiveSigningPort();
resolve();
};
const errorHandler = (evt) => {
cleanup();
const message = evt?.detail?.message || 'Worker initialization failed';
console.error('[init-ndk] Worker reported initialization error:', message);
reject(new Error(message));
};
window.addEventListener('ndkReady', readyHandler);
window.addEventListener('ndkInitError', errorHandler);
});
ndkWorker.port.postMessage({
type: 'init',
pubkey: pubkey
});
console.log('[init-ndk] Waiting for worker ready signal...');
await initPromise;
if (authenticated) {
await bootstrapMuteList();
}
return { authenticated, worker: ndkWorker };
}
/**
* Ensure user is authenticated - works from any page
* If not authenticated, launches nostr-login-lite modal
*/
async function ensureAuthenticated() {
// Check if window.nostr exists (from nostr-login-lite or extension)
if (window.nostr) {
try {
await getPublicKeyWithTimeout(4000);
console.log('[init-ndk] Already authenticated');
return true;
} catch (error) {
console.log('[init-ndk] window.nostr exists but not authenticated/ready:', error.message);
}
}
// Initialize and launch nostr-login-lite
if (window.NOSTR_LOGIN_LITE) {
console.log('[init-ndk] Initializing nostr-login-lite...');
// Initialize if not already done
if (!window.NOSTR_LOGIN_LITE._initialized) {
await window.NOSTR_LOGIN_LITE.init({
methods: {
extension: true,
local: true,
nip46: true,
seedphrase: true,
nsigner: true
}
});
}
// Check if auth was restored during init
if (window.nostr) {
try {
const pubkey = await getPublicKeyWithTimeout(4000);
console.log('[init-ndk] Auth already restored, pubkey:', pubkey);
return true;
} catch (error) {
console.log('[init-ndk] Auth restored but not ready, will launch modal:', error.message);
}
}
console.log('[init-ndk] Launching nostr-login-lite...');
await window.NOSTR_LOGIN_LITE.launch();
// Wait for authentication
return new Promise((resolve) => {
let settled = false;
const cleanup = () => {
window.removeEventListener('nlAuth', authHandler);
window.removeEventListener('nlAuthRestored', authHandler);
window.removeEventListener('nlMethodSelected', authHandler);
if (authTimeout) clearTimeout(authTimeout);
};
const finish = (result) => {
if (settled) return;
settled = true;
cleanup();
resolve(result);
};
const authHandler = async (event) => {
console.log('[init-ndk] Received auth event:', event.type);
// Give window.nostr a moment to be ready
await new Promise(r => setTimeout(r, 100));
if (window.nostr) {
try {
const pubkey = await getPublicKeyWithTimeout(4000);
console.log('[init-ndk] Authentication successful, pubkey:', pubkey);
tryRegisterActiveSigningPort();
finish(true);
} catch (error) {
console.log('[init-ndk] window.nostr not ready yet:', error.message);
// Not ready yet, wait for next event
}
}
};
const authTimeout = setTimeout(() => {
console.error('[init-ndk] Authentication wait timeout');
finish(false);
}, 30000);
window.addEventListener('nlAuth', authHandler);
window.addEventListener('nlAuthRestored', authHandler);
window.addEventListener('nlMethodSelected', authHandler);
window.addEventListener('nlAuthStateChanged', authHandler);
});
}
console.error('[init-ndk] nostr-login-lite not available');
return false;
}
/**
* Handle messages from NDK worker
*/
function handleWorkerMessage(event) {
const message = event.data;
// console.log('[init-ndk] Received from worker:', message.type);
// Handle different message types
if (message.type === 'ready') {
console.log('[init-ndk] Worker ready');
window.dispatchEvent(new CustomEvent('ndkReady'));
} else if (message.type === 'error') {
const detail = message?.data || {};
console.error('[init-ndk] Worker error:', detail.message || detail);
window.dispatchEvent(new CustomEvent('ndkInitError', { detail }));
} else if (message.type === 'event') {
// Broadcast event to page
window.dispatchEvent(new CustomEvent('ndkEvent', { detail: message.data }));
} else if (message.type === 'eose') {
// EOSE message has subId directly on message, not in data
window.dispatchEvent(new CustomEvent('ndkEose', { detail: { subId: message.subId } }));
} else if (message.type === 'profile') {
window.dispatchEvent(new CustomEvent('ndkProfile', { detail: message.data }));
} else if (message.type === 'relays') {
window.dispatchEvent(new CustomEvent('ndkRelays', { detail: message.data }));
} else if (message.type === 'muteListUpdated') {
window.dispatchEvent(new CustomEvent('ndkMuteListUpdated', { detail: message.data || {} }));
} else if (message.type === 'broadcastRelaysUpdated') {
// Broadcast relay list (kind 10088) changed — either from a save in
// this tab, a live subscription update, or a cross-tab edit. Pages
// (e.g. relays.html sidenav) re-render on this event.
window.dispatchEvent(new CustomEvent('ndkBroadcastRelaysUpdated'));
} else if (message.type === 'broadcastProgress') {
// Live per-relay publish progress from handlePublish. Pass through the
// full payload so footers can show "📡 42/150 relays · relay.example.com".
window.dispatchEvent(new CustomEvent('ndkBroadcastProgress', {
detail: {
sessionId: message.sessionId,
phase: message.phase,
eventKind: message.eventKind,
eventId: message.eventId,
total: message.total,
successful: message.successful,
failed: message.failed,
latestRelay: message.latestRelay,
latestError: message.latestError,
relayUrls: message.relayUrls || [],
batchCurrent: message.batchCurrent,
batchTotal: message.batchTotal,
}
}));
} else if (message.type === 'startupStatus') {
window.dispatchEvent(new CustomEvent('ndkStartupStatus', { detail: message.data || {} }));
} else if (message.type === 'relayEvent') {
// Broadcast relay event for debugging
window.dispatchEvent(new CustomEvent('ndkRelayEvent', {
detail: {
relayUrl: message.relayUrl,
event: message.event,
side: message.side,
data: message.data,
timestamp: message.timestamp
}
}));
} else if (message.type === 'relayActivity') {
// Broadcast relay activity to page
window.dispatchEvent(new CustomEvent('ndkRelayActivity', {
detail: {
relayUrl: message.relayUrl,
activity: message.activity,
stats: message.stats
}
}));
} else if (message.type === 'signRequest') {
// Handle signing request from worker
handleSignRequest(message.requestId, message.method, message.params);
} else if (message.type === 'fetchEventsResult') {
// Handle fetchEvents response for single relay
const pending = pendingRequests.get(message.requestId);
if (pending) {
pendingRequests.delete(message.requestId);
if (message.error) {
console.log('[init-ndk] Resolving with error:', message.error);
pending.reject(new Error(message.error));
} else {
pending.resolve(message.events);
}
}
// No warning for missing pending — relay responses arriving after timeout are expected
} else if (message.type === 'queryCacheResult') {
// Handle cache query response
const pending = pendingRequests.get(message.requestId);
if (pending) {
pendingRequests.delete(message.requestId);
if (message.error) {
pending.resolve([]); // Resolve with empty array on error
} else {
pending.resolve(message.events || []);
}
}
} else if (message.type === 'ndkFetchEventsResult') {
// Handle ndkFetchEvents response
const pending = pendingRequests.get(message.requestId);
if (pending) {
pendingRequests.delete(message.requestId);
if (message.error) {
pending.reject(new Error(message.error));
} else {
pending.resolve(message.events || []);
}
}
} else if (message.type === 'warmOutboxResult') {
// Handle warmOutbox response — best-effort pre-warm; always resolves.
const pending = pendingRequests.get(message.requestId);
if (pending) {
pendingRequests.delete(message.requestId);
pending.resolve({
ok: !!message.ok,
count: message.count || 0,
skipped: !!message.skipped,
error: message.error || null
});
}
} else if (message.type === 'setRelayEventLoggingResult') {
// Handle setRelayEventLogging response — fire-and-forget, but resolve
// the promise if the caller chose to await it.
const pending = pendingRequests.get(message.requestId);
if (pending) {
pendingRequests.delete(message.requestId);
pending.resolve({
ok: !!message.ok,
enabled: !!message.enabled,
error: message.error || null
});
}
} else if (message.type === 'getDiscoveredRelaysResult') {
// Handle getDiscoveredRelays response
const pending = pendingRequests.get(message.requestId);
if (pending) {
pendingRequests.delete(message.requestId);
pending.resolve(message.relays || []);
}
} else if (message.type === 'fetchCachedProfileResult') {
// Handle cached profile response
const pending = pendingRequests.get(message.requestId);
if (pending) {
pendingRequests.delete(message.requestId);
pending.resolve(message.profile); // Resolve with profile (could be null)
}
} else if (message.type === 'userSettingsResult') {
const pending = pendingRequests.get(message.requestId);
if (pending) {
pendingRequests.delete(message.requestId);
if (message.error) {
pending.reject(new Error(message.error));
} else {
pending.resolve(message.settings || {});
}
}
} else if (message.type === 'userSettingsUpdated') {
window.dispatchEvent(new CustomEvent('ndkUserSettings', { detail: message.data || {} }));
} else if (message.type === 'walletStatusChanged') {
window.dispatchEvent(new CustomEvent('ndkWalletStatus', { detail: message.data || {} }));
} else if (message.type === 'walletBalanceUpdated') {
window.dispatchEvent(new CustomEvent('ndkWalletBalance', { detail: message.data || {} }));
} else if (message.type === 'walletDepositConfirmed') {
window.dispatchEvent(new CustomEvent('ndkWalletDepositConfirmed', { detail: message.data || {} }));
} else if (message.type === 'walletSendRedeemed') {
console.log('[init-ndk] walletSendRedeemed received from worker', message.data || {});
window.dispatchEvent(new CustomEvent('ndkWalletSendRedeemed', { detail: message.data || {} }));
} else if (message.type === 'walletTransactionReceived') {
window.dispatchEvent(new CustomEvent('ndkWalletTransaction', { detail: message.data || {} }));
} else if (message.type === 'response') {
// Handle request/response pattern
const pending = pendingRequests.get(message.requestId);
if (pending) {
pendingRequests.delete(message.requestId);
if (message.error) {
pending.reject(new Error(message.error));
} else {
pending.resolve(message.data);
}
}
}
}
function isExpectedSignerCapabilityError(method, error) {
const msg = String(error?.message || error || '').toLowerCase();
if (!msg) return false;
if (method === 'nip44Decrypt' && msg.includes('nip-44 decryption not supported')) return true;
if (method === 'nip44Encrypt' && msg.includes('nip-44 encryption not supported')) return true;
return false;
}
/**
* Handle sign request from worker
*/
function tryRegisterActiveSigningPort() {
if (!ndkWorker?.port) return;
try {
const auth = window?.nostr?.authState || window?.NOSTR_LOGIN_LITE?.getAuthState?.() || null;
if (auth?.method === 'nsigner' && auth?.signer?.driver) {
ndkWorker.port.postMessage({ type: 'setActiveSigningPort' });
}
} catch (_) {
// ignore
}
}
async function handleSignRequest(requestId, method, params) {
try {
let result;
if (method === 'getPubkey') {
result = await window.nostr.getPublicKey();
} else if (method === 'signEvent') {
result = await window.nostr.signEvent(params.event);
} else if (method === 'encrypt') {
if (window.nostr.nip04 && window.nostr.nip04.encrypt) {
result = { ciphertext: await window.nostr.nip04.encrypt(params.recipientPubkey, params.plaintext) };
} else {
throw new Error('NIP-04 encryption not supported');
}
} else if (method === 'decrypt') {
if (window.nostr.nip04 && window.nostr.nip04.decrypt) {
result = { plaintext: await window.nostr.nip04.decrypt(params.senderPubkey, params.ciphertext) };
} else {
throw new Error('NIP-04 decryption not supported');
}
} else if (method === 'nip44Encrypt') {
if (window.nostr?.nip44?.encrypt) {
result = { ciphertext: await window.nostr.nip44.encrypt(params.recipientPubkey, params.plaintext) };
} else {
throw new Error('NIP-44 encryption not supported');
}
} else if (method === 'nip44Decrypt') {
if (window.nostr?.nip44?.decrypt) {
result = { plaintext: await window.nostr.nip44.decrypt(params.senderPubkey, params.ciphertext) };
} else {
throw new Error('NIP-44 decryption not supported');
}
} else {
throw new Error(`Unknown sign method: ${method}`);
}
// Send response back to worker
ndkWorker.port.postMessage({
type: 'signResponse',
requestId: requestId,
result: result
});
// If this tab successfully signs using hardware signer, pin worker routing to this tab.
tryRegisterActiveSigningPort();
} catch (error) {
if (isExpectedSignerCapabilityError(method, error)) {
console.warn(`[init-ndk] Sign request skipped (${method}):`, error?.message || error);
} else {
console.error('[init-ndk] Sign request failed:', error);
}
ndkWorker.port.postMessage({
type: 'signResponse',
requestId: requestId,
error: error.message
});
}
}
/**
* Helper to create subscriptions
*/
export function subscribe(filters, opts = {}) {
if (!ndkWorker) {
throw new Error('NDK worker not initialized. Call initNDKPage() first.');
}
const subId = `sub_${Date.now()}_${++requestCounter}`;
ndkWorker.port.postMessage({
type: 'subscribe',
subId: subId,
filters: filters,
opts: opts
});
console.log('[init-ndk] Created subscription:', subId, filters);
// Return subscription handle
return {
subId: subId,
close: () => {
ndkWorker.port.postMessage({
type: 'unsubscribe',
subId: subId
});
console.log('[init-ndk] Closed subscription:', subId);
}
};
}
/**
* Helper to publish events
*/
export async function publishEvent(event) {
if (!ndkWorker) {
throw new Error('NDK worker not initialized. Call initNDKPage() first.');
}
return new Promise((resolve, reject) => {
const requestId = `pub_${Date.now()}_${++requestCounter}`;
pendingRequests.set(requestId, { resolve, reject });
// Set timeout
setTimeout(() => {
if (pendingRequests.has(requestId)) {
pendingRequests.delete(requestId);
reject(new Error('Publish timeout'));
}
}, 30000);
ndkWorker.port.postMessage({
type: 'publish',
requestId: requestId,
event: event
});
console.log('[init-ndk] Publishing event:', event.kind);
});
}
/**
* Helper to publish pre-signed raw events without worker-side signing.
*/
export async function publishRawEvent(event) {
if (!ndkWorker) {
throw new Error('NDK worker not initialized. Call initNDKPage() first.');
}
return new Promise((resolve, reject) => {
const requestId = `pub_raw_${Date.now()}_${++requestCounter}`;
pendingRequests.set(requestId, { resolve, reject });
// Set timeout
setTimeout(() => {
if (pendingRequests.has(requestId)) {
pendingRequests.delete(requestId);
reject(new Error('Publish raw timeout'));
}
}, 30000);
ndkWorker.port.postMessage({
type: 'publishRaw',
requestId: requestId,
event: event
});
console.log('[init-ndk] Publishing raw event:', event.kind);
});
}
export async function publishRawEventToRelay(event, relayUrl) {
if (!ndkWorker) {
throw new Error('NDK worker not initialized. Call initNDKPage() first.');
}
return new Promise((resolve, reject) => {
const requestId = `pub_raw_relay_${Date.now()}_${++requestCounter}`;
pendingRequests.set(requestId, { resolve, reject });
setTimeout(() => {
if (pendingRequests.has(requestId)) {
pendingRequests.delete(requestId);
reject(new Error('Publish raw to relay timeout'));
}
}, 30000);
ndkWorker.port.postMessage({
type: 'publishRawToRelay',
requestId,
event,
relayUrl
});
console.log('[init-ndk] Publishing raw event to relay:', relayUrl, 'kind:', event?.kind);
});
}
export function sendNip17Message(recipientPubkey, content) {
return sendWorkerRequest(
'sendNip17Message',
{
recipientPubkey: String(recipientPubkey || '').trim(),
content: String(content || '')
},
30000,
'sendNip17Message timeout'
);
}
// ---------------------------------------------------------------------------
// Broadcast relays (kind 10088) — thin wrappers over sendWorkerRequest.
// The worker owns the kind 10088 state (active r-tags + skip-marked x-tags)
// and unions the active set into every public publish automatically.
// ---------------------------------------------------------------------------
/** Returns { active: string[], skipped: { url, reason, timestamp }[] } */
export function getBroadcastRelays() {
return sendWorkerRequest(
'getBroadcastRelays',
{},
15000,
'getBroadcastRelays timeout'
);
}
/** Save a new active broadcast relay list (unioned with existing r-tags). */
export function saveBroadcastRelays(urls) {
return sendWorkerRequest(
'saveBroadcastRelays',
{ urls: Array.isArray(urls) ? urls : [] },
30000,
'saveBroadcastRelays timeout'
);
}
/** One-click seed: add all discovered relays to the broadcast list. Returns { added } */
export function seedBroadcastRelays() {
return sendWorkerRequest(
'seedBroadcastRelays',
{},
30000,
'seedBroadcastRelays timeout'
);
}
/** Remove a relay from the skip-mark set so it is included in future publishes. */
export function unskipBroadcastRelay(url) {
return sendWorkerRequest(
'unskipBroadcastRelay',
{ url: String(url || '') },
30000,
'unskipBroadcastRelay timeout'
);
}
/**
* Fetch events from a single relay
* Returns events from that specific relay
* @param {Object} filters - Nostr filters
* @param {string} relayUrl - The relay URL to fetch from
*/
export async function fetchEvents(filters, relayUrl) {
if (!ndkWorker) {
throw new Error('NDK worker not initialized. Call initNDKPage() first.');
}
return new Promise((resolve, reject) => {
requestCounter++;
const requestId = `fetch_${Date.now()}_${requestCounter}`;
pendingRequests.set(requestId, { resolve, reject });
// Set timeout
setTimeout(() => {
if (pendingRequests.has(requestId)) {
pendingRequests.delete(requestId);
reject(new Error('Fetch events timeout'));
}
}, 10000);
ndkWorker.port.postMessage({
type: 'fetchEvents',
requestId: requestId,
filters: filters,
relayUrl: relayUrl
});
// console.log('[init-ndk] Fetching events from', relayUrl, ':', filters);
});
}
/**
* Fetch events using NDK's internal routing and caching
* Returns a flat, deduplicated array of raw events
* @param {Object} filters - Nostr filters
* @returns {Promise<Array>} - Flat array of events
*/
export async function ndkFetchEvents(filters) {
if (!ndkWorker) {
throw new Error('NDK worker not initialized. Call initNDKPage() first.');
}
return new Promise((resolve, reject) => {
requestCounter++;
const requestId = `ndkFetch_${Date.now()}_${requestCounter}`;
pendingRequests.set(requestId, { resolve, reject });
// Set timeout
setTimeout(() => {
if (pendingRequests.has(requestId)) {
pendingRequests.delete(requestId);
reject(new Error('ndkFetchEvents timeout'));
}
}, 15000);
ndkWorker.port.postMessage({
type: 'ndkFetchEvents',
requestId: requestId,
filters: filters
});
});
}
/**
* Pre-warm the NDK outbox tracker for a set of follow pubkeys.
*
* This proactively resolves each follow's kind 10002 relay list via
* ndk.outboxTracker.trackUsers so that subsequent short-lived fetchEvents
* subscriptions route to the correct write relays instead of racing the
* tracker. Pre-warming is best-effort: the returned promise always resolves
* (never rejects). On timeout it resolves with { ok: false, timedOut: true }.
*
* @param {string[]} pubkeys - Array of hex pubkeys to pre-warm.
* @returns {Promise<{ok: boolean, count?: number, skipped?: boolean, timedOut?: boolean, error?: string}>}
*/
export async function warmOutbox(pubkeys) {
if (!ndkWorker) {
throw new Error('NDK worker not initialized. Call initNDKPage() first.');
}
const list = Array.isArray(pubkeys) ? pubkeys.filter(Boolean) : [];
if (list.length === 0) {
return { ok: true, count: 0, skipped: true };
}
return new Promise((resolve) => {
requestCounter++;
const requestId = `warmOutbox_${Date.now()}_${requestCounter}`;
pendingRequests.set(requestId, { resolve, reject: resolve });
// Best-effort: resolve (not reject) on timeout.
setTimeout(() => {
if (pendingRequests.has(requestId)) {
pendingRequests.delete(requestId);
resolve({ ok: false, timedOut: true });
}
}, 15000);
ndkWorker.port.postMessage({
type: 'warmOutbox',
requestId: requestId,
pubkeys: list
});
});
}
/**
* Toggle relay event logging in the worker.
*
* When enabled, the worker broadcasts relayEvent messages to all ports and
* persists relay events to IndexedDB. When disabled (default), both are
* skipped to avoid cross-page broadcast spam and IndexedDB write contention.
* trackRelayRead/trackRelayWrite (relayActivity events) are not affected.
*
* Fire-and-forget: the returned promise resolves when the worker acknowledges,
* but callers may ignore it.
*
* @param {boolean} enabled - Whether relay event logging should be on.
* @returns {Promise<{ok: boolean, enabled: boolean, error?: string}>}
*/
export function setRelayEventLogging(enabled) {
if (!ndkWorker) {
throw new Error('NDK worker not initialized. Call initNDKPage() first.');
}
return new Promise((resolve) => {
requestCounter++;
const requestId = `setRelayEventLogging_${Date.now()}_${requestCounter}`;
pendingRequests.set(requestId, { resolve, reject: resolve });
// Fire-and-forget timeout — resolve gracefully if the worker never
// acknowledges (it always should, but be safe).
setTimeout(() => {
if (pendingRequests.has(requestId)) {
pendingRequests.delete(requestId);
resolve({ ok: false, enabled: !!enabled, error: 'setRelayEventLogging timeout' });
}
}, 5000);
ndkWorker.port.postMessage({
type: 'setRelayEventLogging',
requestId: requestId,
enabled: !!enabled
});
});
}
/**
* Get the set of "discovered" relays currently in the NDK pool that are NOT
* part of the user's own kind 10002 relay list. These are temporary outbox
* relays NDK connected to in order to fetch events from followed authors who
* write to relays the user does not subscribe to.
*
* Each entry is { url, status, connected, servingPubkeys: string[] }.
*
* @returns {Promise<Array<{url: string, status: number, connected: boolean, servingPubkeys: string[]}>>}
*/
export async function getDiscoveredRelays() {
if (!ndkWorker) {
throw new Error('NDK worker not initialized. Call initNDKPage() first.');
}
return new Promise((resolve) => {
requestCounter++;
const requestId = `getDiscoveredRelays_${Date.now()}_${requestCounter}`;
pendingRequests.set(requestId, { resolve, reject: resolve });
// Resolve with [] on timeout — this is a read-only diagnostic call.
setTimeout(() => {
if (pendingRequests.has(requestId)) {
pendingRequests.delete(requestId);
resolve([]);
}
}, 5000);
ndkWorker.port.postMessage({
type: 'getDiscoveredRelays',
requestId: requestId
});
});
}
/**
* Fetch events from all relays in parallel
* Returns events grouped by relay URL
* @param {Object} filters - Nostr filters
* @param {Object} opts - Options
* @param {Function} opts.onProgress - Callback for incremental progress (relayUrl, events, completed, total)
* @returns {Object} eventsByRelay - Object mapping relay URLs to arrays of events
*/
export async function fetchEventsFromAllRelays(filters, opts = {}) {
if (!ndkWorker) {
throw new Error('NDK worker not initialized. Call initNDKPage() first.');
}
// For backward compatibility (especially event.html), we still return an object mapping relay URL to events.
// However, we now use NDK's internal routing to get the events.
// Note: NDK fetchEvents deduplicates across relays, so we can't perfectly attribute events to specific relays
// in the same way the old raw WebSocket fetch did. We'll put all events under a 'combined' key or similar
// if we can't get per-relay attribution from NDK easily.
try {
const events = await ndkFetchEvents(filters);
// Return in the format expected by existing callers: { "ndk": [events] }
// Most callers do Object.values(result).flat() so this works perfectly.
return { "ndk": events || [] };
} catch (error) {
console.warn('[init-ndk] fetchEventsFromAllRelays (via NDK) failed:', error);
return {};
}
}
/**
* Query the local cache for events without going to relays
* Returns cached events immediately from Dexie/IndexedDB
* @param {Object} filters - Nostr filters (kinds, authors, etc.)
* @returns {Array} Array of cached events
*/
export async function queryCache(filters) {
if (!ndkWorker) {
throw new Error('NDK worker not initialized. Call initNDKPage() first.');
}
return new Promise((resolve, reject) => {
requestCounter++;
const requestId = `cache_${Date.now()}_${requestCounter}`;
pendingRequests.set(requestId, { resolve, reject });
// Set timeout - cache queries should be fast
setTimeout(() => {
if (pendingRequests.has(requestId)) {
pendingRequests.delete(requestId);
reject(new Error('Cache query timeout'));
}
}, 5000);
ndkWorker.port.postMessage({
type: 'queryCache',
requestId: requestId,
filters: filters
});
});
}
/**
* Purge cached playlist events for one playlist identifier from worker cache/IndexedDB.
* This only affects local cache storage, not relays.
*/
export function purgePlaylistCache({
playlistIdentifier,
ownerPubkey,
playlistKind,
eventIds = []
} = {}) {
const payload = {
playlistIdentifier: String(playlistIdentifier || '').trim(),
ownerPubkey: String(ownerPubkey || '').trim(),
playlistKind: Number(playlistKind || 0),
eventIds: Array.isArray(eventIds)
? eventIds.map((id) => String(id || '').trim()).filter(Boolean)
: []
};
console.log('[init-ndk] purgePlaylistCache request', payload);
return sendWorkerRequest(
'purgePlaylistCache',
payload,
15000,
'purgePlaylistCache timeout'
).then((result) => {
console.log('[init-ndk] purgePlaylistCache response', result);
return result;
}).catch((error) => {
console.error('[init-ndk] purgePlaylistCache failed', error);
throw error;
});
}
/**
* Helper to get current user's pubkey
*/
export async function getPubkey() {
if (!window.nostr) {
throw new Error('Not authenticated');
}
return await window.nostr.getPublicKey();
}
/**
* Helper to get relay data from worker
*/
export async function getRelayData() {
if (!ndkWorker) {
throw new Error('NDK worker not initialized. Call initNDKPage() first.');
}
return new Promise((resolve, reject) => {
const requestId = `relay_${Date.now()}_${++requestCounter}`;
pendingRequests.set(requestId, { resolve, reject });
// Set timeout (15s — worker may be busy processing outbox/NDK events)
setTimeout(() => {
if (pendingRequests.has(requestId)) {
pendingRequests.delete(requestId);
// Resolve with empty array instead of rejecting to prevent UI errors
console.warn('[init-ndk] Get relay data timeout, returning empty array');
resolve([]);
}
}, 15000);
ndkWorker.port.postMessage({
type: 'getRelayData',
requestId: requestId
});
// console.log('[init-ndk] Requesting relay data');
});
}
/**
* Helper to get relay activity statistics from worker
* @returns {Promise<Object>} Object mapping relay URLs to their activity stats
*/
export async function getRelayStats() {
if (!ndkWorker) {
throw new Error('NDK worker not initialized. Call initNDKPage() first.');
}
return new Promise((resolve, reject) => {
const requestId = `stats_${Date.now()}_${++requestCounter}`;
pendingRequests.set(requestId, { resolve, reject });
// Set timeout (15s — worker may be busy processing outbox/NDK events)
setTimeout(() => {
if (pendingRequests.has(requestId)) {
pendingRequests.delete(requestId);
// Resolve with empty object instead of rejecting to prevent UI errors
console.warn('[init-ndk] Get relay stats timeout, returning empty object');
resolve({});
}
}, 15000);
ndkWorker.port.postMessage({
type: 'getRelayStats',
requestId: requestId
});
// console.log('[init-ndk] Requesting relay stats');
});
}
/**
* Fetch a profile from the local cache (instant, no relay fetch)
* Returns cached profile data immediately from in-memory/Dexie cache
* @param {string} pubkey - The user's pubkey
* @returns {Promise<Object|null>} Profile object or null if not in cache
*/
export async function fetchCachedProfile(pubkey) {
if (!ndkWorker) {
throw new Error('NDK worker not initialized. Call initNDKPage() first.');
}
return new Promise((resolve, reject) => {
requestCounter++;
const requestId = `profile_${Date.now()}_${requestCounter}`;
pendingRequests.set(requestId, { resolve, reject });
// Set timeout - cache lookups should be fast
setTimeout(() => {
if (pendingRequests.has(requestId)) {
pendingRequests.delete(requestId);
resolve(null); // Resolve with null on timeout, don't reject
}
}, 1000); // 1 second timeout for cache lookup
ndkWorker.port.postMessage({
type: 'fetchCachedProfile',
requestId: requestId,
pubkey: pubkey
});
// console.log('[init-ndk] Requesting cached profile for:', pubkey.substring(0, 8) + '…');
});
}
/**
* Store a fetched profile in the worker's Dexie cache for future instant lookups.
* Fire-and-forget — no response needed.
* @param {string} pubkey - The user's pubkey
* @param {Object} profile - The profile object { name, display_name, picture, ... }
*/
/**
* Inject a login button (same footprint as avatar) into top-right header area.
* Clicking launches nostr-login-lite.
*/
export async function injectHeaderLoginButton() {
const headerRight = document.getElementById('divHeaderFlexRight');
if (!headerRight) {
console.warn('[init-ndk] injectHeaderLoginButton: #divHeaderFlexRight not found');
return;
}
if (headerRight.querySelector('.divHeaderAvatarLink') || headerRight.querySelector('.divHeaderLoginBtn')) {
return;
}
const loginButton = document.createElement('button');
loginButton.type = 'button';
loginButton.className = 'divHeaderLoginBtn';
loginButton.title = 'Login';
loginButton.setAttribute('aria-label', 'Login with Nostr');
loginButton.innerHTML = `<svg viewBox="0 0 10 10" class="svgHeaderButtons"><circle stroke-width="2" stroke-linecap="round" stroke-linejoin="round" stroke="black" stroke-opacity="1" fill="black" fill-opacity="0" r="4" cx="5" cy="10" /><circle stroke-width="2" stroke-linecap="round" stroke-linejoin="round" stroke="black" stroke-opacity="1" fill="black" fill-opacity="0" r="1.4142135623730951" cx="5" cy="3" /></svg>`;
loginButton.addEventListener('click', async () => {
try {
if (!window.NOSTR_LOGIN_LITE) {
console.warn('[init-ndk] injectHeaderLoginButton: NOSTR_LOGIN_LITE unavailable');
return;
}
if (!window.NOSTR_LOGIN_LITE._initialized) {
await window.NOSTR_LOGIN_LITE.init({
methods: {
extension: true,
local: true,
nip46: true,
seedphrase: true,
nsigner: true
}
});
}
await window.NOSTR_LOGIN_LITE.launch();
} catch (error) {
console.warn('[init-ndk] injectHeaderLoginButton: failed to launch modal', error);
}
});
headerRight.appendChild(loginButton);
}
/**
* Inject signed-in user's avatar into the top-right header area.
* @param {string} pubkey - Current authenticated user's pubkey
*/
export async function injectHeaderAvatar(pubkey) {
if (!pubkey) {
console.warn('[init-ndk] injectHeaderAvatar: missing pubkey');
return;
}
const headerRight = document.getElementById('divHeaderFlexRight');
if (!headerRight) {
console.warn('[init-ndk] injectHeaderAvatar: #divHeaderFlexRight not found');
return;
}
// Avoid duplicate injection
if (headerRight.querySelector('.divHeaderAvatarLink')) {
console.log('[init-ndk] injectHeaderAvatar: avatar already injected');
return;
}
const loginButton = headerRight.querySelector('.divHeaderLoginBtn');
if (loginButton) {
loginButton.remove();
}
const shortPubkey = `${pubkey.slice(0, 8)}…`;
console.log('[init-ndk] injectHeaderAvatar: requesting cached profile for', shortPubkey);
const avatarLink = document.createElement('a');
avatarLink.className = 'divHeaderAvatarLink';
avatarLink.href = './profile.html';
avatarLink.title = 'Profile';
avatarLink.setAttribute('aria-label', 'Open profile');
const avatar = document.createElement('img');
avatar.className = 'divHeaderAvatar';
avatar.alt = '';
avatar.loading = 'lazy';
avatar.decoding = 'async';
avatar.referrerPolicy = 'no-referrer';
const applyPicture = (nextPicture, source = 'unknown') => {
const resolvedPicture = String(nextPicture || '').trim();
if (!resolvedPicture) return false;
avatar.classList.remove('is-placeholder');
avatar.addEventListener('load', () => {
console.log(`[init-ndk] injectHeaderAvatar: image loaded (${source})`);
}, { once: true });
avatar.addEventListener('error', () => {
console.warn(`[init-ndk] injectHeaderAvatar: image failed (${source}), using placeholder border`);
avatar.removeAttribute('src');
avatar.classList.add('is-placeholder');
}, { once: true });
avatar.src = resolvedPicture;
return true;
};
let profile = null;
try {
profile = await fetchCachedProfile(pubkey);
console.log('[init-ndk] injectHeaderAvatar: cache response', profile ? 'HIT' : 'MISS');
} catch (error) {
console.warn('[init-ndk] injectHeaderAvatar: cache lookup failed', error);
profile = null;
}
const picture = String(profile?.picture || '').trim();
console.log('[init-ndk] injectHeaderAvatar: picture present =', Boolean(picture));
if (!applyPicture(picture, 'cache')) {
avatar.classList.add('is-placeholder');
console.log('[init-ndk] injectHeaderAvatar: no cached picture, rendering placeholder border');
let profileListenerTimeout = null;
const onProfile = (event) => {
const livePicture = String(event?.detail?.picture || '').trim();
if (!livePicture) {
console.log('[init-ndk] injectHeaderAvatar: ndkProfile arrived without picture');
return;
}
console.log('[init-ndk] injectHeaderAvatar: ndkProfile picture received, updating avatar');
applyPicture(livePicture, 'ndkProfile');
window.removeEventListener('ndkProfile', onProfile);
if (profileListenerTimeout) clearTimeout(profileListenerTimeout);
};
window.addEventListener('ndkProfile', onProfile);
profileListenerTimeout = setTimeout(() => {
window.removeEventListener('ndkProfile', onProfile);
console.log('[init-ndk] injectHeaderAvatar: ndkProfile listener expired after 30s');
}, 30000);
}
avatarLink.appendChild(avatar);
// In row-reverse, appending makes this the visual top-right item.
headerRight.appendChild(avatarLink);
console.log('[init-ndk] injectHeaderAvatar: avatar element appended');
}
export function storeProfile(pubkey, profile) {
if (!ndkWorker || !pubkey || !profile) return;
ndkWorker.port.postMessage({
type: 'storeProfile',
pubkey,
profile
});
}
/**
* Get all cached profiles from the worker's Dexie cache for pre-warming.
* @returns {Promise<Array>} Array of profile records from the profiles table
*/
export function getAllCachedProfiles() {
return new Promise((resolve) => {
if (!ndkWorker) {
resolve([]);
return;
}
const requestId = `getAllProfiles_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const handler = (e) => {
if (e.data.type === 'getAllCachedProfilesResult' && e.data.requestId === requestId) {
ndkWorker.port.removeEventListener('message', handler);
resolve(e.data.profiles || []);
}
};
ndkWorker.port.addEventListener('message', handler);
ndkWorker.port.postMessage({ type: 'getAllCachedProfiles', requestId });
// Timeout after 2 seconds
setTimeout(() => {
ndkWorker.port.removeEventListener('message', handler);
resolve([]);
}, 2000);
});
}
/**
* Get merged app-wide user settings from worker (cache + relay hydrated).
* @returns {Promise<Object>}
*/
export function getUserSettings() {
if (!ndkWorker) {
throw new Error('NDK worker not initialized. Call initNDKPage() first.');
}
return new Promise((resolve, reject) => {
const requestId = `userSettings_get_${Date.now()}_${++requestCounter}`;
pendingRequests.set(requestId, { resolve, reject });
setTimeout(() => {
if (pendingRequests.has(requestId)) {
pendingRequests.delete(requestId);
reject(new Error('Get user settings timeout'));
}
}, 7000);
ndkWorker.port.postMessage({
type: 'getUserSettings',
requestId
});
});
}
/**
* Patch app-wide user settings with a partial object.
* @param {Object} patch
* @param {Object} options
* @returns {Promise<Object>}
*/
export function patchUserSettings(patch, options = {}) {
if (!ndkWorker) {
throw new Error('NDK worker not initialized. Call initNDKPage() first.');
}
return new Promise((resolve, reject) => {
const requestId = `userSettings_patch_${Date.now()}_${++requestCounter}`;
pendingRequests.set(requestId, { resolve, reject });
setTimeout(() => {
if (pendingRequests.has(requestId)) {
pendingRequests.delete(requestId);
reject(new Error('Patch user settings timeout'));
}
}, 7000);
ndkWorker.port.postMessage({
type: 'patchUserSettings',
requestId,
patch,
options
});
});
}
/**
* Subscribe to worker-broadcast user settings updates.
* @param {(settings: Object) => void} callback
* @returns {() => void} unsubscribe
*/
export function onUserSettings(callback) {
const handler = (e) => callback(e.detail || {});
window.addEventListener('ndkUserSettings', handler);
return () => window.removeEventListener('ndkUserSettings', handler);
}
/**
* Replace app-wide user settings document.
* @param {Object} settings
* @param {Object} options
* @returns {Promise<Object>}
*/
export function setUserSettings(settings, options = {}) {
if (!ndkWorker) {
throw new Error('NDK worker not initialized. Call initNDKPage() first.');
}
return new Promise((resolve, reject) => {
const requestId = `userSettings_set_${Date.now()}_${++requestCounter}`;
pendingRequests.set(requestId, { resolve, reject });
setTimeout(() => {
if (pendingRequests.has(requestId)) {
pendingRequests.delete(requestId);
reject(new Error('Set user settings timeout'));
}
}, 7000);
ndkWorker.port.postMessage({
type: 'setUserSettings',
requestId,
settings,
options
});
});
}
/**
* Reset app-wide user settings document to defaults.
* @param {Object} options
* @returns {Promise<Object>}
*/
export function resetUserSettings(options = {}) {
if (!ndkWorker) {
throw new Error('NDK worker not initialized. Call initNDKPage() first.');
}
return new Promise((resolve, reject) => {
const requestId = `userSettings_reset_${Date.now()}_${++requestCounter}`;
pendingRequests.set(requestId, { resolve, reject });
setTimeout(() => {
if (pendingRequests.has(requestId)) {
pendingRequests.delete(requestId);
reject(new Error('Reset user settings timeout'));
}
}, 7000);
ndkWorker.port.postMessage({
type: 'resetUserSettings',
requestId,
options
});
});
}
function sendWorkerRequest(type, payload = {}, timeoutMs = 15000, timeoutMessage = `${type} timeout`) {
if (!ndkWorker) {
throw new Error('NDK worker not initialized. Call initNDKPage() first.');
}
return new Promise((resolve, reject) => {
const requestId = `${type}_${Date.now()}_${++requestCounter}`;
pendingRequests.set(requestId, { resolve, reject });
setTimeout(() => {
if (pendingRequests.has(requestId)) {
pendingRequests.delete(requestId);
reject(new Error(timeoutMessage));
}
}, timeoutMs);
ndkWorker.port.postMessage({
type,
requestId,
...payload
});
});
}
export function encryptContent(recipientPubkey, plaintext, scheme = 'nip44') {
return sendWorkerRequest(
'encryptContent',
{ recipientPubkey, plaintext: String(plaintext || ''), scheme },
15000,
'encryptContent timeout'
);
}
export function decryptContent(senderPubkey, ciphertext, scheme = 'auto') {
return sendWorkerRequest(
'decryptContent',
{ senderPubkey, ciphertext: String(ciphertext || ''), scheme },
15000,
'decryptContent timeout'
);
}
export function walletInit() {
return sendWorkerRequest('walletInit', {}, 12000, 'walletInit timeout');
}
export function walletStart(pubkey = null) {
return sendWorkerRequest('walletStart', { pubkey }, 30000, 'walletStart timeout');
}
export function walletCreate(mints, relays = []) {
return sendWorkerRequest('walletCreate', { mints, relays }, 45000, 'walletCreate timeout');
}
export function walletHasWallet() {
return sendWorkerRequest('walletHasWallet', {}, 8000, 'walletHasWallet timeout');
}
export function walletGetBalance() {
return sendWorkerRequest('walletGetBalance', {}, 12000, 'walletGetBalance timeout');
}
export function walletGetMints() {
return sendWorkerRequest('walletGetMints', {}, 12000, 'walletGetMints timeout');
}
export function walletReceiveToken(token, description = 'Receive token') {
return sendWorkerRequest('walletReceiveToken', { token, description }, 30000, 'walletReceiveToken timeout');
}
export function walletSendToken(amount, memo = 'Cashu send', mint = null) {
return sendWorkerRequest('walletSendToken', { amount, memo, mint }, 30000, 'walletSendToken timeout');
}
export function walletSendNutzap({
amount,
memo = '',
targetPubkey,
eventId = null,
mint = null,
recipientMints = [],
recipientP2pk = null,
recipientRelays = []
} = {}) {
return sendWorkerRequest(
'walletSendNutzap',
{ amount, memo, targetPubkey, eventId, mint, recipientMints, recipientP2pk, recipientRelays },
45000,
'walletSendNutzap timeout'
);
}
export function walletCreateDeposit(amount, mint) {
return sendWorkerRequest('walletCreateDeposit', { amount, mint }, 30000, 'walletCreateDeposit timeout');
}
export function walletPayInvoice(invoice, mint = null) {
return sendWorkerRequest('walletPayInvoice', { invoice, mint }, 90000, 'walletPayInvoice timeout');
}
export function walletCheckProofs() {
return sendWorkerRequest('walletCheckProofs', {}, 45000, 'walletCheckProofs timeout');
}
export function walletAddMint(mintUrl) {
return sendWorkerRequest('walletAddMint', { mintUrl }, 20000, 'walletAddMint timeout');
}
export function walletRemoveMint(mintUrl) {
return sendWorkerRequest('walletRemoveMint', { mintUrl }, 20000, 'walletRemoveMint timeout');
}
export function walletGetTransactions() {
return sendWorkerRequest('walletGetTransactions', {}, 20000, 'walletGetTransactions timeout');
}
export function walletShutdown() {
return sendWorkerRequest('walletShutdown', {}, 10000, 'walletShutdown timeout');
}
export function walletRepublish() {
return sendWorkerRequest('walletRepublish', {}, 30000, 'walletRepublish timeout');
}
export function walletPublishMintList(relays = [], receiveMints = []) {
return sendWorkerRequest('walletPublishMintList', { relays, receiveMints }, 25000, 'walletPublishMintList timeout');
}
export function walletFetchMintList(targetPubkey) {
return sendWorkerRequest('walletFetchMintList', { targetPubkey }, 12000, 'walletFetchMintList timeout');
}
export function onWalletStatus(callback) {
const handler = (e) => callback(e.detail || {});
window.addEventListener('ndkWalletStatus', handler);
return () => window.removeEventListener('ndkWalletStatus', handler);
}
export function onWalletBalance(callback) {
const handler = (e) => callback(e.detail || {});
window.addEventListener('ndkWalletBalance', handler);
return () => window.removeEventListener('ndkWalletBalance', handler);
}
export function onWalletDepositConfirmed(callback) {
const handler = (e) => callback(e.detail || {});
window.addEventListener('ndkWalletDepositConfirmed', handler);
return () => window.removeEventListener('ndkWalletDepositConfirmed', handler);
}
export function onWalletTransaction(callback) {
const handler = (e) => callback(e.detail || {});
window.addEventListener('ndkWalletTransaction', handler);
return () => window.removeEventListener('ndkWalletTransaction', handler);
}
export function onWalletSendRedeemed(callback) {
const handler = (e) => callback(e.detail || {});
window.addEventListener('ndkWalletSendRedeemed', handler);
return () => window.removeEventListener('ndkWalletSendRedeemed', handler);
}
/**
* Helper to reconnect a specific relay
*/
export function reconnectRelay(relayUrl) {
if (!ndkWorker) {
throw new Error('NDK worker not initialized. Call initNDKPage() first.');
}
ndkWorker.port.postMessage({
type: 'reconnectRelay',
relayUrl: relayUrl
});
console.log('[init-ndk] Requesting reconnect for relay:', relayUrl);
}
/**
* Helper to disconnect worker
*/
export function disconnect() {
if (ndkWorker) {
ndkWorker.port.postMessage({ type: 'disconnect' });
ndkWorker.port.close();
ndkWorker = null;
console.log('[init-ndk] Disconnected from worker');
}
}
/**
* Get worker instance (for advanced usage)
*/
export function getWorker() {
return ndkWorker;
}
/**
* Get version information
* Waits for version to be loaded if not already loaded
* @returns {Promise<Object>} Version data including VERSION, VERSION_NUMBER, and BUILD_DATE
*/
export async function getVersion() {
if (!versionLoaded) {
await versionPromise;
}
return versionData || { VERSION: VERSION, VERSION_NUMBER: VERSION, BUILD_DATE: null };
}
/**
* Update version display element if it exists
* Call this after page initialization to update the version display
*/
export async function updateVersionDisplay() {
if (!versionLoaded) {
await versionPromise;
}
const versionDisplay = document.getElementById('versionDisplay');
if (versionDisplay && VERSION) {
versionDisplay.textContent = VERSION;
console.log('[init-ndk] Updated version display to:', VERSION);
}
}
export async function ensureMuteListLoaded(options = {}) {
return await bootstrapMuteList(options || {});
}
export async function addMute(type, value, isPrivate = false) {
await addMuteCore(type, value, isPrivate);
await syncWorkerMuteList();
}
export async function removeMute(type, value) {
await removeMuteCore(type, value);
await syncWorkerMuteList();
}
export {
isEventMuted,
getMuteList,
getMuteCount,
onMuteListChanged,
isMuteListLoaded,
syncWorkerMuteList
};
console.log(`[init-ndk v${VERSION}] Module loaded`);