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:
Jure
2026-04-01 12:10:11 +02:00
parent c1029327e7
commit e3f5020eeb
14 changed files with 1342 additions and 7 deletions

View File

@@ -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 &middot; {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
View 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)`);
}

View File

@@ -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`);

View File

@@ -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(),