mirror of
https://github.com/hoornet/vega.git
synced 2026-05-13 08:48:35 -07:00
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:
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@@ -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",
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(() => []);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
Reference in New Issue
Block a user