Files
bitvid/js/services/hashtagPreferencesService.js
T
thePR0M3TH3ANandClaude Opus 4.8 47f2a9a865 [nostr] Fix spurious "failed" errors on unconfirmed list publishes across the board
Same class of bug as the subscription fix, applied everywhere an idempotent,
replaceable list is published with an optimistic UI: when no relay ACKs within the
10s window (slow/flaky relays), the event was still sent and almost always
persists (it shows up after a refresh), but assertAnyRelayAccepted threw -> the UI
showed a scary error / reverted, even though it worked.

- nostrPublish.js: add shared helpers relayPublishFailuresAreAllTimeouts() and
  assertAnyRelayAcceptedOrUnconfirmed() — the latter returns a summary flagged
  { unconfirmed: true } on all-timeout instead of throwing; explicit relay
  rejections (and pre-publish signer/encryption errors) still throw.
- Applied to the replaceable-list publishes: subscriptions (kind 30000, refactored
  to the shared helper), user mute/block list (10000), relay list (10002), DM
  relay hints (10050), hashtag preferences (30015).

Left as-is for now (higher-stakes, separate follow-up): the generic content
publish path (video/edit/delete/revert/rebroadcast) in publishHelpers/client.js —
same latent issue but the video outcome-describer needs careful handling.

Tests: tests/subscription-publish-timeout.test.mjs (8) now cover both the
classifier and assertAnyRelayAcceptedOrUnconfirmed (confirmed->summary,
all-timeout->soft, rejection->throws). hashtag-preferences suite green (14).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 15:20:58 -04:00

1490 lines
47 KiB
JavaScript

// js/services/hashtagPreferencesService.js
import {
nostrClient,
requestDefaultExtensionPermissions,
getActiveSigner,
} from "../nostrClientFacade.js";
import { isSessionActor } from "../nostr/sessionActor.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;
}
const createdAt = Math.floor(Date.now() / 1000);
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 };