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 }>) => {