mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
channel: per-channel persisted cache for instant cold-load paint
A cold hard-refresh on a profile previously sat on "Loading videos…" for ~5s — the optimistic in-memory cache is empty on cold start (ingest hasn't injected yet) and the grid waited on relay connection. Now channelProfileVideos.js persists the last-rendered wall per author (localStorage bitvid:channel-videos:v1) and loadUserVideos paints it immediately on cold load, before relays connect; the live fetch then replaces it. Bounded (60 videos/channel, 20 channels LRU-evicted) and strips the heavy raw `tags` array. Same proven pattern as the main feed's optimistic cache. Closes TODO 17d per-channel cache. Tests + mutation-verified (round-trip, tag-strip, per-channel + channel-count caps). 1267/1268 unit (known flake). Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
1075d3f822
commit
80f058f67c
4 files changed
+132
-5
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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(""), []);
|
||||
});
|
||||
@@ -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).
|
||||
|
||||
Reference in new issue
Block a user