mirror of
https://github.com/hoornet/vega.git
synced 2026-05-06 12:19:11 -07:00
Add embedded Nostr relay with catch-up sync on startup
Embedded relay (strfry-lite in Rust) stores events locally in SQLite. On startup, syncs user notes, follow feed (24h), mentions (7d), and profile/contacts from remote relays into the local relay. Uses since timestamp for incremental syncs. Toggle in Settings with event count and DB size display. Add webkit2gtk GPU acceleration workaround and connectToRelays safety timeout for NDK hang.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { save } from "@tauri-apps/plugin-dialog";
|
||||
import { writeTextFile } from "@tauri-apps/plugin-fs";
|
||||
import { useUserStore } from "../../stores/user";
|
||||
@@ -8,12 +8,22 @@ import { useMuteStore } from "../../stores/mute";
|
||||
import { useBookmarkStore } from "../../stores/bookmark";
|
||||
import { getStoredRelayUrls } from "../../lib/nostr";
|
||||
import { useProfile } from "../../hooks/useProfile";
|
||||
import { profileName } from "../../lib/utils";
|
||||
import { NWCWizard } from "./NWCWizard";
|
||||
import { getNotificationSettings, saveNotificationSettings, ensurePermission } from "../../lib/notifications";
|
||||
import {
|
||||
isLocalRelayEnabled,
|
||||
setLocalRelayEnabled,
|
||||
connectLocalRelay,
|
||||
disconnectLocalRelay,
|
||||
getRelayPort,
|
||||
getRelayStats,
|
||||
type RelayStats,
|
||||
} from "../../lib/localRelay";
|
||||
|
||||
function MutedRow({ pubkey, onUnmute }: { pubkey: string; onUnmute: () => void }) {
|
||||
const profile = useProfile(pubkey);
|
||||
const name = profile?.displayName || profile?.name || pubkey.slice(0, 12) + "…";
|
||||
const name = profileName(profile, pubkey.slice(0, 12) + "…");
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-3 py-2 border border-border text-[12px] group">
|
||||
{profile?.picture && (
|
||||
@@ -355,6 +365,77 @@ function FontSizeSection() {
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function ExperimentalSection() {
|
||||
const [enabled, setEnabled] = useState(isLocalRelayEnabled);
|
||||
const [port, setPort] = useState<number | null>(null);
|
||||
const [stats, setStats] = useState<RelayStats | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled) {
|
||||
getRelayPort().then(setPort);
|
||||
getRelayStats().then(setStats);
|
||||
}
|
||||
}, [enabled]);
|
||||
|
||||
const toggle = () => {
|
||||
const next = !enabled;
|
||||
setEnabled(next);
|
||||
setLocalRelayEnabled(next);
|
||||
if (next) {
|
||||
connectLocalRelay().catch(() => {});
|
||||
} else {
|
||||
disconnectLocalRelay();
|
||||
setPort(null);
|
||||
setStats(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2 className="text-text text-[11px] font-medium uppercase tracking-widest mb-2 text-text-dim">
|
||||
Experimental
|
||||
</h2>
|
||||
<p className="text-text-dim text-[11px] mb-3">
|
||||
Features under development. May change or be removed.
|
||||
</p>
|
||||
<label className="flex items-center gap-3 cursor-pointer group">
|
||||
<button
|
||||
onClick={toggle}
|
||||
className={`w-9 h-5 rounded-full transition-colors relative shrink-0 ${
|
||||
enabled ? "bg-accent" : "bg-border"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white transition-transform ${
|
||||
enabled ? "translate-x-4" : "translate-x-0"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<span className="text-text text-[12px]">Personal relay</span>
|
||||
</label>
|
||||
<p className="text-text-dim text-[10px] mt-1.5 ml-12">
|
||||
Run a local Nostr relay for offline access and faster reads.
|
||||
</p>
|
||||
{enabled && (port || stats) && (
|
||||
<div className="text-text-dim text-[10px] mt-2 ml-12 space-y-0.5">
|
||||
{port && <p>Running on port {port}</p>}
|
||||
{stats && (
|
||||
<p>
|
||||
{stats.event_count} events stored · {formatBytes(stats.db_size_bytes)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsView() {
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
@@ -367,6 +448,7 @@ export function SettingsView() {
|
||||
<FontSizeSection />
|
||||
<WalletSection />
|
||||
<NotificationSection />
|
||||
<ExperimentalSection />
|
||||
<ExportSection />
|
||||
<IdentitySection />
|
||||
<MuteSection />
|
||||
|
||||
221
src/lib/localRelay.ts
Normal file
221
src/lib/localRelay.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { NDKEvent, NDKFilter, NDKKind, NDKRelay } from "@nostr-dev-kit/ndk";
|
||||
import { getNDK, fetchWithTimeout, FEED_TIMEOUT } from "./nostr";
|
||||
|
||||
const STORAGE_KEY = "vega_local_relay_enabled";
|
||||
const LAST_SYNC_KEY = "vega_local_relay_last_sync";
|
||||
const LOCAL_RELAY_PREFIX = "ws://127.0.0.1:48";
|
||||
|
||||
export function isLocalRelayEnabled(): boolean {
|
||||
return localStorage.getItem(STORAGE_KEY) === "true";
|
||||
}
|
||||
|
||||
export function setLocalRelayEnabled(enabled: boolean): void {
|
||||
localStorage.setItem(STORAGE_KEY, enabled ? "true" : "false");
|
||||
}
|
||||
|
||||
export async function getRelayPort(): Promise<number | null> {
|
||||
try {
|
||||
return await invoke<number | null>("relay_get_port");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface RelayStats {
|
||||
event_count: number;
|
||||
db_size_bytes: number;
|
||||
}
|
||||
|
||||
export async function getRelayStats(): Promise<RelayStats | null> {
|
||||
try {
|
||||
return await invoke<RelayStats>("relay_get_stats");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the local relay to NDK's pool without persisting to the relay list.
|
||||
* Retries once after 500ms if port isn't available yet (race with server startup).
|
||||
*/
|
||||
export async function connectLocalRelay(): Promise<void> {
|
||||
let port = await getRelayPort();
|
||||
if (port === null) {
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
port = await getRelayPort();
|
||||
}
|
||||
if (port === null) return;
|
||||
|
||||
const url = `ws://127.0.0.1:${port}`;
|
||||
const instance = getNDK();
|
||||
|
||||
if (instance.pool?.relays.has(url)) return;
|
||||
|
||||
const relay = new NDKRelay(url, undefined, instance);
|
||||
instance.pool?.addRelay(relay, true);
|
||||
console.log(`[Vega] Local relay connected: ${url}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove any local relay (ws://127.0.0.1:48XX) from NDK's pool.
|
||||
* Does NOT touch the stored relay list.
|
||||
*/
|
||||
export function disconnectLocalRelay(): void {
|
||||
const instance = getNDK();
|
||||
if (!instance.pool?.relays) return;
|
||||
|
||||
for (const [url, relay] of instance.pool.relays.entries()) {
|
||||
if (url.startsWith(LOCAL_RELAY_PREFIX)) {
|
||||
relay.disconnect();
|
||||
instance.pool.relays.delete(url);
|
||||
console.log(`[Vega] Local relay disconnected: ${url}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Catch-up sync ──────────────────────────────────────────────────────────
|
||||
|
||||
function getLastSyncTimestamp(): number | null {
|
||||
const stored = localStorage.getItem(LAST_SYNC_KEY);
|
||||
return stored ? parseInt(stored, 10) : null;
|
||||
}
|
||||
|
||||
function setLastSyncTimestamp(ts: number): void {
|
||||
localStorage.setItem(LAST_SYNC_KEY, String(ts));
|
||||
}
|
||||
|
||||
/**
|
||||
* Write events to the local relay via a direct WebSocket connection.
|
||||
* Bypasses NDK's publish to avoid overwhelming WebKit.
|
||||
* Sends NIP-01 EVENT messages one at a time sequentially.
|
||||
*/
|
||||
async function writeEventsToLocalRelay(events: NDKEvent[]): Promise<number> {
|
||||
if (events.length === 0) return 0;
|
||||
|
||||
const port = await getRelayPort();
|
||||
if (!port) return 0;
|
||||
|
||||
const url = `ws://127.0.0.1:${port}`;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const ws = new WebSocket(url);
|
||||
let written = 0;
|
||||
let idx = 0;
|
||||
|
||||
const sendNext = () => {
|
||||
if (idx >= events.length) {
|
||||
ws.close();
|
||||
resolve(written);
|
||||
return;
|
||||
}
|
||||
const event = events[idx++];
|
||||
const raw = event.rawEvent();
|
||||
ws.send(JSON.stringify(["EVENT", raw]));
|
||||
};
|
||||
|
||||
ws.onopen = () => sendNext();
|
||||
|
||||
ws.onmessage = (msg) => {
|
||||
try {
|
||||
const parsed = JSON.parse(msg.data);
|
||||
// ["OK", id, success, message]
|
||||
if (parsed[0] === "OK" && parsed[2]) {
|
||||
written++;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
sendNext();
|
||||
};
|
||||
|
||||
ws.onerror = () => resolve(written);
|
||||
ws.onclose = () => resolve(written);
|
||||
|
||||
// Safety timeout — don't hang forever
|
||||
setTimeout(() => {
|
||||
ws.close();
|
||||
resolve(written);
|
||||
}, 30000);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync recent events from remote relays into the local relay.
|
||||
* Non-blocking — intended to run in background after connect.
|
||||
*/
|
||||
export async function syncToLocalRelay(
|
||||
userPubkey: string,
|
||||
followPubkeys: string[],
|
||||
): Promise<void> {
|
||||
console.log("[Vega] Starting local relay catch-up...");
|
||||
const syncStart = performance.now();
|
||||
const instance = getNDK();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const lastSync = getLastSyncTimestamp();
|
||||
|
||||
// Determine time windows
|
||||
const feedSince = lastSync ?? (now - 24 * 3600); // 24h or since last sync
|
||||
const mentionsSince = lastSync ?? (now - 7 * 24 * 3600); // 7 days or since last sync
|
||||
|
||||
const allEvents: NDKEvent[] = [];
|
||||
|
||||
// 1. User's own notes (last 50, all-time for first sync)
|
||||
try {
|
||||
const filter: NDKFilter = { kinds: [NDKKind.Text], authors: [userPubkey], limit: 50 };
|
||||
const events = await fetchWithTimeout(instance, filter, FEED_TIMEOUT);
|
||||
allEvents.push(...Array.from(events));
|
||||
} catch { /* continue */ }
|
||||
|
||||
// 2. User's profile (kind 0) and contact list (kind 3)
|
||||
try {
|
||||
const filter: NDKFilter = { kinds: [0 as NDKKind, 3 as NDKKind], authors: [userPubkey], limit: 2 };
|
||||
const events = await fetchWithTimeout(instance, filter, FEED_TIMEOUT);
|
||||
allEvents.push(...Array.from(events));
|
||||
} catch { /* continue */ }
|
||||
|
||||
// 3. Follow feed (since last sync or 24h)
|
||||
if (followPubkeys.length > 0) {
|
||||
try {
|
||||
// Batch follows to avoid oversized filters
|
||||
const batchSize = 50;
|
||||
for (let i = 0; i < followPubkeys.length; i += batchSize) {
|
||||
const batch = followPubkeys.slice(i, i + batchSize);
|
||||
const filter: NDKFilter = {
|
||||
kinds: [NDKKind.Text],
|
||||
authors: batch,
|
||||
since: feedSince,
|
||||
limit: 100,
|
||||
};
|
||||
const events = await fetchWithTimeout(instance, filter, FEED_TIMEOUT);
|
||||
allEvents.push(...Array.from(events));
|
||||
}
|
||||
} catch { /* continue */ }
|
||||
}
|
||||
|
||||
// 4. Mentions (since last sync or 7 days)
|
||||
try {
|
||||
const filter: NDKFilter = {
|
||||
kinds: [NDKKind.Text],
|
||||
"#p": [userPubkey],
|
||||
since: mentionsSince,
|
||||
limit: 100,
|
||||
};
|
||||
const events = await fetchWithTimeout(instance, filter, 12000);
|
||||
allEvents.push(...Array.from(events));
|
||||
} catch { /* continue */ }
|
||||
|
||||
// Deduplicate by event ID
|
||||
const seen = new Set<string>();
|
||||
const unique = allEvents.filter((e) => {
|
||||
const id = e.id;
|
||||
if (seen.has(id)) return false;
|
||||
seen.add(id);
|
||||
return true;
|
||||
});
|
||||
|
||||
// Write to local relay
|
||||
const written = await writeEventsToLocalRelay(unique);
|
||||
setLastSyncTimestamp(now);
|
||||
|
||||
const elapsed = Math.round(performance.now() - syncStart);
|
||||
console.log(`[Vega] Synced ${written}/${unique.length} events to local relay (${elapsed}ms)`);
|
||||
}
|
||||
@@ -127,6 +127,14 @@ export async function resetNDK(): Promise<void> {
|
||||
console.log("[Vega] NDK instance reset — connecting fresh relays");
|
||||
await ndk.connect();
|
||||
await waitForConnectedRelay(ndk, 5000);
|
||||
|
||||
// Re-add local relay if enabled (dynamic import to avoid circular dependency)
|
||||
import("../localRelay").then(({ isLocalRelayEnabled, connectLocalRelay }) => {
|
||||
if (isLocalRelayEnabled()) {
|
||||
connectLocalRelay().catch(() => {});
|
||||
}
|
||||
}).catch(() => {});
|
||||
|
||||
const relays = Array.from(ndk.pool?.relays?.values() ?? []);
|
||||
const connected = relays.filter((r) => r.connected).length;
|
||||
console.log(`[Vega] Fresh connection: ${connected}/${relays.length} relays connected`);
|
||||
|
||||
@@ -5,6 +5,8 @@ import { seedReactionsCache } from "../hooks/useReactions";
|
||||
import { useToastStore } from "./toast";
|
||||
import { dbLoadFeed, dbSaveNotes } from "../lib/db";
|
||||
import { diagWrapFetch, logDiag, startRelaySnapshots, getRelayStates } from "../lib/feedDiagnostics";
|
||||
// Local relay imports deferred to avoid circular dependency
|
||||
// import { isLocalRelayEnabled, connectLocalRelay } from "../lib/localRelay";
|
||||
|
||||
const TRENDING_CACHE_KEY = "wrystr_trending_cache";
|
||||
const TRENDING_TTL = 10 * 60 * 1000; // 10 minutes
|
||||
@@ -50,8 +52,30 @@ export const useFeedStore = create<FeedState>((set, get) => ({
|
||||
try {
|
||||
set({ error: null });
|
||||
const connectStart = performance.now();
|
||||
await connectToRelays();
|
||||
// connectToRelays() can hang if NDK's instance.connect() never resolves — safety timeout
|
||||
await Promise.race([
|
||||
connectToRelays(),
|
||||
new Promise<void>((resolve) => setTimeout(resolve, 15000)),
|
||||
]);
|
||||
set({ connected: true });
|
||||
|
||||
// Connect local embedded relay if enabled, then sync recent events
|
||||
try {
|
||||
const { isLocalRelayEnabled, connectLocalRelay, syncToLocalRelay } = await import("../lib/localRelay");
|
||||
if (isLocalRelayEnabled()) {
|
||||
await connectLocalRelay();
|
||||
const { useUserStore } = await import("./user");
|
||||
const { pubkey, follows } = useUserStore.getState();
|
||||
if (pubkey) {
|
||||
syncToLocalRelay(pubkey, follows).catch((err) =>
|
||||
console.warn("[Vega] Local relay sync failed:", err),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[Vega] Local relay setup failed:", err);
|
||||
}
|
||||
|
||||
const connectMs = Math.round(performance.now() - connectStart);
|
||||
logDiag({
|
||||
ts: new Date().toISOString(),
|
||||
|
||||
Reference in New Issue
Block a user