Refactor user-specific lists to use centralized profileCache

This commit is contained in:
google-labs-jules[bot] committed 2026-01-17 20:47:27 +00:00
1 parent 32445f7eb8
commit 926e6ebf39
7 files changed
+361 -438

No files matched your search

+36 -147
View File
@@ -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) {
+16 -26
View File
@@ -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() {
+3
View File
@@ -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;
+42
View File
@@ -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(
+216
View File
@@ -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();
+14 -44
View File
@@ -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) {
+34 -221
View File
@@ -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);
}
}