mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-05 12:22:28 +00:00
Refactor user-specific lists to use centralized profileCache
This commit is contained in:
1 parent
32445f7eb8
commit
926e6ebf39
7 files changed
+361
-438
No files matched your search
+36
-147
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user