From 5d94220e5d57bd19b66fe3b27f4c78e8897097cc Mon Sep 17 00:00:00 2001 From: Jure <44338+hoornet@users.noreply.github.com> Date: Sun, 29 Mar 2026 20:46:18 +0200 Subject: [PATCH] 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. --- src-tauri/src/lib.rs | 81 +++++++++++++++++- src/components/article/ArticleFeed.tsx | 44 ++++++++-- src/components/bookmark/BookmarkView.tsx | 102 +++++++++++++++-------- src/lib/db.ts | 20 +++++ 4 files changed, 205 insertions(+), 42 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b80eedf..0946c34 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -77,7 +77,14 @@ fn open_db(data_dir: std::path::PathBuf) -> rusqlite::Result { cached_at INTEGER NOT NULL, PRIMARY KEY (pubkey, owner_pubkey) ); - CREATE INDEX IF NOT EXISTS idx_followers_owner ON followers(owner_pubkey);", + CREATE INDEX IF NOT EXISTS idx_followers_owner ON followers(owner_pubkey); + CREATE TABLE IF NOT EXISTS bookmarked_notes ( + id TEXT PRIMARY KEY, + owner_pubkey TEXT NOT NULL, + raw TEXT NOT NULL, + cached_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_bookmarks_owner ON bookmarked_notes(owner_pubkey);", )?; Ok(conn) } @@ -289,6 +296,75 @@ fn db_load_followers( Ok(result) } +// ── Bookmarks cache ───────────────────────────────────────────────────────── + +#[tauri::command] +fn db_save_bookmarked_notes( + state: tauri::State, + notes: Vec, + owner_pubkey: String, +) -> Result<(), String> { + let conn = state.0.lock().map_err(|e| e.to_string())?; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + for raw in ¬es { + let v: serde_json::Value = serde_json::from_str(raw).map_err(|e| e.to_string())?; + let id = v["id"].as_str().unwrap_or_default(); + conn.execute( + "INSERT OR REPLACE INTO bookmarked_notes (id, owner_pubkey, raw, cached_at) VALUES (?1,?2,?3,?4)", + params![id, owner_pubkey, raw, now], + ) + .map_err(|e| e.to_string())?; + } + // Prune to 500 per owner + conn.execute( + "DELETE FROM bookmarked_notes WHERE owner_pubkey=?1 AND id NOT IN \ + (SELECT id FROM bookmarked_notes WHERE owner_pubkey=?1 ORDER BY cached_at DESC LIMIT 500)", + params![owner_pubkey], + ) + .map_err(|e| e.to_string())?; + Ok(()) +} + +#[tauri::command] +fn db_load_bookmarked_notes( + state: tauri::State, + owner_pubkey: String, +) -> Result, String> { + let conn = state.0.lock().map_err(|e| e.to_string())?; + let mut stmt = conn + .prepare("SELECT raw FROM bookmarked_notes WHERE owner_pubkey=?1 ORDER BY cached_at DESC") + .map_err(|e| e.to_string())?; + let rows = stmt + .query_map([&owner_pubkey], |row| row.get::<_, String>(0)) + .map_err(|e| e.to_string())?; + let mut result = Vec::new(); + for row in rows { + result.push(row.map_err(|e| e.to_string())?); + } + Ok(result) +} + +// ── Articles cache ────────────────────────────────────────────────────────── + +#[tauri::command] +fn db_load_articles(state: tauri::State, limit: u32) -> Result, String> { + let conn = state.0.lock().map_err(|e| e.to_string())?; + let mut stmt = conn + .prepare("SELECT raw FROM notes WHERE kind=30023 ORDER BY created_at DESC LIMIT ?1") + .map_err(|e| e.to_string())?; + let rows = stmt + .query_map([limit], |row| row.get::<_, String>(0)) + .map_err(|e| e.to_string())?; + let mut result = Vec::new(); + for row in rows { + result.push(row.map_err(|e| e.to_string())?); + } + Ok(result) +} + // ── App entry ──────────────────────────────────────────────────────────────── #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -372,6 +448,9 @@ pub fn run() { db_newest_notification_ts, db_save_followers, db_load_followers, + db_save_bookmarked_notes, + db_load_bookmarked_notes, + db_load_articles, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/components/article/ArticleFeed.tsx b/src/components/article/ArticleFeed.tsx index eaa5aae..459570c 100644 --- a/src/components/article/ArticleFeed.tsx +++ b/src/components/article/ArticleFeed.tsx @@ -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]); diff --git a/src/components/bookmark/BookmarkView.tsx b/src/components/bookmark/BookmarkView.tsx index 875ed52..6f86ed8 100644 --- a/src/components/bookmark/BookmarkView.tsx +++ b/src/components/bookmark/BookmarkView.tsx @@ -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; diff --git a/src/lib/db.ts b/src/lib/db.ts index df787f9..4c7e491 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -62,3 +62,23 @@ export function dbSaveFollowers(followers: string[], ownerPubkey: string): void export async function dbLoadFollowers(ownerPubkey: string): Promise { return invoke("db_load_followers", { ownerPubkey }).catch(() => []); } + +// ── Bookmarks cache ──────────────────────────────────────────────────────── + +/** Save bookmarked note events to SQLite. Fire-and-forget. */ +export function dbSaveBookmarkedNotes(raws: string[], ownerPubkey: string): void { + if (raws.length === 0) return; + invoke("db_save_bookmarked_notes", { notes: raws, ownerPubkey }).catch(() => {}); +} + +/** Load cached bookmarked note JSONs for owner. */ +export async function dbLoadBookmarkedNotes(ownerPubkey: string): Promise { + return invoke("db_load_bookmarked_notes", { ownerPubkey }).catch(() => []); +} + +// ── Articles cache ───────────────────────────────────────────────────────── + +/** Load cached articles (kind 30023) from the notes table. */ +export async function dbLoadArticles(limit = 100): Promise { + return invoke("db_load_articles", { limit }).catch(() => []); +}