From 4cc844df28538cd94e67f46abd79d2e0f2dff179 Mon Sep 17 00:00:00 2001 From: Jure <44338+hoornet@users.noreply.github.com> Date: Sun, 29 Mar 2026 16:12:36 +0200 Subject: [PATCH] SQLite-backed notifications, WoT profile fix, reaction queue fix Notifications now load instantly from SQLite on startup instead of waiting for relay responses. New events merge in as they arrive. Read state persists in DB across restarts. Also: filter profile owner from WoT followers list, make "+N more" clickable to expand, fix reaction throttle queue jamming on errors. --- src-tauri/src/lib.rs | 113 +++++++++++++++++++++++- src/components/profile/ProfileView.tsx | 37 +++++--- src/hooks/useReactions.ts | 18 ++-- src/lib/db.ts | 29 +++++++ src/lib/notificationPoller.ts | 114 ++++++++++--------------- src/stores/notifications.ts | 109 ++++++++++++++++------- 6 files changed, 301 insertions(+), 119 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 121ca77..7272edf 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -59,7 +59,18 @@ fn open_db(data_dir: std::path::PathBuf) -> rusqlite::Result { pubkey TEXT PRIMARY KEY, content TEXT NOT NULL, cached_at INTEGER NOT NULL - );", + ); + CREATE TABLE IF NOT EXISTS notifications ( + id TEXT PRIMARY KEY, + owner_pubkey TEXT NOT NULL, + pubkey TEXT NOT NULL, + created_at INTEGER NOT NULL, + kind INTEGER NOT NULL, + notif_type TEXT NOT NULL, + 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);", )?; Ok(conn) } @@ -133,6 +144,102 @@ fn db_load_profile(state: tauri::State, pubkey: String) -> Result, + notifications: Vec, + owner_pubkey: String, + notif_type: String, +) -> Result<(), String> { + let conn = state.0.lock().map_err(|e| e.to_string())?; + for raw in ¬ifications { + 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(); + let pubkey = v["pubkey"].as_str().unwrap_or_default(); + let created_at = v["created_at"].as_i64().unwrap_or(0); + let kind = v["kind"].as_i64().unwrap_or(0); + conn.execute( + "INSERT OR IGNORE INTO notifications (id, owner_pubkey, pubkey, created_at, kind, notif_type, raw) \ + VALUES (?1,?2,?3,?4,?5,?6,?7)", + params![id, owner_pubkey, pubkey, created_at, kind, notif_type, raw], + ) + .map_err(|e| e.to_string())?; + } + // Prune to newest 500 per owner + conn.execute( + "DELETE FROM notifications WHERE owner_pubkey=?1 AND id NOT IN \ + (SELECT id FROM notifications WHERE owner_pubkey=?1 ORDER BY created_at DESC LIMIT 500)", + params![owner_pubkey], + ) + .map_err(|e| e.to_string())?; + Ok(()) +} + +#[tauri::command] +fn db_load_notifications( + state: tauri::State, + owner_pubkey: String, + limit: u32, +) -> Result, String> { + let conn = state.0.lock().map_err(|e| e.to_string())?; + let mut stmt = conn + .prepare( + "SELECT raw, read FROM notifications WHERE owner_pubkey=?1 ORDER BY created_at DESC LIMIT ?2", + ) + .map_err(|e| e.to_string())?; + let rows = stmt + .query_map(params![owner_pubkey, limit], |row| { + let raw: String = row.get(0)?; + let read: i32 = row.get(1)?; + Ok(format!("{{\"raw\":{},\"read\":{}}}", raw, read)) + }) + .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) +} + +#[tauri::command] +fn db_mark_notification_read( + state: tauri::State, + ids: Vec, +) -> Result<(), String> { + if ids.is_empty() { + return Ok(()); + } + let conn = state.0.lock().map_err(|e| e.to_string())?; + let placeholders: Vec = ids.iter().enumerate().map(|(i, _)| format!("?{}", i + 1)).collect(); + let sql = format!( + "UPDATE notifications SET read=1 WHERE id IN ({})", + placeholders.join(",") + ); + let params: Vec<&dyn rusqlite::types::ToSql> = ids.iter().map(|s| s as &dyn rusqlite::types::ToSql).collect(); + conn.execute(&sql, params.as_slice()).map_err(|e| e.to_string())?; + Ok(()) +} + +#[tauri::command] +fn db_newest_notification_ts( + state: tauri::State, + owner_pubkey: String, + notif_type: String, +) -> Result, String> { + let conn = state.0.lock().map_err(|e| e.to_string())?; + match conn.query_row( + "SELECT MAX(created_at) FROM notifications WHERE owner_pubkey=?1 AND notif_type=?2", + params![owner_pubkey, notif_type], + |row| row.get::<_, Option>(0), + ) { + Ok(ts) => Ok(ts), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.to_string()), + } +} + // ── App entry ──────────────────────────────────────────────────────────────── #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -210,6 +317,10 @@ pub fn run() { db_load_feed, db_save_profile, db_load_profile, + db_save_notifications, + db_load_notifications, + db_mark_notification_read, + db_newest_notification_ts, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/components/profile/ProfileView.tsx b/src/components/profile/ProfileView.tsx index b81be2f..f8f3b8d 100644 --- a/src/components/profile/ProfileView.tsx +++ b/src/components/profile/ProfileView.tsx @@ -64,6 +64,7 @@ export function ProfileView() { const [bannerLightbox, setBannerLightbox] = useState(false); const [bannerLoaded, setBannerLoaded] = useState(false); const [bannerError, setBannerError] = useState(false); + const [wotExpanded, setWotExpanded] = useState(false); const isFollowing = follows.includes(pubkey); const { mutedPubkeys, mute, unmute } = useMuteStore(); @@ -95,6 +96,7 @@ export function ProfileView() { setProfileTab("notes"); setBannerLoaded(false); setBannerError(false); + setWotExpanded(false); fetchUserNotesNIP65(pubkey).then((events) => { setNotes(events); setLoading(false); @@ -239,21 +241,28 @@ export function ProfileView() { {/* Web of Trust — powered by Vertex */} - {reputation.data && reputation.data.topFollowers.length > 0 && ( -
-
Followed by people you trust
-
- {reputation.data.topFollowers.slice(0, 5).map((f) => ( - - ))} - {reputation.data.topFollowers.length > 5 && ( - - +{reputation.data.topFollowers.length - 5} more - - )} + {(() => { + const wotFollowers = reputation.data?.topFollowers.filter((f) => f.pubkey !== pubkey) ?? []; + if (wotFollowers.length === 0) return null; + return ( +
+
Followed by people you trust
+
+ {(wotExpanded ? wotFollowers : wotFollowers.slice(0, 5)).map((f) => ( + + ))} + {wotFollowers.length > 5 && !wotExpanded && ( + + )} +
-
- )} + ); + })()} {reputation.loading && (
diff --git a/src/hooks/useReactions.ts b/src/hooks/useReactions.ts index cc652b4..862c6f8 100644 --- a/src/hooks/useReactions.ts +++ b/src/hooks/useReactions.ts @@ -24,12 +24,18 @@ function throttledFetch(eventId: string, pubkey?: string): Promise((resolve) => { const doFetch = () => { activeCount++; - fetchReactions(eventId, pubkey).then((result) => { - activeCount--; - pending.delete(eventId); - resolve(result); - runNext(); - }); + fetchReactions(eventId, pubkey) + .then((result) => { + resolve(result); + }) + .catch(() => { + resolve({ groups: new Map(), myReactions: new Set(), total: 0 }); + }) + .finally(() => { + activeCount--; + pending.delete(eventId); + runNext(); + }); }; if (activeCount < MAX_CONCURRENT) { diff --git a/src/lib/db.ts b/src/lib/db.ts index 36d3666..62dc72c 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -20,3 +20,32 @@ export function dbSaveProfile(pubkey: string, content: string): void { export async function dbLoadProfile(pubkey: string): Promise { return invoke("db_load_profile", { pubkey }).catch(() => null); } + +// ── Notification cache ────────────────────────────────────────────────────── + +/** Save notification events to SQLite. Fire-and-forget. */ +export function dbSaveNotifications(raws: string[], ownerPubkey: string, notifType: string): void { + if (raws.length === 0) return; + invoke("db_save_notifications", { notifications: raws, ownerPubkey, notifType }).catch(() => {}); +} + +/** Load cached notifications with read state. Newest first. */ +export async function dbLoadNotifications(ownerPubkey: string, limit = 200): Promise<{ raw: string; read: boolean }[]> { + return invoke("db_load_notifications", { ownerPubkey, limit }) + .then((rows) => rows.map((r) => { + const o = JSON.parse(r); + return { raw: typeof o.raw === "string" ? o.raw : JSON.stringify(o.raw), read: !!o.read }; + })) + .catch(() => []); +} + +/** Mark notification IDs as read in SQLite. Fire-and-forget. */ +export function dbMarkNotificationRead(ids: string[]): void { + if (ids.length === 0) return; + invoke("db_mark_notification_read", { ids }).catch(() => {}); +} + +/** Get the newest created_at timestamp for a notification type. */ +export async function dbNewestNotificationTs(ownerPubkey: string, notifType: string): Promise { + return invoke("db_newest_notification_ts", { ownerPubkey, notifType }).catch(() => null); +} diff --git a/src/lib/notificationPoller.ts b/src/lib/notificationPoller.ts index a18f2b4..8eb1790 100644 --- a/src/lib/notificationPoller.ts +++ b/src/lib/notificationPoller.ts @@ -1,35 +1,12 @@ import { fetchMentions, fetchZapsReceived, fetchNewFollowers, fetchProfile, ensureConnected } from "./nostr"; import { notifyMention, notifyZap, notifyFollower } from "./notifications"; import { useNotificationsStore } from "../stores/notifications"; +import { dbSaveNotifications, dbNewestNotificationTs } from "./db"; import { debug } from "./debug"; const POLL_INTERVAL = 60_000; // 60 seconds -const POLL_TS_KEY = "wrystr_notif_poll_ts"; -const MAX_SEEN = 200; let intervalId: ReturnType | null = null; -const recentlySeen = new Set(); - -function loadPollTimestamps(): Record { - try { - return JSON.parse(localStorage.getItem(POLL_TS_KEY) ?? "{}"); - } catch { - return {}; - } -} - -function savePollTimestamps(ts: Record) { - localStorage.setItem(POLL_TS_KEY, JSON.stringify(ts)); -} - -function trimSeenSet() { - if (recentlySeen.size > MAX_SEEN) { - const arr = Array.from(recentlySeen); - arr.splice(0, arr.length - MAX_SEEN); - recentlySeen.clear(); - arr.forEach((id) => recentlySeen.add(id)); - } -} async function getProfileName(pubkey: string): Promise { try { @@ -52,74 +29,76 @@ async function pollOnce(pubkey: string) { } } catch { return; } - const ts = loadPollTimestamps(); const now = Math.floor(Date.now() / 1000); + const existingIds = new Set( + useNotificationsStore.getState().notifications.map((e) => e.id!) + ); // Mentions try { - const mentionsSince = ts.mentions || (now - 300); + const mentionsSince = (await dbNewestNotificationTs(pubkey, "mention")) ?? (now - 300); const mentions = await fetchMentions(pubkey, mentionsSince, 10); - for (const e of mentions) { - if (recentlySeen.has(e.id!)) continue; - if (e.pubkey === pubkey) continue; // don't notify self-mentions - recentlySeen.add(e.id!); - const name = await getProfileName(e.pubkey); - notifyMention(name, e.content?.slice(0, 120) || "mentioned you").catch(() => {}); + const newMentions = mentions.filter((e) => e.pubkey !== pubkey && !existingIds.has(e.id!)); + if (newMentions.length > 0) { + dbSaveNotifications(newMentions.map((e) => JSON.stringify(e.rawEvent())), pubkey, "mention"); + for (const e of newMentions) { + const name = await getProfileName(e.pubkey); + notifyMention(name, e.content?.slice(0, 120) || "mentioned you").catch(() => {}); + } } - if (mentions.length > 0) ts.mentions = now; - // Also update the notifications store unread count + // Also update the notifications store useNotificationsStore.getState().fetchNotifications(pubkey).catch(() => {}); } catch { /* non-critical */ } // Zaps try { - const zapsSince = ts.zaps || (now - 300); + const zapsSince = (await dbNewestNotificationTs(pubkey, "zap")) ?? (now - 300); const zaps = await fetchZapsReceived(pubkey, 10); - for (const e of zaps) { - if (recentlySeen.has(e.id!)) continue; - if ((e.created_at ?? 0) <= zapsSince) continue; - recentlySeen.add(e.id!); - // Extract sender and amount from zap receipt - const desc = e.tags.find((t) => t[0] === "description")?.[1]; - let senderName = "Someone"; - let amount = 0; - if (desc) { - try { - const zapReq = JSON.parse(desc) as { pubkey?: string; tags?: string[][] }; - if (zapReq.pubkey) senderName = await getProfileName(zapReq.pubkey); - const amountTag = zapReq.tags?.find((t) => t[0] === "amount"); - if (amountTag?.[1]) amount = Math.round(parseInt(amountTag[1]) / 1000); - } catch { /* malformed */ } - } - if (amount > 0) { - notifyZap(senderName, amount).catch(() => {}); + const newZaps = zaps.filter((e) => !existingIds.has(e.id!) && (e.created_at ?? 0) > zapsSince); + if (newZaps.length > 0) { + dbSaveNotifications(newZaps.map((e) => JSON.stringify(e.rawEvent())), pubkey, "zap"); + for (const e of newZaps) { + const desc = e.tags.find((t) => t[0] === "description")?.[1]; + let senderName = "Someone"; + let amount = 0; + if (desc) { + try { + const zapReq = JSON.parse(desc) as { pubkey?: string; tags?: string[][] }; + if (zapReq.pubkey) senderName = await getProfileName(zapReq.pubkey); + const amountTag = zapReq.tags?.find((t) => t[0] === "amount"); + if (amountTag?.[1]) amount = Math.round(parseInt(amountTag[1]) / 1000); + } catch { /* malformed */ } + } + if (amount > 0) { + notifyZap(senderName, amount).catch(() => {}); + } } } - ts.zaps = now; } catch { /* non-critical */ } // New followers try { - const followersSince = ts.followers || (now - 300); + const followersSince = (await dbNewestNotificationTs(pubkey, "follower")) ?? (now - 300); const followers = await fetchNewFollowers(pubkey, followersSince, 5); - for (const e of followers) { - if (recentlySeen.has(e.id!)) continue; - if (e.pubkey === pubkey) continue; - recentlySeen.add(e.id!); - const name = await getProfileName(e.pubkey); - notifyFollower(name).catch(() => {}); - useNotificationsStore.getState().incrementNewFollowers(); + const newFollowers = followers.filter((e) => e.pubkey !== pubkey && !existingIds.has(e.id!)); + if (newFollowers.length > 0) { + dbSaveNotifications(newFollowers.map((e) => JSON.stringify(e.rawEvent())), pubkey, "follower"); + for (const e of newFollowers) { + const name = await getProfileName(e.pubkey); + notifyFollower(name).catch(() => {}); + useNotificationsStore.getState().incrementNewFollowers(); + } } - if (followers.length > 0) ts.followers = now; } catch { /* non-critical */ } - - trimSeenSet(); - savePollTimestamps(ts); } export function startNotificationPoller(pubkey: string) { stopNotificationPoller(); - // Wait for relay connection before first fetch — avoids empty results on startup + + // Instant: load cached notifications from DB (no flicker) + useNotificationsStore.getState().loadFromDb(pubkey); + + // Then connect to relays and fetch new data in background (async () => { try { const connected = await ensureConnected(); @@ -128,6 +107,7 @@ export function startNotificationPoller(pubkey: string) { debug.log("notif:poller initial fetch for", pubkey.slice(0, 8)); useNotificationsStore.getState().fetchNotifications(pubkey).catch(() => {}); })(); + // Run first full poll after a longer delay (give relays more time) setTimeout(() => pollOnce(pubkey).catch(() => {}), 8000); intervalId = setInterval(() => pollOnce(pubkey).catch(() => {}), POLL_INTERVAL); diff --git a/src/stores/notifications.ts b/src/stores/notifications.ts index 1e2f180..2f9b3a4 100644 --- a/src/stores/notifications.ts +++ b/src/stores/notifications.ts @@ -1,11 +1,12 @@ import { create } from "zustand"; import { NDKEvent } from "@nostr-dev-kit/ndk"; import { fetchMentions } from "../lib/nostr"; +import { dbSaveNotifications, dbLoadNotifications, dbMarkNotificationRead } from "../lib/db"; import { debug } from "../lib/debug"; -const NOTIF_READ_KEY = "wrystr_notif_read_ids"; const DM_SEEN_KEY = "wrystr_dm_last_seen"; -const MAX_NOTIFICATIONS = 15; +const LEGACY_READ_KEY = "wrystr_notif_read_ids"; +const MAX_NOTIFICATIONS = 200; interface NotificationsState { notifications: NDKEvent[]; @@ -17,6 +18,7 @@ interface NotificationsState { dmUnreadCount: number; newFollowersCount: number; + loadFromDb: (pubkey: string) => Promise; fetchNotifications: (pubkey: string) => Promise; markRead: (eventId: string) => void; markAllRead: () => void; @@ -27,21 +29,6 @@ interface NotificationsState { clearNewFollowers: () => void; } -function loadReadIds(): Set { - try { - const arr = JSON.parse(localStorage.getItem(NOTIF_READ_KEY) ?? "[]"); - return new Set(arr); - } catch { - return new Set(); - } -} - -function saveReadIds(ids: Set) { - // Only keep the most recent entries to avoid unbounded growth - const arr = Array.from(ids).slice(-200); - localStorage.setItem(NOTIF_READ_KEY, JSON.stringify(arr)); -} - function loadDMLastSeen(): Record { try { return JSON.parse(localStorage.getItem(DM_SEEN_KEY) ?? "{}"); @@ -50,16 +37,60 @@ function loadDMLastSeen(): Record { } } +/** Migrate read IDs from localStorage (one-time, first run after upgrade). */ +function migrateLegacyReadIds(): Set { + try { + const raw = localStorage.getItem(LEGACY_READ_KEY); + if (raw) { + const arr = JSON.parse(raw); + return new Set(arr); + } + } catch { /* ignore */ } + return new Set(); +} + export const useNotificationsStore = create((set, get) => ({ notifications: [], unreadCount: 0, - readIds: loadReadIds(), + readIds: migrateLegacyReadIds(), loading: false, currentPubkey: null, dmLastSeen: loadDMLastSeen(), dmUnreadCount: 0, newFollowersCount: 0, + loadFromDb: async (pubkey: string) => { + const isNewAccount = pubkey !== get().currentPubkey; + if (isNewAccount) { + set({ notifications: [], currentPubkey: pubkey }); + } + + const rows = await dbLoadNotifications(pubkey, MAX_NOTIFICATIONS); + if (rows.length === 0) { + debug.log("notif:db empty for", pubkey.slice(0, 8)); + return; + } + + const readIds = new Set(get().readIds); + const events: NDKEvent[] = []; + + for (const row of rows) { + try { + const parsed = JSON.parse(row.raw); + const event = new NDKEvent(undefined, parsed); + events.push(event); + if (row.read) readIds.add(event.id!); + } catch { /* skip malformed */ } + } + + const unreadCount = events.filter((e) => !readIds.has(e.id!)).length; + debug.log("notif:db loaded", events.length, "notifications,", unreadCount, "unread"); + set({ notifications: events, readIds, unreadCount, loading: false }); + + // Clear legacy localStorage read IDs now that DB is the source of truth + localStorage.removeItem(LEGACY_READ_KEY); + }, + fetchNotifications: async (pubkey: string) => { const state = get(); const isNewAccount = pubkey !== state.currentPubkey; @@ -68,24 +99,37 @@ export const useNotificationsStore = create((set, get) => ({ } set({ loading: true }); try { - // Always fetch recent notifications (last 7 days), keep up to MAX_NOTIFICATIONS const since = Math.floor(Date.now() / 1000) - 7 * 86400; - // Fetch more than we need since we filter out own events - const events = await fetchMentions(pubkey, since, MAX_NOTIFICATIONS * 3); + const events = await fetchMentions(pubkey, since, MAX_NOTIFICATIONS); const others = events.filter((e) => e.pubkey !== pubkey); - const sorted = others.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)).slice(0, MAX_NOTIFICATIONS); - debug.log("notif:fetch", events.length, "raw →", others.length, "others →", sorted.length, "kept"); + debug.log("notif:fetch", events.length, "raw →", others.length, "others"); // Don't overwrite existing notifications with empty results (relay timeout/disconnect) const { readIds, notifications: existing } = get(); - if (sorted.length === 0 && existing.length > 0) { + if (others.length === 0 && existing.length > 0) { debug.warn("notif:fetch empty result, keeping", existing.length, "existing"); return; } - const unreadCount = sorted.filter((e) => !readIds.has(e.id!)).length; - debug.log("notif:set", sorted.length, "notifications,", unreadCount, "unread"); - set({ notifications: sorted, unreadCount }); + // Merge with existing (dedup by id) + const existingIds = new Set(existing.map((e) => e.id!)); + const newEvents = others.filter((e) => !existingIds.has(e.id!)); + + // Save new events to DB + if (newEvents.length > 0) { + const raws = newEvents.map((e) => JSON.stringify(e.rawEvent())); + dbSaveNotifications(raws, pubkey, "mention"); + debug.log("notif:db saved", newEvents.length, "new mentions"); + } + + // Combine, sort, cap + const merged = [...existing, ...newEvents] + .sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)) + .slice(0, MAX_NOTIFICATIONS); + + const unreadCount = merged.filter((e) => !readIds.has(e.id!)).length; + debug.log("notif:set", merged.length, "notifications,", unreadCount, "unread"); + set({ notifications: merged, unreadCount }); } catch { // Non-critical — keep existing notifications on error } finally { @@ -98,7 +142,7 @@ export const useNotificationsStore = create((set, get) => ({ if (readIds.has(eventId)) return; const updated = new Set(readIds); updated.add(eventId); - saveReadIds(updated); + dbMarkNotificationRead([eventId]); const unreadCount = notifications.filter((e) => !updated.has(e.id!)).length; set({ readIds: updated, unreadCount }); }, @@ -106,10 +150,14 @@ export const useNotificationsStore = create((set, get) => ({ markAllRead: () => { const { notifications, readIds } = get(); const updated = new Set(readIds); + const newIds: string[] = []; for (const e of notifications) { - if (e.id) updated.add(e.id); + if (e.id && !readIds.has(e.id)) { + updated.add(e.id); + newIds.push(e.id); + } } - saveReadIds(updated); + dbMarkNotificationRead(newIds); set({ readIds: updated, unreadCount: 0 }); }, @@ -122,7 +170,6 @@ export const useNotificationsStore = create((set, get) => ({ const dmLastSeen = { ...get().dmLastSeen, [partnerPubkey]: now }; localStorage.setItem(DM_SEEN_KEY, JSON.stringify(dmLastSeen)); set({ dmLastSeen }); - // dmUnreadCount will be recomputed by computeDMUnread on next DM view render }, computeDMUnread: (conversations: Array<{ partnerPubkey: string; lastAt: number }>) => {