Bump to v0.7.1 — relay health checker, advanced search

This commit is contained in:
Jure
2026-03-19 19:23:39 +01:00
parent 092553ab9b
commit d257075023
24 changed files with 1550 additions and 68 deletions

109
src/stores/feed.test.ts Normal file
View File

@@ -0,0 +1,109 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NDKEvent } from "@nostr-dev-kit/ndk";
// Mock the nostr module
vi.mock("../lib/nostr", () => ({
connectToRelays: vi.fn(),
fetchGlobalFeed: vi.fn(),
fetchBatchEngagement: vi.fn(),
getNDK: vi.fn(() => ({ pool: { relays: new Map() } })),
}));
// Mock the db module
vi.mock("../lib/db", () => ({
dbLoadFeed: vi.fn().mockResolvedValue([]),
dbSaveNotes: vi.fn(),
}));
import { useFeedStore } from "./feed";
import { fetchGlobalFeed, fetchBatchEngagement } from "../lib/nostr";
function makeMockNote(id: string, created_at: number): NDKEvent {
const event = { id, created_at, content: "test", kind: 1, pubkey: "pk", tags: [], sig: "", rawEvent: () => ({ id, created_at, content: "test", kind: 1, pubkey: "pk", tags: [], sig: "" }) } as unknown as NDKEvent;
return event;
}
describe("useFeedStore - loadTrendingFeed", () => {
beforeEach(() => {
useFeedStore.setState({
notes: [],
trendingNotes: [],
trendingLoading: false,
loading: false,
connected: false,
error: null,
focusedNoteIndex: -1,
});
vi.clearAllMocks();
localStorage.clear();
});
it("scores and sorts notes by engagement", async () => {
const notes = [
makeMockNote("a", 1000),
makeMockNote("b", 1001),
makeMockNote("c", 1002),
];
const engagement = new Map([
["a", { reactions: 10, replies: 0, zapSats: 0 }], // score: 10
["b", { reactions: 0, replies: 5, zapSats: 0 }], // score: 15
["c", { reactions: 1, replies: 1, zapSats: 100 }], // score: 5
]);
vi.mocked(fetchGlobalFeed).mockResolvedValue(notes);
vi.mocked(fetchBatchEngagement).mockResolvedValue(engagement);
await useFeedStore.getState().loadTrendingFeed(true);
const trending = useFeedStore.getState().trendingNotes;
expect(trending).toHaveLength(3);
expect(trending[0].id).toBe("b"); // highest score: 15
expect(trending[1].id).toBe("a"); // score: 10
expect(trending[2].id).toBe("c"); // score: 5
});
it("filters out notes with zero engagement", async () => {
const notes = [
makeMockNote("a", 1000),
makeMockNote("b", 1001),
];
const engagement = new Map([
["a", { reactions: 5, replies: 0, zapSats: 0 }],
["b", { reactions: 0, replies: 0, zapSats: 0 }],
]);
vi.mocked(fetchGlobalFeed).mockResolvedValue(notes);
vi.mocked(fetchBatchEngagement).mockResolvedValue(engagement);
await useFeedStore.getState().loadTrendingFeed(true);
const trending = useFeedStore.getState().trendingNotes;
expect(trending).toHaveLength(1);
expect(trending[0].id).toBe("a");
});
it("limits results to 50", async () => {
const notes = Array.from({ length: 60 }, (_, i) => makeMockNote(`n${i}`, i));
const engagement = new Map(
notes.map((n) => [n.id, { reactions: 10, replies: 1, zapSats: 0 }])
);
vi.mocked(fetchGlobalFeed).mockResolvedValue(notes);
vi.mocked(fetchBatchEngagement).mockResolvedValue(engagement);
await useFeedStore.getState().loadTrendingFeed(true);
expect(useFeedStore.getState().trendingNotes).toHaveLength(50);
});
it("handles empty feed gracefully", async () => {
vi.mocked(fetchGlobalFeed).mockResolvedValue([]);
await useFeedStore.getState().loadTrendingFeed(true);
expect(useFeedStore.getState().trendingNotes).toHaveLength(0);
expect(useFeedStore.getState().trendingLoading).toBe(false);
});
});

28
src/stores/relayHealth.ts Normal file
View File

