Files
vega/src/stores/feed.ts
Jure 5cbaa7b741 Rename Wrystr to Vega
Named after Jurij Vega (1754-1802), Slovenian mathematician who made
knowledge accessible through his logarithm tables. Rebrand before
OpenSats application (April 1).

- Product name, window title, identifiers, binary name all renamed
- Cargo package: wrystr -> vega, wrystr_lib -> vega_lib
- PKGBUILD: wrystr -> vega (new AUR package name)
- Release workflow: artifact names, release text updated
- README: new tagline referencing Jurij Vega
- DB migration: auto-renames wrystr.db -> vega.db on first launch
- Keyring service name kept as "wrystr" for backward compatibility
- localStorage keys kept as wrystr_* to preserve existing user data

Pending manual steps:
- Rename GitHub repo hoornet/wrystr -> hoornet/vega
- Create new AUR package vega-git, orphan wrystr-git
- Update local .desktop launcher
2026-03-30 21:14:02 +02:00

270 lines
9.4 KiB
TypeScript

import { create } from "zustand";
import { NDKEvent, NDKFilter, NDKKind, NDKSubscription, NDKSubscriptionCacheUsage } from "@nostr-dev-kit/ndk";
import { connectToRelays, ensureConnected, resetNDK, fetchGlobalFeed, fetchBatchEngagement, fetchTrendingCandidates, getNDK } from "../lib/nostr";
import { seedReactionsCache } from "../hooks/useReactions";
import { useToastStore } from "./toast";
import { dbLoadFeed, dbSaveNotes } from "../lib/db";
import { diagWrapFetch, logDiag, startRelaySnapshots, getRelayStates } from "../lib/feedDiagnostics";
const TRENDING_CACHE_KEY = "wrystr_trending_cache";
const TRENDING_TTL = 10 * 60 * 1000; // 10 minutes
const MAX_FEED_SIZE = 200;
// Live subscription handle — persists across store calls
let liveSub: NDKSubscription | null = null;
export function isLiveSubActive(): boolean {
return liveSub !== null;
}
let saveTimer: ReturnType<typeof setTimeout> | null = null;
interface FeedState {
notes: NDKEvent[];
loading: boolean;
connected: boolean;
error: string | null;
focusedNoteIndex: number;
lastUpdated: Record<string, number>;
trendingNotes: NDKEvent[];
trendingLoading: boolean;
connect: () => Promise<void>;
loadCachedFeed: () => Promise<void>;
loadFeed: () => Promise<void>;
startLiveFeed: () => void;
loadTrendingFeed: (force?: boolean) => Promise<void>;
setFocusedNoteIndex: (n: number) => void;
}
export const useFeedStore = create<FeedState>((set, get) => ({
notes: [],
loading: false,
connected: false,
error: null,
focusedNoteIndex: -1,
lastUpdated: {},
trendingNotes: [],
trendingLoading: false,
setFocusedNoteIndex: (n: number) => set({ focusedNoteIndex: n }),
connect: async () => {
try {
set({ error: null });
const connectStart = performance.now();
await connectToRelays();
set({ connected: true });
const connectMs = Math.round(performance.now() - connectStart);
logDiag({
ts: new Date().toISOString(),
action: "relay_connect",
durationMs: connectMs,
relayStates: getRelayStates(),
details: `Initial connection complete`,
});
startRelaySnapshots();
// Monitor relay connectivity — check every 5s, reconnect if needed.
// Always call getNDK() fresh — instance may be replaced by resetNDK().
let offlineStreak = 0;
const checkConnection = () => {
const currentNdk = getNDK();
const relays = Array.from(currentNdk.pool?.relays?.values() ?? []);
const hasConnected = relays.some((r) => r.connected);
if (hasConnected) {
if (offlineStreak > 0) {
useToastStore.getState().addToast("Back online", "success");
}
offlineStreak = 0;
if (!get().connected) set({ connected: true });
} else {
offlineStreak++;
// Mark offline after 3 consecutive checks (15s grace)
if (offlineStreak >= 3 && get().connected) {
set({ connected: false });
logDiag({ ts: new Date().toISOString(), action: "connection_lost", details: `No relays connected after ${offlineStreak} checks` });
useToastStore.getState().addToast("Connection lost \u2014 reconnecting\u2026", "warning");
// Nuclear reset after 6 consecutive failures (30s)
if (offlineStreak >= 6) {
offlineStreak = 0;
useToastStore.getState().addToast("Resetting relay connections\u2026", "info");
resetNDK().then(() => {
if (getNDK().pool?.relays) {
const after = Array.from(getNDK().pool.relays.values());
if (after.some((r) => r.connected)) {
set({ connected: true });
useToastStore.getState().addToast("Relays reconnected", "success");
// Restart live sub after NDK reset
get().startLiveFeed();
}
}
}).catch(() => {});
} else {
currentNdk.connect().catch(() => {});
}
}
}
};
setInterval(checkConnection, 5000);
} catch (err) {
set({ error: `Connection failed: ${err}` });
}
},
loadCachedFeed: async () => {
try {
const rawNotes = await dbLoadFeed(MAX_FEED_SIZE);
if (rawNotes.length === 0) return;
const ndk = getNDK();
const events = rawNotes.map((raw) => new NDKEvent(ndk, JSON.parse(raw)));
set({ notes: events });
} catch {
// Cache read failure is non-critical
}
},
/**
* One-shot feed fetch — loads initial batch, then starts live subscription.
*/
loadFeed: async () => {
if (get().loading) return;
set({ loading: true, error: null });
try {
await ensureConnected();
const fresh = await diagWrapFetch("global_fetch", () => fetchGlobalFeed(80));
// Merge with currently displayed notes
const freshIds = new Set(fresh.map((n) => n.id));
const kept = get().notes.filter((n) => !freshIds.has(n.id));
const merged = [...fresh, ...kept]
.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))
.slice(0, MAX_FEED_SIZE);
set({ notes: merged, loading: false, focusedNoteIndex: -1, lastUpdated: { ...get().lastUpdated, global: Date.now() } });
// Persist fresh notes to SQLite (fire-and-forget)
dbSaveNotes(fresh.map((e) => JSON.stringify(e.rawEvent())));
// Start live subscription after initial load
get().startLiveFeed();
} catch (err) {
set({ error: `Feed failed: ${err}`, loading: false });
}
},
/**
* Start a persistent live subscription for new notes.
* New events stream in and are prepended to the feed in real time.
*/
startLiveFeed: () => {
// Close existing subscription if any
if (liveSub) {
try { liveSub.stop(); } catch { /* ignore */ }
liveSub = null;
}
const ndk = getNDK();
const since = Math.floor(Date.now() / 1000);
const filter: NDKFilter = { kinds: [NDKKind.Text], since, limit: 20 };
const sub = ndk.subscribe(filter, {
closeOnEose: false, // Keep subscription open — this is the key difference
groupable: false,
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY,
});
sub.on("event", (event: NDKEvent) => {
const current = get().notes;
// Deduplicate
if (current.some((n) => n.id === event.id)) return;
const updated = [event, ...current]
.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))
.slice(0, MAX_FEED_SIZE);
set({ notes: updated, lastUpdated: { ...get().lastUpdated, global: Date.now() } });
// Debounced save to SQLite — batch saves every 5s
if (!saveTimer) {
saveTimer = setTimeout(() => {
saveTimer = null;
const toSave = get().notes.slice(0, 20);
dbSaveNotes(toSave.map((e) => JSON.stringify(e.rawEvent())));
}, 5000);
}
});
sub.on("eose", () => {
logDiag({
ts: new Date().toISOString(),
action: "live_feed_eose",
details: "Live subscription received EOSE — now streaming new events",
});
});
liveSub = sub;
console.log("[Vega] Live feed subscription started");
},
loadTrendingFeed: async (force?: boolean) => {
if (get().trendingLoading) return;
// Check cache first (skip if forced refresh)
if (!force) {
try {
const cached = localStorage.getItem(TRENDING_CACHE_KEY);
if (cached) {
const { timestamp } = JSON.parse(cached) as { noteIds: string[]; timestamp: number };
if (Date.now() - timestamp < TRENDING_TTL && get().trendingNotes.length > 0) {
return; // Cache still valid and notes already in store
}
}
} catch { /* ignore cache errors */ }
}
set({ trendingLoading: true, ...(force ? { trendingNotes: [] } : {}) });
try {
const notes = await fetchTrendingCandidates(200, 24);
if (notes.length === 0) {
set({ trendingNotes: [], trendingLoading: false });
return;
}
const eventIds = notes.map((n) => n.id).filter(Boolean) as string[];
const engagement = await fetchBatchEngagement(eventIds);
// Seed per-note reaction cache so emoji pills render instantly
for (const [id, eng] of engagement) {
if (eng.reactionGroups.size > 0) {
seedReactionsCache(id, eng.reactionGroups, eng.myReactions);
}
}
const now = Math.floor(Date.now() / 1000);
const scored = notes
.map((note) => {
const eng = engagement.get(note.id) ?? { reactions: 0, replies: 0, zapSats: 0 };
const ageHours = (now - (note.created_at ?? now)) / 3600;
const decay = 1 / (1 + ageHours * 0.15);
const engScore = eng.reactions * 1 + eng.replies * 3 + eng.zapSats * 0.01;
// Base recency score ensures notes appear even when engagement data times out
const score = (engScore + 0.1) * decay;
return { note, score };
})
.sort((a, b) => b.score - a.score)
.slice(0, 50)
.map((s) => s.note);
set({ trendingNotes: scored, trendingLoading: false, lastUpdated: { ...get().lastUpdated, trending: Date.now() } });
// Cache note IDs + timestamp
localStorage.setItem(TRENDING_CACHE_KEY, JSON.stringify({
noteIds: scored.map((n) => n.id),
timestamp: Date.now(),
}));
} catch (err) {
set({ error: `Trending failed: ${err}`, trendingLoading: false });
}
},
}));