mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
File-size decomposition (no behavior change). Move the pure, state-free helpers isTransientDecryptError, computeRetryDelay (+ their transient-error tables and MAX_DECRYPT_RETRY_DELAY_MS) and normalizeHexPubkey out into a new js/services/hashtagPreferencesHelpers.js and import them back. HEX64_REGEX is now imported by the helper module instead of the service. hashtagPreferencesService.js: 1541 -> 1480 lines (back under its 1484 limit). Baseline left at 1434 (file fits its original allowance again; not bumped up). Build + full unit suite green. Co-Authored-By: Claude Opus 4.8 <[email protected]>
1481 lines
46 KiB
JavaScript
1481 lines
46 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,
|
|
assertAnyRelayAccepted,
|
|
} 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 } 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();
|
|
const relays = 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,
|
|
);
|
|
|
|
const publishSummary = assertAnyRelayAccepted(publishResults, {
|
|
context: "hashtag-preferences",
|
|
});
|
|
|
|
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 };
|