@@ -0,0 +1,28 @@
import { create } from "zustand";
import { checkAllRelays, type RelayHealthResult } from "../lib/nostr/relayHealth";
import { getStoredRelayUrls } from "../lib/nostr";
interface RelayHealthState {
results: RelayHealthResult[];
checking: boolean;
lastChecked: number | null;
checkAll: () => Promise<void>;
}
export const useRelayHealthStore = create<RelayHealthState>((set, get) => ({
results: [],
checking: false,
lastChecked: null,
checkAll: async () => {
if (get().checking) return;
set({ checking: true });
try {
const urls = getStoredRelayUrls();
const results = await checkAllRelays(urls);
set({ results, lastChecked: Date.now(), checking: false });
} catch {
set({ checking: false });
}
},
}));

100
src/stores/ui.test.ts Normal file
View File

@@ -0,0 +1,100 @@
import { describe, it, expect, beforeEach } from "vitest";
import { useUIStore } from "./ui";
describe("useUIStore", () => {
beforeEach(() => {
// Reset store to initial state
useUIStore.setState({
currentView: "feed",
selectedPubkey: null,
selectedNote: null,
previousView: "feed",
feedTab: "global",
pendingSearch: null,
pendingDMPubkey: null,
pendingArticleNaddr: null,
showHelp: false,
feedLanguageFilter: null,
});
});
it("setFeedTab changes the tab", () => {
useUIStore.getState().setFeedTab("following");
expect(useUIStore.getState().feedTab).toBe("following");
});
it("setFeedTab to trending", () => {
useUIStore.getState().setFeedTab("trending");
expect(useUIStore.getState().feedTab).toBe("trending");
});
it("openProfile sets pubkey and view", () => {
useUIStore.getState().openProfile("abc123");
const state = useUIStore.getState();
expect(state.currentView).toBe("profile");
expect(state.selectedPubkey).toBe("abc123");
expect(state.previousView).toBe("feed");
});
it("openThread sets note and previousView", () => {
const mockNote = { id: "note1" } as any;
useUIStore.getState().openThread(mockNote, "feed");
const state = useUIStore.getState();
expect(state.currentView).toBe("thread");
expect(state.selectedNote).toBe(mockNote);
expect(state.previousView).toBe("feed");
});
it("goBack returns to previous view", () => {
useUIStore.getState().openProfile("abc123");
expect(useUIStore.getState().currentView).toBe("profile");
useUIStore.getState().goBack();
expect(useUIStore.getState().currentView).toBe("feed");
expect(useUIStore.getState().selectedNote).toBeNull();
});
it("goBack defaults to feed when previousView equals currentView", () => {
// Both are "feed" initially
useUIStore.getState().goBack();
expect(useUIStore.getState().currentView).toBe("feed");
});
it("openSearch sets pending search and view", () => {
useUIStore.getState().openSearch("bitcoin");
const state = useUIStore.getState();
expect(state.currentView).toBe("search");
expect(state.pendingSearch).toBe("bitcoin");
});
it("openDM sets pending DM pubkey", () => {
useUIStore.getState().openDM("pubkey123");
const state = useUIStore.getState();
expect(state.currentView).toBe("dm");
expect(state.pendingDMPubkey).toBe("pubkey123");
});
it("setView changes the current view", () => {
useUIStore.getState().setView("settings");
expect(useUIStore.getState().currentView).toBe("settings");
});
it("toggleHelp toggles showHelp", () => {
expect(useUIStore.getState().showHelp).toBe(false);
useUIStore.getState().toggleHelp();
expect(useUIStore.getState().showHelp).toBe(true);
useUIStore.getState().toggleHelp();
expect(useUIStore.getState().showHelp).toBe(false);
});
it("setFeedLanguageFilter sets the filter", () => {
useUIStore.getState().setFeedLanguageFilter("Latin");
expect(useUIStore.getState().feedLanguageFilter).toBe("Latin");
});
it("setFeedLanguageFilter clears with null", () => {
useUIStore.getState().setFeedLanguageFilter("Latin");
useUIStore.getState().setFeedLanguageFilter(null);
expect(useUIStore.getState().feedLanguageFilter).toBeNull();
});
});