From ac4e39fcbfe4fa60bdfd51a2b288d96b1f837b75 Mon Sep 17 00:00:00 2001 From: Jure <44338+hoornet@users.noreply.github.com> Date: Mon, 23 Mar 2026 11:28:53 +0100 Subject: [PATCH] Add relay status badge, toast notifications, per-tab feed timestamps, relay UX improvements - Relay status badge in feed header: shows connected/total relay count with color coding (green >75%, yellow 25-75%, red <25%), hover tooltip with per-relay status - Toast notification system: transient messages for connection lost, reconnecting, relay reset, and back-online events - Per-tab "last updated" relative timestamp in feed header (global, following, trending tracked independently) - Consolidated all relay management into RelaysView (removed duplicate relay section from Settings); per-relay remove button on health cards - Show all supported NIP badges on relay cards (was filtering to 11 notable) - Tooltips on relay status dots explaining green/yellow/red/gray meaning - Fix relay removal with trailing-slash URL normalization - Fix stale health results lingering after relay removal - Add acknowledgements section to README --- README.md | 11 ++ src/App.tsx | 2 + src/components/feed/Feed.tsx | 36 ++++-- src/components/feed/RelayStatusBadge.tsx | 57 ++++++++++ src/components/shared/RelaysView.tsx | 136 +++++++++++++++++------ src/components/shared/SettingsView.tsx | 114 +------------------ src/components/shared/ToastContainer.tsx | 35 ++++++ src/hooks/useRelayStatus.ts | 37 ++++++ src/lib/nostr/core.ts | 12 +- src/stores/feed.ts | 15 ++- src/stores/relayHealth.ts | 7 +- src/stores/toast.ts | 41 +++++++ 12 files changed, 337 insertions(+), 166 deletions(-) create mode 100644 src/components/feed/RelayStatusBadge.tsx create mode 100644 src/components/shared/ToastContainer.tsx create mode 100644 src/hooks/useRelayStatus.ts create mode 100644 src/stores/toast.ts diff --git a/README.md b/README.md index 4514335..35d33cd 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,17 @@ Wrystr is free and open-source. If it's useful to you: | ♥ GitHub Sponsors | [github.com/sponsors/hoornet](https://github.com/sponsors/hoornet) | | ★ GitHub star | Helps with visibility and grant applications | +## Acknowledgements + +Wrystr is built on the shoulders of excellent open-source projects: + +- [Tauri](https://tauri.app/) — the desktop shell that makes cross-platform Rust+Web apps possible +- [NDK (Nostr Dev Kit)](https://github.com/nostr-dev-kit/ndk) by [Pablo Fernandez](https://github.com/pablof7z) — the Nostr protocol library that powers all relay communication +- [Nostr protocol](https://github.com/nostr-protocol/nostr) by [fiatjaf](https://github.com/fiatjaf) — the protocol itself +- [React](https://react.dev/), [Vite](https://vite.dev/), [Tailwind CSS](https://tailwindcss.com/), [Zustand](https://github.com/pmndrs/zustand) — the frontend stack +- [ants](https://github.com/dergigi/ants) by [Gigi](https://github.com/dergigi) — inspiration and Nostr ecosystem advocacy +- The Nostr community — for building an open, censorship-resistant communication layer + ## License MIT License — Copyright (c) 2026 Jure Sršen — [hoornet](https://github.com/hoornet) diff --git a/src/App.tsx b/src/App.tsx index b9680ed..88d434d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,6 +20,7 @@ import { BookmarkView } from "./components/bookmark/BookmarkView"; 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 { HelpModal } from "./components/shared/HelpModal"; import { useUIStore } from "./stores/ui"; import { useUpdater } from "./hooks/useUpdater"; @@ -108,6 +109,7 @@ function App() { + {showHelp && } ); diff --git a/src/components/feed/Feed.tsx b/src/components/feed/Feed.tsx index 64a4718..0d88ab1 100644 --- a/src/components/feed/Feed.tsx +++ b/src/components/feed/Feed.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useState, useCallback } from "react"; import { useFeedStore } from "../../stores/feed"; import { useUserStore } from "../../stores/user"; import { useMuteStore } from "../../stores/mute"; @@ -10,15 +10,33 @@ import { NoteCard } from "./NoteCard"; import { ArticleCard } from "../article/ArticleCard"; import { ComposeBox } from "./ComposeBox"; import { SkeletonNoteList } from "../shared/Skeleton"; +import { RelayStatusBadge } from "./RelayStatusBadge"; import { NDKEvent } from "@nostr-dev-kit/ndk"; +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`; +} + export function Feed() { - const { notes, loading, connected, error, connect, loadCachedFeed, loadFeed, trendingNotes, trendingLoading, loadTrendingFeed, focusedNoteIndex } = useFeedStore(); + const { notes, loading, error, connect, loadCachedFeed, loadFeed, trendingNotes, trendingLoading, loadTrendingFeed, focusedNoteIndex, lastUpdated } = useFeedStore(); const { loggedIn, follows } = useUserStore(); const { mutedPubkeys, contentMatchesMutedKeyword } = useMuteStore(); const { feedTab: tab, setFeedTab: setTab, feedLanguageFilter, setFeedLanguageFilter } = useUIStore(); const [followNotes, setFollowNotes] = useState([]); const [followLoading, setFollowLoading] = useState(false); + const [, setTick] = useState(0); + + // Tick every 10s to keep "last updated" relative time fresh + useEffect(() => { + const id = setInterval(() => setTick((t) => t + 1), 10000); + return () => clearInterval(id); + }, []); useEffect(() => { // Show cached notes immediately, then fetch fresh ones once connected @@ -36,16 +54,18 @@ export function Feed() { } }, [tab, follows]); - const loadFollowFeed = async () => { + const loadFollowFeed = useCallback(async () => { setFollowLoading(true); try { await ensureConnected(); const events = await diagWrapFetch("follow_fetch", () => fetchFollowFeed(follows)); setFollowNotes(events); + const prev = useFeedStore.getState().lastUpdated; + useFeedStore.setState({ lastUpdated: { ...prev, following: Date.now() } }); } finally { setFollowLoading(false); } - }; + }, [follows]); const isFollowing = tab === "following"; const isTrending = tab === "trending"; @@ -135,11 +155,9 @@ export function Feed() { ))} - {connected && ( - - - connected - + + {lastUpdated[tab] && ( + {timeAgo(lastUpdated[tab])} )} {expanded ? "▾" : "▸"} @@ -105,9 +113,6 @@ function RelayHealthCard({ result, poolConnected }: { result: RelayHealthResult; {nip11.supported_nips && nip11.supported_nips.length > 0 && (
- - {nip11.supported_nips.length} NIPs supported -
)} @@ -125,12 +130,21 @@ function RelayHealthCard({ result, poolConnected }: { result: RelayHealthResult; } /** Fallback row for relays not yet health-checked */ -function RelayPoolRow({ url, connected }: { url: string; connected: boolean }) { +function RelayPoolRow({ url, connected, onRemove }: { url: string; connected: boolean; onRemove: () => void }) { return ( -
- +
+ {url} {connected ? "connected" : "—"} +
); } @@ -142,6 +156,11 @@ export function RelaysView() { const poolRelays = Array.from(ndk.pool?.relays?.values() ?? []); const poolConnectedUrls = new Set(poolRelays.filter((r) => r.connected).map((r) => r.url)); + const [input, setInput] = useState(""); + const [addError, setAddError] = useState(null); + const [removing, setRemoving] = useState(false); + const [republishing, setRepublishing] = useState(false); + // Auto-check on first mount if no results yet useEffect(() => { if (results.length === 0 && !checking) { @@ -154,15 +173,33 @@ export function RelaysView() { const offlineCount = results.filter((r) => r.status === "offline").length; const deadRelays = results.filter((r) => r.status === "offline"); - const [removing, setRemoving] = useState(false); - const [republishing, setRepublishing] = useState(false); + const handleAddRelay = () => { + const url = input.trim(); + if (!url) return; + if (!url.startsWith("ws://") && !url.startsWith("wss://")) { + setAddError("URL must start with ws:// or wss://"); + return; + } + if (getStoredRelayUrls().includes(url)) { + setAddError("Already in list"); + return; + } + addRelay(url); + setInput(""); + setAddError(null); + checkAll(); + }; + + const handleRemoveRelay = (url: string) => { + removeRelay(url); + checkAll(); + }; const handleRemoveDead = async () => { setRemoving(true); for (const r of deadRelays) { removeRelay(r.url); } - // Re-check remaining await checkAll(); setRemoving(false); }; @@ -175,6 +212,11 @@ export function RelaysView() { setRepublishing(false); }; + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") handleAddRelay(); + if (e.key === "Escape") setInput(""); + }; + // Merge: show health results first, then any pool relays not yet checked const checkedUrls = new Set(results.map((r) => r.url)); const uncheckedPoolRelays = poolRelays.filter((r) => !checkedUrls.has(r.url)); @@ -221,30 +263,48 @@ export function RelaysView() {
+ {/* Add relay input */} +
+
+ { setInput(e.target.value); setAddError(null); }} + onKeyDown={handleKeyDown} + placeholder="wss://relay.example.com" + className="flex-1 bg-bg border border-border px-3 py-1.5 text-text text-[12px] font-mono focus:outline-none focus:border-accent/50 placeholder:text-text-dim" + /> + + {loggedIn && !!getNDK().signer && ( + + )} +
+ {addError &&

{addError}

} +
+ {/* Actions bar — show when there are dead relays */} {deadRelays.length > 0 && (
{deadRelays.length} relay{deadRelays.length > 1 ? "s" : ""} offline -
- - {loggedIn && !!getNDK().signer && ( - - )} -
+
)} @@ -259,11 +319,17 @@ export function RelaysView() { key={result.url} result={result} poolConnected={poolConnectedUrls.has(result.url)} + onRemove={() => handleRemoveRelay(result.url)} /> ))} {uncheckedPoolRelays.map((relay) => ( - + handleRemoveRelay(relay.url)} + /> ))} diff --git a/src/components/shared/SettingsView.tsx b/src/components/shared/SettingsView.tsx index ac44273..d921a09 100644 --- a/src/components/shared/SettingsView.tsx +++ b/src/components/shared/SettingsView.tsx @@ -4,7 +4,7 @@ import { writeTextFile } from "@tauri-apps/plugin-fs"; import { useUserStore } from "../../stores/user"; import { useMuteStore } from "../../stores/mute"; import { useBookmarkStore } from "../../stores/bookmark"; -import { getNDK, getStoredRelayUrls, addRelay, removeRelay, publishRelayList } from "../../lib/nostr"; +import { getStoredRelayUrls } from "../../lib/nostr"; import { useProfile } from "../../hooks/useProfile"; import { NWCWizard } from "./NWCWizard"; import { getNotificationSettings, saveNotificationSettings, ensurePermission } from "../../lib/notifications"; @@ -113,117 +113,6 @@ function MutedKeywordsSection() { ); } -function RelayRow({ url, onRemove }: { url: string; onRemove: () => void }) { - const ndk = getNDK(); - const relay = ndk.pool?.relays.get(url); - const connected = relay?.connected ?? false; - - return ( -
- - {url} - -
- ); -} - -function RelaySection() { - const { loggedIn } = useUserStore(); - const [relays, setRelays] = useState(() => getStoredRelayUrls()); - const [input, setInput] = useState(""); - const [error, setError] = useState(null); - const [publishing, setPublishing] = useState(false); - const [publishedAt, setPublishedAt] = useState(null); - - const handleAdd = () => { - const url = input.trim(); - if (!url) return; - if (!url.startsWith("ws://") && !url.startsWith("wss://")) { - setError("URL must start with ws:// or wss://"); - return; - } - if (relays.includes(url)) { - setError("Already in list"); - return; - } - addRelay(url); - setRelays(getStoredRelayUrls()); - setInput(""); - setError(null); - }; - - const handleRemove = (url: string) => { - removeRelay(url); - setRelays(getStoredRelayUrls()); - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter") handleAdd(); - if (e.key === "Escape") setInput(""); - }; - - const handlePublishRelayList = async () => { - setPublishing(true); - try { - await publishRelayList(getStoredRelayUrls()); - setPublishedAt(Date.now()); - } catch { - // ignore — publishing failure is non-critical - } finally { - setPublishing(false); - } - }; - - return ( -
-

Relays

-
- {relays.length === 0 && ( -

No relays configured.

- )} - {relays.map((url) => ( - handleRemove(url)} /> - ))} -
-
- { setInput(e.target.value); setError(null); }} - onKeyDown={handleKeyDown} - placeholder="wss://relay.example.com" - className="flex-1 bg-bg border border-border px-3 py-1.5 text-text text-[12px] font-mono focus:outline-none focus:border-accent/50 placeholder:text-text-dim" - /> - -
- {error &&

{error}

} - {loggedIn && !!getNDK().signer && ( -
- -

- Saves your relay list as a kind 10002 event (NIP-65) so other clients can find your notes. -

-
- )} -
- ); -} - function IdentitySection() { const { npub, loggedIn } = useUserStore(); const [copied, setCopied] = useState(false); @@ -391,7 +280,6 @@ export function SettingsView() {
- diff --git a/src/components/shared/ToastContainer.tsx b/src/components/shared/ToastContainer.tsx new file mode 100644 index 0000000..8c1f2da --- /dev/null +++ b/src/components/shared/ToastContainer.tsx @@ -0,0 +1,35 @@ +import { useToastStore } from "../../stores/toast"; + +const accentColor: Record = { + info: "bg-accent", + warning: "bg-warning", + success: "bg-success", +}; + +export function ToastContainer() { + const { toasts, removeToast } = useToastStore(); + + if (toasts.length === 0) return null; + + return ( +
+ {toasts.map((toast) => ( +
+
+
+ {toast.message} + +
+
+ ))} +
+ ); +} diff --git a/src/hooks/useRelayStatus.ts b/src/hooks/useRelayStatus.ts new file mode 100644 index 0000000..4107309 --- /dev/null +++ b/src/hooks/useRelayStatus.ts @@ -0,0 +1,37 @@ +import { useState, useEffect } from "react"; +import { getNDK } from "../lib/nostr"; + +interface RelayInfo { + url: string; + connected: boolean; +} + +interface RelayStatus { + connectedCount: number; + totalCount: number; + relays: RelayInfo[]; +} + +function readPool(): RelayStatus { + const ndk = getNDK(); + const relays = Array.from(ndk.pool?.relays?.values() ?? []).map((r) => ({ + url: r.url, + connected: r.connected, + })); + return { + connectedCount: relays.filter((r) => r.connected).length, + totalCount: relays.length, + relays, + }; +} + +export function useRelayStatus(): RelayStatus { + const [status, setStatus] = useState(readPool); + + useEffect(() => { + const id = setInterval(() => setStatus(readPool()), 5000); + return () => clearInterval(id); + }, []); + + return status; +} diff --git a/src/lib/nostr/core.ts b/src/lib/nostr/core.ts index 2775ba8..bbc6972 100644 --- a/src/lib/nostr/core.ts +++ b/src/lib/nostr/core.ts @@ -116,10 +116,14 @@ export function addRelay(url: string): void { export function removeRelay(url: string): void { const instance = getNDK(); - const relay = instance.pool?.relays.get(url); - if (relay) { - relay.disconnect(); - instance.pool?.relays.delete(url); + // NDK may store URLs with or without trailing slash — check both + const variants = [url, url.replace(/\/$/, ""), url.replace(/\/?$/, "/")]; + for (const v of variants) { + const relay = instance.pool?.relays.get(v); + if (relay) { + relay.disconnect(); + instance.pool?.relays.delete(v); + } } saveRelayUrls(getStoredRelayUrls().filter((u) => u !== url)); } diff --git a/src/stores/feed.ts b/src/stores/feed.ts index 492e286..31dff61 100644 --- a/src/stores/feed.ts +++ b/src/stores/feed.ts @@ -1,6 +1,7 @@ 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 { useToastStore } from "./toast"; import { dbLoadFeed, dbSaveNotes } from "../lib/db"; import { diagWrapFetch, logDiag, startRelaySnapshots, getRelayStates } from "../lib/feedDiagnostics"; @@ -18,6 +19,7 @@ interface FeedState { connected: boolean; error: string | null; focusedNoteIndex: number; + lastUpdated: Record; trendingNotes: NDKEvent[]; trendingLoading: boolean; connect: () => Promise; @@ -34,6 +36,7 @@ export const useFeedStore = create((set, get) => ({ connected: false, error: null, focusedNoteIndex: -1, + lastUpdated: {}, trendingNotes: [], trendingLoading: false, setFocusedNoteIndex: (n: number) => set({ focusedNoteIndex: n }), @@ -64,6 +67,9 @@ export const useFeedStore = create((set, get) => ({ 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 { @@ -72,14 +78,17 @@ export const useFeedStore = create((set, get) => ({ 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(); } @@ -126,7 +135,7 @@ export const useFeedStore = create((set, get) => ({ .sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)) .slice(0, MAX_FEED_SIZE); - set({ notes: merged, loading: false, focusedNoteIndex: -1 }); + 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()))); @@ -167,7 +176,7 @@ export const useFeedStore = create((set, get) => ({ const updated = [event, ...current] .sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)) .slice(0, MAX_FEED_SIZE); - set({ notes: updated }); + set({ notes: updated, lastUpdated: { ...get().lastUpdated, global: Date.now() } }); // Debounced save to SQLite — batch saves every 5s if (!saveTimer) { @@ -233,7 +242,7 @@ export const useFeedStore = create((set, get) => ({ .slice(0, 50) .map((s) => s.note); - set({ trendingNotes: scored, trendingLoading: false }); + set({ trendingNotes: scored, trendingLoading: false, lastUpdated: { ...get().lastUpdated, trending: Date.now() } }); // Cache note IDs + timestamp localStorage.setItem(TRENDING_CACHE_KEY, JSON.stringify({ diff --git a/src/stores/relayHealth.ts b/src/stores/relayHealth.ts index 7374a0b..ed611f2 100644 --- a/src/stores/relayHealth.ts +++ b/src/stores/relayHealth.ts @@ -16,9 +16,12 @@ export const useRelayHealthStore = create((set, get) => ({ checkAll: async () => { if (get().checking) return; - set({ checking: true }); + // Immediately prune stale results for relays no longer stored + const currentUrls = new Set(getStoredRelayUrls()); + const pruned = get().results.filter((r) => currentUrls.has(r.url)); + set({ checking: true, results: pruned }); try { - const urls = getStoredRelayUrls(); + const urls = Array.from(currentUrls); const results = await checkAllRelays(urls); set({ results, lastChecked: Date.now(), checking: false }); } catch { diff --git a/src/stores/toast.ts b/src/stores/toast.ts new file mode 100644 index 0000000..ae86567 --- /dev/null +++ b/src/stores/toast.ts @@ -0,0 +1,41 @@ +import { create } from "zustand"; + +interface Toast { + id: string; + message: string; + type: "info" | "warning" | "success"; + autoCloseMs: number; +} + +interface ToastState { + toasts: Toast[]; + addToast: (message: string, type: Toast["type"], autoCloseMs?: number) => void; + removeToast: (id: string) => void; +} + +const MAX_TOASTS = 5; + +export const useToastStore = create((set, get) => ({ + toasts: [], + + addToast: (message, type, autoCloseMs = 4000) => { + // Deduplicate — don't add if same message already visible + if (get().toasts.some((t) => t.message === message)) return; + + const id = crypto.randomUUID(); + const toast: Toast = { id, message, type, autoCloseMs }; + + set((state) => { + const toasts = [...state.toasts, toast]; + // Drop oldest if over limit + if (toasts.length > MAX_TOASTS) toasts.shift(); + return { toasts }; + }); + + setTimeout(() => get().removeToast(id), autoCloseMs); + }, + + removeToast: (id) => { + set((state) => ({ toasts: state.toasts.filter((t) => t.id !== id) })); + }, +}));