diff --git a/js/channelProfile.js b/js/channelProfile.js index b1ec7893..e8e4935d 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -1,7 +1,7 @@ // js/channelProfile.js import { nostrClient } from "./nostrClientFacade.js"; -import { convertChannelEvent, buildChannelVideoFilters } from "./channelProfileVideos.js"; +import { convertChannelEvent, buildChannelVideoFilters, loadCachedChannelVideos, saveCachedChannelVideos } from "./channelProfileVideos.js"; import { dedupeVideos } from "./utils/videoDeduper.js"; import { DEFAULT_RELAY_URLS } from "./nostr/toolkit.js"; import { subscriptions } from "./subscriptions.js"; @@ -5353,10 +5353,13 @@ async function loadUserVideos(pubkey) { let renderedFromCache = false; if (container) { + // In-memory cache (warm) first; else the persisted per-channel cache so a + // COLD hard-refresh paints last-seen videos before relays connect. const cachedEvents = getCachedChannelVideoEvents(pubkey); - if (cachedEvents.length) { + const cacheSource = cachedEvents.length ? cachedEvents : loadCachedChannelVideos(pubkey); + if (cacheSource.length) { const cachedVideos = buildRenderableChannelVideos({ - events: cachedEvents, + events: cacheSource, app }); if (cachedVideos.length) { @@ -5438,6 +5441,7 @@ async function loadUserVideos(pubkey) { } const videos = buildRenderableChannelVideos({ events, app }); + saveCachedChannelVideos(pubkey, videos); const rendered = await renderChannelVideosFromList({ videos, container, diff --git a/js/channelProfileVideos.js b/js/channelProfileVideos.js index 474d3de7..766d8f3b 100644 --- a/js/channelProfileVideos.js +++ b/js/channelProfileVideos.js @@ -27,3 +27,59 @@ export function convertChannelEvent(entry) { } return sharedConvertEventToVideo(entry); } + +// ---- Per-channel persisted cache (instant cold-load paint) ------------------- +// Mirrors the main feed's optimistic cache: on a cold hard-refresh of a profile, +// render the last-seen videos from localStorage BEFORE relays connect, then the +// live fetch replaces them. Keyed per author; bounded so storage can't grow +// without limit. + +const CHANNEL_CACHE_KEY = "bitvid:channel-videos:v1"; +const MAX_CACHED_CHANNELS = 20; +const MAX_VIDEOS_PER_CHANNEL = 60; + +function normPubkey(pubkey) { + return typeof pubkey === "string" ? pubkey.trim().toLowerCase() : ""; +} + +function readChannelCacheMap() { + try { + if (typeof localStorage === "undefined") return {}; + const raw = localStorage.getItem(CHANNEL_CACHE_KEY); + const parsed = raw ? JSON.parse(raw) : {}; + return parsed && typeof parsed === "object" ? parsed : {}; + } catch (error) { + return {}; + } +} + +export function loadCachedChannelVideos(pubkey) { + const pk = normPubkey(pubkey); + if (!pk) return []; + const entry = readChannelCacheMap()[pk]; + return entry && Array.isArray(entry.videos) ? entry.videos : []; +} + +export function saveCachedChannelVideos(pubkey, videos) { + const pk = normPubkey(pubkey); + if (!pk || !Array.isArray(videos) || typeof localStorage === "undefined") { + return; + } + try { + const map = readChannelCacheMap(); + // Drop the heavy raw `tags` array — the render + dedupe paths use parsed + // top-level fields; the live fetch restores full objects moments later. + const slim = videos.slice(0, MAX_VIDEOS_PER_CHANNEL).map(({ tags, ...rest }) => rest); + map[pk] = { videos: slim, ts: Date.now() }; + const keys = Object.keys(map); + if (keys.length > MAX_CACHED_CHANNELS) { + keys + .sort((a, b) => (map[a]?.ts || 0) - (map[b]?.ts || 0)) + .slice(0, keys.length - MAX_CACHED_CHANNELS) + .forEach((k) => delete map[k]); + } + localStorage.setItem(CHANNEL_CACHE_KEY, JSON.stringify(map)); + } catch (error) { + // best-effort; cache is non-critical + } +} diff --git a/tests/channel-video-cache.test.mjs b/tests/channel-video-cache.test.mjs new file mode 100644 index 00000000..b270cece --- /dev/null +++ b/tests/channel-video-cache.test.mjs @@ -0,0 +1,64 @@ +// Per-channel persisted cache: instant cold-load paint of a profile wall. +// Round-trips last-seen videos via localStorage, strips heavy tags, and bounds +// both per-channel count and number of cached channels. + +import "./test-helpers/setup-localstorage.mjs"; +import assert from "node:assert/strict"; +import test from "node:test"; +import { + loadCachedChannelVideos, + saveCachedChannelVideos, +} from "../js/channelProfileVideos.js"; + +const PK = "a".repeat(64); + +test("round-trips videos for a channel, stripping raw tags", () => { + localStorage.clear(); + saveCachedChannelVideos(PK, [ + { id: "v1", title: "One", pubkey: PK, created_at: 1, url: "https://e/1.mp4", tags: [["e", "x"]] }, + ]); + const out = loadCachedChannelVideos(PK); + assert.equal(out.length, 1); + assert.equal(out[0].id, "v1"); + assert.equal(out[0].url, "https://e/1.mp4"); + assert.equal(out[0].tags, undefined, "heavy tags array not persisted"); +}); + +test("scoped per pubkey", () => { + localStorage.clear(); + saveCachedChannelVideos(PK, [{ id: "v1", title: "x", pubkey: PK, created_at: 1 }]); + assert.equal(loadCachedChannelVideos("b".repeat(64)).length, 0); +}); + +test("caps videos per channel (60)", () => { + localStorage.clear(); + const many = Array.from({ length: 100 }, (_, i) => ({ + id: `v${i}`, + title: `t${i}`, + pubkey: PK, + created_at: i, + })); + saveCachedChannelVideos(PK, many); + assert.equal(loadCachedChannelVideos(PK).length, 60); +}); + +test("evicts the least-recently-saved channel past the cap (20)", () => { + localStorage.clear(); + for (let i = 0; i < 22; i += 1) { + const pk = i.toString(16).padStart(64, "0"); + saveCachedChannelVideos(pk, [{ id: `v${i}`, title: "t", pubkey: pk, created_at: i }]); + } + // The two oldest channels (0,1) should have been evicted; the newest remains. + const oldest = "0".padStart(64, "0"); + const newest = (21).toString(16).padStart(64, "0"); + assert.equal(loadCachedChannelVideos(oldest).length, 0, "oldest evicted"); + assert.equal(loadCachedChannelVideos(newest).length, 1, "newest kept"); +}); + +test("missing / empty inputs are safe no-ops", () => { + localStorage.clear(); + saveCachedChannelVideos("", [{ id: "v" }]); // no pubkey + saveCachedChannelVideos(PK, null); // bad videos + assert.deepEqual(loadCachedChannelVideos(PK), []); + assert.deepEqual(loadCachedChannelVideos(""), []); +}); diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index 8279ef57..747031bc 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -355,8 +355,11 @@ has a persisted cache; channel/search now render optimistically from manager's streaming + render incrementally (first-relay-wins), like the main feed's buffered subscription. Extract the fetch into `channelProfileVideos.js` (channelProfile.js is at its size cap). -- [ ] **Per-channel persisted cache** for instant revisits (mirror the main feed's - `bitvid:filtered-videos` optimistic cache, keyed per author). +- [x] **Per-channel persisted cache** (`channelProfileVideos.js`, + `bitvid:channel-videos:v1`): cold hard-refresh of a profile now paints the + last-seen videos from localStorage before relays connect, then the live fetch + replaces them. Bounded (60 videos/channel, 20 channels LRU), strips raw tags. + Tests + mutation-verified. - [ ] **My Videos: include NIP-71** in its `allEvents` read so a creator's own cross-posted videos appear in management too (reuse the cross-ecosystem dedupe so the bitvid version wins).