diff --git a/js/nostr/watchHistory.js b/js/nostr/watchHistory.js index 9e03147f..0a5ce49f 100644 --- a/js/nostr/watchHistory.js +++ b/js/nostr/watchHistory.js @@ -22,6 +22,7 @@ import { } from "./toolkit.js"; import { DEFAULT_NIP07_PERMISSION_METHODS } from "./nip07Permissions.js"; import { devLogger, userLogger } from "../utils/logger.js"; +import { profileCache } from "../state/profileCache.js"; /** * Domain utilities for watch-history interactions. This module owns pointer @@ -29,8 +30,6 @@ import { devLogger, userLogger } from "../utils/logger.js"; * relay publishing, fetch/decrypt flows, and exposes a manager factory that is * dependency-injected with the nostr client hooks it needs. */ -const WATCH_HISTORY_STORAGE_KEY = "bitvid:watch-history:v5"; -const WATCH_HISTORY_STORAGE_VERSION = 5; const WATCH_HISTORY_REPUBLISH_BASE_DELAY_MS = 2000; const WATCH_HISTORY_REPUBLISH_MAX_DELAY_MS = 5 * 60 * 1000; const WATCH_HISTORY_REPUBLISH_MAX_ATTEMPTS = 8; @@ -977,148 +976,50 @@ class WatchHistoryManager { return resolved; } - getStorage() { - if (this.storage && this.storage.version === WATCH_HISTORY_STORAGE_VERSION) { - return this.storage; + getStorage(actorInput = null) { + const actorKey = normalizeActorKey(actorInput) || normalizeActorKey(this.deps.getActivePubkey?.()); + if (!actorKey) { + return { actors: {} }; // Return empty structure if no actor resolved } - const emptyStorage = { version: WATCH_HISTORY_STORAGE_VERSION, actors: {} }; - if (typeof localStorage === "undefined") { - this.storage = emptyStorage; - return this.storage; - } - let raw = null; - try { - raw = localStorage.getItem(WATCH_HISTORY_STORAGE_KEY); - } catch (error) { - devLogger.warn("[nostr] Failed to read watch history storage:", error); - this.storage = emptyStorage; - return this.storage; - } - if (!raw || typeof raw !== "string") { - this.storage = emptyStorage; - return this.storage; - } - let parsed = null; - try { - parsed = JSON.parse(raw); - } catch (error) { - devLogger.warn("[nostr] Failed to parse watch history storage:", error); - this.storage = emptyStorage; - return this.storage; - } - const now = Date.now(); - const ttl = this.getCacheTtlMs(); - const actors = - parsed && typeof parsed === "object" && parsed.actors && typeof parsed.actors === "object" - ? parsed.actors - : {}; - const sanitizedActors = {}; - let mutated = false; - for (const [actorKeyRaw, entry] of Object.entries(actors)) { - const actorKey = normalizeActorKey(actorKeyRaw); - if (!actorKey) { - mutated = true; - continue; - } - const savedAt = Number(entry?.savedAt); - if (!Number.isFinite(savedAt) || savedAt <= 0 || now - savedAt > ttl) { - mutated = true; - continue; - } - // Migration from v2 (flat items) to v3 (bucketed records) - let records = {}; - let items = []; - if (parsed.version < 3 && Array.isArray(entry.items)) { - records = canonicalizeWatchHistoryItems(entry.items, WATCH_HISTORY_MAX_ITEMS); - items = Object.values(records).flat(); - mutated = true; - } else if (entry.records && typeof entry.records === "object") { - // Re-canonicalize to ensure structure - const flatItems = Object.values(entry.records).flat(); - records = canonicalizeWatchHistoryItems(flatItems, WATCH_HISTORY_MAX_ITEMS); - items = Object.values(records).flat(); - } else if (Array.isArray(entry.items)) { - // Fallback if records missing but items present in v3 (shouldn't happen if persisted correctly but safe to handle) - records = canonicalizeWatchHistoryItems(entry.items, WATCH_HISTORY_MAX_ITEMS); - items = Object.values(records).flat(); - } - - const snapshotId = typeof entry?.snapshotId === "string" ? entry.snapshotId : ""; - const fingerprint = typeof entry?.fingerprint === "string" ? entry.fingerprint : ""; - const metadata = - entry?.metadata && typeof entry.metadata === "object" - ? { ...entry.metadata } - : {}; - sanitizedActors[actorKey] = { - actor: - typeof entry?.actor === "string" && entry.actor.trim() - ? entry.actor.trim() - : actorKey, - snapshotId, - fingerprint, - savedAt, - records, - items, - metadata, - }; + const cached = profileCache.get("watchHistory"); + if (!cached) { + return { actors: {} }; } - const storage = { - version: WATCH_HISTORY_STORAGE_VERSION, - actors: sanitizedActors, + + // Wrap single entry in actors map for compatibility + return { + actors: { + [actorKey]: cached + } }; - if (mutated || parsed?.version !== WATCH_HISTORY_STORAGE_VERSION) { - try { - localStorage.setItem(WATCH_HISTORY_STORAGE_KEY, JSON.stringify(storage)); - } catch (error) { - devLogger.warn("[nostr] Failed to rewrite watch history storage:", error); - } - } - this.storage = storage; - return this.storage; } persistEntry(actorInput, entry) { const actorKey = normalizeActorKey(actorInput); - if (!actorKey) { + if (!actorKey || !entry) { return; } - const storage = this.getStorage(); - const actors = { ...storage.actors }; - const now = Date.now(); - const ttl = this.getCacheTtlMs(); - let mutated = false; - for (const [key, value] of Object.entries(actors)) { - const savedAt = Number(value?.savedAt); - if (!Number.isFinite(savedAt) || savedAt <= 0 || now - savedAt > ttl) { - delete actors[key]; - mutated = true; - } - } - if (!entry) { - if (actors[actorKey]) { - delete actors[actorKey]; - mutated = true; - } - } else { - // Entry can have `items` (flat) or `records` (buckets). We standardize to records. - let records = {}; - if (entry.records && typeof entry.records === "object") { - const flat = Object.values(entry.records).flat(); - records = canonicalizeWatchHistoryItems(flat, WATCH_HISTORY_MAX_ITEMS); - } else if (Array.isArray(entry.items)) { - records = canonicalizeWatchHistoryItems(entry.items, WATCH_HISTORY_MAX_ITEMS); - } - const items = Object.values(records).flat(); - const snapshotId = typeof entry.snapshotId === "string" ? entry.snapshotId : ""; - const fingerprint = typeof entry.fingerprint === "string" ? entry.fingerprint : ""; - const savedAt = Number.isFinite(entry.savedAt) && entry.savedAt > 0 ? entry.savedAt : now; - const actorValue = + // Entry can have `items` (flat) or `records` (buckets). We standardize to records. + let records = {}; + if (entry.records && typeof entry.records === "object") { + const flat = Object.values(entry.records).flat(); + records = canonicalizeWatchHistoryItems(flat, WATCH_HISTORY_MAX_ITEMS); + } else if (Array.isArray(entry.items)) { + records = canonicalizeWatchHistoryItems(entry.items, WATCH_HISTORY_MAX_ITEMS); + } + const items = Object.values(records).flat(); + + const snapshotId = typeof entry.snapshotId === "string" ? entry.snapshotId : ""; + const fingerprint = typeof entry.fingerprint === "string" ? entry.fingerprint : ""; + const savedAt = Number.isFinite(entry.savedAt) && entry.savedAt > 0 ? entry.savedAt : Date.now(); + const actorValue = typeof entry.actor === "string" && entry.actor.trim() - ? entry.actor.trim() - : actorInput || actorKey; - actors[actorKey] = { + ? entry.actor.trim() + : actorInput || actorKey; + + const data = { actor: actorValue, snapshotId, fingerprint, @@ -1126,22 +1027,10 @@ class WatchHistoryManager { records, items, metadata: {}, - }; - mutated = true; - } - const payload = { - version: WATCH_HISTORY_STORAGE_VERSION, - actors, }; - this.storage = payload; - if (!mutated || typeof localStorage === "undefined") { - return; - } - try { - localStorage.setItem(WATCH_HISTORY_STORAGE_KEY, JSON.stringify(payload)); - } catch (error) { - devLogger.warn("[nostr] Failed to persist watch history entry:", error); - } + + // profileCache keys by active profile, so we just set the data for "watchHistory" + profileCache.set("watchHistory", data); } cancelRepublish(taskId = null) { diff --git a/js/relayManager.js b/js/relayManager.js index 0278ad79..77a9acc8 100644 --- a/js/relayManager.js +++ b/js/relayManager.js @@ -12,13 +12,13 @@ import { publishEventToRelays, assertAnyRelayAccepted, } from "./nostrPublish.js"; +import { profileCache } from "./state/profileCache.js"; const MODE_SEQUENCE = ["both", "read", "write"]; const FAST_RELAY_FETCH_LIMIT = 3; const FAST_RELAY_TIMEOUT_MS = 2500; const BACKGROUND_RELAY_TIMEOUT_MS = 6000; -const RELAY_PREFS_STORAGE_KEY = "bitvid:relay-preferences:v1"; function normalizeHexPubkey(pubkey) { if (typeof pubkey !== "string") { @@ -201,39 +201,29 @@ class RelayPreferencesManager { } else { this.setEntries(this.defaultEntries, { allowEmpty: false, updateClient: true }); } + + profileCache.subscribe((event, detail) => { + if (event === "profileChanged") { + const fresh = this.loadFromStorage(); + if (fresh && fresh.length) { + this.setEntries(fresh, { allowEmpty: false, updateClient: true }); + } else { + this.setEntries(this.defaultEntries, { allowEmpty: false, updateClient: true }); + } + } + }); } loadFromStorage() { - const policy = CACHE_POLICIES[NOTE_TYPES.RELAY_LIST]; - if (policy?.storage !== STORAGE_TIERS.LOCAL_STORAGE) { - return null; - } - if (typeof localStorage === "undefined") return null; - try { - const raw = localStorage.getItem(RELAY_PREFS_STORAGE_KEY); - if (raw) { - const parsed = JSON.parse(raw); - if (Array.isArray(parsed)) { - return parsed; - } - } - } catch (err) { - devLogger.warn("[RelayPreferencesManager] Failed to load from storage", err); + const cached = profileCache.get("relays"); + if (Array.isArray(cached)) { + return cached; } return null; } saveToStorage() { - const policy = CACHE_POLICIES[NOTE_TYPES.RELAY_LIST]; - if (policy?.storage !== STORAGE_TIERS.LOCAL_STORAGE) { - return; - } - if (typeof localStorage === "undefined") return; - try { - localStorage.setItem(RELAY_PREFS_STORAGE_KEY, serializeEntries(this.entries)); - } catch (err) { - devLogger.warn("[RelayPreferencesManager] Failed to save to storage", err); - } + profileCache.set("relays", this.entries.map((entry) => ({ url: entry.url, mode: entry.mode }))); } snapshot() { diff --git a/js/services/authService.js b/js/services/authService.js index dafec531..8f0e9c4c 100644 --- a/js/services/authService.js +++ b/js/services/authService.js @@ -22,6 +22,7 @@ import { setProfileCacheEntry as setCachedProfileEntry, resetModerationSettings, } from "../state/cache.js"; +import { profileCache } from "../state/profileCache.js"; import getDefaultAuthProvider, { providers as defaultAuthProviders, } from "./authProviders/index.js"; @@ -534,11 +535,13 @@ export default class AuthService { if (this.nostrClient && typeof this.nostrClient === "object") { this.nostrClient.pubkey = normalized; } + profileCache.setActiveProfile(normalized); } else { setPubkey(nextPubkey); if (this.nostrClient && typeof this.nostrClient === "object") { this.nostrClient.pubkey = trimmed ? trimmed.toLowerCase() : ""; } + profileCache.setActiveProfile(trimmed ? trimmed.toLowerCase() : null); } const npub = candidateNpub; diff --git a/js/services/hashtagPreferencesService.js b/js/services/hashtagPreferencesService.js index 3ed72af4..093c22ee 100644 --- a/js/services/hashtagPreferencesService.js +++ b/js/services/hashtagPreferencesService.js @@ -14,6 +14,7 @@ import { assertAnyRelayAccepted, } from "../nostrPublish.js"; import { userLogger } from "../utils/logger.js"; +import { profileCache } from "../state/profileCache.js"; const LOG_PREFIX = "[HashtagPreferences]"; const HASHTAG_IDENTIFIER = "bitvid:tag-preferences"; @@ -244,6 +245,12 @@ class HashtagPreferencesService { this.eventCreatedAt = null; this.loaded = false; this.preferencesVersion = DEFAULT_VERSION; + + profileCache.subscribe((event, detail) => { + if (event === "profileChanged") { + this.reset(); + } + }); } on(eventName, handler) { @@ -260,6 +267,35 @@ class HashtagPreferencesService { this.emitChange("reset"); } + saveToCache() { + const interests = this.getInterests(); + const disinterests = this.getDisinterests(); + profileCache.set("interests", { + interests, + disinterests, + version: this.preferencesVersion, + eventId: this.eventId, + createdAt: this.eventCreatedAt, + }); + } + + loadFromCache(pubkey) { + // profileCache internally uses activePubkey, but we should verify if it matches + const cached = profileCache.get("interests"); + 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; + } + return false; + } + getInterests() { return Array.from(this.interests).sort((a, b) => a.localeCompare(b)); } @@ -357,6 +393,11 @@ class HashtagPreferencesService { return; } + // Try cache first + if (this.loadFromCache(normalized)) { + wasLoadedForUser = true; + } + if ( !nostrClient || !nostrClient.pool || @@ -516,6 +557,7 @@ class HashtagPreferencesService { ? latest.created_at : null; this.loaded = true; + this.saveToCache(); this.emitChange("sync", { scheme: decryptResult.scheme }); } catch (error) { userLogger.warn( diff --git a/js/state/profileCache.js b/js/state/profileCache.js new file mode 100644 index 00000000..bc07fd38 --- /dev/null +++ b/js/state/profileCache.js @@ -0,0 +1,216 @@ +import { CACHE_POLICIES, STORAGE_TIERS } from "../nostr/cachePolicies.js"; +import { userLogger } from "../utils/logger.js"; + +const PROFILE_CACHE_VERSION = 1; +const STORAGE_KEY_PREFIX = "bitvid:profile:"; + +class ProfileCache { + constructor() { + this.activePubkey = null; + this.memoryCache = new Map(); // For runtime decrypted data: pubkey:section -> data + this.listeners = new Set(); + } + + normalizeHexPubkey(pubkey) { + if (typeof pubkey !== "string") { + return null; + } + const trimmed = pubkey.trim(); + if (!trimmed) { + return null; + } + // Simple hex check, relying on callers to provide valid hex mostly, + // but robust enough to handle basic whitespace. + // For full validation we'd need nostr-tools or similar regex. + // Assuming standard 64-char hex for now. + if (/^[0-9a-f]{64}$/i.test(trimmed)) { + return trimmed.toLowerCase(); + } + return null; + } + + getStorageKey(pubkey, section) { + return `${STORAGE_KEY_PREFIX}${pubkey}:${section}:v${PROFILE_CACHE_VERSION}`; + } + + getStorageTier(section) { + // We assume section maps to a NOTE_TYPE or we map it manually. + // For now, let's map known sections to cache policies. + // If unknown, default to LOCAL_STORAGE for safety or MEMORY if ephemeral. + + // Mapping section names used in services to CACHE_POLICIES keys + // subscriptions -> NOTE_TYPES.SUBSCRIPTION_LIST (30000) + // blocks -> NOTE_TYPES.USER_BLOCK_LIST (30002/10000) + // interests -> NOTE_TYPES.HASHTAG_PREFERENCES (30015/30005) + // watchHistory -> NOTE_TYPES.WATCH_HISTORY (30078) + // relays -> NOTE_TYPES.RELAY_LIST (10002) + + // We'll rely on the caller passing the correct section key that matches CACHE_POLICIES logic + // OR we define a map here. + + // Let's use simple string keys for sections and map them here. + const policyMap = { + "subscriptions": "subscription_list", // maps to NOTE_TYPES.SUBSCRIPTION_LIST + "blocks": "user_block_list", + "interests": "hashtag_preferences", + "watchHistory": "watch_history", + "relays": "relay_list", + // Add others as needed + }; + + // Note: CACHE_POLICIES keys are the note types (strings or numbers). + // In `js/nostr/cachePolicies.js`: + // [NOTE_TYPES.SUBSCRIPTION_LIST]: ... + // We need to know what NOTE_TYPES.SUBSCRIPTION_LIST evaluates to. + // However, we can just look up by the policy object directly if we import NOTE_TYPES. + // But to avoid circular deps or heavy imports, we can just check the storage tier directly if provided via config + // or assume LOCAL_STORAGE for these user lists as per requirements. + + // For now, hardcode LOCAL_STORAGE for the known persistent types, as per the plan's context. + return STORAGE_TIERS.LOCAL_STORAGE; + } + + setActiveProfile(pubkey) { + const normalized = this.normalizeHexPubkey(pubkey); + if (this.activePubkey === normalized) { + return; + } + + // Clear memory cache for the old profile to remove sensitive decrypted data + if (this.activePubkey) { + this.clearMemoryCache(this.activePubkey); + } + + this.activePubkey = normalized; + + // Emit change event + this.emit("profileChanged", { pubkey: this.activePubkey }); + } + + getActiveProfile() { + return this.activePubkey; + } + + get(section) { + if (!this.activePubkey) { + return null; + } + return this.getProfileData(this.activePubkey, section); + } + + set(section, data) { + if (!this.activePubkey) { + return; + } + this.setProfileData(this.activePubkey, section, data); + } + + getProfileData(pubkey, section) { + // 1. Check memory cache (runtime data) + const memKey = `${pubkey}:${section}`; + if (this.memoryCache.has(memKey)) { + return this.memoryCache.get(memKey); + } + + // 2. Load from persistence (canonical/encrypted data) + // Note: 'get' is often used for the *decrypted* runtime state in services. + // But services need to load the *persisted* data to decrypt it. + // The separation of concerns: + // - profileCache stores the *persisted* blob (encrypted or raw). + // - Services decrypt it and store the *result* back in profileCache's memory tier? + // OR services keep their own runtime state? + // The plan says: "Keep decrypted/plaintext and signer-dependent runtime caches **in memory**" + // "Services remain logic + event emitters that read/write through the partition". + + // So: + // `profileCache.get(section)` should probably return the *persisted* data. + // Services maintain their own runtime state derived from it, OR we allow storing runtime state in `profileCache` too? + // If `profileCache` is just a storage wrapper, `get` returns what's in localStorage. + + // Let's implement `load` from storage. + const storageKey = this.getStorageKey(pubkey, section); + const tier = this.getStorageTier(section); + + if (tier === STORAGE_TIERS.LOCAL_STORAGE && typeof localStorage !== "undefined") { + try { + const raw = localStorage.getItem(storageKey); + if (raw) { + return JSON.parse(raw); + } + } catch (error) { + userLogger.warn(`[ProfileCache] Failed to load ${section} for ${pubkey}`, error); + } + } + + return null; + } + + setProfileData(pubkey, section, data) { + const storageKey = this.getStorageKey(pubkey, section); + const tier = this.getStorageTier(section); + + // Save to storage + if (tier === STORAGE_TIERS.LOCAL_STORAGE && typeof localStorage !== "undefined") { + try { + if (data === null || data === undefined) { + localStorage.removeItem(storageKey); + } else { + localStorage.setItem(storageKey, JSON.stringify(data)); + } + } catch (error) { + userLogger.warn(`[ProfileCache] Failed to save ${section} for ${pubkey}`, error); + } + } + + // Update memory cache? + // If `get` reads from storage, `set` writes to storage. + // We can also cache in memory to avoid parsing JSON every time if we want. + // But if services manage runtime state, `profileCache` acts as the persistence layer. + + this.emit("update", { pubkey, section, data }); + } + + // Runtime memory cache methods (for decrypted data, if services want to use it) + // Or maybe services just use `get` and `set` for persistence? + // The plan says: "Keep decrypted/plaintext... in memory keyed by pubkey... and clear them on signer change." + // This implies `profileCache` SHOULD manage the memory cache too. + + setMemoryData(section, data) { + if (!this.activePubkey) return; + const key = `${this.activePubkey}:${section}`; + this.memoryCache.set(key, data); + } + + getMemoryData(section) { + if (!this.activePubkey) return null; + const key = `${this.activePubkey}:${section}`; + return this.memoryCache.get(key); + } + + clearMemoryCache(pubkey) { + // Clear all entries starting with pubkey: + const prefix = `${pubkey}:`; + for (const key of this.memoryCache.keys()) { + if (key.startsWith(prefix)) { + this.memoryCache.delete(key); + } + } + } + + subscribe(callback) { + this.listeners.add(callback); + return () => this.listeners.delete(callback); + } + + emit(event, detail) { + for (const listener of this.listeners) { + try { + listener(event, detail); + } catch (error) { + console.error("[ProfileCache] Listener error", error); + } + } + } +} + +export const profileCache = new ProfileCache(); diff --git a/js/subscriptions.js b/js/subscriptions.js index 63835cd8..04d39969 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -30,12 +30,11 @@ import { ALLOW_NSFW_CONTENT } from "./config.js"; import { devLogger, userLogger } from "./utils/logger.js"; import moderationService from "./services/moderationService.js"; import nostrService from "./services/nostrService.js"; +import { profileCache } from "./state/profileCache.js"; const SUBSCRIPTION_SET_KIND = getNostrEventSchema(NOTE_TYPES.SUBSCRIPTION_LIST)?.kind ?? 30000; -const SUBSCRIPTIONS_CACHE_KEY_PREFIX = "bitvid:subscriptions:v1:"; - function normalizeHexPubkey(value) { if (typeof value !== "string") { return ""; @@ -240,6 +239,14 @@ class SubscriptionsManager { this.isRunningFeed = false; this.hasRenderedOnce = false; this.ensureNostrServiceListener(); + + profileCache.subscribe((event, detail) => { + if (event === "profileChanged") { + this.reset(); + // Optionally reload immediately if we have a pubkey? + // Usually UI triggers reload via showSubscriptionVideos + } + }); } /** @@ -254,10 +261,10 @@ class SubscriptionsManager { const normalizedUserPubkey = normalizeHexPubkey(userPubkey) || userPubkey; // 1. Attempt to load from cache first - const cached = this.loadFromCache(normalizedUserPubkey); - if (cached) { + const cached = profileCache.get("subscriptions"); + if (Array.isArray(cached)) { devLogger.log("[SubscriptionsManager] Loaded subscriptions from cache."); - this.subscribedPubkeys = cached; + this.subscribedPubkeys = new Set(cached); this.currentUserPubkey = normalizedUserPubkey; this.loaded = true; @@ -272,46 +279,9 @@ class SubscriptionsManager { await this.updateFromRelays(userPubkey); } - getCacheKey(userPubkey) { - if (!userPubkey) return null; - return `${SUBSCRIPTIONS_CACHE_KEY_PREFIX}${userPubkey}`; - } - - loadFromCache(userPubkey) { - const policy = CACHE_POLICIES[NOTE_TYPES.SUBSCRIPTION_LIST]; - if (policy?.storage !== STORAGE_TIERS.LOCAL_STORAGE) { - return null; - } - if (typeof localStorage === "undefined") return null; - const key = this.getCacheKey(userPubkey); - if (!key) return null; - const raw = localStorage.getItem(key); - if (!raw) return null; - try { - const parsed = JSON.parse(raw); - if (Array.isArray(parsed)) { - return new Set(parsed); - } - } catch (e) { - devLogger.warn("[SubscriptionsManager] Failed to parse cache:", e); - } - return null; - } - saveToCache(userPubkey) { - const policy = CACHE_POLICIES[NOTE_TYPES.SUBSCRIPTION_LIST]; - if (policy?.storage !== STORAGE_TIERS.LOCAL_STORAGE) { - return; - } - if (typeof localStorage === "undefined") return; - const key = this.getCacheKey(userPubkey); - if (!key) return; - const list = Array.from(this.subscribedPubkeys); - try { - localStorage.setItem(key, JSON.stringify(list)); - } catch (e) { - devLogger.warn("[SubscriptionsManager] Failed to save to cache:", e); - } + // We assume userPubkey matches active profile, enforced by profileCache + profileCache.set("subscriptions", Array.from(this.subscribedPubkeys)); } async updateFromRelays(userPubkey) { diff --git a/js/userBlocks.js b/js/userBlocks.js index 4e9f5df1..843f87ad 100644 --- a/js/userBlocks.js +++ b/js/userBlocks.js @@ -11,6 +11,7 @@ import { publishEventToRelays, assertAnyRelayAccepted, } from "./nostrPublish.js"; +import { profileCache } from "./state/profileCache.js"; class TinyEventEmitter { constructor() { @@ -65,24 +66,6 @@ const FAST_BLOCKLIST_RELAY_LIMIT = 3; const FAST_BLOCKLIST_TIMEOUT_MS = 2500; const BACKGROUND_BLOCKLIST_TIMEOUT_MS = 6000; -const BLOCKLIST_STORAGE_PREFIX = "bitvid:user-blocks"; -const BLOCKLIST_SEEDED_KEY_PREFIX = `${BLOCKLIST_STORAGE_PREFIX}:seeded:v1`; -const BLOCKLIST_REMOVALS_KEY_PREFIX = `${BLOCKLIST_STORAGE_PREFIX}:removals:v1`; -const BLOCKLIST_LOCAL_KEY_PREFIX = `${BLOCKLIST_STORAGE_PREFIX}:local:v1`; - -function resolveStorage() { - if (typeof localStorage !== "undefined") { - return localStorage; - } - if (typeof window !== "undefined" && window.localStorage) { - return window.localStorage; - } - if (typeof globalThis !== "undefined" && globalThis.localStorage) { - return globalThis.localStorage; - } - return null; -} - function normalizeEncryptionToken(value) { if (typeof value !== "string") { return ""; @@ -612,119 +595,6 @@ function decodeNpubToHex(npub) { return normalizeDecodedHex(fallbackDecodeNpubToHex(trimmed)); } -function readSeededFlag(actorHex) { - if (typeof actorHex !== "string" || !actorHex) { - return false; - } - - if (typeof localStorage === "undefined") { - return false; - } - - const key = `${BLOCKLIST_SEEDED_KEY_PREFIX}:${actorHex}`; - - try { - const value = localStorage.getItem(key); - return value === "1"; - } catch (error) { - userLogger.warn( - `[UserBlockList] Failed to read seeded baseline state for ${actorHex}:`, - error, - ); - return false; - } -} - -function writeSeededFlag(actorHex, seeded) { - if (typeof actorHex !== "string" || !actorHex) { - return; - } - - const storage = resolveStorage(); - if (!storage) { - return; - } - - const key = `${BLOCKLIST_SEEDED_KEY_PREFIX}:${actorHex}`; - - try { - if (seeded) { - localStorage.setItem(key, "1"); - } else { - localStorage.removeItem(key); - } - } catch (error) { - userLogger.warn( - `[UserBlockList] Failed to persist seeded baseline state for ${actorHex}:`, - error, - ); - } -} - -function readRemovalSet(actorHex) { - const empty = new Set(); - if (typeof actorHex !== "string" || !actorHex) { - return empty; - } - - if (typeof localStorage === "undefined") { - return empty; - } - - const key = `${BLOCKLIST_REMOVALS_KEY_PREFIX}:${actorHex}`; - - try { - const raw = localStorage.getItem(key); - if (raw === null) { - return empty; - } - - const parsed = JSON.parse(raw); - if (!Array.isArray(parsed)) { - return empty; - } - - const normalized = parsed - .map((entry) => (typeof entry === "string" ? entry.trim().toLowerCase() : "")) - .filter((entry) => /^[0-9a-f]{64}$/.test(entry)); - - return new Set(normalized); - } catch (error) { - userLogger.warn( - `[UserBlockList] Failed to read seed removal state for ${actorHex}:`, - error, - ); - return empty; - } -} - -function writeRemovalSet(actorHex, removals) { - if (typeof actorHex !== "string" || !actorHex) { - return; - } - - const storage = resolveStorage(); - if (!storage) { - return; - } - - const key = `${BLOCKLIST_REMOVALS_KEY_PREFIX}:${actorHex}`; - - try { - if (!removals || !removals.size) { - localStorage.removeItem(key); - return; - } - - localStorage.setItem(key, JSON.stringify(Array.from(removals))); - } catch (error) { - userLogger.warn( - `[UserBlockList] Failed to persist seed removal state for ${actorHex}:`, - error, - ); - } -} - function normalizeHex(pubkey) { if (typeof pubkey !== "string") { return null; @@ -747,73 +617,6 @@ function normalizeHex(pubkey) { return null; } -function readLocalBlocks(actorHex) { - if (typeof actorHex !== "string" || !actorHex) { - return null; - } - - const storage = resolveStorage(); - if (!storage) { - return null; - } - - const key = `${BLOCKLIST_LOCAL_KEY_PREFIX}:${actorHex}`; - - try { - const raw = storage.getItem(key); - if (raw === null) { - return null; - } - - const parsed = JSON.parse(raw); - if (!Array.isArray(parsed)) { - return new Set(); - } - - const normalized = parsed - .map((entry) => - typeof entry === "string" ? entry.trim().toLowerCase() : "", - ) - .filter((entry) => /^[0-9a-f]{64}$/.test(entry)); - - return new Set(normalized); - } catch (error) { - userLogger.warn( - `[UserBlockList] Failed to read local blocks for ${actorHex}:`, - error, - ); - return null; - } -} - -function writeLocalBlocks(actorHex, blocks) { - if (typeof actorHex !== "string" || !actorHex) { - return; - } - - const storage = resolveStorage(); - if (!storage) { - return; - } - - const key = `${BLOCKLIST_LOCAL_KEY_PREFIX}:${actorHex}`; - - try { - if (!blocks || !blocks.size) { - // We persist empty sets as [] to distinguish from "not found" (null) - storage.setItem(key, "[]"); - return; - } - - storage.setItem(key, JSON.stringify(Array.from(blocks))); - } catch (error) { - userLogger.warn( - `[UserBlockList] Failed to persist local blocks for ${actorHex}:`, - error, - ); - } -} - class UserBlockListManager { constructor() { this.blockedPubkeys = new Set(); @@ -825,6 +628,12 @@ class UserBlockListManager { this.loaded = false; this.emitter = new TinyEventEmitter(); this.seedStateCache = new Map(); + + profileCache.subscribe((event, detail) => { + if (event === "profileChanged") { + this.reset(); + } + }); } reset() { @@ -1449,19 +1258,15 @@ class UserBlockListManager { } _loadLocal(actorHex) { - const policy = CACHE_POLICIES[NOTE_TYPES.USER_BLOCK_LIST]; - if (policy?.storage !== STORAGE_TIERS.LOCAL_STORAGE) { - return null; + const cached = profileCache.get("blocks"); + if (Array.isArray(cached)) { + return new Set(cached); } - return readLocalBlocks(actorHex); + return null; } _saveLocal(actorHex) { - const policy = CACHE_POLICIES[NOTE_TYPES.USER_BLOCK_LIST]; - if (policy?.storage !== STORAGE_TIERS.LOCAL_STORAGE) { - return; - } - writeLocalBlocks(actorHex, this.blockedPubkeys); + profileCache.set("blocks", Array.from(this.blockedPubkeys)); } _getSeedState(actorHex) { @@ -1474,48 +1279,56 @@ class UserBlockListManager { return this.seedStateCache.get(normalized); } - const seeded = readSeededFlag(normalized); - const removals = readRemovalSet(normalized); + const stored = profileCache.get("blocks_meta") || {}; + const seeded = !!stored.seeded; + const removals = new Set(Array.isArray(stored.removals) ? stored.removals : []); + const state = { seeded, removals }; this.seedStateCache.set(normalized, state); return state; } + _persistSeedState(actorHex) { + const normalized = normalizeHex(actorHex); + if (!normalized) return; + const state = this.seedStateCache.get(normalized); + if (!state) return; + + profileCache.set("blocks_meta", { + seeded: state.seeded, + removals: Array.from(state.removals), + }); + } + _setSeeded(actorHex, seeded) { const normalized = normalizeHex(actorHex); - if (!normalized) { - return; - } + if (!normalized) return; const state = this._getSeedState(normalized); state.seeded = Boolean(seeded); - writeSeededFlag(normalized, state.seeded); + this._persistSeedState(normalized); } _addSeedRemoval(actorHex, targetHex) { const normalizedActor = normalizeHex(actorHex); const normalizedTarget = normalizeHex(targetHex); - if (!normalizedActor || !normalizedTarget) { - return; - } + if (!normalizedActor || !normalizedTarget) return; const state = this._getSeedState(normalizedActor); if (!state.removals.has(normalizedTarget)) { state.removals.add(normalizedTarget); - writeRemovalSet(normalizedActor, state.removals); + this._persistSeedState(normalizedActor); } } _clearSeedRemoval(actorHex, targetHex) { const normalizedActor = normalizeHex(actorHex); const normalizedTarget = normalizeHex(targetHex); - if (!normalizedActor || !normalizedTarget) { - return; - } + if (!normalizedActor || !normalizedTarget) return; const state = this._getSeedState(normalizedActor); if (state.removals.delete(normalizedTarget)) { - writeRemovalSet(normalizedActor, state.removals); + this._persistSeedState(normalizedActor); } }