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
This commit is contained in:
Jure
2026-03-23 11:28:53 +01:00
parent c9a92b9b47
commit ac4e39fcbf
12 changed files with 337 additions and 166 deletions

View File

@@ -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<string, number>;
trendingNotes: NDKEvent[];
trendingLoading: boolean;
connect: () => Promise<void>;
@@ -34,6 +36,7 @@ export const useFeedStore = create<FeedState>((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<FeedState>((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<FeedState>((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<FeedState>((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<FeedState>((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<FeedState>((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({

View File

@@ -16,9 +16,12 @@ export const useRelayHealthStore = create<RelayHealthState>((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 {

41
src/stores/toast.ts Normal file
View File

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