diff --git a/js/app.js b/js/app.js index 82290ff5..064b8406 100644 --- a/js/app.js +++ b/js/app.js @@ -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); } diff --git a/js/nostr.js b/js/nostr.js index f690723a..6df1dffc 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -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)); } /**