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

View File

@@ -1,6 +1,7 @@
import { useState, useRef, useEffect } from "react";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { searchNotes, searchUsers, searchArticles, getStoredRelayUrls, fetchFollowSuggestions, fetchProfile } from "../../lib/nostr";
import { getStoredRelayUrls, fetchFollowSuggestions, fetchProfile, advancedSearch } from "../../lib/nostr";
import { parseSearchQuery, describeSearch } from "../../lib/search";
import { getNip50Relays } from "../../lib/nostr/relayInfo";
import { useUserStore } from "../../stores/user";
import { useUIStore } from "../../stores/ui";
@@ -135,7 +136,8 @@ export function SearchView() {
const [suggestionsLoading, setSuggestionsLoading] = useState(false);
const [suggestionsLoaded, setSuggestionsLoaded] = useState(false);
const isHashtag = query.trim().startsWith("#");
const [searchHint, setSearchHint] = useState<string | null>(null);
const isHashtag = query.trim().startsWith("#") && !query.includes(":");
// Check relay NIP-50 support once on mount (background, non-blocking)
useEffect(() => {
@@ -191,17 +193,23 @@ export function SearchView() {
if (overrideQuery) setQuery(overrideQuery);
setLoading(true);
setSearched(false);
setSearchHint(null);
try {
const isTag = q.startsWith("#");
const [notes, userEvents, articleEvents] = await Promise.all([
searchNotes(q),
isTag ? Promise.resolve([]) : searchUsers(q),
searchArticles(q),
]);
setNoteResults(notes);
setUserResults(userEvents.map(parseUserEvent));
setArticleResults(articleEvents);
setActiveTab(notes.length > 0 ? "notes" : articleEvents.length > 0 ? "articles" : "people");
const parsed = parseSearchQuery(q);
const isAdvanced = parsed.authors.length > 0 || parsed.unresolvedNip05.length > 0 ||
parsed.kinds.length > 0 || parsed.hasFilters.length > 0 ||
parsed.since !== null || parsed.until !== null || parsed.mentions.length > 0 ||
parsed.orQueries !== null;
if (isAdvanced) {
setSearchHint(describeSearch(parsed));
}
const results = await advancedSearch(parsed);
setNoteResults(results.notes);
setUserResults(results.users.map(parseUserEvent));
setArticleResults(results.articles);
setActiveTab(results.notes.length > 0 ? "notes" : results.articles.length > 0 ? "articles" : "people");
} finally {
setLoading(false);
setSearched(true);
@@ -236,7 +244,7 @@ export function SearchView() {
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="search notes, #hashtags, or people…"
placeholder="search… try by:name, #tag, has:image, is:article, since:2026-01-01"
autoFocus
className="flex-1 bg-transparent text-text text-[13px] placeholder:text-text-dim focus:outline-none"
/>
@@ -272,22 +280,59 @@ export function SearchView() {
</div>
)}
{/* Search hint bar */}
{searchHint && searched && (
<div className="border-b border-border px-4 py-1.5 bg-bg-raised shrink-0">
<span className="text-text-dim text-[10px]">{searchHint}</span>
</div>
)}
{/* Results area */}
<div className="flex-1 overflow-y-auto">
{/* Idle / pre-search hint */}
{!searched && !loading && (
<div className="px-4 py-8 text-center space-y-2">
<p className="text-text-dim text-[12px]">
Use <span className="text-accent">#hashtag</span> to browse topics, or type a keyword for full-text search.
</p>
{nip50Relays !== null && (
<p className="text-text-dim text-[11px] opacity-70">
{nip50Count === 0
? "None of your relays support full-text search — #hashtag search always works."
: `${nip50Count} of ${allRelays.length} relay${allRelays.length !== 1 ? "s" : ""} support full-text search.`}
<div className="px-4 py-6 space-y-4">
<div className="text-center space-y-2">
<p className="text-text-dim text-[12px]">
Type a keyword, <span className="text-accent">#hashtag</span>, or use search modifiers.
</p>
)}
{nip50Relays !== null && (
<p className="text-text-dim text-[11px] opacity-70">
{nip50Count === 0
? "None of your relays support full-text search — #hashtag search always works."
: `${nip50Count} of ${allRelays.length} relay${allRelays.length !== 1 ? "s" : ""} support full-text search.`}
</p>
)}
</div>
{/* Search syntax help */}
<div className="max-w-md mx-auto">
<h3 className="text-text-dim text-[10px] uppercase tracking-widest mb-2">Search modifiers</h3>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-[11px]">
<span className="text-accent font-mono">by:name</span>
<span className="text-text-dim">notes from author</span>
<span className="text-accent font-mono">by:user@domain</span>
<span className="text-text-dim">NIP-05 author lookup</span>
<span className="text-accent font-mono">#bitcoin</span>
<span className="text-text-dim">hashtag search</span>
<span className="text-accent font-mono">has:image</span>
<span className="text-text-dim">with images</span>
<span className="text-accent font-mono">has:video</span>
<span className="text-text-dim">with video</span>
<span className="text-accent font-mono">is:article</span>
<span className="text-text-dim">long-form only</span>
<span className="text-accent font-mono">since:2026-01-01</span>
<span className="text-text-dim">after date</span>
<span className="text-accent font-mono">until:2026-03-01</span>
<span className="text-text-dim">before date</span>
<span className="text-accent font-mono">A OR B</span>
<span className="text-text-dim">match either term</span>
</div>
<p className="text-text-dim text-[10px] mt-2 opacity-60">
Combine freely: <span className="font-mono text-text-muted">bitcoin by:dergigi has:image since:2026-01-01</span>
</p>
</div>
</div>
)}

View File

@@ -1,36 +1,276 @@
import { getNDK } from "../../lib/nostr";
import { useEffect, useState } from "react";
import { getNDK, getStoredRelayUrls, removeRelay, publishRelayList } from "../../lib/nostr";
import { useRelayHealthStore } from "../../stores/relayHealth";
import { useUserStore } from "../../stores/user";
import type { RelayHealthResult } from "../../lib/nostr/relayHealth";
function statusColor(status: RelayHealthResult["status"]): string {
switch (status) {
case "online": return "bg-success";
case "slow": return "bg-warning";
case "offline": return "bg-danger";
}
}
function statusLabel(status: RelayHealthResult["status"]): string {
switch (status) {
case "online": return "online";
case "slow": return "slow";
case "offline": return "offline";
}
}
function formatLatency(ms: number | null): string {
if (ms === null || ms < 0) return "—";
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
function NipBadges({ nips }: { nips: number[] }) {
const notable = [1, 4, 11, 17, 23, 25, 50, 57, 65, 96, 98];
const supported = notable.filter((n) => nips.includes(n));
if (supported.length === 0) return null;
return (
<div className="flex flex-wrap gap-1 mt-1">
{supported.map((n) => (
<span key={n} className="px-1 py-0 text-[9px] border border-border text-text-dim rounded-sm">
NIP-{String(n).padStart(2, "0")}
</span>
))}
</div>
);
}
function RelayHealthCard({ result, poolConnected }: { result: RelayHealthResult; poolConnected: boolean }) {
const [expanded, setExpanded] = useState(false);
const nip11 = result.nip11;
return (
<div className="border border-border">
<div
className="flex items-center gap-3 px-3 py-2 text-[12px] cursor-pointer hover:bg-bg-hover transition-colors"
onClick={() => setExpanded((v) => !v)}
>
<span className={`w-2 h-2 rounded-full shrink-0 ${statusColor(result.status)}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-text truncate font-mono">{result.url}</span>
{nip11?.name && (
<span className="text-text-dim text-[10px] truncate">({nip11.name})</span>
)}
</div>
</div>
<div className="flex items-center gap-3 shrink-0">
{result.latencyMs !== null && (
<span className={`text-[10px] font-mono ${result.latencyMs > 2000 ? "text-warning" : "text-text-dim"}`}>
{formatLatency(result.latencyMs)}
</span>
)}
<span className={`text-[10px] ${result.status === "offline" ? "text-danger" : "text-text-dim"}`}>
{statusLabel(result.status)}
</span>
{poolConnected && (
<span className="w-1.5 h-1.5 rounded-full bg-accent shrink-0" title="NDK connected" />
)}
<span className="text-text-dim text-[10px]">{expanded ? "▾" : "▸"}</span>
</div>
</div>
{expanded && (
<div className="px-3 py-2 border-t border-border bg-bg-raised text-[11px] space-y-1.5">
{nip11 ? (
<>
{nip11.description && (
<p className="text-text-muted">{nip11.description}</p>
)}
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-[10px]">
{nip11.software && (
<div>
<span className="text-text-dim">Software: </span>
<span className="text-text">{nip11.software}{nip11.version ? ` ${nip11.version}` : ""}</span>
</div>
)}
{nip11.contact && (
<div>
<span className="text-text-dim">Contact: </span>
<span className="text-text">{nip11.contact}</span>
</div>
)}
{nip11.pubkey && (
<div className="col-span-2">
<span className="text-text-dim">Pubkey: </span>
<span className="text-text font-mono">{nip11.pubkey.slice(0, 16)}</span>
</div>
)}
</div>
{nip11.supported_nips && nip11.supported_nips.length > 0 && (
<div>
<span className="text-text-dim text-[10px]">
{nip11.supported_nips.length} NIPs supported
</span>
<NipBadges nips={nip11.supported_nips} />
</div>
)}
</>
) : (
<p className="text-text-dim">No NIP-11 info available{result.error ? `${result.error}` : ""}</p>
)}
<div className="text-text-dim text-[9px]">
Checked {new Date(result.checkedAt).toLocaleTimeString()}
</div>
</div>
)}
</div>
);
}
/** Fallback row for relays not yet health-checked */
function RelayPoolRow({ url, connected }: { url: string; connected: boolean }) {
return (
<div className="flex items-center gap-3 px-3 py-2 border border-border text-[12px]">
<span className={`w-2 h-2 rounded-full shrink-0 ${connected ? "bg-success" : "bg-text-dim"}`} />
<span className="text-text truncate flex-1 font-mono">{url}</span>
<span className="text-text-dim text-[10px]">{connected ? "connected" : "—"}</span>
</div>
);
}
export function RelaysView() {
const { results, checking, lastChecked, checkAll } = useRelayHealthStore();
const { loggedIn } = useUserStore();
const ndk = getNDK();
const relays = Array.from(ndk.pool?.relays?.values() ?? []);
const poolRelays = Array.from(ndk.pool?.relays?.values() ?? []);
const poolConnectedUrls = new Set(poolRelays.filter((r) => r.connected).map((r) => r.url));
// Auto-check on first mount if no results yet
useEffect(() => {
if (results.length === 0 && !checking) {
checkAll();
}
}, []);
const onlineCount = results.filter((r) => r.status === "online").length;
const slowCount = results.filter((r) => r.status === "slow").length;
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 handleRemoveDead = async () => {
setRemoving(true);
for (const r of deadRelays) {
removeRelay(r.url);
}
// Re-check remaining
await checkAll();
setRemoving(false);
};
const handleRepublish = async () => {
setRepublishing(true);
try {
await publishRelayList(getStoredRelayUrls());
} catch { /* ignore */ }
setRepublishing(false);
};
// 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));
// Sort: online first, then slow, then offline
const sortedResults = [...results].sort((a, b) => {
const order = { online: 0, slow: 1, offline: 2 };
return order[a.status] - order[b.status];
});
return (
<div className="h-full flex flex-col">
<header className="border-b border-border px-4 py-2.5 shrink-0">
<h1 className="text-text text-sm font-medium tracking-wide">Relays</h1>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<h1 className="text-text text-sm font-medium tracking-wide">Relays</h1>
{results.length > 0 && (
<div className="flex items-center gap-2 text-[10px]">
{onlineCount > 0 && <span className="text-success">{onlineCount} online</span>}
{slowCount > 0 && <span className="text-warning">{slowCount} slow</span>}
{offlineCount > 0 && <span className="text-danger">{offlineCount} offline</span>}
</div>
)}
</div>
<div className="flex items-center gap-2">
{lastChecked && (
<span className="text-text-dim text-[9px]">
{new Date(lastChecked).toLocaleTimeString()}
</span>
)}
<button
onClick={checkAll}
disabled={checking}
className="px-3 py-1 text-[11px] border border-border text-text-muted hover:text-accent hover:border-accent/40 transition-colors disabled:opacity-40"
>
{checking ? (
<span className="inline-flex items-center gap-1">
<span className="w-3 h-3 border border-accent border-t-transparent rounded-full animate-spin" />
checking
</span>
) : "check all"}
</button>
</div>
</div>
</header>
<div className="flex-1 overflow-y-auto p-4">
{relays.length === 0 ? (
<p className="text-text-dim text-[12px]">No relays configured.</p>
) : (
<div className="space-y-1">
{relays.map((relay) => (
<div
key={relay.url}
className="flex items-center gap-3 px-3 py-2 border border-border text-[12px]"
{/* Actions bar — show when there are dead relays */}
{deadRelays.length > 0 && (
<div className="border-b border-border px-4 py-2 bg-danger/5 flex items-center justify-between shrink-0">
<span className="text-danger text-[11px]">
{deadRelays.length} relay{deadRelays.length > 1 ? "s" : ""} offline
</span>
<div className="flex items-center gap-2">
<button
onClick={handleRemoveDead}
disabled={removing}
className="px-3 py-1 text-[11px] border border-danger/30 text-danger hover:bg-danger/10 transition-colors disabled:opacity-40"
>
{removing ? "removing…" : "remove dead"}
</button>
{loggedIn && !!getNDK().signer && (
<button
onClick={handleRepublish}
disabled={republishing}
className="px-3 py-1 text-[11px] border border-border text-text-muted hover:text-accent hover:border-accent/40 transition-colors disabled:opacity-40"
>
<span
className={`w-1.5 h-1.5 rounded-full shrink-0 ${
relay.connected ? "bg-success" : "bg-danger"
}`}
/>
<span className="text-text truncate flex-1 font-mono">{relay.url}</span>
<span className="text-text-dim shrink-0">
{relay.connected ? "connected" : "disconnected"}
</span>
</div>
))}
{republishing ? "publishing…" : "republish list"}
</button>
)}
</div>
</div>
)}
<div className="flex-1 overflow-y-auto p-4">
{results.length === 0 && !checking && poolRelays.length === 0 && (
<p className="text-text-dim text-[12px]">No relays configured.</p>
)}
<div className="space-y-1">
{sortedResults.map((result) => (
<RelayHealthCard
key={result.url}
result={result}
poolConnected={poolConnectedUrls.has(result.url)}
/>
))}
{uncheckedPoolRelays.map((relay) => (
<RelayPoolRow key={relay.url} url={relay.url} connected={relay.connected} />
))}
</div>
{checking && results.length === 0 && (
<div className="flex items-center gap-2 text-text-dim text-[12px] py-8 justify-center">
<span className="w-4 h-4 border-2 border-accent border-t-transparent rounded-full animate-spin" />
Checking relay health
</div>
)}
</div>