Fix hashtag preferences loading and profile persistence issues

- Migrated Kind 0 profile metadata storage to unified `profileCache.js` with partitioning and persistence.
- Added `defaultDTag` for hashtag preferences in `cachePolicies.js` to ensure consistent key resolution.
- Updated `hashtagPreferencesService.js` to use `profileCache` and implemented legacy data migration.
- Added `handleEvent` to `NostrClient` to centrally ingest Kind 0 events into the cache.
- Updated `authService.js` and `cache.js` to delegate profile operations to the unified cache system.
- Added `PROFILE_METADATA` schema to `nostrEventSchemas.js`.
This commit is contained in:
google-labs-jules[bot] committed 2026-01-18 02:09:04 +00:00
1 parent 983f67fa1b
commit a2de2b550d
7 files changed
+220 -124

No files matched your search

+7
View File
@@ -84,6 +84,13 @@ export const CACHE_POLICIES = Object.freeze({
storage: STORAGE_TIERS.LOCAL_STORAGE,
ttl: Infinity,
addressing: "kind:pubkey:d",
defaultDTag: "bitvid:tag-preferences",
merge: MERGE_STRATEGIES.REPLACEABLE,
},
[NOTE_TYPES.PROFILE_METADATA]: {
storage: STORAGE_TIERS.LOCAL_STORAGE,
ttl: Infinity,
addressing: "kind:pubkey",
merge: MERGE_STRATEGIES.REPLACEABLE,
},
});
+21
View File
@@ -164,6 +164,15 @@ import {
} from "./nip46Client.js";
import { profileCache } from "../state/profileCache.js";
function normalizeProfileFromEvent(event) {
if (!event || !event.content) return null;
try {
return JSON.parse(event.content);
} catch (err) {
return null;
}
}
let activeSigner = null;
const activeSignerRegistry = new Map();
@@ -6989,6 +6998,18 @@ export class NostrClient {
(a, b) => b.created_at - a.created_at
);
}
handleEvent(event) {
if (!event || typeof event !== "object") return;
// Kind 0: Profile Metadata
if (event.kind === 0 && typeof event.pubkey === "string") {
const normalized = normalizeProfileFromEvent(event);
if (normalized) {
profileCache.setProfile(event.pubkey, normalized, { persist: true });
}
}
}
}
export {
+11
View File
@@ -28,6 +28,7 @@ export const NOTE_TYPES = Object.freeze({
ADMIN_BLACKLIST: "adminBlacklist",
ADMIN_WHITELIST: "adminWhitelist",
STORAGE_CHALLENGE: "storageChallenge",
PROFILE_METADATA: "profileMetadata",
});
export const SUBSCRIPTION_LIST_IDENTIFIER = "subscriptions";
@@ -459,6 +460,16 @@ const BASE_SCHEMAS = {
"Ephemeral challenge event used for key derivation; never published.",
},
},
[NOTE_TYPES.PROFILE_METADATA]: {
type: NOTE_TYPES.PROFILE_METADATA,
label: "Profile metadata",
kind: 0,
appendTags: DEFAULT_APPEND_TAGS,
content: {
format: "json",
description: "Standard NIP-01 profile metadata (name, about, picture, etc.).",
},
},
};
let schemaOverrides = {};
+26 -30
View File
@@ -1060,15 +1060,9 @@ export default class AuthService {
});
const newest = selectNewestEvent(aggregated);
if (!newest) {
return;
if (newest && this.nostrClient && typeof this.nostrClient.handleEvent === "function") {
this.nostrClient.handleEvent(newest);
}
const profile = buildProfileFromEvent(newest);
this.setProfileCacheEntry(normalized, profile, {
persist: true,
reason: "load-own-profile:background",
});
})
.catch((error) => {
this.log("[AuthService] Background profile refresh failed", error);
@@ -1096,11 +1090,12 @@ export default class AuthService {
if (fastResult?.events?.length) {
const newest = selectNewestEvent(fastResult.events);
if (newest) {
if (this.nostrClient && typeof this.nostrClient.handleEvent === "function") {
this.nostrClient.handleEvent(newest);
}
const profile = buildProfileFromEvent(newest);
this.setProfileCacheEntry(normalized, profile, {
persist: true,
reason: "load-own-profile:fast",
});
// setProfileCacheEntry now delegates to profileCache.setProfile, but handleEvent also does it.
// Doing it here returns the profile object which might be needed immediately.
background.catch(() => {});
return profile;
}
@@ -1159,24 +1154,25 @@ export default class AuthService {
}
}
if (newest?.content) {
const data = JSON.parse(newest.content);
const profile = {
name: data.display_name || data.name || FALLBACK_PROFILE.name,
picture: data.picture || FALLBACK_PROFILE.picture,
about: typeof data.about === "string" ? data.about : FALLBACK_PROFILE.about,
website:
typeof data.website === "string" ? data.website : FALLBACK_PROFILE.website,
banner:
typeof data.banner === "string" ? data.banner : FALLBACK_PROFILE.banner,
lud16: typeof data.lud16 === "string" ? data.lud16 : FALLBACK_PROFILE.lud16,
lud06: typeof data.lud06 === "string" ? data.lud06 : FALLBACK_PROFILE.lud06,
};
this.setProfileCacheEntry(normalized, profile, {
persist: true,
reason: forceRefresh ? "force-refresh" : "fetch-profile",
});
return profile;
if (newest) {
if (this.nostrClient && typeof this.nostrClient.handleEvent === "function") {
this.nostrClient.handleEvent(newest);
}
if (newest.content) {
const data = JSON.parse(newest.content);
const profile = {
name: data.display_name || data.name || FALLBACK_PROFILE.name,
picture: data.picture || FALLBACK_PROFILE.picture,
about: typeof data.about === "string" ? data.about : FALLBACK_PROFILE.about,
website:
typeof data.website === "string" ? data.website : FALLBACK_PROFILE.website,
banner:
typeof data.banner === "string" ? data.banner : FALLBACK_PROFILE.banner,
lud16: typeof data.lud16 === "string" ? data.lud16 : FALLBACK_PROFILE.lud16,
lud06: typeof data.lud06 === "string" ? data.lud06 : FALLBACK_PROFILE.lud06,
};
return profile;
}
}
} catch (error) {
this.log("[AuthService] Failed to fetch profile", error);
+42 -14
View File
@@ -14,7 +14,7 @@ import {
publishEventToRelays,
assertAnyRelayAccepted,
} from "../nostrPublish.js";
import { userLogger } from "../utils/logger.js";
import { userLogger, devLogger } from "../utils/logger.js";
import { profileCache } from "../state/profileCache.js";
const LOG_PREFIX = "[HashtagPreferences]";
@@ -276,28 +276,56 @@ class HashtagPreferencesService {
saveToCache() {
const interests = this.getInterests();
const disinterests = this.getDisinterests();
profileCache.set("interests", {
const payload = {
interests,
disinterests,
version: this.preferencesVersion,
eventId: this.eventId,
createdAt: this.eventCreatedAt,
});
};
// Use the note type key directly, though "interests" maps to HASHTAG_PREFERENCES
// in profileCache. Using NOTE_TYPES.HASHTAG_PREFERENCES explicitly is safer if we import it,
// but relying on the mapped key "interests" is also consistent with current usage.
// However, the fix requires us to ensure we use the unified path.
// We'll stick to "interests" which maps to NOTE_TYPES.HASHTAG_PREFERENCES in profileCache.js
profileCache.set("interests", payload);
}
loadFromCache(pubkey) {
// profileCache internally uses activePubkey, but we should verify if it matches
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;
try {
let cached = profileCache.get("interests");
// Legacy fallback migration
if (!cached && typeof localStorage !== "undefined") {
try {
const legacy = localStorage.getItem(HASHTAG_IDENTIFIER);
if (legacy) {
const parsed = JSON.parse(legacy);
// Migrate to unified profile cache
profileCache.set("interests", parsed);
localStorage.removeItem(HASHTAG_IDENTIFIER);
cached = parsed;
devLogger.log(`${LOG_PREFIX} Migrated legacy preferences to profileCache.`);
}
} catch (err) {
devLogger.warn(`${LOG_PREFIX} Legacy migration failed:`, err);
}
}
if (cached && typeof cached === "object") {
this.activePubkey = normalizeHexPubkey(pubkey);
this.interests = new Set(Array.isArray(cached.interests) ? cached.interests : []);
this.disinterests = new Set(Array.isArray(cached.disinterests) ? cached.disinterests : []);
this.eventId = cached.eventId || null;
this.eventCreatedAt = cached.createdAt || null;
this.preferencesVersion = cached.version || DEFAULT_VERSION;
this.loaded = true;
this.emitChange("cache-load");
return true;
}
} catch (err) {
devLogger.warn(`${LOG_PREFIX} loadFromCache failed:`, err);
}
return false;
}
+11 -80
View File
@@ -820,91 +820,22 @@ export function persistProfileCacheToStorage() {
}
}
import { profileCache as unifiedProfileCache } from "./profileCache.js";
export function getProfileCacheEntry(pubkey) {
const normalized = normalizeHexPubkey(pubkey);
if (!normalized) {
return null;
const profile = unifiedProfileCache.getProfile(pubkey);
// Maintain backward compatibility with { profile, timestamp } return shape
// although consumers mostly check .profile
if (profile) {
// We can fetch the raw data to get the timestamp if needed, but for now
// returning a synthetic entry matches most usage patterns.
return { profile, timestamp: Date.now() };
}
const entry = profileCache.get(normalized);
if (!entry) {
return null;
}
const timestamp = typeof entry.timestamp === "number" ? entry.timestamp : 0;
if (!timestamp || Date.now() - timestamp > PROFILE_CACHE_TTL_MS) {
profileCache.delete(normalized);
persistProfileCacheToStorage();
return null;
}
return entry;
return null;
}
export function setProfileCacheEntry(pubkey, profile, { persist = true } = {}) {
const normalizedPubkey = normalizeHexPubkey(pubkey);
if (!normalizedPubkey || !profile) {
return null;
}
const normalized = {
name: profile.name || profile.display_name || "Unknown",
picture:
sanitizeProfileMediaUrl(profile.picture || profile.image) ||
"assets/svg/default-profile.svg",
};
const about = sanitizeProfileString(profile.about || profile.aboutMe);
if (about) {
normalized.about = about;
}
const website = sanitizeProfileString(profile.website || profile.url);
if (website) {
normalized.website = website;
}
const banner = sanitizeProfileMediaUrl(
profile.banner ||
profile.header ||
profile.background ||
profile.cover ||
profile.cover_image ||
profile.coverImage
);
if (banner) {
normalized.banner = banner;
}
const lud16 = sanitizeProfileString(profile.lud16);
if (lud16) {
normalized.lud16 = lud16;
}
const lud06 = sanitizeProfileString(profile.lud06);
if (lud06) {
normalized.lud06 = lud06;
}
const lightningCandidates = [
sanitizeProfileString(profile.lightningAddress),
lud16,
lud06,
].filter(Boolean);
if (lightningCandidates.length) {
normalized.lightningAddress = lightningCandidates[0];
}
const entry = {
profile: normalized,
timestamp: Date.now(),
};
profileCache.set(normalizedPubkey, entry);
if (persist) {
persistProfileCacheToStorage();
}
const entry = unifiedProfileCache.setProfile(pubkey, profile, { persist });
return entry;
}
+102
View File
@@ -1,6 +1,7 @@
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:";
@@ -12,8 +13,16 @@ const SECTION_TO_NOTE_TYPE = {
"blocks": NOTE_TYPES.USER_BLOCK_LIST,
"interests": NOTE_TYPES.HASHTAG_PREFERENCES,
"relays": NOTE_TYPES.RELAY_LIST,
"profile": NOTE_TYPES.PROFILE_METADATA,
};
function sanitizeProfileString(value) {
if (typeof value !== "string") {
return "";
}
return value.trim();
}
class ProfileCache {
constructor() {
this.activePubkey = null;
@@ -146,6 +155,87 @@ class ProfileCache {
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 normalized = {
name: profile.name || profile.display_name || "Unknown",
picture:
sanitizeProfileMediaUrl(profile.picture || profile.image) ||
"assets/svg/default-profile.svg",
};
const about = sanitizeProfileString(profile.about || profile.aboutMe);
if (about) {
normalized.about = about;
}
const website = sanitizeProfileString(profile.website || profile.url);
if (website) {
normalized.website = website;
}
const banner = sanitizeProfileMediaUrl(
profile.banner ||
profile.header ||
profile.background ||
profile.cover ||
profile.cover_image ||
profile.coverImage
);
if (banner) {
normalized.banner = banner;
}
const lud16 = sanitizeProfileString(profile.lud16);
if (lud16) {
normalized.lud16 = lud16;
}
const lud06 = sanitizeProfileString(profile.lud06);
if (lud06) {
normalized.lud06 = lud06;
}
const lightningCandidates = [
sanitizeProfileString(profile.lightningAddress),
lud16,
lud06,
].filter(Boolean);
if (lightningCandidates.length) {
normalized.lightningAddress = lightningCandidates[0];
}
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 memKey = `${pubkey}:${section}`;
@@ -180,6 +270,8 @@ class ProfileCache {
}
}
// Populate memory cache to avoid repeat parsing
this.memoryCache.set(memKey, parsed);
return parsed;
}
} catch (error) {
@@ -207,10 +299,20 @@ class ProfileCache {
}
}
// Update memory cache
const memKey = `${pubkey}:${section}`;
this.memoryCache.set(memKey, data);
this.emit("update", { pubkey, section, data });
this.emit("partition-updated", { pubkey, key: section });
}
setMemoryDataForPubkey(pubkey, section, data) {
const memKey = `${pubkey}:${section}`;
this.memoryCache.set(memKey, data);
this.emit("update", { pubkey, section, data });
}
setMemoryData(section, data) {
if (!this.activePubkey) return;
const key = `${this.activePubkey}:${section}`;