SQLite-backed caching for bookmarks and articles feed

Bookmarks load instantly from DB cache, then fetch missing notes
from relays in background. Articles feed shows cached kind-30023
events immediately on the latest tab. Both persist to SQLite for
instant load on next visit.
This commit is contained in:
Jure
2026-03-29 20:46:18 +02:00
parent d450f8fdeb
commit 5d94220e5d
4 changed files with 205 additions and 42 deletions

View File

@@ -1,8 +1,9 @@
import { useState, useEffect } from "react";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { fetchArticleFeed } from "../../lib/nostr";
import { fetchArticleFeed, getNDK } from "../../lib/nostr";
import { useUserStore } from "../../stores/user";
import { useUIStore } from "../../stores/ui";
import { dbLoadArticles, dbSaveNotes } from "../../lib/db";
import { ArticleCard } from "./ArticleCard";
type ArticleTab = "latest" | "following";
@@ -21,11 +22,42 @@ export function ArticleFeed() {
if (tab === "following" && follows.length === 0) return;
let cancelled = false;
setLoading(true);
const authors = tab === "following" ? follows : undefined;
fetchArticleFeed(40, authors)
.then((result) => { if (!cancelled) setArticles(result); })
.catch(() => { if (!cancelled) setArticles([]); })
.finally(() => { if (!cancelled) setLoading(false); });
(async () => {
// 1) Instant: load from SQLite cache (latest tab only — following is filtered)
if (tab === "latest") {
const cached = await dbLoadArticles(40);
if (!cancelled && cached.length > 0) {
const ndk = getNDK();
const events = cached
.map((raw) => { try { return new NDKEvent(ndk, JSON.parse(raw)); } catch { return null; } })
.filter((e): e is NDKEvent => e !== null)
.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0));
if (events.length > 0) {
setArticles(events);
setLoading(false);
}
}
}
// 2) Background: fetch from relays and merge
const authors = tab === "following" ? follows : undefined;
try {
const result = await fetchArticleFeed(40, authors);
if (!cancelled) {
setArticles(result);
// Save to notes table for next time
if (result.length > 0) {
dbSaveNotes(result.map((e) => JSON.stringify(e.rawEvent())));
}
}
} catch {
if (!cancelled && articles.length === 0) setArticles([]);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [followsKey]);

View File

@@ -2,7 +2,8 @@ import { useEffect, useState } from "react";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { useBookmarkStore } from "../../stores/bookmark";
import { useUserStore } from "../../stores/user";
import { fetchNoteById, fetchByAddr } from "../../lib/nostr";
import { fetchNoteById, fetchByAddr, getNDK } from "../../lib/nostr";
import { dbLoadBookmarkedNotes, dbSaveBookmarkedNotes } from "../../lib/db";
import { NoteCard } from "../feed/NoteCard";
import { ArticleCard } from "../article/ArticleCard";
import { SkeletonNoteList } from "../shared/Skeleton";
@@ -46,53 +47,84 @@ export function BookmarkView() {
if (pubkey) fetchBookmarks(pubkey);
}, [pubkey]);
// Load bookmarked notes: DB cache first (instant), then relay fetch for missing
useEffect(() => {
if (bookmarkedIds.length === 0) {
setNotes([]);
return;
}
loadNotes();
let cancelled = false;
setLoadingNotes(true);
(async () => {
// 1) Instant: load from SQLite cache
if (pubkey) {
const cached = await dbLoadBookmarkedNotes(pubkey);
if (!cancelled && cached.length > 0) {
const ndk = getNDK();
const events = cached
.map((raw) => { try { return new NDKEvent(ndk, JSON.parse(raw)); } catch { return null; } })
.filter((e): e is NDKEvent => e !== null)
.filter((e) => bookmarkedIds.includes(e.id))
.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0));
if (events.length > 0) {
setNotes(events);
setLoadingNotes(false);
}
}
}
// 2) Background: fetch from relays and merge
try {
const results = await Promise.all(
bookmarkedIds.map((id) => fetchNoteById(id))
);
if (!cancelled) {
const fetched = results
.filter((e): e is NDKEvent => e !== null)
.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0));
setNotes(fetched);
// Save to DB for next time
if (pubkey && fetched.length > 0) {
dbSaveBookmarkedNotes(fetched.map((e) => JSON.stringify(e.rawEvent())), pubkey);
}
}
} finally {
if (!cancelled) setLoadingNotes(false);
}
})();
return () => { cancelled = true; };
}, [bookmarkedIds]);
// Load bookmarked articles (no DB cache yet — articles are fetched by addr)
useEffect(() => {
if (bookmarkedArticleAddrs.length === 0) {
setArticles([]);
return;
}
loadArticles();
}, [bookmarkedArticleAddrs]);
const loadNotes = async () => {
setLoadingNotes(true);
try {
const results = await Promise.all(
bookmarkedIds.map((id) => fetchNoteById(id))
);
setNotes(
results
.filter((e): e is NDKEvent => e !== null)
.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))
);
} finally {
setLoadingNotes(false);
}
};
const loadArticles = async () => {
let cancelled = false;
setLoadingArticles(true);
try {
const results = await Promise.all(
bookmarkedArticleAddrs.map((addr) => fetchByAddr(addr))
);
setArticles(
results
.filter((e): e is NDKEvent => e !== null)
.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))
);
} finally {
setLoadingArticles(false);
}
};
(async () => {
try {
const results = await Promise.all(
bookmarkedArticleAddrs.map((addr) => fetchByAddr(addr))
);
if (!cancelled) {
setArticles(
results
.filter((e): e is NDKEvent => e !== null)
.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))
);
}
} finally {
if (!cancelled) setLoadingArticles(false);
}
})();
return () => { cancelled = true; };
}, [bookmarkedArticleAddrs]);
const totalCount = bookmarkedIds.length + bookmarkedArticleAddrs.length;
const loading = tab === "notes" ? loadingNotes : loadingArticles;