mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
Merge pull request #1296 from PR0M3TH3AN/fix-hashtag-prefs-profile-cache-16997779974733027289
Fix hashtag preferences loading and profile persistence issues
This commit is contained in:
7 files changed
+220
-124
No files matched your search
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
Reference in new issue
Block a user