mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
When hashtag preferences decryption fails with "permission-required" during background loading (allowPermissionPrompt=false), the service silently returned without setting loaded state, scheduling a retry, or emitting a change event. This left the UI stuck showing empty hashtag lists. The subscriptions module already handled this case correctly. Also fix error scheme mapping in Promise.any() catch blocks for both hashtag preferences and subscriptions — the index into the original order array was wrong after filtering out deduplicated scheme families. https://claude.ai/code/session_016xYxLQ1U2XVVY5rFuCRKfc
1325 lines
39 KiB
JavaScript
1325 lines
39 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 { 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";
|
|
|
|
const LOG_PREFIX = "[HashtagPreferences]";
|
|
const HASHTAG_IDENTIFIER = "bitvid:tag-preferences";
|
|
const HEX64_REGEX = /^[0-9a-f]{64}$/i;
|
|
const DEFAULT_VERSION = 1;
|
|
const DECRYPT_TIMEOUT_MS = 20000;
|
|
const DECRYPT_RETRY_DELAY_MS = 10000;
|
|
const DECRYPTION_CACHE_TTL_MS = 10 * 60 * 1000;
|
|
|
|
const decryptionSuccessCache = new Map();
|
|
|
|
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 normalizeHexPubkey(pubkey) {
|
|
if (typeof pubkey !== "string") {
|
|
return null;
|
|
}
|
|
|
|
const trimmed = pubkey.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
|
|
if (HEX64_REGEX.test(trimmed)) {
|
|
return trimmed.toLowerCase();
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
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.loaded = false;
|
|
this.backgroundLoading = false;
|
|
this.preferencesVersion = DEFAULT_VERSION;
|
|
this.decryptRetryTimeoutId = null;
|
|
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.loaded = false;
|
|
this.backgroundLoading = false;
|
|
this.preferencesVersion = DEFAULT_VERSION;
|
|
this.lastLoadError = null;
|
|
if (this.decryptRetryTimeoutId) {
|
|
clearTimeout(this.decryptRetryTimeoutId);
|
|
this.decryptRetryTimeoutId = null;
|
|
}
|
|
this.loadPromise = null;
|
|
this.loadingPubkey = null;
|
|
this.emitChange("reset");
|
|
}
|
|
|
|
stopHashtagPreferencesSubscription() {
|
|
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;
|
|
|
|
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, though "interests" maps to HASHTAG_PREFERENCES
|
|
// in profileCache. Using NOTE_TYPES.HASHTAG_PREFERENCES explicitly is safer if we import it,
|
|
// but relying on the mapped key "interests" is also consistent with current usage.
|
|
// However, the fix requires us to ensure we use the unified path.
|
|
// We'll stick to "interests" which maps to NOTE_TYPES.HASHTAG_PREFERENCES in profileCache.js
|
|
profileCache.set("interests", payload);
|
|
}
|
|
|
|
loadFromCache(pubkey) {
|
|
// profileCache internally uses activePubkey, but we should verify if it matches
|
|
try {
|
|
let cached = profileCache.getProfileData(pubkey, "interests");
|
|
|
|
// 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("interests", 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.loaded = 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,
|
|
...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);
|
|
}
|
|
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);
|
|
});
|
|
}, DECRYPT_RETRY_DELAY_MS);
|
|
devLogger.log(
|
|
`${LOG_PREFIX} Decryption timed out; scheduling retry in ${DECRYPT_RETRY_DELAY_MS / 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 {
|
|
return await loadPromise;
|
|
} finally {
|
|
if (this.loadPromise === loadPromise) {
|
|
this.loadPromise = null;
|
|
this.loadingPubkey = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
async loadInternal(pubkey, options = {}) {
|
|
const normalized = normalizeHexPubkey(pubkey);
|
|
const allowPermissionPrompt = options?.allowPermissionPrompt !== false;
|
|
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.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.reset();
|
|
this.loaded = 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.reset();
|
|
this.loaded = 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 ? Number(this.eventCreatedAt) || 0 : 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.reset();
|
|
this.loaded = true;
|
|
if (wasBackgroundLoading) {
|
|
this.backgroundLoading = false;
|
|
this.emitChange("background-loaded", { background: false });
|
|
}
|
|
return;
|
|
}
|
|
|
|
const latest = events.reduce((current, candidate) => {
|
|
if (!candidate) {
|
|
return current;
|
|
}
|
|
if (!current) {
|
|
return candidate;
|
|
}
|
|
const candidateTs = Number(candidate.created_at) || 0;
|
|
const currentTs = Number(current.created_at) || 0;
|
|
if (candidateTs === currentTs) {
|
|
if (
|
|
candidate.kind === canonicalKind &&
|
|
current.kind !== canonicalKind
|
|
) {
|
|
return candidate;
|
|
}
|
|
if (
|
|
current.kind === canonicalKind &&
|
|
candidate.kind !== canonicalKind
|
|
) {
|
|
return current;
|
|
}
|
|
return candidate.id > current.id ? candidate : current;
|
|
}
|
|
return candidateTs > currentTs ? candidate : current;
|
|
}, null);
|
|
|
|
if (!latest) {
|
|
if (wasLoadedForUser) {
|
|
if (wasBackgroundLoading) {
|
|
this.backgroundLoading = false;
|
|
this.emitChange("background-loaded", { background: false });
|
|
}
|
|
return;
|
|
}
|
|
this.reset();
|
|
this.loaded = true;
|
|
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.
|
|
if (wasLoadedForUser && currentCreatedAt >= latestCreatedAt) {
|
|
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,
|
|
});
|
|
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;
|
|
userLogger.warn(
|
|
`${LOG_PREFIX} Failed to decrypt hashtag preferences`,
|
|
decryptResult.error,
|
|
);
|
|
|
|
if (decryptResult.error?.code === "hashtag-preferences-decrypt-timeout") {
|
|
if (!wasLoadedForUser && !this.loaded) {
|
|
this.loaded = true;
|
|
}
|
|
this.scheduleDecryptRetry(normalized, decryptResult.error, {
|
|
allowPermissionPrompt,
|
|
});
|
|
return;
|
|
}
|
|
if (
|
|
!allowPermissionPrompt &&
|
|
decryptResult.error?.code === "hashtag-preferences-permission-required"
|
|
) {
|
|
if (!wasLoadedForUser && !this.loaded) {
|
|
this.loaded = true;
|
|
}
|
|
this.scheduleDecryptRetry(normalized, decryptResult.error, {
|
|
allowPermissionPrompt: true,
|
|
});
|
|
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.reset();
|
|
this.loaded = 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.loaded = true;
|
|
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.reset();
|
|
this.loaded = true;
|
|
}
|
|
}
|
|
|
|
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;
|
|
let signer = getActiveSigner();
|
|
if (
|
|
!signer &&
|
|
typeof nostrClient?.ensureActiveSignerForPubkey === "function" &&
|
|
allowPermissionPrompt
|
|
) {
|
|
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 };
|
|
}
|
|
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);
|
|
};
|
|
|
|
// PERF: Previous 5s no-prompt timeout was too aggressive — extensions that
|
|
// have already granted permission still need time to decrypt. 8s gives a
|
|
// realistic window while still failing faster than the full 15s interactive
|
|
// timeout.
|
|
const nip07DecryptTimeoutMs = allowPermissionPrompt ? 15000 : 8000;
|
|
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 };
|
|
}
|
|
|
|
const cachedEntry = decryptionSuccessCache.get(userPubkey);
|
|
const cachedScheme =
|
|
cachedEntry && Date.now() - cachedEntry.timestamp < DECRYPTION_CACHE_TTL_MS
|
|
? cachedEntry.scheme
|
|
: null;
|
|
|
|
const order = determineDecryptionOrder(
|
|
event,
|
|
Array.from(decryptors.keys()),
|
|
cachedScheme,
|
|
);
|
|
// 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 = [];
|
|
const attempts = order
|
|
.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);
|
|
return 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);
|
|
decryptionSuccessCache.set(userPubkey, {
|
|
scheme: result.scheme,
|
|
timestamp: Date.now(),
|
|
});
|
|
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") {
|
|
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 };
|