mirror of
https://github.com/hoornet/vega.git
synced 2026-05-08 05:09:12 -07:00
Bump to v0.7.1 — relay health checker, advanced search
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user