SQLite-backed followers cache and instant own-profile load

Followers now load instantly from SQLite on startup, then merge
relay results in background. Own profile name/picture loads from
DB cache so sidebar badge never shows raw npub on slow relays.
This commit is contained in:
Jure
2026-03-29 20:28:25 +02:00
parent 2fed4e3e85
commit d450f8fdeb
6 changed files with 108 additions and 8 deletions

2
src-tauri/Cargo.lock generated
View File

@@ -6157,7 +6157,7 @@ dependencies = [
[[package]] [[package]]
name = "wrystr" name = "wrystr"
version = "0.9.7" version = "0.9.9"
dependencies = [ dependencies = [
"keyring", "keyring",
"rusqlite", "rusqlite",

View File

@@ -70,7 +70,14 @@ fn open_db(data_dir: std::path::PathBuf) -> rusqlite::Result<Connection> {
read INTEGER NOT NULL DEFAULT 0, read INTEGER NOT NULL DEFAULT 0,
raw TEXT NOT NULL raw TEXT NOT NULL
); );
CREATE INDEX IF NOT EXISTS idx_notif_owner ON notifications(owner_pubkey, created_at DESC);", CREATE INDEX IF NOT EXISTS idx_notif_owner ON notifications(owner_pubkey, created_at DESC);
CREATE TABLE IF NOT EXISTS followers (
pubkey TEXT NOT NULL,
owner_pubkey TEXT NOT NULL,
cached_at INTEGER NOT NULL,
PRIMARY KEY (pubkey, owner_pubkey)
);
CREATE INDEX IF NOT EXISTS idx_followers_owner ON followers(owner_pubkey);",
)?; )?;
Ok(conn) Ok(conn)
} }
@@ -240,6 +247,48 @@ fn db_newest_notification_ts(
} }
} }
// ── Followers cache ─────────────────────────────────────────────────────────
#[tauri::command]
fn db_save_followers(
state: tauri::State<DbState>,
followers: 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 pk in &followers {
conn.execute(
"INSERT OR REPLACE INTO followers (pubkey, owner_pubkey, cached_at) VALUES (?1,?2,?3)",
params![pk, owner_pubkey, now],
)
.map_err(|e| e.to_string())?;
}
Ok(())
}
#[tauri::command]
fn db_load_followers(
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 pubkey FROM followers 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)
}
// ── App entry ──────────────────────────────────────────────────────────────── // ── App entry ────────────────────────────────────────────────────────────────
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
@@ -321,6 +370,8 @@ pub fn run() {
db_load_notifications, db_load_notifications,
db_mark_notification_read, db_mark_notification_read,
db_newest_notification_ts, db_newest_notification_ts,
db_save_followers,
db_load_followers,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

View File

@@ -5,6 +5,7 @@ import { useNotificationsStore } from "../../stores/notifications";
import { useProfile } from "../../hooks/useProfile"; import { useProfile } from "../../hooks/useProfile";
import { useNip05Verified } from "../../hooks/useNip05Verified"; import { useNip05Verified } from "../../hooks/useNip05Verified";
import { fetchFollowers, ensureConnected } from "../../lib/nostr"; import { fetchFollowers, ensureConnected } from "../../lib/nostr";
import { dbLoadFollowers, dbSaveFollowers } from "../../lib/db";
import { shortenPubkey } from "../../lib/utils"; import { shortenPubkey } from "../../lib/utils";
function FollowRow({ function FollowRow({
@@ -93,7 +94,7 @@ export function FollowsView() {
clearNewFollowers(); clearNewFollowers();
}, []); }, []);
// Fetch followers when tab is selected // Load followers: DB cache first (instant), then relay fetch to merge new ones
useEffect(() => { useEffect(() => {
if (followsTab !== "followers" || !pubkey || followersFetched) return; if (followsTab !== "followers" || !pubkey || followersFetched) return;
let cancelled = false; let cancelled = false;
@@ -102,20 +103,35 @@ export function FollowsView() {
(async () => { (async () => {
try { try {
// 1) Instant: load from SQLite cache
const cached = await dbLoadFollowers(pubkey);
if (!cancelled && cached.length > 0) {
setFollowers(cached);
setFollowersLoading(false); // show cached immediately
}
// 2) Background: fetch from relays and merge
await ensureConnected(); await ensureConnected();
let result = await fetchFollowers(pubkey); let result = await fetchFollowers(pubkey);
// Retry once if empty — relays may not be ready yet if (result.length === 0 && !cancelled) {
if (result.length === 0) {
await new Promise((r) => setTimeout(r, 3000)); await new Promise((r) => setTimeout(r, 3000));
if (cancelled) return; if (cancelled) return;
result = await fetchFollowers(pubkey); result = await fetchFollowers(pubkey);
} }
if (!cancelled) { if (!cancelled) {
setFollowers(result); // Merge: union of cached + relay results (relay may return partial set)
const merged = Array.from(new Set([...result, ...cached]));
setFollowers(merged);
setFollowersFetched(true); setFollowersFetched(true);
// Persist merged set to DB
if (result.length > 0) {
dbSaveFollowers(merged, pubkey);
}
} }
} catch (err) { } catch (err) {
if (!cancelled) setFollowersError(`Failed to load followers: ${err}`); if (!cancelled && followers.length === 0) {
setFollowersError(`Failed to load followers: ${err}`);
}
} finally { } finally {
if (!cancelled) setFollowersLoading(false); if (!cancelled) setFollowersLoading(false);
} }

View File

@@ -49,3 +49,16 @@ export function dbMarkNotificationRead(ids: string[]): void {
export async function dbNewestNotificationTs(ownerPubkey: string, notifType: string): Promise<number | null> { export async function dbNewestNotificationTs(ownerPubkey: string, notifType: string): Promise<number | null> {
return invoke<number | null>("db_newest_notification_ts", { ownerPubkey, notifType }).catch(() => null); return invoke<number | null>("db_newest_notification_ts", { ownerPubkey, notifType }).catch(() => null);
} }
// ── Followers cache ────────────────────────────────────────────────────────
/** Save follower pubkeys to SQLite. Fire-and-forget. */
export function dbSaveFollowers(followers: string[], ownerPubkey: string): void {
if (followers.length === 0) return;
invoke("db_save_followers", { followers, ownerPubkey }).catch(() => {});
}
/** Load cached follower pubkeys for owner. */
export async function dbLoadFollowers(ownerPubkey: string): Promise<string[]> {
return invoke<string[]>("db_load_followers", { ownerPubkey }).catch(() => []);
}

View File

@@ -85,10 +85,11 @@ export async function fetchMentions(pubkey: string, since: number, limit = 50):
export async function fetchFollowers(pubkey: string, limit = 200): Promise<string[]> { export async function fetchFollowers(pubkey: string, limit = 200): Promise<string[]> {
const instance = getNDK(); const instance = getNDK();
// #p queries on kind 3 are slow on most relays — give them extra time
const events = await fetchWithTimeout( const events = await fetchWithTimeout(
instance, instance,
{ kinds: [3 as NDKKind], "#p": [pubkey], limit }, { kinds: [3 as NDKKind], "#p": [pubkey], limit },
FEED_TIMEOUT, 15000,
); );
const followerPubkeys = new Set<string>(); const followerPubkeys = new Set<string>();
for (const e of events) { for (const e of events) {

View File

@@ -9,6 +9,7 @@ import { useUIStore } from "./ui";
import { useNotificationsStore } from "./notifications"; import { useNotificationsStore } from "./notifications";
import { useFeedStore } from "./feed"; import { useFeedStore } from "./feed";
import { startNotificationPoller, stopNotificationPoller } from "../lib/notificationPoller"; import { startNotificationPoller, stopNotificationPoller } from "../lib/notificationPoller";
import { dbLoadProfile } from "../lib/db";
export interface SavedAccount { export interface SavedAccount {
pubkey: string; pubkey: string;
@@ -418,6 +419,24 @@ export const useUserStore = create<UserState>((set, get) => ({
const { pubkey } = get(); const { pubkey } = get();
if (!pubkey) return; if (!pubkey) return;
// Instant: load from SQLite cache so name/picture show immediately
if (!get().profile) {
try {
const cached = await dbLoadProfile(pubkey);
if (cached && !get().profile) {
const parsed = JSON.parse(cached);
set({ profile: parsed });
const name = parsed?.displayName || parsed?.name;
const picture = parsed?.picture;
if (name) {
const accounts = upsertAccount(get().accounts, { pubkey, npub: get().npub!, name, picture });
persistAccounts(accounts);
set({ accounts });
}
}
} catch { /* cache miss is fine */ }
}
try { try {
const ndk = getNDK(); const ndk = getNDK();
const user = ndk.getUser({ pubkey }); const user = ndk.getUser({ pubkey });