diff --git a/src/App.tsx b/src/App.tsx index 88d434d..2535ff1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,6 +21,7 @@ import { HashtagFeed } from "./components/feed/HashtagFeed"; import { PodcastsView } from "./components/podcast/PodcastsView"; import { PodcastPlayerBar } from "./components/podcast/PodcastPlayerBar"; import { ToastContainer } from "./components/shared/ToastContainer"; +import { DebugPanel } from "./components/shared/DebugPanel"; import { HelpModal } from "./components/shared/HelpModal"; import { useUIStore } from "./stores/ui"; import { useUpdater } from "./hooks/useUpdater"; @@ -53,6 +54,8 @@ function App() { const currentView = useUIStore((s) => s.currentView); const showHelp = useUIStore((s) => s.showHelp); const toggleHelp = useUIStore((s) => s.toggleHelp); + const showDebugPanel = useUIStore((s) => s.showDebugPanel); + const toggleDebugPanel = useUIStore((s) => s.toggleDebugPanel); const [onboardingDone, setOnboardingDone] = useState( () => !!localStorage.getItem("wrystr_pubkey") ); @@ -110,6 +113,7 @@ function App() { + {showDebugPanel && } {showHelp && } ); diff --git a/src/components/shared/DebugPanel.tsx b/src/components/shared/DebugPanel.tsx new file mode 100644 index 0000000..5a7e260 --- /dev/null +++ b/src/components/shared/DebugPanel.tsx @@ -0,0 +1,140 @@ +import { useState, useEffect } from "react"; +import { getNDK, getNDKUptimeMs } from "../../lib/nostr"; +import { isLiveSubActive, useFeedStore } from "../../stores/feed"; +import { getRecentDiagEntries, type DiagEntry } from "../../lib/feedDiagnostics"; + +interface RelayInfo { + url: string; + connected: boolean; +} + +interface DebugState { + uptimeMs: number | null; + liveSubActive: boolean; + relays: RelayInfo[]; + lastUpdated: Record; + recentDiag: DiagEntry[]; +} + +function formatUptime(ms: number): string { + const sec = Math.floor(ms / 1000); + if (sec < 60) return `${sec}s`; + const min = Math.floor(sec / 60); + const remSec = sec % 60; + if (min < 60) return `${min}m ${remSec}s`; + const hr = Math.floor(min / 60); + const remMin = min % 60; + return `${hr}h ${remMin}m`; +} + +function shortenUrl(url: string): string { + return url.replace(/^wss?:\/\//, "").replace(/\/$/, ""); +} + +function timeAgo(ts: number): string { + const sec = Math.floor((Date.now() - ts) / 1000); + if (sec < 5) return "just now"; + if (sec < 60) return `${sec}s ago`; + const min = Math.floor(sec / 60); + if (min < 60) return `${min}m ago`; + const hr = Math.floor(min / 60); + return `${hr}h ago`; +} + +function readState(): DebugState { + const ndk = getNDK(); + const relays = Array.from(ndk.pool?.relays?.values() ?? []).map((r) => ({ + url: r.url, + connected: r.connected, + })); + return { + uptimeMs: getNDKUptimeMs(), + liveSubActive: isLiveSubActive(), + relays, + lastUpdated: useFeedStore.getState().lastUpdated, + recentDiag: getRecentDiagEntries(5), + }; +} + +export function DebugPanel({ onClose }: { onClose: () => void }) { + const [state, setState] = useState(readState); + + useEffect(() => { + const id = setInterval(() => setState(readState()), 2000); + return () => clearInterval(id); + }, []); + + const connectedCount = state.relays.filter((r) => r.connected).length; + + return ( +
+ {/* Header */} +
+ Debug + +
+ + {/* Uptime + Live Sub */} +
+ + NDK uptime: {state.uptimeMs !== null ? formatUptime(state.uptimeMs) : "—"} + + + + live sub {state.liveSubActive ? "on" : "off"} + +
+ + {/* Relays */} +
+
Relays ({connectedCount}/{state.relays.length})
+
+ {state.relays.map((r) => ( +
+ + {shortenUrl(r.url)} +
+ ))} +
+
+ + {/* Feed Timestamps */} +
+
Last updated
+
+ {(["global", "following", "trending"] as const).map((tab) => ( +
+ {tab}: + {state.lastUpdated[tab] ? timeAgo(state.lastUpdated[tab]) : "—"} +
+ ))} +
+
+ + {/* Recent Diagnostics */} +
+
Recent log
+ {state.recentDiag.length === 0 ? ( + No entries + ) : ( +
+ {state.recentDiag.map((d, i) => ( +
+ + {new Date(d.ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" })} + + {d.action} + {d.durationMs !== undefined && ( + {d.durationMs}ms + )} + {d.eventsReturned !== undefined && ( + {d.eventsReturned}ev + )} +
+ ))} +
+ )} +
+
+ ); +} diff --git a/src/hooks/useKeyboardShortcuts.ts b/src/hooks/useKeyboardShortcuts.ts index e869c4d..82d40fa 100644 --- a/src/hooks/useKeyboardShortcuts.ts +++ b/src/hooks/useKeyboardShortcuts.ts @@ -3,11 +3,18 @@ import { useUIStore } from "../stores/ui"; import { useFeedStore } from "../stores/feed"; export function useKeyboardShortcuts() { - const { currentView, setView, goBack, toggleHelp } = useUIStore(); + const { currentView, setView, goBack, toggleHelp, showDebugPanel, toggleDebugPanel } = useUIStore(); const { focusedNoteIndex, setFocusedNoteIndex, notes } = useFeedStore(); useEffect(() => { const handler = (e: KeyboardEvent) => { + // Ctrl+Shift+D works everywhere, even in text fields + if (e.ctrlKey && e.shiftKey && e.key === "D") { + e.preventDefault(); + toggleDebugPanel(); + return; + } + const tag = (e.target as HTMLElement).tagName; if (tag === "INPUT" || tag === "TEXTAREA" || (e.target as HTMLElement).isContentEditable) return; switch (e.key) { @@ -21,6 +28,7 @@ export function useKeyboardShortcuts() { setTimeout(() => (document.querySelector("[data-search-input]") as HTMLInputElement)?.focus(), 50); break; case "Escape": + if (showDebugPanel) { toggleDebugPanel(); break; } goBack(); break; case "?": @@ -38,5 +46,5 @@ export function useKeyboardShortcuts() { }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [currentView, focusedNoteIndex, notes.length]); + }, [currentView, focusedNoteIndex, notes.length, showDebugPanel]); } diff --git a/src/lib/feedDiagnostics.ts b/src/lib/feedDiagnostics.ts index 523b608..5aaba18 100644 --- a/src/lib/feedDiagnostics.ts +++ b/src/lib/feedDiagnostics.ts @@ -32,6 +32,10 @@ function getLog(): DiagEntry[] { } } +export function getRecentDiagEntries(count = 5): DiagEntry[] { + return getLog().slice(-count).reverse(); +} + function saveLog(entries: DiagEntry[]) { localStorage.setItem(DIAG_KEY, JSON.stringify(entries.slice(-MAX_ENTRIES))); } diff --git a/src/lib/nostr/core.ts b/src/lib/nostr/core.ts index bbc6972..0e516fd 100644 --- a/src/lib/nostr/core.ts +++ b/src/lib/nostr/core.ts @@ -57,16 +57,22 @@ export function saveRelayUrls(urls: string[]) { } let ndk: NDK | null = null; +let ndkCreatedAt: number | null = null; export function getNDK(): NDK { if (!ndk) { ndk = new NDK({ explicitRelayUrls: getStoredRelayUrls(), }); + ndkCreatedAt = Date.now(); } return ndk; } +export function getNDKUptimeMs(): number | null { + return ndkCreatedAt ? Date.now() - ndkCreatedAt : null; +} + /** * Destroy the current NDK instance and create a fresh one. * Preserves the signer (login state) but resets all relay connections. @@ -87,6 +93,7 @@ export async function resetNDK(): Promise { ndk = new NDK({ explicitRelayUrls: getStoredRelayUrls(), }); + ndkCreatedAt = Date.now(); // Restore signer so user stays logged in if (oldSigner) { diff --git a/src/lib/nostr/index.ts b/src/lib/nostr/index.ts index 31aa1d1..ad97af3 100644 --- a/src/lib/nostr/index.ts +++ b/src/lib/nostr/index.ts @@ -1,4 +1,4 @@ -export { getNDK, connectToRelays, ensureConnected, resetNDK, getStoredRelayUrls, addRelay, removeRelay, fetchWithTimeout, withTimeout, FEED_TIMEOUT, THREAD_TIMEOUT, SINGLE_TIMEOUT } from "./core"; +export { getNDK, getNDKUptimeMs, connectToRelays, ensureConnected, resetNDK, getStoredRelayUrls, addRelay, removeRelay, fetchWithTimeout, withTimeout, FEED_TIMEOUT, THREAD_TIMEOUT, SINGLE_TIMEOUT } from "./core"; export { fetchGlobalFeed, fetchFollowFeed, fetchUserNotes, fetchUserNotesNIP65, fetchNoteById, fetchReplies, publishNote, publishReply, publishRepost, publishQuote, fetchHashtagFeed, fetchThreadEvents, fetchAncestors } from "./notes"; export { publishProfile, publishContactList, fetchProfile, fetchFollowSuggestions, fetchMentions, fetchNewFollowers } from "./social"; export { publishArticle, fetchArticle, fetchAuthorArticles, fetchArticleFeed, searchArticles, fetchByAddr } from "./articles"; diff --git a/src/stores/feed.ts b/src/stores/feed.ts index 31dff61..749e269 100644 --- a/src/stores/feed.ts +++ b/src/stores/feed.ts @@ -11,6 +11,10 @@ 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 | null = null; interface FeedState { diff --git a/src/stores/ui.ts b/src/stores/ui.ts index d3b4fc0..302b737 100644 --- a/src/stores/ui.ts +++ b/src/stores/ui.ts @@ -27,6 +27,7 @@ interface UIState { pendingArticleEvent: NDKEvent | null; pendingHashtag: string | null; showHelp: boolean; + showDebugPanel: boolean; feedLanguageFilter: string | null; setView: (view: View) => void; setFeedTab: (tab: FeedTab) => void; @@ -40,6 +41,7 @@ interface UIState { setFeedLanguageFilter: (filter: string | null) => void; toggleSidebar: () => void; toggleHelp: () => void; + toggleDebugPanel: () => void; } const SIDEBAR_KEY = "wrystr_sidebar_collapsed"; @@ -58,6 +60,7 @@ export const useUIStore = create((set, _get) => ({ pendingArticleEvent: null, pendingHashtag: null, showHelp: false, + showDebugPanel: false, feedLanguageFilter: null, setView: (currentView) => set({ currentView }), setFeedTab: (feedTab) => set({ feedTab }), @@ -94,4 +97,5 @@ export const useUIStore = create((set, _get) => ({ return { sidebarCollapsed: next }; }), toggleHelp: () => set((s) => ({ showHelp: !s.showHelp })), + toggleDebugPanel: () => set((s) => ({ showDebugPanel: !s.showDebugPanel })), }));