Files
bitvid/js/state/profileCache.js
Claude 696a30051b Fix lint failures, console.* convention violations, and CI Node version mismatch
- Add docs/capacitor-native-app-plan.md to hex-lint ignore list (code examples
  naturally reference hex colors in Capacitor config)
- Replace direct console.error/console.log calls with logger utility in
  historyView.js, profileCache.js, EventDetailsModal.js, ZapInterface.js,
  and notificationController.js
- Update CI workflow from Node 20 to Node 22 to match package.json engines

https://claude.ai/code/session_01LzErpH9FNw4hAPsjoYb1v9
2026-02-10 02:35:24 +00:00

424 lines
14 KiB
JavaScript

import { CACHE_POLICIES, STORAGE_TIERS } from "../nostr/cachePolicies.js";
import { NOTE_TYPES } from "../nostrEventSchemas.js";
import { userLogger } from "../utils/logger.js";
import { sanitizeProfileMediaUrl } from "../utils/profileMedia.js";
const PROFILE_CACHE_VERSION = 1;
const STORAGE_KEY_PREFIX = "bitvid:profile:";
// Maps legacy/service section names to NOTE_TYPES for policy lookup
const SECTION_TO_NOTE_TYPE = {
"watchHistory": NOTE_TYPES.WATCH_HISTORY,
"subscriptions": NOTE_TYPES.SUBSCRIPTION_LIST,
"blocks": NOTE_TYPES.USER_BLOCK_LIST,
"interests": NOTE_TYPES.HASHTAG_PREFERENCES,
"relays": NOTE_TYPES.RELAY_LIST,
"profile": NOTE_TYPES.PROFILE_METADATA,
};
// Helper to defer non-critical work to idle periods
function runIdle(callback) {
if (typeof requestIdleCallback !== "undefined") {
requestIdleCallback(callback);
} else {
setTimeout(callback, 1);
}
}
function sanitizeProfileString(value) {
if (typeof value !== "string") {
return "";
}
return value.trim();
}
class ProfileCache {
constructor() {
this.activePubkey = null;
// Optimized: Use nested map (pubkey -> section -> data) for O(1) clearing
this.memoryCache = new Map(); // For runtime decrypted data: pubkey -> Map<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;
}
/**
* Resolves the cache policy for a given section/noteType.
* @param {string} section - The section identifier or noteType.
* @returns {object|null} The policy object or null.
*/
getPolicy(section) {
if (!section) return null;
// Check if section is a known alias
const noteType = SECTION_TO_NOTE_TYPE[section] || section;
return CACHE_POLICIES[noteType] || null;
}
/**
* Resolves the canonical storage key for a section based on policy addressing.
* @param {string} section - The section identifier or noteType.
* @param {object} params - Parameters for key resolution { pubkey, dTag, kind }.
* @returns {string} The canonical storage key.
*/
resolveAddressKey(section, { pubkey, dTag } = {}) {
const policy = this.getPolicy(section);
const resolvedPubkey = this.normalizeHexPubkey(pubkey || this.activePubkey);
if (!resolvedPubkey) {
// Fallback or error? For now, we return a key with "unknown" or empty if no pubkey available
// but typically we should have one.
return "";
}
if (policy && policy.addressing === "kind:pubkey:d") {
const resolvedDTag = dTag || policy.defaultDTag;
const noteType = SECTION_TO_NOTE_TYPE[section] || section;
// We can use the noteType string as part of the key if we don't have the numeric kind easily accessible
// or we can hardcode the mapping if we really want kind numbers.
// However, the policy key in CACHE_POLICIES is the note type string (e.g. "watchHistory").
// To satisfy "kind:pubkey:d", we should ideally use the kind number.
// But `CACHE_POLICIES` is keyed by string type.
// Let's use the noteType string in the key to be unique and consistent with CACHE_POLICIES keys.
// Format: bitvid:profile:{pubkey}:{noteType}:{dTag}:v{VERSION}
// If dTag is missing for a replaceable list, it's an issue, but we'll use "default" or empty.
const dSegment = resolvedDTag || "default";
// Note: We use the noteType string (e.g. "watchHistory") instead of numeric kind
// because we don't import the numeric mappings here directly beyond what's in schemas,
// and noteType is unique enough.
return `${STORAGE_KEY_PREFIX}${resolvedPubkey}:${noteType}:${dSegment}:v${PROFILE_CACHE_VERSION}`;
} else if (policy && policy.addressing === "kind:pubkey") {
const noteType = SECTION_TO_NOTE_TYPE[section] || section;
return `${STORAGE_KEY_PREFIX}${resolvedPubkey}:${noteType}:v${PROFILE_CACHE_VERSION}`;
}
// Fallback legacy key format
return `${STORAGE_KEY_PREFIX}${resolvedPubkey}:${section}:v${PROFILE_CACHE_VERSION}`;
}
getStorageKey(pubkey, section) {
// Forward to resolveAddressKey which handles the logic,
// but getStorageKey signature is (pubkey, section).
// We assume default dTag if applicable.
return this.resolveAddressKey(section, { pubkey });
}
getStorageTier(section) {
const policy = this.getPolicy(section);
if (policy) {
return policy.storage;
}
// Default to LOCAL_STORAGE for unknown sections to match legacy behavior
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);
}
getProfile(pubkey) {
const data = this.getProfileData(pubkey, "profile");
return data && data.profile ? data.profile : null;
}
setProfile(pubkey, profile, { persist = true } = {}) {
const normalizedPubkey = this.normalizeHexPubkey(pubkey);
if (!normalizedPubkey || !profile) {
return null;
}
const existingEntry = this.getProfileData(normalizedPubkey, "profile");
const existingProfile = existingEntry?.profile || {};
const existingName = sanitizeProfileString(existingProfile.name);
const existingPicture = sanitizeProfileMediaUrl(existingProfile.picture);
const existingAbout = sanitizeProfileString(existingProfile.about);
const existingWebsite = sanitizeProfileString(existingProfile.website);
const existingBanner = sanitizeProfileMediaUrl(existingProfile.banner);
const existingLud16 = sanitizeProfileString(existingProfile.lud16);
const existingLud06 = sanitizeProfileString(existingProfile.lud06);
const existingLightningAddress = sanitizeProfileString(
existingProfile.lightningAddress,
);
const normalized = {
name:
sanitizeProfileString(profile.name || profile.display_name) ||
existingName ||
"Unknown",
picture:
sanitizeProfileMediaUrl(profile.picture || profile.image) ||
existingPicture ||
"assets/svg/default-profile.svg",
};
const about =
sanitizeProfileString(profile.about || profile.aboutMe) || existingAbout;
if (about) {
normalized.about = about;
}
const website =
sanitizeProfileString(profile.website || profile.url) || existingWebsite;
if (website) {
normalized.website = website;
}
const banner = sanitizeProfileMediaUrl(
profile.banner ||
profile.header ||
profile.background ||
profile.cover ||
profile.cover_image ||
profile.coverImage,
);
const resolvedBanner = banner || existingBanner;
if (resolvedBanner) {
normalized.banner = resolvedBanner;
}
const lud16 = sanitizeProfileString(profile.lud16) || existingLud16;
if (lud16) {
normalized.lud16 = lud16;
}
const lud06 = sanitizeProfileString(profile.lud06) || existingLud06;
if (lud06) {
normalized.lud06 = lud06;
}
const lightningCandidates = [
sanitizeProfileString(profile.lightningAddress),
lud16,
lud06,
].filter(Boolean);
const resolvedLightningAddress = lightningCandidates.length
? lightningCandidates[0]
: existingLightningAddress;
if (resolvedLightningAddress) {
normalized.lightningAddress = resolvedLightningAddress;
}
const entry = {
profile: normalized,
timestamp: Date.now(),
};
// We can update memory directly if it's the active pubkey or we track everyone?
// ProfileCache logic for setProfileData updates memory cache if using setMemoryData,
// but typically getProfileData checks persistence if memory is empty.
// However, for profiles, we probably want persistence.
// The instructions say: "normalizes, stores, sets timestamp and persists".
// If persist is false, we might only update memory.
if (persist) {
this.setProfileData(normalizedPubkey, "profile", entry);
} else {
// Just memory
this.setMemoryDataForPubkey(normalizedPubkey, "profile", entry);
}
return entry;
}
getProfileData(pubkey, section) {
// 1. Check memory cache (runtime data)
const userCache = this.memoryCache.get(pubkey);
if (userCache && userCache.has(section)) {
return userCache.get(section);
}
// 2. Load from persistence
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) {
const parsed = JSON.parse(raw);
// TTL Check
const policy = this.getPolicy(section);
if (policy && Number.isFinite(policy.ttl) && policy.ttl > 0) {
// We check if data has savedAt or similar timestamp.
// If parsed is an object and has savedAt
const savedAt = parsed?.savedAt || parsed?.timestamp;
if (Number.isFinite(savedAt)) {
const now = Date.now();
// Check if savedAt is seconds or ms. Usually ms in JS, but watchHistory might use one or other.
// In watchHistory.js: savedAt = Date.now(). So it's ms.
if (now - savedAt > policy.ttl) {
userLogger.info(`[ProfileCache] Expired ${section} for ${pubkey}`);
return null;
}
}
}
// Populate memory cache to avoid repeat parsing
let userCache = this.memoryCache.get(pubkey);
if (!userCache) {
userCache = new Map();
this.memoryCache.set(pubkey, userCache);
}
userCache.set(section, parsed);
return parsed;
}
} 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 (Optimized: defer blocking I/O)
if (tier === STORAGE_TIERS.LOCAL_STORAGE && typeof localStorage !== "undefined") {
runIdle(() => {
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
let userCache = this.memoryCache.get(pubkey);
if (!userCache) {
userCache = new Map();
this.memoryCache.set(pubkey, userCache);
}
userCache.set(section, data);
this.emit("update", { pubkey, section, data });
this.emit("partition-updated", { pubkey, key: section });
}
setMemoryDataForPubkey(pubkey, section, data) {
let userCache = this.memoryCache.get(pubkey);
if (!userCache) {
userCache = new Map();
this.memoryCache.set(pubkey, userCache);
}
userCache.set(section, data);
this.emit("update", { pubkey, section, data });
}
setMemoryData(section, data) {
if (!this.activePubkey) return;
let userCache = this.memoryCache.get(this.activePubkey);
if (!userCache) {
userCache = new Map();
this.memoryCache.set(this.activePubkey, userCache);
}
userCache.set(section, data);
}
getMemoryData(section) {
if (!this.activePubkey) return null;
const userCache = this.memoryCache.get(this.activePubkey);
return userCache ? userCache.get(section) : undefined;
}
/**
* Clears all cached data for a specific public key from memory.
* This operation is O(1) thanks to the nested map structure.
* @param {string} pubkey - The hex public key to clear.
*/
clearMemoryCache(pubkey) {
this.memoryCache.delete(pubkey);
}
clearSignerRuntime(pubkey) {
const normalized = this.normalizeHexPubkey(pubkey);
if (!normalized) {
return;
}
this.clearMemoryCache(normalized);
this.emit("runtimeCleared", { pubkey: normalized });
}
reset() {
const previousPubkey = this.activePubkey;
this.activePubkey = null;
this.memoryCache.clear();
this.emit("profileChanged", { pubkey: null, previousPubkey: previousPubkey || null });
this.emit("reset", { previousPubkey: previousPubkey || null });
}
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) {
userLogger.error("[ProfileCache] Listener error", error);
}
}
}
}
export const profileCache = new ProfileCache();