diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 993332f..5b81d3e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -6157,7 +6157,7 @@ dependencies = [ [[package]] name = "wrystr" -version = "0.9.7" +version = "0.9.9" dependencies = [ "keyring", "rusqlite", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7272edf..b80eedf 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -70,7 +70,14 @@ fn open_db(data_dir: std::path::PathBuf) -> rusqlite::Result { read INTEGER NOT NULL DEFAULT 0, 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) } @@ -240,6 +247,48 @@ fn db_newest_notification_ts( } } +// ── Followers cache ───────────────────────────────────────────────────────── + +#[tauri::command] +fn db_save_followers( + state: tauri::State, + followers: 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 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, + owner_pubkey: String, +) -> Result, 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 ──────────────────────────────────────────────────────────────── #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -321,6 +370,8 @@ pub fn run() { db_load_notifications, db_mark_notification_read, db_newest_notification_ts, + db_save_followers, + db_load_followers, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/components/follows/FollowsView.tsx b/src/components/follows/FollowsView.tsx index 739b627..b8ee16c 100644 --- a/src/components/follows/FollowsView.tsx +++ b/src/components/follows/FollowsView.tsx @@ -5,6 +5,7 @@ import { useNotificationsStore } from "../../stores/notifications"; import { useProfile } from "../../hooks/useProfile"; import { useNip05Verified } from "../../hooks/useNip05Verified"; import { fetchFollowers, ensureConnected } from "../../lib/nostr"; +import { dbLoadFollowers, dbSaveFollowers } from "../../lib/db"; import { shortenPubkey } from "../../lib/utils"; function FollowRow({ @@ -93,7 +94,7 @@ export function FollowsView() { clearNewFollowers(); }, []); - // Fetch followers when tab is selected + // Load followers: DB cache first (instant), then relay fetch to merge new ones useEffect(() => { if (followsTab !== "followers" || !pubkey || followersFetched) return; let cancelled = false; @@ -102,20 +103,35 @@ export function FollowsView() { (async () => { 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(); let result = await fetchFollowers(pubkey); - // Retry once if empty — relays may not be ready yet - if (result.length === 0) { + if (result.length === 0 && !cancelled) { await new Promise((r) => setTimeout(r, 3000)); if (cancelled) return; result = await fetchFollowers(pubkey); } 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); + // Persist merged set to DB + if (result.length > 0) { + dbSaveFollowers(merged, pubkey); + } } } catch (err) { - if (!cancelled) setFollowersError(`Failed to load followers: ${err}`); + if (!cancelled && followers.length === 0) { + setFollowersError(`Failed to load followers: ${err}`); + } } finally { if (!cancelled) setFollowersLoading(false); } diff --git a/src/lib/db.ts b/src/lib/db.ts index 62dc72c..df787f9 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -49,3 +49,16 @@ export function dbMarkNotificationRead(ids: string[]): void { export async function dbNewestNotificationTs(ownerPubkey: string, notifType: string): Promise { return invoke("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 { + return invoke("db_load_followers", { ownerPubkey }).catch(() => []); +} diff --git a/src/lib/nostr/social.ts b/src/lib/nostr/social.ts index 589bb5a..291d00c 100644 --- a/src/lib/nostr/social.ts +++ b/src/lib/nostr/social.ts @@ -85,10 +85,11 @@ export async function fetchMentions(pubkey: string, since: number, limit = 50): export async function fetchFollowers(pubkey: string, limit = 200): Promise { const instance = getNDK(); + // #p queries on kind 3 are slow on most relays — give them extra time const events = await fetchWithTimeout( instance, { kinds: [3 as NDKKind], "#p": [pubkey], limit }, - FEED_TIMEOUT, + 15000, ); const followerPubkeys = new Set(); for (const e of events) { diff --git a/src/stores/user.ts b/src/stores/user.ts index dee18cc..4c196ac 100644 --- a/src/stores/user.ts +++ b/src/stores/user.ts @@ -9,6 +9,7 @@ import { useUIStore } from "./ui"; import { useNotificationsStore } from "./notifications"; import { useFeedStore } from "./feed"; import { startNotificationPoller, stopNotificationPoller } from "../lib/notificationPoller"; +import { dbLoadProfile } from "../lib/db"; export interface SavedAccount { pubkey: string; @@ -418,6 +419,24 @@ export const useUserStore = create((set, get) => ({ const { pubkey } = get(); 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 { const ndk = getNDK(); const user = ndk.getUser({ pubkey });