mirror of
https://github.com/hoornet/vega.git
synced 2026-05-12 21:18:35 -07:00
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:
@@ -77,7 +77,14 @@ fn open_db(data_dir: std::path::PathBuf) -> rusqlite::Result<Connection> {
|
|||||||
cached_at INTEGER NOT NULL,
|
cached_at INTEGER NOT NULL,
|
||||||
PRIMARY KEY (pubkey, owner_pubkey)
|
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)
|
Ok(conn)
|
||||||
}
|
}
|
||||||
@@ -289,6 +296,75 @@ fn db_load_followers(
|
|||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Bookmarks cache ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn db_save_bookmarked_notes(
|
||||||
|
state: tauri::State<DbState>,
|
||||||
|
notes: Vec<String>,
|
||||||
|
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<DbState>,
|
||||||
|
owner_pubkey: String,
|
||||||
|
) -> Result<Vec<String>, 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<DbState>, limit: u32) -> Result<Vec<String>, 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 ────────────────────────────────────────────────────────────────
|
// ── App entry ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
@@ -372,6 +448,9 @@ pub fn run() {
|
|||||||
db_newest_notification_ts,
|
db_newest_notification_ts,
|
||||||
db_save_followers,
|
db_save_followers,
|
||||||
db_load_followers,
|
db_load_followers,
|
||||||
|
db_save_bookmarked_notes,
|
||||||
|
db_load_bookmarked_notes,
|
||||||
|
db_load_articles,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||||
import { fetchArticleFeed } from "../../lib/nostr";
|
import { fetchArticleFeed, getNDK } from "../../lib/nostr";
|
||||||
import { useUserStore } from "../../stores/user";
|
import { useUserStore } from "../../stores/user";
|
||||||
import { useUIStore } from "../../stores/ui";
|
import { useUIStore } from "../../stores/ui";
|
||||||
|
import { dbLoadArticles, dbSaveNotes } from "../../lib/db";
|
||||||
import { ArticleCard } from "./ArticleCard";
|
import { ArticleCard } from "./ArticleCard";
|
||||||
|
|
||||||
type ArticleTab = "latest" | "following";
|
type ArticleTab = "latest" | "following";
|
||||||
@@ -21,11 +22,42 @@ export function ArticleFeed() {
|
|||||||
if (tab === "following" && follows.length === 0) return;
|
if (tab === "following" && follows.length === 0) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const authors = tab === "following" ? follows : undefined;
|
|
||||||
fetchArticleFeed(40, authors)
|
(async () => {
|
||||||
.then((result) => { if (!cancelled) setArticles(result); })
|
// 1) Instant: load from SQLite cache (latest tab only — following is filtered)
|
||||||
.catch(() => { if (!cancelled) setArticles([]); })
|
if (tab === "latest") {
|
||||||
.finally(() => { if (!cancelled) setLoading(false); });
|
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; };
|
return () => { cancelled = true; };
|
||||||
}, [followsKey]);
|
}, [followsKey]);
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { useEffect, useState } from "react";
|
|||||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||||
import { useBookmarkStore } from "../../stores/bookmark";
|
import { useBookmarkStore } from "../../stores/bookmark";
|
||||||
import { useUserStore } from "../../stores/user";
|
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 { NoteCard } from "../feed/NoteCard";
|
||||||
import { ArticleCard } from "../article/ArticleCard";
|
import { ArticleCard } from "../article/ArticleCard";
|
||||||
import { SkeletonNoteList } from "../shared/Skeleton";
|
import { SkeletonNoteList } from "../shared/Skeleton";
|
||||||
@@ -46,53 +47,84 @@ export function BookmarkView() {
|
|||||||
if (pubkey) fetchBookmarks(pubkey);
|
if (pubkey) fetchBookmarks(pubkey);
|
||||||
}, [pubkey]);
|
}, [pubkey]);
|
||||||
|
|
||||||
|
// Load bookmarked notes: DB cache first (instant), then relay fetch for missing
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (bookmarkedIds.length === 0) {
|
if (bookmarkedIds.length === 0) {
|
||||||
setNotes([]);
|
setNotes([]);
|
||||||
return;
|
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]);
|
}, [bookmarkedIds]);
|
||||||
|
|
||||||
|
// Load bookmarked articles (no DB cache yet — articles are fetched by addr)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (bookmarkedArticleAddrs.length === 0) {
|
if (bookmarkedArticleAddrs.length === 0) {
|
||||||
setArticles([]);
|
setArticles([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
loadArticles();
|
let cancelled = false;
|
||||||
}, [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 () => {
|
|
||||||
setLoadingArticles(true);
|
setLoadingArticles(true);
|
||||||
try {
|
|
||||||
const results = await Promise.all(
|
(async () => {
|
||||||
bookmarkedArticleAddrs.map((addr) => fetchByAddr(addr))
|
try {
|
||||||
);
|
const results = await Promise.all(
|
||||||
setArticles(
|
bookmarkedArticleAddrs.map((addr) => fetchByAddr(addr))
|
||||||
results
|
);
|
||||||
.filter((e): e is NDKEvent => e !== null)
|
if (!cancelled) {
|
||||||
.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))
|
setArticles(
|
||||||
);
|
results
|
||||||
} finally {
|
.filter((e): e is NDKEvent => e !== null)
|
||||||
setLoadingArticles(false);
|
.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 totalCount = bookmarkedIds.length + bookmarkedArticleAddrs.length;
|
||||||
const loading = tab === "notes" ? loadingNotes : loadingArticles;
|
const loading = tab === "notes" ? loadingNotes : loadingArticles;
|
||||||
|
|||||||
@@ -62,3 +62,23 @@ export function dbSaveFollowers(followers: string[], ownerPubkey: string): void
|
|||||||
export async function dbLoadFollowers(ownerPubkey: string): Promise<string[]> {
|
export async function dbLoadFollowers(ownerPubkey: string): Promise<string[]> {
|
||||||
return invoke<string[]>("db_load_followers", { ownerPubkey }).catch(() => []);
|
return invoke<string[]>("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<string[]> {
|
||||||
|
return invoke<string[]>("db_load_bookmarked_notes", { ownerPubkey }).catch(() => []);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Articles cache ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Load cached articles (kind 30023) from the notes table. */
|
||||||
|
export async function dbLoadArticles(limit = 100): Promise<string[]> {
|
||||||
|
return invoke<string[]>("db_load_articles", { limit }).catch(() => []);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user