mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-30 17:32:26 +00:00
Merge pull request #89 from PR0M3TH3AN/codex/add-caching-for-nostr-notes-and-profiles
Add local caching for events and profiles
This commit is contained in:
2 files changed
+332
-50
No files matched your search
@@ -29,6 +29,9 @@ const UNSUPPORTED_BTITH_MESSAGE =
|
||||
const FALLBACK_THUMBNAIL_SRC = "assets/jpg/video-thumbnail-fallback.jpg";
|
||||
const TRACKING_SCRIPT_PATTERN = /(?:^|\/)tracking\.js(?:$|\?)/;
|
||||
const EMPTY_VIDEO_LIST_SIGNATURE = "__EMPTY__";
|
||||
const PROFILE_CACHE_STORAGE_KEY = "bitvid:profileCache:v1";
|
||||
const PROFILE_CACHE_VERSION = 1;
|
||||
const PROFILE_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||
// We probe hosted URLs often enough that a naive implementation would spam
|
||||
// remote CDNs. A medium-lived cache (roughly 45 minutes) keeps us from
|
||||
// hammering dead hosts while still giving the UI timely updates when a CDN
|
||||
@@ -528,6 +531,148 @@ class bitvidApp {
|
||||
}
|
||||
}
|
||||
|
||||
loadProfileCacheFromStorage() {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const raw = localStorage.getItem(PROFILE_CACHE_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.version !== PROFILE_CACHE_VERSION) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = parsed.entries;
|
||||
if (!entries || typeof entries !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [pubkey, entry] of Object.entries(entries)) {
|
||||
if (!pubkey || !entry || typeof entry !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const timestamp = typeof entry.timestamp === "number" ? entry.timestamp : 0;
|
||||
if (!timestamp || now - timestamp > PROFILE_CACHE_TTL_MS) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const profile = entry.profile;
|
||||
if (!profile || typeof profile !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalized = {
|
||||
name: profile.name || profile.display_name || "Unknown",
|
||||
picture: profile.picture || "assets/svg/default-profile.svg",
|
||||
};
|
||||
|
||||
this.profileCache.set(pubkey, {
|
||||
profile: normalized,
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Failed to parse stored profile cache:", err);
|
||||
}
|
||||
}
|
||||
|
||||
persistProfileCacheToStorage() {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const entries = {};
|
||||
|
||||
for (const [pubkey, entry] of this.profileCache.entries()) {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
continue;
|
||||
}
|
||||
const timestamp = typeof entry.timestamp === "number" ? entry.timestamp : 0;
|
||||
if (!timestamp || now - timestamp > PROFILE_CACHE_TTL_MS) {
|
||||
this.profileCache.delete(pubkey);
|
||||
continue;
|
||||
}
|
||||
|
||||
entries[pubkey] = {
|
||||
profile: entry.profile,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
const payload = {
|
||||
version: PROFILE_CACHE_VERSION,
|
||||
savedAt: now,
|
||||
entries,
|
||||
};
|
||||
|
||||
if (Object.keys(entries).length === 0) {
|
||||
try {
|
||||
localStorage.removeItem(PROFILE_CACHE_STORAGE_KEY);
|
||||
} catch (err) {
|
||||
console.warn("Failed to clear profile cache storage:", err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem(PROFILE_CACHE_STORAGE_KEY, JSON.stringify(payload));
|
||||
} catch (err) {
|
||||
console.warn("Failed to persist profile cache:", err);
|
||||
}
|
||||
}
|
||||
|
||||
getProfileCacheEntry(pubkey) {
|
||||
if (!pubkey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entry = this.profileCache.get(pubkey);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timestamp = typeof entry.timestamp === "number" ? entry.timestamp : 0;
|
||||
if (!timestamp || Date.now() - timestamp > PROFILE_CACHE_TTL_MS) {
|
||||
this.profileCache.delete(pubkey);
|
||||
this.persistProfileCacheToStorage();
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
setProfileCacheEntry(pubkey, profile) {
|
||||
if (!pubkey || !profile) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = {
|
||||
name: profile.name || profile.display_name || "Unknown",
|
||||
picture: profile.picture || "assets/svg/default-profile.svg",
|
||||
};
|
||||
|
||||
const entry = {
|
||||
profile: normalized,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
this.profileCache.set(pubkey, entry);
|
||||
this.persistProfileCacheToStorage();
|
||||
return entry;
|
||||
}
|
||||
|
||||
forceRefreshAllProfiles() {
|
||||
// 1) Grab the newest set of videos from nostrClient
|
||||
const activeVideos = nostrClient.getActiveVideos();
|
||||
@@ -550,6 +695,8 @@ class bitvidApp {
|
||||
});
|
||||
}
|
||||
|
||||
this.loadProfileCacheFromStorage();
|
||||
|
||||
// 1. Initialize the video modal (components/video-modal.html)
|
||||
await this.initModal();
|
||||
this.updateModalElements();
|
||||
@@ -1149,6 +1296,11 @@ class bitvidApp {
|
||||
picture = data.picture || "assets/svg/default-profile.svg";
|
||||
}
|
||||
|
||||
this.setProfileCacheEntry(pubkey, {
|
||||
name: displayName,
|
||||
picture,
|
||||
});
|
||||
|
||||
// If you have a top-bar avatar (profileAvatar)
|
||||
if (this.profileAvatar) {
|
||||
this.profileAvatar.src = picture;
|
||||
@@ -1166,14 +1318,12 @@ class bitvidApp {
|
||||
}
|
||||
|
||||
async fetchAndRenderProfile(pubkey, forceRefresh = false) {
|
||||
const now = Date.now();
|
||||
|
||||
// 1) Check if we have a cached entry
|
||||
const cacheEntry = this.profileCache.get(pubkey);
|
||||
if (!forceRefresh && cacheEntry && now - cacheEntry.timestamp < 60000) {
|
||||
// If it's less than 60 seconds old, just update DOM with it
|
||||
const cacheEntry = this.getProfileCacheEntry(pubkey);
|
||||
if (cacheEntry) {
|
||||
this.updateProfileInDOM(pubkey, cacheEntry.profile);
|
||||
return;
|
||||
if (!forceRefresh) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Otherwise, fetch from Nostr
|
||||
@@ -1189,7 +1339,7 @@ class bitvidApp {
|
||||
};
|
||||
|
||||
// Cache it
|
||||
this.profileCache.set(pubkey, { profile, timestamp: now });
|
||||
this.setProfileCacheEntry(pubkey, profile);
|
||||
// Update DOM
|
||||
this.updateProfileInDOM(pubkey, profile);
|
||||
}
|
||||
@@ -1202,10 +1352,25 @@ class bitvidApp {
|
||||
const pubkeys = Array.from(authorSet);
|
||||
if (!pubkeys.length) return;
|
||||
|
||||
const toFetch = [];
|
||||
|
||||
pubkeys.forEach((pubkey) => {
|
||||
const cacheEntry = this.getProfileCacheEntry(pubkey);
|
||||
if (cacheEntry) {
|
||||
this.updateProfileInDOM(pubkey, cacheEntry.profile);
|
||||
} else {
|
||||
toFetch.push(pubkey);
|
||||
}
|
||||
});
|
||||
|
||||
if (!toFetch.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filter = {
|
||||
kinds: [0],
|
||||
authors: pubkeys,
|
||||
limit: pubkeys.length,
|
||||
authors: toFetch,
|
||||
limit: toFetch.length,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -1236,7 +1401,7 @@ class bitvidApp {
|
||||
name: data.name || data.display_name || "Unknown",
|
||||
picture: data.picture || "assets/svg/default-profile.svg",
|
||||
};
|
||||
this.profileCache.set(pubkey, { profile, timestamp: Date.now() });
|
||||
this.setProfileCacheEntry(pubkey, profile);
|
||||
this.updateProfileInDOM(pubkey, profile);
|
||||
} catch (err) {
|
||||
console.error("Profile parse error:", err);
|
||||
@@ -1851,6 +2016,27 @@ class bitvidApp {
|
||||
async loadVideos(forceFetch = false) {
|
||||
console.log("Starting loadVideos... (forceFetch =", forceFetch, ")");
|
||||
|
||||
const shouldIncludeVideo = (video) => {
|
||||
if (!video || typeof video !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.blacklistedEventIds.has(video.id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const authorNpub = this.safeEncodeNpub(video.pubkey) || video.pubkey;
|
||||
if (initialBlacklist.includes(authorNpub)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isWhitelistEnabled && !initialWhitelist.includes(authorNpub)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// If forceFetch is true, unsubscribe from the old subscription to start fresh
|
||||
if (forceFetch && this.videoSubscription) {
|
||||
// Call unsubscribe on the subscription object directly.
|
||||
@@ -1873,29 +2059,17 @@ class bitvidApp {
|
||||
const updatedAll = nostrClient.getActiveVideos();
|
||||
|
||||
// Filter out blacklisted authors & blacklisted event IDs
|
||||
const filteredVideos = updatedAll.filter((video) => {
|
||||
// 1) If the event ID is in our blacklistedEventIds set, skip
|
||||
if (this.blacklistedEventIds.has(video.id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2) Check if the author's npub is in initialBlacklist
|
||||
const authorNpub = this.safeEncodeNpub(video.pubkey) || video.pubkey;
|
||||
if (initialBlacklist.includes(authorNpub)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3) If whitelist mode is enabled, only keep authors in initialWhitelist
|
||||
if (isWhitelistEnabled && !initialWhitelist.includes(authorNpub)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
const filteredVideos = updatedAll.filter(shouldIncludeVideo);
|
||||
|
||||
this.renderVideoList(filteredVideos);
|
||||
});
|
||||
|
||||
const cachedActiveVideos = nostrClient.getActiveVideos();
|
||||
const cachedFiltered = cachedActiveVideos.filter(shouldIncludeVideo);
|
||||
if (cachedFiltered.length) {
|
||||
this.renderVideoList(cachedFiltered);
|
||||
}
|
||||
|
||||
if (this.videoSubscription) {
|
||||
console.log(
|
||||
"[loadVideos] subscription remains open to get live updates."
|
||||
@@ -1905,22 +2079,7 @@ class bitvidApp {
|
||||
// Already subscribed: just show what's cached
|
||||
const allCached = nostrClient.getActiveVideos();
|
||||
|
||||
const filteredCached = allCached.filter((video) => {
|
||||
if (this.blacklistedEventIds.has(video.id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const authorNpub = this.safeEncodeNpub(video.pubkey) || video.pubkey;
|
||||
if (initialBlacklist.includes(authorNpub)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isWhitelistEnabled && !initialWhitelist.includes(authorNpub)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
const filteredCached = allCached.filter(shouldIncludeVideo);
|
||||
|
||||
this.renderVideoList(filteredCached);
|
||||
}
|
||||
|
||||
+127
-4
@@ -17,6 +17,10 @@ const RELAY_URLS = [
|
||||
"wss://relay.nostr.band",
|
||||
];
|
||||
|
||||
const EVENTS_CACHE_STORAGE_KEY = "bitvid:eventsCache:v1";
|
||||
const LEGACY_EVENTS_STORAGE_KEY = "bitvidEvents";
|
||||
const EVENTS_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
// To limit error spam
|
||||
let errorLogCount = 0;
|
||||
const MAX_ERROR_LOGS = 100;
|
||||
@@ -326,6 +330,107 @@ class NostrClient {
|
||||
|
||||
// “activeMap” holds only the newest version for each root
|
||||
this.activeMap = new Map();
|
||||
|
||||
this.hasRestoredLocalData = false;
|
||||
}
|
||||
|
||||
restoreLocalData() {
|
||||
if (this.hasRestoredLocalData) {
|
||||
return this.allEvents.size > 0;
|
||||
}
|
||||
|
||||
this.hasRestoredLocalData = true;
|
||||
|
||||
if (typeof localStorage === "undefined") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const parsePayload = (raw) => {
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === "object") {
|
||||
return parsed;
|
||||
}
|
||||
} catch (err) {
|
||||
if (isDevMode) {
|
||||
console.warn("[nostr] Failed to parse cached events:", err);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
let payload = parsePayload(localStorage.getItem(EVENTS_CACHE_STORAGE_KEY));
|
||||
|
||||
if (!payload) {
|
||||
const legacyRaw = localStorage.getItem(LEGACY_EVENTS_STORAGE_KEY);
|
||||
const legacyParsed = parsePayload(legacyRaw);
|
||||
if (legacyParsed) {
|
||||
payload = {
|
||||
version: 1,
|
||||
savedAt: now,
|
||||
events: legacyParsed,
|
||||
};
|
||||
}
|
||||
if (legacyRaw) {
|
||||
try {
|
||||
localStorage.removeItem(LEGACY_EVENTS_STORAGE_KEY);
|
||||
} catch (err) {
|
||||
if (isDevMode) {
|
||||
console.warn("[nostr] Failed to remove legacy cache:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!payload || payload.version !== 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof payload.savedAt !== "number" ||
|
||||
payload.savedAt <= 0 ||
|
||||
now - payload.savedAt > EVENTS_CACHE_TTL_MS
|
||||
) {
|
||||
try {
|
||||
localStorage.removeItem(EVENTS_CACHE_STORAGE_KEY);
|
||||
} catch (err) {
|
||||
if (isDevMode) {
|
||||
console.warn("[nostr] Failed to clear expired cache:", err);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const events = payload.events;
|
||||
if (!events || typeof events !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.allEvents.clear();
|
||||
this.activeMap.clear();
|
||||
|
||||
for (const [id, video] of Object.entries(events)) {
|
||||
if (!id || !video || typeof video !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.allEvents.set(id, video);
|
||||
if (video.deleted) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const activeKey = getActiveKey(video);
|
||||
const existing = this.activeMap.get(activeKey);
|
||||
if (!existing || video.created_at > existing.created_at) {
|
||||
this.activeMap.set(activeKey, video);
|
||||
}
|
||||
}
|
||||
|
||||
return this.allEvents.size > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -334,6 +439,8 @@ class NostrClient {
|
||||
async init() {
|
||||
if (isDevMode) console.log("Connecting to relays...");
|
||||
|
||||
this.restoreLocalData();
|
||||
|
||||
try {
|
||||
this.pool = new window.NostrTools.SimplePool();
|
||||
const results = await this.connectToRelays();
|
||||
@@ -868,12 +975,28 @@ class NostrClient {
|
||||
* Saves all known events to localStorage (or a different storage if you prefer).
|
||||
*/
|
||||
saveLocalData() {
|
||||
// Convert our allEvents map into a plain object for JSON storage
|
||||
const allEventsObject = {};
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
version: 1,
|
||||
savedAt: Date.now(),
|
||||
events: {},
|
||||
};
|
||||
|
||||
for (const [id, vid] of this.allEvents.entries()) {
|
||||
allEventsObject[id] = vid;
|
||||
payload.events[id] = vid;
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem(EVENTS_CACHE_STORAGE_KEY, JSON.stringify(payload));
|
||||
localStorage.removeItem(LEGACY_EVENTS_STORAGE_KEY);
|
||||
} catch (err) {
|
||||
if (isDevMode) {
|
||||
console.warn("[nostr] Failed to persist events cache:", err);
|
||||
}
|
||||
}
|
||||
localStorage.setItem("bitvidEvents", JSON.stringify(allEventsObject));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user