// js/services/hashtagPreferencesService.js import { nostrClient, requestDefaultExtensionPermissions, getActiveSigner, } from "../nostrClientFacade.js"; import { isSessionActor } from "../nostr/sessionActor.js"; import { nextReplaceableCreatedAt } from "../nostr/replaceableCreatedAt.js"; import { buildHashtagPreferenceEvent, getNostrEventSchema, NOTE_TYPES, } from "../nostrEventSchemas.js"; import { publishEventToRelays, assertAnyRelayAcceptedOrUnconfirmed, } from "../nostrPublish.js"; import { userLogger, devLogger } from "../utils/logger.js"; import { noteSignerDecryptTimeout, noteSignerDecryptSuccess, } from "../utils/signerHealthNotice.js"; import { sanitizeRelayList } from "../nostr/nip46Client.js"; import { normalizeHashtag } from "../utils/hashtagNormalization.js"; import { profileCache } from "../state/profileCache.js"; import { DEFAULT_RELAY_URLS, capReadRelays } from "../nostr/toolkit.js"; import { relayManager } from "../relayManager.js"; import { DEFAULT_NIP07_ENCRYPTION_METHODS, runNip07WithRetry, NIP07_PRIORITY, } from "../nostr/nip07Permissions.js"; import { relaySubscriptionService } from "./relaySubscriptionService.js"; import { getLastSuccessfulScheme, setLastSuccessfulScheme, } from "../nostr/decryptionSchemeCache.js"; import { compareListEventsDesc, computeIncrementalSinceWithOverlap, selectNewestListEvent, } from "../nostr/listEventOrdering.js"; import { isTransientDecryptError, computeRetryDelay, normalizeHexPubkey, } from "./hashtagPreferencesHelpers.js"; import { SHORT_TIMEOUT_MS, NETWORK_RETRY_DELAY_MS } from "../constants.js"; const LOG_PREFIX = "[HashtagPreferences]"; const HASHTAG_IDENTIFIER = "bitvid:tag-preferences"; const DEFAULT_VERSION = 1; // Cold-load decryption competes with other encrypted lists for the serialized // nip-07 extension, and the main thread can be busy connecting to relays. 6s // proved too aggressive on real accounts (lists timed out and showed empty); // 15s matches the block-list timeout and lets decryption complete under load. // Generous decrypt budget: real extensions can take 15-25s to answer a nip-07 // decrypt under cold-login load even though the crypto is instant. A tighter // budget times out mid-flight and retries forever (KNOWN_BUGS #0). const DECRYPT_TIMEOUT_MS = 30000; // PERF: Reduced from 3s to 1.5s — extensions that already granted permission // should recover near-instantly. Shorter delay speeds up the login path. const DECRYPT_RETRY_DELAY_MS = NETWORK_RETRY_DELAY_MS; class TinyEventEmitter { constructor() { this.listeners = new Map(); } on(eventName, handler) { if (typeof eventName !== "string" || typeof handler !== "function") { return () => {}; } if (!this.listeners.has(eventName)) { this.listeners.set(eventName, new Set()); } const handlers = this.listeners.get(eventName); handlers.add(handler); return () => { handlers.delete(handler); if (!handlers.size) { this.listeners.delete(eventName); } }; } emit(eventName, detail) { const handlers = this.listeners.get(eventName); if (!handlers || !handlers.size) { return; } for (const handler of Array.from(handlers)) { try { handler(detail); } catch (error) { userLogger.warn( `${LOG_PREFIX} listener for "${eventName}" threw an error`, error, ); } } } } const EVENTS = Object.freeze({ CHANGE: "change", }); function normalizeEncryptionToken(value) { if (typeof value !== "string") { return ""; } return value .trim() .toLowerCase() .replace(/[^a-z0-9]/g, ""); } function normalizePreferencesPayload(payload) { const versionCandidate = Number(payload?.version); const version = Number.isFinite(versionCandidate) && versionCandidate > 0 ? versionCandidate : DEFAULT_VERSION; const rawInterests = Array.isArray(payload?.interests) ? payload.interests : []; const rawDisinterests = Array.isArray(payload?.disinterests) ? payload.disinterests : []; const interests = new Set(); for (const tag of rawInterests) { const normalizedTag = normalizeHashtag(tag); if (normalizedTag) { interests.add(normalizedTag); } } const disinterests = new Set(); for (const tag of rawDisinterests) { const normalizedTag = normalizeHashtag(tag); if (!normalizedTag) { continue; } disinterests.add(normalizedTag); interests.delete(normalizedTag); } return { version, interests, disinterests, }; } function extractEncryptionHints(event) { if (!event || typeof event !== "object") { return []; } const tags = Array.isArray(event.tags) ? event.tags : []; const hints = []; const pushUnique = (scheme) => { if (!scheme || hints.includes(scheme)) { return; } hints.push(scheme); }; for (const tag of tags) { if (!Array.isArray(tag) || tag.length < 2) { continue; } const label = typeof tag[0] === "string" ? tag[0].trim().toLowerCase() : ""; if (label !== "encrypted" && label !== "encryption") { continue; } const rawValue = typeof tag[1] === "string" ? tag[1] : ""; if (!rawValue) { continue; } const parts = rawValue .split(/[\s,]+/) .map((part) => normalizeEncryptionToken(part)) .filter(Boolean); for (const part of parts) { if (part === "nip44v2" || part === "nip44v02") { pushUnique("nip44_v2"); continue; } if (part === "nip44") { pushUnique("nip44"); continue; } if (part === "nip04" || part === "nip4") { pushUnique("nip04"); } } } return hints; } function determineDecryptionOrder(event, availableSchemes, cachedScheme = null) { const available = Array.isArray(availableSchemes) ? availableSchemes : []; const availableSet = new Set(available); const prioritized = []; if (cachedScheme && availableSet.has(cachedScheme)) { prioritized.push(cachedScheme); } const hints = extractEncryptionHints(event); const aliasMap = { nip04: ["nip04"], nip44: ["nip44", "nip44_v2"], nip44_v2: ["nip44_v2", "nip44"], }; for (const hint of hints) { const candidates = Array.isArray(aliasMap[hint]) ? aliasMap[hint] : [hint]; for (const candidate of candidates) { if (availableSet.has(candidate) && !prioritized.includes(candidate)) { prioritized.push(candidate); break; } } } for (const fallback of ["nip44_v2", "nip44", "nip04"]) { if (availableSet.has(fallback) && !prioritized.includes(fallback)) { prioritized.push(fallback); } } return prioritized.length ? prioritized : available; } class HashtagPreferencesService { constructor() { this.emitter = new TinyEventEmitter(); this.interests = new Set(); this.disinterests = new Set(); this.activePubkey = null; this.eventId = null; this.eventCreatedAt = null; this.uiReady = false; this.dataReady = false; this.loaded = false; this.loadedFromCache = false; this.lastSuccessfulSyncAt = null; this.lastAttemptAt = null; this.backgroundLoading = false; this.preferencesVersion = DEFAULT_VERSION; this.decryptRetryTimeoutId = null; this.decryptRetryAttempt = 0; this.loadPromise = null; this.loadingPubkey = null; this.subscriptionKey = null; this.lastLoadError = null; profileCache.subscribe((event, detail) => { if (event === "profileChanged") { this.reset(); if (detail.pubkey) { this.load(detail.pubkey, { allowPermissionPrompt: false }); } } else if (event === "runtimeCleared" && detail.pubkey === this.activePubkey) { this.reset(); if (this.activePubkey) { this.load(this.activePubkey, { allowPermissionPrompt: false }); } } }); } on(eventName, handler) { return this.emitter.on(eventName, handler); } reset() { this.stopHashtagPreferencesSubscription(); this.interests = new Set(); this.disinterests = new Set(); this.eventId = null; this.eventCreatedAt = null; this.uiReady = false; this.dataReady = false; this.loaded = false; this.loadedFromCache = false; this.lastSuccessfulSyncAt = null; this.lastAttemptAt = null; this.backgroundLoading = false; this.preferencesVersion = DEFAULT_VERSION; this.lastLoadError = null; if (this.decryptRetryTimeoutId) { clearTimeout(this.decryptRetryTimeoutId); this.decryptRetryTimeoutId = null; } this.decryptRetryAttempt = 0; this.loadPromise = null; this.loadingPubkey = null; this.emitChange("reset"); } stopHashtagPreferencesSubscription() { if (this.subscriptionHandle) { try { this.subscriptionHandle.close(); } catch (error) { devLogger.warn(`${LOG_PREFIX} Failed to close hashtag subscription handle`, error); } this.subscriptionHandle = null; } if (this.subscriptionKey) { relaySubscriptionService.stopSubscription(this.subscriptionKey, "reset"); this.subscriptionKey = null; } } ensureHashtagPreferencesSubscription(pubkey, relays) { const normalized = normalizeHexPubkey(pubkey); if (!normalized || !nostrClient?.pool) { return null; } const schema = getNostrEventSchema(NOTE_TYPES.HASHTAG_PREFERENCES); const canonicalKind = schema?.kind ?? 30015; const legacyKind = 30005; const kinds = canonicalKind === legacyKind ? [canonicalKind] : [canonicalKind, legacyKind]; const filters = kinds.map((kind) => ({ kinds: [kind], authors: [normalized], "#d": [HASHTAG_IDENTIFIER], })); const key = `hashtags:${normalized}`; this.subscriptionKey = key; // Route through the L1 SubscriptionManager when available (dedup + reconnect // re-issue); fall back to relaySubscriptionService in manager-less envs. const manager = typeof nostrClient.getSubscriptionManager === "function" ? nostrClient.getSubscriptionManager() : null; if (manager) { this.subscriptionHandle = manager.subscribe({ key, relays, filters, label: "hashtag-preferences", onEvent: (event) => this.handleHashtagPreferencesEvent(event), }); return this.subscriptionHandle; } return relaySubscriptionService.ensureSubscription({ key, pool: nostrClient.pool, relays, filters, label: "hashtag-preferences", onEvent: (event) => this.handleHashtagPreferencesEvent(event), }); } handleHashtagPreferencesEvent(event) { const normalized = normalizeHexPubkey(event?.pubkey); if (!normalized || normalized !== this.activePubkey) { return; } this.load(normalized, { allowPermissionPrompt: false }).catch((error) => { devLogger.warn(`${LOG_PREFIX} Failed to refresh after hashtag event`, error); }); } saveToCache() { const interests = this.getInterests(); const disinterests = this.getDisinterests(); const payload = { interests, disinterests, version: this.preferencesVersion, eventId: this.eventId, createdAt: this.eventCreatedAt, }; // Use the note type key directly. This maps to the same storage key as the legacy "interests" // mapping in profileCache, ensuring backward compatibility while using the unified path. profileCache.set(NOTE_TYPES.HASHTAG_PREFERENCES, payload); } loadFromCache(pubkey) { // profileCache internally uses activePubkey, but we should verify if it matches try { let cached = profileCache.getProfileData(pubkey, NOTE_TYPES.HASHTAG_PREFERENCES); // Legacy fallback migration if (!cached && typeof localStorage !== "undefined") { try { const legacy = localStorage.getItem(HASHTAG_IDENTIFIER); if (legacy) { const parsed = JSON.parse(legacy); // Migrate to unified profile cache profileCache.set(NOTE_TYPES.HASHTAG_PREFERENCES, parsed); localStorage.removeItem(HASHTAG_IDENTIFIER); cached = parsed; devLogger.log(`${LOG_PREFIX} Migrated legacy preferences to profileCache.`); } } catch (err) { devLogger.warn(`${LOG_PREFIX} Legacy migration failed:`, err); } } if (cached && typeof cached === "object") { this.activePubkey = normalizeHexPubkey(pubkey); this.interests = new Set(Array.isArray(cached.interests) ? cached.interests : []); this.disinterests = new Set(Array.isArray(cached.disinterests) ? cached.disinterests : []); this.eventId = cached.eventId || null; this.eventCreatedAt = cached.createdAt || null; this.preferencesVersion = cached.version || DEFAULT_VERSION; this.uiReady = true; this.dataReady = true; this.loaded = true; this.loadedFromCache = true; this.emitChange("cache-load"); return true; } } catch (err) { devLogger.warn(`${LOG_PREFIX} loadFromCache failed:`, err); } return false; } getInterests() { return Array.from(this.interests).sort((a, b) => a.localeCompare(b)); } getDisinterests() { return Array.from(this.disinterests).sort((a, b) => a.localeCompare(b)); } async addInterest(tag) { const normalized = normalizeHashtag(tag); if (!normalized) { return false; } const hadInterest = this.interests.has(normalized); const removedFromDisinterests = this.disinterests.delete(normalized); this.interests.add(normalized); if (!hadInterest || removedFromDisinterests) { this.emitChange("interest-added", { tag: normalized }); try { await this.publish(); } catch (err) { userLogger.warn(`${LOG_PREFIX} Auto-save failed`, err); } return true; } return false; } async removeInterest(tag) { const normalized = normalizeHashtag(tag); if (!normalized || !this.interests.has(normalized)) { return false; } this.interests.delete(normalized); this.emitChange("interest-removed", { tag: normalized }); try { await this.publish(); } catch (err) { userLogger.warn(`${LOG_PREFIX} Auto-save failed`, err); } return true; } async addDisinterest(tag) { const normalized = normalizeHashtag(tag); if (!normalized) { return false; } const hadDisinterest = this.disinterests.has(normalized); const removedFromInterests = this.interests.delete(normalized); this.disinterests.add(normalized); if (!hadDisinterest || removedFromInterests) { this.emitChange("disinterest-added", { tag: normalized }); try { await this.publish(); } catch (err) { userLogger.warn(`${LOG_PREFIX} Auto-save failed`, err); } return true; } return false; } async removeDisinterest(tag) { const normalized = normalizeHashtag(tag); if (!normalized || !this.disinterests.has(normalized)) { return false; } this.disinterests.delete(normalized); this.emitChange("disinterest-removed", { tag: normalized }); try { await this.publish(); } catch (err) { userLogger.warn(`${LOG_PREFIX} Auto-save failed`, err); } return true; } emitChange(action, detail = {}) { try { this.emitter.emit(EVENTS.CHANGE, { action, interests: this.getInterests(), disinterests: this.getDisinterests(), eventId: this.eventId, createdAt: this.eventCreatedAt, version: this.preferencesVersion, loaded: this.loaded === true, uiReady: this.uiReady === true, dataReady: this.dataReady === true, loadedFromCache: this.loadedFromCache === true, backgroundLoading: this.backgroundLoading === true, lastLoadError: this.lastLoadError || null, activePubkey: this.activePubkey || null, ...detail, }); } catch (error) { userLogger.warn(`${LOG_PREFIX} Failed to emit change event`, error); } } scheduleDecryptRetry(pubkey, error, options = {}) { const normalized = normalizeHexPubkey(pubkey); if (!normalized) { return; } if (this.decryptRetryTimeoutId) { clearTimeout(this.decryptRetryTimeoutId); } const attempt = this.decryptRetryAttempt; const retryDelayMs = computeRetryDelay(DECRYPT_RETRY_DELAY_MS, attempt); this.decryptRetryAttempt = attempt + 1; this.decryptRetryTimeoutId = setTimeout(() => { this.decryptRetryTimeoutId = null; if (this.activePubkey && this.activePubkey !== normalized) { return; } this.load(normalized, options).catch((retryError) => { userLogger.warn(`${LOG_PREFIX} Decryption retry failed`, retryError); }); }, retryDelayMs); devLogger.log( `${LOG_PREFIX} Decryption timed out; scheduling retry in ${retryDelayMs / 1000}s.`, error, ); } async load(pubkey, options = {}) { const normalized = normalizeHexPubkey(pubkey); if (this.loadPromise && this.loadingPubkey === normalized) { devLogger.log(`${LOG_PREFIX} Reusing in-flight preferences load.`, { pubkey: normalized, }); return this.loadPromise; } if (this.loadPromise && this.loadingPubkey && this.loadingPubkey !== normalized) { devLogger.log(`${LOG_PREFIX} Waiting for existing preferences load to settle.`, { previous: this.loadingPubkey, next: normalized, }); await this.loadPromise.catch(() => {}); } const loadPromise = this.loadInternal(normalized, options); this.loadPromise = loadPromise; this.loadingPubkey = normalized; try { await loadPromise; return { ok: this.dataReady === true, error: this.lastLoadError || null, state: this.getLoadState(), }; } finally { this.emitChange("load-settled", { ...this.getLoadState(), loaded: this.loaded === true, backgroundLoading: this.backgroundLoading === true, }); if (this.loadPromise === loadPromise) { this.loadPromise = null; this.loadingPubkey = null; } } } async loadInternal(pubkey, options = {}) { const normalized = normalizeHexPubkey(pubkey); const allowPermissionPrompt = options?.allowPermissionPrompt !== false; const signerReadinessGate = options?.signerReadinessGate && typeof options.signerReadinessGate === "object" ? options.signerReadinessGate : null; const shouldSuppressPermissionEscalation = signerReadinessGate && (signerReadinessGate.status === "permission-denied" || signerReadinessGate.status === "extension-unavailable"); this.lastAttemptAt = Date.now(); this.lastLoadError = null; let wasLoadedForUser = this.activePubkey && this.activePubkey === normalized && (this.interests.size > 0 || this.disinterests.size > 0 || this.loaded); this.activePubkey = normalized; if (allowPermissionPrompt) { this.backgroundLoading = false; } if (!normalized) { this.reset(); this.uiReady = true; this.dataReady = true; this.loaded = true; return; } // Try cache first if (this.loadFromCache(normalized)) { wasLoadedForUser = true; if (!allowPermissionPrompt && !this.backgroundLoading) { this.backgroundLoading = true; this.emitChange("background-loading", { background: true }); } } const wasBackgroundLoading = this.backgroundLoading; if ( !nostrClient || !nostrClient.pool || typeof nostrClient.pool.list !== "function" ) { userLogger.warn( `${LOG_PREFIX} nostrClient.pool.list unavailable; treating preferences as empty.`, ); // If we already have data for this user, do not reset on transient client issues. if (wasLoadedForUser) { userLogger.warn( `${LOG_PREFIX} Keeping existing preferences despite client unavailability.`, ); if (wasBackgroundLoading) { this.backgroundLoading = false; this.emitChange("background-loaded", { background: false }); } return; } this.uiReady = true; if (wasBackgroundLoading) { this.backgroundLoading = false; this.emitChange("background-loaded", { background: false }); } return; } const readRelays = relayManager.getReadRelayUrls(); // capReadRelays bounds to the §17 read cap (≤8) so this doesn't fan out across the full NIP-65 set at login. const relays = capReadRelays(readRelays.length > 0 ? readRelays : Array.from(DEFAULT_RELAY_URLS)); if (!relays.length) { if (wasLoadedForUser) { userLogger.warn( `${LOG_PREFIX} Keeping existing preferences; no relays available for refresh.`, ); if (wasBackgroundLoading) { this.backgroundLoading = false; this.emitChange("background-loaded", { background: false }); } return; } this.uiReady = true; if (wasBackgroundLoading) { this.backgroundLoading = false; this.emitChange("background-loaded", { background: false }); } return; } if (!nostrClient?.pool && typeof nostrClient?.ensurePool === "function") { try { await nostrClient.ensurePool(); } catch (error) { devLogger.warn( `${LOG_PREFIX} Failed to initialize relay pool for subscription`, error, ); } } this.ensureHashtagPreferencesSubscription(normalized, relays); const schema = getNostrEventSchema(NOTE_TYPES.HASHTAG_PREFERENCES); const canonicalKind = schema?.kind ?? 30015; const legacyKind = 30005; const kinds = [canonicalKind]; if (canonicalKind !== legacyKind) { kinds.push(legacyKind); } let events = []; let fetchError = null; try { // Use incremental fetching for both kinds concurrently (if multiple kinds) // fetchListIncrementally takes a single kind. // kinds is array of [canonicalKind, legacyKind] if different. // Anchor the fetch to the service's current state to prevent desync. // If we have cached data, we ask for updates since that timestamp. // If we have no data, we force a full fetch (since=0). const since = wasLoadedForUser ? computeIncrementalSinceWithOverlap(this.eventCreatedAt, 1) : 0; const promises = kinds.map((kind) => nostrClient.fetchListIncrementally({ kind, pubkey: normalized, dTag: HASHTAG_IDENTIFIER, relayUrls: relays, since, timeoutMs: 12000, }), ); const results = await Promise.all(promises); events = results.flat(); } catch (error) { fetchError = error; userLogger.warn( `${LOG_PREFIX} Failed to load hashtag preferences from relays`, error, ); events = []; } if (!events.length) { if (fetchError) { userLogger.warn( `${LOG_PREFIX} Failed to load hashtag preferences (keeping local state if any).`, fetchError, ); if (wasBackgroundLoading) { this.backgroundLoading = false; this.emitChange("background-loaded", { background: false }); } return; } // If we failed to fetch (network error) but already have data for this user, // preserve the existing state instead of wiping it. // If fetchListIncrementally returns empty, it means no new updates or full fetch yielded nothing. if (wasLoadedForUser) { userLogger.warn( `${LOG_PREFIX} Keeping existing preferences despite empty relay response.`, ); if (wasBackgroundLoading) { this.backgroundLoading = false; this.emitChange("background-loaded", { background: false }); } return; } this.interests = new Set(); this.disinterests = new Set(); this.eventId = null; this.eventCreatedAt = null; this.preferencesVersion = DEFAULT_VERSION; this.uiReady = true; this.dataReady = true; this.loaded = true; this.loadedFromCache = false; this.lastSuccessfulSyncAt = Date.now(); if (wasBackgroundLoading) { this.backgroundLoading = false; this.emitChange("background-loaded", { background: false }); } return; } const latest = selectNewestListEvent(events, { preferredKinds: [canonicalKind, legacyKind], }); if (!latest) { if (wasLoadedForUser) { if (wasBackgroundLoading) { this.backgroundLoading = false; this.emitChange("background-loaded", { background: false }); } return; } this.interests = new Set(); this.disinterests = new Set(); this.eventId = null; this.eventCreatedAt = null; this.preferencesVersion = DEFAULT_VERSION; this.uiReady = true; this.dataReady = true; this.loaded = true; this.loadedFromCache = false; this.lastSuccessfulSyncAt = Date.now(); if (wasBackgroundLoading) { this.backgroundLoading = false; this.emitChange("background-loaded", { background: false }); } return; } const currentCreatedAt = Number(this.eventCreatedAt) || 0; const latestCreatedAt = Number(latest.created_at) || 0; // Protection against overwriting optimistic updates: // If the local version is newer (because user just edited), ignore the remote fetch. const currentEvent = { created_at: this.eventCreatedAt, id: this.eventId, kind: canonicalKind, }; if (wasLoadedForUser && compareListEventsDesc(currentEvent, latest) <= 0) { devLogger.log( `${LOG_PREFIX} Ignoring stale/concurrent preferences event (remote: ${latestCreatedAt}, local: ${currentCreatedAt}).`, ); if (wasBackgroundLoading) { this.backgroundLoading = false; this.emitChange("background-loaded", { background: false }); } return; } let decryptResult; try { const decryptPromise = this.decryptEvent(latest, normalized, { allowPermissionPrompt, signerReadinessGate, }); let timeoutId; const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout(() => { const timeoutError = new Error( `Decryption timed out after ${DECRYPT_TIMEOUT_MS / 1000}s`, ); timeoutError.code = "hashtag-preferences-decrypt-timeout"; reject(timeoutError); }, DECRYPT_TIMEOUT_MS); }); try { decryptResult = await Promise.race([decryptPromise, timeoutPromise]); } finally { if (timeoutId) clearTimeout(timeoutId); } } catch (error) { decryptResult = { ok: false, error }; } if (!decryptResult.ok) { this.lastLoadError = decryptResult.error || null; this.uiReady = true; userLogger.warn( `${LOG_PREFIX} Failed to decrypt hashtag preferences`, decryptResult.error, ); if (isTransientDecryptError(decryptResult.error)) { // Channel/timeout failures recover shortly — keep any cached prefs and // retry instead of giving up until a page refresh. If the signer is // present but simply not answering, surface the shared unresponsive // notice so the user knows to reload/unlock their extension. if (decryptResult.error?.code === "hashtag-preferences-decrypt-timeout") { noteSignerDecryptTimeout("hashtags"); } this.scheduleDecryptRetry(normalized, decryptResult.error, { allowPermissionPrompt, signerReadinessGate, }); return; } if ( !allowPermissionPrompt && decryptResult.error?.code === "hashtag-preferences-permission-required" ) { if (shouldSuppressPermissionEscalation) { userLogger.warn( `${LOG_PREFIX} Skipping permission escalation due to shared signer gate outcome.`, signerReadinessGate, ); if (wasBackgroundLoading) { this.backgroundLoading = false; this.emitChange("background-loaded", { background: false }); } return; } this.scheduleDecryptRetry(normalized, decryptResult.error, { allowPermissionPrompt: true, signerReadinessGate, }); if (wasBackgroundLoading) { this.backgroundLoading = false; this.emitChange("background-loaded", { background: false }); } return; } // If we already have loaded preferences (e.g. from cache), preserve them // rather than wiping everything just because the remote update couldn't be decrypted. if (wasLoadedForUser) { userLogger.warn( `${LOG_PREFIX} Preserving cached preferences despite decryption failure.`, ); if (wasBackgroundLoading) { this.backgroundLoading = false; this.emitChange("background-loaded", { background: false }); } return; } this.uiReady = true; if (wasBackgroundLoading) { this.backgroundLoading = false; this.emitChange("background-loaded", { background: false }); } return; } try { const payload = JSON.parse(decryptResult.plaintext); // Normalize the decrypted payload into the canonical preferences shape so // downstream consumers receive a single structure. const normalizedPayload = normalizePreferencesPayload(payload); this.preferencesVersion = normalizedPayload.version; this.interests = normalizedPayload.interests; this.disinterests = normalizedPayload.disinterests; this.eventId = latest.id || null; this.eventCreatedAt = Number.isFinite(latest?.created_at) ? latest.created_at : null; this.uiReady = true; this.dataReady = true; this.loaded = true; this.loadedFromCache = false; this.lastSuccessfulSyncAt = Date.now(); this.decryptRetryAttempt = 0; noteSignerDecryptSuccess(); if (this.backgroundLoading) { this.backgroundLoading = false; } this.saveToCache(); this.emitChange("sync", { scheme: decryptResult.scheme }); } catch (error) { userLogger.warn( `${LOG_PREFIX} Failed to parse decrypted hashtag preferences`, error, ); this.uiReady = true; } } getLoadState() { return { uiReady: this.uiReady === true, dataReady: this.dataReady === true, lastLoadError: this.lastLoadError || null, loadedFromCache: this.loadedFromCache === true, lastSuccessfulSyncAt: Number.isFinite(this.lastSuccessfulSyncAt) ? Number(this.lastSuccessfulSyncAt) : null, lastAttemptAt: Number.isFinite(this.lastAttemptAt) ? Number(this.lastAttemptAt) : null, }; } async decryptEvent(event, userPubkey, options = {}) { const ciphertext = typeof event?.content === "string" ? event.content : ""; if (!ciphertext) { const error = new Error("Preference event missing ciphertext content."); error.code = "hashtag-preferences-empty"; return { ok: false, error }; } const allowPermissionPrompt = options?.allowPermissionPrompt !== false; const signerReadinessGate = options?.signerReadinessGate && typeof options.signerReadinessGate === "object" ? options.signerReadinessGate : null; const suppressPermissionPrompt = signerReadinessGate && (signerReadinessGate.status === "permission-denied" || signerReadinessGate.status === "extension-unavailable"); let signer = getActiveSigner(); // FIX: Always attempt to resolve the signer, not only when // allowPermissionPrompt is true. ensureActiveSignerForPubkey does not // prompt the user — it only waits for an already-injected extension. // Without this, background refreshes can never decrypt. if ( !signer && typeof nostrClient?.ensureActiveSignerForPubkey === "function" ) { signer = await nostrClient.ensureActiveSignerForPubkey(userPubkey); } const signerHasNip04 = typeof signer?.nip04Decrypt === "function"; const signerHasNip44 = typeof signer?.nip44Decrypt === "function"; const hints = extractEncryptionHints(event); const requiresNip44 = hints.includes("nip44") || hints.includes("nip44_v2"); const requiresNip04 = !hints.length || hints.includes("nip04"); if ( (!signerHasNip44 && requiresNip44) || (!signerHasNip04 && requiresNip04) ) { if (!allowPermissionPrompt) { const error = new Error( "Decrypt permissions are required to read hashtag preferences.", ); error.code = "hashtag-preferences-permission-required"; return { ok: false, error }; } if (suppressPermissionPrompt) { const error = new Error( "Shared signer gate indicates extension permissions are unavailable.", ); error.code = "hashtag-preferences-permission-required"; return { ok: false, error }; } try { const permissionResult = await requestDefaultExtensionPermissions( DEFAULT_NIP07_ENCRYPTION_METHODS, ); if (!permissionResult?.ok) { userLogger.warn( `${LOG_PREFIX} Extension permission request failed/denied, but attempting operation via fallback.`, permissionResult?.error, ); } } catch (error) { userLogger.warn( `${LOG_PREFIX} Extension permissions request threw, attempting fallback.`, error, ); } } const decryptors = new Map(); const sources = new Map(); const registerDecryptor = (scheme, handler, source = "unknown") => { if (!scheme || typeof handler !== "function" || decryptors.has(scheme)) { return; } decryptors.set(scheme, handler); sources.set(scheme, source); }; // Per-call nip-07 decrypt budget. A short value kills in-progress decrypts // on slow extensions (15-25s under cold-login load), so the list never // decrypts and retries forever. Give the interactive call real room; the // background refresh stays tighter for fast scheme fallback. const nip07DecryptTimeoutMs = allowPermissionPrompt ? 25000 : 10000; const signerDecryptOptions = { priority: NIP07_PRIORITY.NORMAL, timeoutMs: nip07DecryptTimeoutMs, retryMultiplier: 1, }; if (signerHasNip44) { registerDecryptor( "nip44", (payload) => signer.nip44Decrypt(userPubkey, payload, signerDecryptOptions), "active-signer", ); registerDecryptor( "nip44_v2", (payload) => signer.nip44Decrypt(userPubkey, payload, signerDecryptOptions), "active-signer", ); } if (signerHasNip04) { registerDecryptor( "nip04", (payload) => signer.nip04Decrypt(userPubkey, payload, signerDecryptOptions), "active-signer", ); } const extensionDecryptOptions = { priority: NIP07_PRIORITY.NORMAL, timeoutMs: nip07DecryptTimeoutMs, retryMultiplier: 1, }; const nostrApi = typeof window !== "undefined" && window?.nostr ? window.nostr : typeof globalThis !== "undefined" && globalThis?.nostr ? globalThis.nostr : null; if (nostrApi) { if (typeof nostrApi.nip04?.decrypt === "function") { registerDecryptor( "nip04", (payload) => runNip07WithRetry( () => nostrApi.nip04.decrypt(userPubkey, payload), { label: "nip04.decrypt", ...extensionDecryptOptions }, ), "extension", ); } const nip44 = nostrApi.nip44 && typeof nostrApi.nip44 === "object" ? nostrApi.nip44 : null; if (nip44) { if (typeof nip44.decrypt === "function") { registerDecryptor( "nip44", (payload) => runNip07WithRetry( () => nip44.decrypt(userPubkey, payload), { label: "nip44.decrypt", ...extensionDecryptOptions, }, ), "extension", ); } const nip44v2 = nip44.v2 && typeof nip44.v2 === "object" ? nip44.v2 : null; if (nip44v2 && typeof nip44v2.decrypt === "function") { registerDecryptor( "nip44_v2", (payload) => runNip07WithRetry( () => nip44v2.decrypt(userPubkey, payload), { label: "nip44.v2.decrypt", ...extensionDecryptOptions, }, ), "extension", ); if (!decryptors.has("nip44")) { registerDecryptor( "nip44", (payload) => runNip07WithRetry( () => nip44v2.decrypt(userPubkey, payload), { label: "nip44.v2.decrypt", ...extensionDecryptOptions, }, ), "extension", ); } } } } if (!decryptors.size) { const error = new Error( "No decryptors available for hashtag preferences payload.", ); error.code = "hashtag-preferences-no-decryptors"; return { ok: false, error }; } // Use the shared cross-service cache so all list services benefit from // the first successful decrypt during login. const cachedScheme = getLastSuccessfulScheme(userPubkey); const order = determineDecryptionOrder( event, Array.from(decryptors.keys()), cachedScheme, ); // PERF: Fast path — if the shared cache already knows the correct scheme // (e.g. blocks or subscriptions decrypted first), try ONLY that scheme // before falling back to the full parallel probe. This avoids queuing // redundant NIP-07 extension calls. if (cachedScheme && decryptors.has(cachedScheme)) { try { const plaintext = await decryptors.get(cachedScheme)(ciphertext); if (typeof plaintext === "string") { setLastSuccessfulScheme(userPubkey, cachedScheme); return { ok: true, plaintext, scheme: cachedScheme }; } } catch (_fastPathError) { // Cached scheme failed — fall through to parallel probe. } } // PERF: Try all decryption schemes in parallel via Promise.any(). // The first scheme to succeed wins, avoiding sequential 3-15s timeouts // per scheme that previously made worst-case decryption take 45s+. // Deduplicate schemes in the same family (nip44/nip44_v2 both call // the same underlying signer.nip44Decrypt) to avoid redundant calls. const getSchemeFamily = (s) => s === "nip44" || s === "nip44_v2" ? "nip44" : s; const seenFamilies = new Set(); const attemptSchemes = []; // Route by ciphertext format: only the family matching the payload can // decrypt it (NIP-04 carries "?iv="; otherwise NIP-44). Racing the other // family is wasted work — and under a NIP-46 remote signer it's a wasted // PUBLISHED relay RPC that floods the relay ("rate-limited") and stalls // decryption. Restrict the probe to the matching family. const formatFamily = typeof ciphertext === "string" && ciphertext.includes("?iv=") ? "nip04" : "nip44"; const matchingOrder = order.filter( (scheme) => getSchemeFamily(scheme) === formatFamily, ); const probeOrder = matchingOrder.length ? matchingOrder : order; const attempts = probeOrder .map((scheme) => { const family = getSchemeFamily(scheme); if (seenFamilies.has(family)) return null; seenFamilies.add(family); const decryptFn = decryptors.get(scheme); if (!decryptFn) return null; attemptSchemes.push(scheme); // FIX: Wrap in Promise.resolve().then() to catch synchronous throws // from decryptor functions (e.g. NIP-07 adapter nip44Decrypt throws // immediately if the extension hasn't injected its nip44 module yet). return Promise.resolve() .then(() => decryptFn(ciphertext)) .then((plaintext) => { if (typeof plaintext !== "string") { throw new Error("Decryption returned non-string payload."); } return { plaintext, scheme }; }); }) .filter(Boolean); if (attempts.length) { try { const result = await Promise.any(attempts); setLastSuccessfulScheme(userPubkey, result.scheme); return { ok: true, plaintext: result.plaintext, scheme: result.scheme }; } catch (aggregateError) { const attemptErrors = (aggregateError.errors || []).map((err, i) => ({ scheme: attemptSchemes[i] || "unknown", error: err, })); const error = new Error("Failed to decrypt hashtag preferences."); error.code = "hashtag-preferences-decrypt-failed"; if (attemptErrors.length) { error.cause = attemptErrors; } return { ok: false, error, errors: attemptErrors }; } } const error = new Error("Failed to decrypt hashtag preferences."); error.code = "hashtag-preferences-decrypt-failed"; return { ok: false, error, errors: [] }; } async publish(options = {}) { // nostrClient imported from nostrClientFacade if (isSessionActor(nostrClient)) { const error = new Error( "Publishing preferences is not allowed for session actors." ); error.code = "session-actor-publish-blocked"; throw error; } const targetPubkey = normalizeHexPubkey( typeof options?.pubkey === "string" ? options.pubkey : this.activePubkey, ); if (!targetPubkey) { const error = new Error("Active pubkey is required to publish preferences."); error.code = "hashtag-preferences-missing-pubkey"; throw error; } let signer = getActiveSigner(); if (!signer) { signer = await nostrClient.ensureActiveSignerForPubkey(targetPubkey); } const canSign = typeof signer?.canSign === "function" ? signer.canSign() : typeof signer?.signEvent === "function"; if (!canSign || typeof signer?.signEvent !== "function") { const error = new Error("An active signer is required to publish preferences."); error.code = "hashtag-preferences-missing-signer"; throw error; } if (signer.type === "extension" || signer.type === "nip07") { const permissionResult = await requestDefaultExtensionPermissions( DEFAULT_NIP07_ENCRYPTION_METHODS, ); if (!permissionResult?.ok) { const error = new Error( "The active signer must allow encryption before publishing preferences.", ); error.code = "hashtag-preferences-extension-denied"; error.cause = permissionResult?.error || null; throw error; } } const interests = this.getInterests(); const disinterests = this.getDisinterests().filter( (tag) => !interests.includes(tag), ); const payload = { version: DEFAULT_VERSION, interests, disinterests, }; const plaintext = JSON.stringify(payload); const encryptors = []; const registerEncryptor = (scheme, handler) => { if (!scheme || typeof handler !== "function") { return; } encryptors.push({ scheme, handler }); }; if (typeof signer.nip44Encrypt === "function") { registerEncryptor("nip44_v2", (value) => signer.nip44Encrypt(targetPubkey, value)); registerEncryptor("nip44", (value) => signer.nip44Encrypt(targetPubkey, value)); } if (typeof signer.nip04Encrypt === "function") { registerEncryptor("nip04", (value) => signer.nip04Encrypt(targetPubkey, value)); } if (!encryptors.length) { const error = new Error("No encryptors available to publish preferences."); error.code = "hashtag-preferences-no-encryptor"; throw error; } let ciphertext = ""; let schemeUsed = ""; const encryptionErrors = []; const triedSchemes = new Set(); for (const candidate of encryptors) { if (triedSchemes.has(candidate.scheme)) { continue; } triedSchemes.add(candidate.scheme); try { const encrypted = await candidate.handler(plaintext); if (typeof encrypted === "string" && encrypted) { ciphertext = encrypted; schemeUsed = candidate.scheme; break; } } catch (error) { encryptionErrors.push({ scheme: candidate.scheme, error }); } } if (!ciphertext) { const error = new Error("Failed to encrypt hashtag preferences."); error.code = "hashtag-preferences-encrypt-failed"; error.cause = encryptionErrors; throw error; } // Replaceable event: created_at MUST be strictly greater than the last one we // published, or relays reject the update as "not newer" and keep the old copy. // Two quick edits (e.g. adding a second disinterest within the same wall-clock // second) otherwise collide — the second errored on the client yet landed on // relays that resolve same-created_at ties by id, so it "failed but works after // refresh". Monotonic created_at removes the collision entirely. const createdAt = nextReplaceableCreatedAt(this.eventCreatedAt); const event = buildHashtagPreferenceEvent({ pubkey: targetPubkey, created_at: createdAt, content: ciphertext, }); if (!Array.isArray(event.tags)) { event.tags = []; } let updatedSchemeTag = false; for (const tag of event.tags) { if (!Array.isArray(tag) || tag.length < 2) { continue; } if (typeof tag[0] === "string" && tag[0].toLowerCase() === "encrypted") { tag[1] = schemeUsed; updatedSchemeTag = true; break; } } if (!updatedSchemeTag) { event.tags.push(["encrypted", schemeUsed]); } const signedEvent = await signer.signEvent(event); const writeRelays = sanitizeRelayList(nostrClient.writeRelays); const relayFallback = writeRelays.length ? writeRelays : sanitizeRelayList(nostrClient.relays); if (!relayFallback.length) { const error = new Error("No relays configured for publishing preferences."); error.code = "hashtag-preferences-no-relays"; throw error; } const publishResults = await publishEventToRelays( nostrClient.pool, relayFallback, signedEvent, ); // All-timeout (unconfirmed) publish of the replaceable preferences list is a // soft success — sent + almost always persisted; don't throw. Explicit // rejections still throw. const publishSummary = assertAnyRelayAcceptedOrUnconfirmed(publishResults, { context: "hashtag-preferences", }); if (publishSummary.unconfirmed) { userLogger.warn( `${LOG_PREFIX} Preferences not acknowledged by any relay within the timeout; treating as optimistic success (reconciles on next load).`, ); } if (publishSummary.failed?.length) { publishSummary.failed.forEach(({ url, error }) => { userLogger.warn( `${LOG_PREFIX} Preferences not accepted by ${url}: ${error}`, error, ); }); } this.eventId = signedEvent.id || null; this.eventCreatedAt = Number.isFinite(signedEvent?.created_at) ? signedEvent.created_at : createdAt; this.emitChange("published", { event: signedEvent, scheme: schemeUsed }); return signedEvent; } } const hashtagPreferences = new HashtagPreferencesService(); export default hashtagPreferences; export { HashtagPreferencesService, hashtagPreferences, EVENTS as HASHTAG_PREFERENCES_EVENTS };