mirror of
https://github.com/hoornet/vega.git
synced 2026-04-23 22:30:00 -07:00
Bump to v0.7.1 — relay health checker, advanced search
This commit is contained in:
19
.github/workflows/release.yml
vendored
19
.github/workflows/release.yml
vendored
@@ -69,15 +69,16 @@ jobs:
|
||||
|
||||
> **Windows note:** The installer is not yet code-signed. Windows SmartScreen will show an "Unknown publisher" warning — click "More info → Run anyway" to install.
|
||||
|
||||
### New in v0.7.0 — Writer Tools & Upload Fix
|
||||
- **NIP-98 HTTP Auth uploads** — image uploads now authenticate via signed kind 27235 events; fallback to void.cat and nostrimg.com if nostr.build fails
|
||||
- **Markdown toolbar** — bold, italic, heading, link, image, quote, code, list buttons above the article editor; keyboard shortcuts Ctrl+B/I/K
|
||||
- **Multi-draft management** — create multiple article drafts; draft list with word count, timestamps, delete; auto-migrates old single-draft
|
||||
- **Cover image file picker** — upload button next to URL input in article meta panel
|
||||
- **Article bookmarks** — NIP-51 `a` tag support; Notes/Articles tab toggle in bookmark view
|
||||
- **Upload moved to TypeScript** — removed Rust upload command; dropped `reqwest`/`mime_guess` deps; lighter binary
|
||||
- **Upload spinner** — animated spinner during image uploads in compose and editor
|
||||
- **Draft count badge** — sidebar shows how many drafts you have
|
||||
### New in v0.7.1 — Relay Health Checker & Advanced Search
|
||||
- **Relay health checker** — NIP-11 info fetch + WebSocket latency probing; relays classified as online/slow/offline; expandable cards show software, description, supported NIPs; "Remove dead" strips offline relays; "Republish list" publishes cleaned NIP-65 relay list
|
||||
- **Advanced search** — ants-inspired query parser with modifiers: `by:author`, `mentions:npub`, `kind:N`, `is:article`, `has:image`, `since:2026-01-01`, `until:2026-12-31`, `#hashtag`, `"exact phrase"`, boolean `OR`; NIP-05 resolution for author lookups; search help panel
|
||||
|
||||
### Previous: v0.7.0 — Writer Tools & Upload Fix
|
||||
- NIP-98 HTTP Auth uploads with fallback services
|
||||
- Markdown toolbar with keyboard shortcuts (Ctrl+B/I/K)
|
||||
- Multi-draft management with draft list
|
||||
- Cover image file picker, article bookmarks (NIP-51 `a` tags)
|
||||
- Upload moved to TypeScript, draft count badge
|
||||
|
||||
### Previous: v0.6.1 — Media Upload & Feed Polish
|
||||
- Native file picker, file path paste upload, mention name resolution, connection stability
|
||||
|
||||
13
CLAUDE.md
13
CLAUDE.md
@@ -45,19 +45,21 @@ CI triggers on the tag and builds all three platforms (Ubuntu, Windows, macOS AR
|
||||
**Frontend** (`src/`): React 19 + TypeScript + Vite + Tailwind CSS 4
|
||||
|
||||
- `src/App.tsx` — root component; shows `OnboardingFlow` for new users, then view routing via UI store
|
||||
- `src/stores/` — Zustand stores per domain: `feed.ts`, `user.ts`, `ui.ts`, `lightning.ts`, `drafts.ts`
|
||||
- `src/stores/` — Zustand stores per domain: `feed.ts`, `user.ts`, `ui.ts`, `lightning.ts`, `drafts.ts`, `relayHealth.ts`
|
||||
- `src/lib/nostr/` — NDK wrapper (`client.ts` + `index.ts`); all Nostr calls go through here
|
||||
- `src/lib/lightning/` — NWC client (`nwc.ts`); Lightning payment logic
|
||||
- `src/hooks/` — `useProfile.ts`, `useReactionCount.ts`
|
||||
- `src/components/feed/` — Feed, NoteCard, NoteContent, ComposeBox
|
||||
- `src/components/profile/` — ProfileView (own + others, edit form)
|
||||
- `src/components/thread/` — ThreadView
|
||||
- `src/components/search/` — SearchView (NIP-50, hashtag, people, articles)
|
||||
- `src/components/search/` — SearchView (advanced search with modifiers, NIP-50, hashtag, people, articles)
|
||||
- `src/lib/search.ts` — Advanced search query parser (by:, has:, is:, kind:, since:, until:, OR)
|
||||
- `src/lib/nostr/relayHealth.ts` — Relay health checker (NIP-11, latency probing, status classification)
|
||||
- `src/components/article/` — ArticleEditor, ArticleView, ArticleFeed, ArticleCard, MarkdownToolbar (NIP-23)
|
||||
- `src/components/bookmark/` — BookmarkView
|
||||
- `src/components/zap/` — ZapModal
|
||||
- `src/components/onboarding/` — OnboardingFlow (welcome, create key, backup, login)
|
||||
- `src/components/shared/` — RelaysView, SettingsView (relay mgmt + NWC + identity)
|
||||
- `src/components/shared/` — RelaysView (relay health dashboard + management), SettingsView (NWC + identity)
|
||||
- `src/components/sidebar/` — Sidebar navigation
|
||||
|
||||
**Backend** (`src-tauri/`): Rust + Tauri 2.0
|
||||
@@ -83,7 +85,7 @@ CI triggers on the tag and builds all three platforms (Ubuntu, Windows, macOS AR
|
||||
|
||||
- **P1 (core):** NIP-01, 02, 03, 10, 11, 19, 21, 25, 27, 50
|
||||
- **P2 (monetization):** NIP-47 (NWC/Lightning), NIP-57 (zaps), NIP-65 (relay lists)
|
||||
- **P3 (advanced):** NIP-04/44 (DMs), NIP-23 (articles), NIP-96 (file storage), NIP-98 (HTTP Auth — implemented for uploads)
|
||||
- **P3 (advanced):** NIP-04/44 (DMs), NIP-11 (relay info — used by health checker), NIP-23 (articles), NIP-96 (file storage), NIP-98 (HTTP Auth — implemented for uploads)
|
||||
|
||||
## Current State
|
||||
|
||||
@@ -100,9 +102,10 @@ CI triggers on the tag and builds all three platforms (Ubuntu, Windows, macOS AR
|
||||
- **Article cards** — reusable component with title, summary, author, cover thumbnail, reading time, tags
|
||||
- **NIP-98 HTTP Auth** for image uploads with fallback services (nostr.build, void.cat, nostrimg.com)
|
||||
- Zaps: NWC wallet connect (NIP-47) + NIP-57 via NDKZapper
|
||||
- **Advanced search** — query parser with modifiers: `by:author`, `mentions:npub`, `kind:N`, `is:article`, `has:image`, `since:date`, `until:date`, `#hashtag`, `"phrase"`, boolean `OR`; NIP-05 resolution; client-side content filters; search help panel
|
||||
- Search: NIP-50 full-text, hashtag (#t filter), people, articles
|
||||
- Settings: relay add/remove (persisted to localStorage), NWC URI, npub copy
|
||||
- Relay connection status view
|
||||
- **Relay health checker** — NIP-11 info fetch, WebSocket latency probing, online/slow/offline status; expandable cards with supported NIPs, software info; "Remove dead" + "Republish list" workflow
|
||||
- OS keychain integration — nsec persists across restarts via `keyring` crate
|
||||
- SQLite note + profile cache
|
||||
- Direct messages (NIP-04 + NIP-17 gift wrap)
|
||||
|
||||
2
PKGBUILD
2
PKGBUILD
@@ -1,6 +1,6 @@
|
||||
# Maintainer: hoornet <hoornet@users.noreply.github.com>
|
||||
pkgname=wrystr
|
||||
pkgver=0.7.0
|
||||
pkgver=0.7.1
|
||||
pkgrel=1
|
||||
pkgdesc="Cross-platform Nostr desktop client with Lightning integration"
|
||||
arch=('x86_64')
|
||||
|
||||
@@ -59,6 +59,7 @@ sudo dnf install gstreamer1-plugins-base gstreamer1-plugins-good gstreamer1-liba
|
||||
- **Notifications** — mentions view (🔔 in sidebar) with unread badge; clears on open
|
||||
|
||||
**Relay & network**
|
||||
- **Relay health checker** — NIP-11 info fetch, WebSocket latency probing, online/slow/offline classification; expandable cards show supported NIPs, software, description; "Remove dead" strips offline relays, "Republish list" publishes cleaned NIP-65 relay list; auto-checks on mount
|
||||
- Relay management: add/remove relays with live connection status
|
||||
- **NIP-65 outbox model** — reads user relay lists (kind 10002) so you see notes from people who publish to their own relays; publish your own relay list to Nostr from Settings
|
||||
|
||||
@@ -72,6 +73,7 @@ sudo dnf install gstreamer1-plugins-base gstreamer1-plugins-good gstreamer1-liba
|
||||
|
||||
**Discovery**
|
||||
- **Discover people** — "follows of follows" suggestions on the Search page with mutual follow counts and one-click follow
|
||||
- **Advanced search** — `by:author`, `mentions:npub`, `kind:number`, `is:article`, `has:image`, `since:2026-01-01`, `until:2026-12-31`, `#hashtag`, `"exact phrase"`, boolean `OR`; NIP-05 resolution for author lookups; client-side content filters for media types; search help panel with modifier reference
|
||||
- Search: NIP-50 full-text, `#hashtag`, people search with inline follow, **article search** (kind 30023)
|
||||
|
||||
**Performance & UX**
|
||||
@@ -109,7 +111,6 @@ npm run tauri build # production binary
|
||||
See [ROADMAP.md](./ROADMAP.md) for the full prioritised next steps.
|
||||
|
||||
Up next:
|
||||
- Relay health checker
|
||||
- Web of Trust scoring
|
||||
- NIP-46 remote signer support
|
||||
- Custom feeds / lists
|
||||
|
||||
19
ROADMAP.md
19
ROADMAP.md
@@ -61,6 +61,19 @@ Bugs found during testing are fixed before Phase N+1 starts. A release is cut be
|
||||
|
||||
## Brainstorm backlog (not yet scheduled)
|
||||
|
||||
### Relay health checker — ✓ SHIPPED (v0.7.1)
|
||||
- ✓ NIP-11 info fetch + WebSocket latency probing
|
||||
- ✓ Online/slow/offline classification with summary counts
|
||||
- ✓ "Remove dead" + "Republish list" workflow
|
||||
- ✓ NIP badge display, expandable relay cards
|
||||
|
||||
### Advanced search — ✓ SHIPPED (v0.7.1)
|
||||
- ✓ Query parser with modifiers (by:, has:, is:, kind:, since:, until:, #hashtag, "phrase", OR)
|
||||
- ✓ NIP-05 resolution for author lookups
|
||||
- ✓ Client-side content filters (image, video, audio, code, link, youtube)
|
||||
- ✓ Search help panel with modifier reference
|
||||
- Remaining: search relay discovery (kind 10007), WoT-powered search ranking
|
||||
|
||||
### Web of Trust (WOT)
|
||||
- Social graph distance for trust scoring
|
||||
- Could power: feed ranking, spam filtering, people search, follow suggestions
|
||||
@@ -92,6 +105,12 @@ Bugs found during testing are fixed before Phase N+1 starts. A release is cut be
|
||||
|
||||
## What's already shipped
|
||||
|
||||
### v0.7.1 — Relay Health Checker & Advanced Search
|
||||
- **Relay health checker** — NIP-11 info fetch + WebSocket latency probing; relays classified as online/slow/offline; expandable cards show software, description, supported NIPs (badges for 1, 4, 11, 17, 23, 25, 50, 57, 65, 96, 98); header summary counts; "Remove dead" strips offline relays; "Republish list" publishes cleaned NIP-65 relay list; auto-checks on mount
|
||||
- **Advanced search** — full query parser inspired by ants (dergigi/ants); modifiers: `by:author`, `mentions:npub`, `kind:N`, `is:article`, `has:image`, `since:2026-01-01`, `until:2026-12-31`, `#hashtag`, `"exact phrase"`, boolean `OR`; NIP-05 resolution for author lookups; client-side content filters for media types; search help panel with modifier reference
|
||||
- New files: `src/lib/nostr/relayHealth.ts`, `src/stores/relayHealth.ts`, `src/lib/search.ts`
|
||||
- `RelaysView.tsx` rewritten from simple list to full health dashboard
|
||||
|
||||
### v0.7.0 — Writer Tools & Upload Fix
|
||||
- **NIP-98 HTTP Auth uploads** — image uploads now authenticate via signed kind 27235 events; fallback to void.cat and nostrimg.com if nostr.build fails
|
||||
- **Markdown toolbar** — bold, italic, heading, link, image, quote, code, list buttons above the article editor textarea
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "wrystr",
|
||||
"private": true,
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "wrystr"
|
||||
version = "0.7.0"
|
||||
version = "0.7.1"
|
||||
description = "Cross-platform Nostr desktop client with Lightning integration"
|
||||
authors = ["hoornet"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Wrystr",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"identifier": "com.hoornet.wrystr",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -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,14 +280,22 @@ 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">
|
||||
<div className="px-4 py-6 space-y-4">
|
||||
<div className="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.
|
||||
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">
|
||||
@@ -289,6 +305,35 @@ export function SearchView() {
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Discover — follow suggestions */}
|
||||
|
||||
@@ -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">
|
||||
<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]"
|
||||
>
|
||||
<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"}
|
||||
{/* 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"
|
||||
>
|
||||
{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>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
--color-accent-hover: #7c3aed;
|
||||
--color-zap: #f59e0b;
|
||||
--color-danger: #ef4444;
|
||||
--color-warning: #f59e0b;
|
||||
--color-success: #22c55e;
|
||||
--font-mono: "JetBrains Mono", "Fira Code", "SF Mono", monospace;
|
||||
}
|
||||
|
||||
76
src/lib/language.test.ts
Normal file
76
src/lib/language.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { detectScript, getEventLanguageTag } from "./language";
|
||||
|
||||
describe("detectScript", () => {
|
||||
it("detects Latin script", () => {
|
||||
expect(detectScript("Hello world")).toBe("Latin");
|
||||
});
|
||||
|
||||
it("detects CJK script", () => {
|
||||
expect(detectScript("你好世界")).toBe("CJK");
|
||||
});
|
||||
|
||||
it("detects Cyrillic script", () => {
|
||||
expect(detectScript("Привет мир")).toBe("Cyrillic");
|
||||
});
|
||||
|
||||
it("detects Arabic script", () => {
|
||||
expect(detectScript("مرحبا بالعالم")).toBe("Arabic");
|
||||
});
|
||||
|
||||
it("detects Korean script", () => {
|
||||
expect(detectScript("안녕하세요")).toBe("Korean");
|
||||
});
|
||||
|
||||
it("returns dominant script for mixed content", () => {
|
||||
expect(detectScript("Hello 你好世界中文测试")).toBe("CJK");
|
||||
});
|
||||
|
||||
it("strips URLs before detection", () => {
|
||||
expect(detectScript("https://example.com 你好世界")).toBe("CJK");
|
||||
});
|
||||
|
||||
it("strips nostr mentions before detection", () => {
|
||||
expect(detectScript("nostr:npub1abc123 Привет")).toBe("Cyrillic");
|
||||
});
|
||||
|
||||
it("strips hashtags before detection", () => {
|
||||
expect(detectScript("#bitcoin Hola mundo")).toBe("Latin");
|
||||
});
|
||||
|
||||
it("returns Unknown for empty input", () => {
|
||||
expect(detectScript("")).toBe("Unknown");
|
||||
});
|
||||
|
||||
it("returns Unknown for whitespace-only input", () => {
|
||||
expect(detectScript(" ")).toBe("Unknown");
|
||||
});
|
||||
|
||||
it("returns Unknown for only URLs/mentions", () => {
|
||||
expect(detectScript("https://example.com nostr:npub1abc")).toBe("Unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEventLanguageTag", () => {
|
||||
it("finds ISO-639-1 language tag", () => {
|
||||
const tags = [
|
||||
["t", "bitcoin"],
|
||||
["l", "en", "ISO-639-1"],
|
||||
];
|
||||
expect(getEventLanguageTag(tags)).toBe("en");
|
||||
});
|
||||
|
||||
it("returns null when no language tag exists", () => {
|
||||
const tags = [["t", "nostr"], ["p", "abc123"]];
|
||||
expect(getEventLanguageTag(tags)).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores l tags without ISO-639-1 namespace", () => {
|
||||
const tags = [["l", "en", "some-other-namespace"]];
|
||||
expect(getEventLanguageTag(tags)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for empty tags", () => {
|
||||
expect(getEventLanguageTag([])).toBeNull();
|
||||
});
|
||||
});
|
||||
46
src/lib/lightning/nwc.test.ts
Normal file
46
src/lib/lightning/nwc.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseNwcUri, isValidNwcUri } from "./nwc";
|
||||
|
||||
describe("parseNwcUri", () => {
|
||||
it("parses a valid NWC URI", () => {
|
||||
const uri = "nostr+walletconnect://abc123?relay=wss://relay.example.com&secret=mysecret";
|
||||
const result = parseNwcUri(uri);
|
||||
expect(result.walletPubkey).toBe("abc123");
|
||||
expect(result.relayUrl).toBe("wss://relay.example.com");
|
||||
expect(result.secret).toBe("mysecret");
|
||||
});
|
||||
|
||||
it("throws on missing relay", () => {
|
||||
const uri = "nostr+walletconnect://abc123?secret=mysecret";
|
||||
expect(() => parseNwcUri(uri)).toThrow("Invalid NWC URI");
|
||||
});
|
||||
|
||||
it("throws on missing secret", () => {
|
||||
const uri = "nostr+walletconnect://abc123?relay=wss://relay.example.com";
|
||||
expect(() => parseNwcUri(uri)).toThrow("Invalid NWC URI");
|
||||
});
|
||||
|
||||
it("throws on completely invalid URI", () => {
|
||||
expect(() => parseNwcUri("not-a-uri")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidNwcUri", () => {
|
||||
it("returns true for valid NWC URI", () => {
|
||||
const uri = "nostr+walletconnect://abc123?relay=wss://relay.example.com&secret=mysecret";
|
||||
expect(isValidNwcUri(uri)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for invalid URI", () => {
|
||||
expect(isValidNwcUri("not-valid")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for wrong prefix", () => {
|
||||
const uri = "https://abc123?relay=wss://relay.example.com&secret=mysecret";
|
||||
expect(isValidNwcUri(uri)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for missing fields", () => {
|
||||
expect(isValidNwcUri("nostr+walletconnect://abc123")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import NDK, { NDKEvent, NDKFilter, NDKKind, NDKRelay, NDKRelaySet, NDKSubscriptionCacheUsage, nip19, giftWrap, giftUnwrap } from "@nostr-dev-kit/ndk";
|
||||
import NDK, { NDKEvent, NDKFilter, NDKKind, NDKRelay, NDKRelaySet, NDKSubscriptionCacheUsage, NDKUser, nip19, giftWrap, giftUnwrap } from "@nostr-dev-kit/ndk";
|
||||
import { type ParsedSearch, matchesHasFilter } from "../search";
|
||||
|
||||
const RELAY_STORAGE_KEY = "wrystr_relays";
|
||||
|
||||
@@ -783,3 +784,130 @@ export async function fetchMentions(pubkey: string, since: number, limit = 50):
|
||||
);
|
||||
return Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0));
|
||||
}
|
||||
|
||||
// ── NIP-05 Resolution ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function resolveNip05(identifier: string): Promise<string | null> {
|
||||
const instance = getNDK();
|
||||
try {
|
||||
const user = new NDKUser({ nip05: identifier });
|
||||
user.ndk = instance;
|
||||
await user.fetchProfile();
|
||||
return user.pubkey || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Advanced Search ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface AdvancedSearchResults {
|
||||
notes: NDKEvent[];
|
||||
articles: NDKEvent[];
|
||||
users: NDKEvent[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an advanced search using a ParsedSearch query.
|
||||
* Resolves NIP-05 identifiers, builds filters, runs queries,
|
||||
* and applies client-side filters (has:image, has:code, etc.).
|
||||
*/
|
||||
export async function advancedSearch(parsed: ParsedSearch, limit = 50): Promise<AdvancedSearchResults> {
|
||||
const instance = getNDK();
|
||||
|
||||
// Handle OR queries — run each sub-query and merge
|
||||
if (parsed.orQueries && parsed.orQueries.length > 0) {
|
||||
const subResults = await Promise.all(parsed.orQueries.map((q) => advancedSearch(q, limit)));
|
||||
const seenNotes = new Set<string>();
|
||||
const seenArticles = new Set<string>();
|
||||
const seenUsers = new Set<string>();
|
||||
const notes: NDKEvent[] = [];
|
||||
const articles: NDKEvent[] = [];
|
||||
const users: NDKEvent[] = [];
|
||||
for (const r of subResults) {
|
||||
for (const e of r.notes) { if (!seenNotes.has(e.id!)) { seenNotes.add(e.id!); notes.push(e); } }
|
||||
for (const e of r.articles) { if (!seenArticles.has(e.id!)) { seenArticles.add(e.id!); articles.push(e); } }
|
||||
for (const e of r.users) { if (!seenUsers.has(e.pubkey)) { seenUsers.add(e.pubkey); users.push(e); } }
|
||||
}
|
||||
return {
|
||||
notes: notes.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)).slice(0, limit),
|
||||
articles: articles.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)).slice(0, limit),
|
||||
users,
|
||||
};
|
||||
}
|
||||
|
||||
// Resolve any NIP-05 or name-based author identifiers
|
||||
const resolvedAuthors = [...parsed.authors];
|
||||
for (const nip05 of parsed.unresolvedNip05) {
|
||||
const resolved = await resolveNip05(nip05.includes("@") || nip05.includes(".") ? nip05 : `_@${nip05}`);
|
||||
if (resolved) {
|
||||
resolvedAuthors.push(resolved);
|
||||
} else {
|
||||
const nameResults = await searchUsers(nip05, 1);
|
||||
if (nameResults.length > 0) {
|
||||
resolvedAuthors.push(nameResults[0].pubkey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine which kinds to search
|
||||
const hasKindFilter = parsed.kinds.length > 0;
|
||||
const noteKinds = hasKindFilter
|
||||
? parsed.kinds.filter((k) => k === 1)
|
||||
: [1];
|
||||
const articleKinds = hasKindFilter
|
||||
? parsed.kinds.filter((k) => k === 30023)
|
||||
: [30023];
|
||||
|
||||
const searchText = parsed.searchTerms.join(" ").trim();
|
||||
const hasSearch = searchText.length > 0;
|
||||
const hasHashtags = parsed.hashtags.length > 0;
|
||||
|
||||
const buildFilter = (kinds: number[]): (NDKFilter & { search?: string }) | null => {
|
||||
if (kinds.length === 0 && hasKindFilter) return null;
|
||||
const filter: NDKFilter & { search?: string } = {
|
||||
kinds: kinds.map((k) => k as NDKKind),
|
||||
limit,
|
||||
};
|
||||
if (hasSearch) filter.search = searchText;
|
||||
if (hasHashtags) filter["#t"] = parsed.hashtags;
|
||||
if (resolvedAuthors.length > 0) filter.authors = resolvedAuthors;
|
||||
if (parsed.mentions.length > 0) filter["#p"] = parsed.mentions;
|
||||
if (parsed.since) filter.since = parsed.since;
|
||||
if (parsed.until) filter.until = parsed.until;
|
||||
if (!hasSearch && !hasHashtags && resolvedAuthors.length === 0 && parsed.mentions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return filter;
|
||||
};
|
||||
|
||||
const opts = { cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY };
|
||||
|
||||
const noteFilter = noteKinds.length > 0 ? buildFilter(noteKinds) : null;
|
||||
const articleFilter = articleKinds.length > 0 ? buildFilter(articleKinds) : null;
|
||||
const shouldSearchUsers = (!hasKindFilter || parsed.kinds.includes(0)) && hasSearch && !hasHashtags;
|
||||
|
||||
const [noteEvents, articleEvents, userEvents] = await Promise.all([
|
||||
noteFilter ? instance.fetchEvents(noteFilter, opts) : Promise.resolve(new Set<NDKEvent>()),
|
||||
articleFilter ? instance.fetchEvents(articleFilter, opts) : Promise.resolve(new Set<NDKEvent>()),
|
||||
shouldSearchUsers ? instance.fetchEvents({ kinds: [NDKKind.Metadata], search: searchText, limit: 20 } as NDKFilter & { search: string }, opts) : Promise.resolve(new Set<NDKEvent>()),
|
||||
]);
|
||||
|
||||
let notes = Array.from(noteEvents);
|
||||
let articles = Array.from(articleEvents);
|
||||
const users = Array.from(userEvents);
|
||||
|
||||
// Client-side filters: has:image, has:video, has:code, etc.
|
||||
if (parsed.hasFilters.length > 0) {
|
||||
const applyHas = (events: NDKEvent[]) =>
|
||||
events.filter((e) => parsed.hasFilters.every((f) => matchesHasFilter(e.content, f)));
|
||||
notes = applyHas(notes);
|
||||
articles = applyHas(articles);
|
||||
}
|
||||
|
||||
return {
|
||||
notes: notes.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)),
|
||||
articles: articles.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)),
|
||||
users,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { getNDK, connectToRelays, fetchGlobalFeed, fetchFollowFeed, fetchReplies, publishNote, publishArticle, publishProfile, publishReaction, publishRepost, publishQuote, publishReply, publishContactList, fetchBatchEngagement, fetchReactionCount, fetchReplyCount, fetchZapCount, fetchNoteById, fetchUserNotes, fetchProfile, fetchArticle, fetchAuthorArticles, fetchArticleFeed, searchArticles, fetchZapsReceived, fetchZapsSent, fetchDMConversations, fetchDMThread, sendDM, decryptDM, fetchBookmarkList, publishBookmarkList, fetchBookmarkListFull, publishBookmarkListFull, fetchByAddr, fetchMuteList, publishMuteList, getStoredRelayUrls, addRelay, removeRelay, searchNotes, searchUsers, fetchUserRelayList, publishRelayList, fetchUserNotesNIP65, fetchFollowSuggestions, fetchMentions } from "./client";
|
||||
export type { UserRelayList } from "./client";
|
||||
export { getNDK, connectToRelays, fetchGlobalFeed, fetchFollowFeed, fetchReplies, publishNote, publishArticle, publishProfile, publishReaction, publishRepost, publishQuote, publishReply, publishContactList, fetchBatchEngagement, fetchReactionCount, fetchReplyCount, fetchZapCount, fetchNoteById, fetchUserNotes, fetchProfile, fetchArticle, fetchAuthorArticles, fetchArticleFeed, searchArticles, fetchZapsReceived, fetchZapsSent, fetchDMConversations, fetchDMThread, sendDM, decryptDM, fetchBookmarkList, publishBookmarkList, fetchBookmarkListFull, publishBookmarkListFull, fetchByAddr, fetchMuteList, publishMuteList, getStoredRelayUrls, addRelay, removeRelay, searchNotes, searchUsers, fetchUserRelayList, publishRelayList, fetchUserNotesNIP65, fetchFollowSuggestions, fetchMentions, resolveNip05, advancedSearch } from "./client";
|
||||
export type { UserRelayList, AdvancedSearchResults } from "./client";
|
||||
|
||||
130
src/lib/nostr/relayHealth.ts
Normal file
130
src/lib/nostr/relayHealth.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Relay health checking — NIP-11 info, latency measurement, connection probe.
|
||||
*/
|
||||
|
||||
export interface RelayNip11Info {
|
||||
name?: string;
|
||||
description?: string;
|
||||
pubkey?: string;
|
||||
contact?: string;
|
||||
supported_nips?: number[];
|
||||
software?: string;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export interface RelayHealthResult {
|
||||
url: string;
|
||||
status: "online" | "slow" | "offline";
|
||||
latencyMs: number | null;
|
||||
nip11: RelayNip11Info | null;
|
||||
checkedAt: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch NIP-11 relay information document.
|
||||
* Converts wss:// to https:// and requests with application/nostr+json.
|
||||
*/
|
||||
export async function fetchNip11(relayUrl: string): Promise<RelayNip11Info | null> {
|
||||
const httpUrl = relayUrl.replace(/^wss:\/\//, "https://").replace(/^ws:\/\//, "http://");
|
||||
try {
|
||||
const resp = await fetch(httpUrl, {
|
||||
headers: { Accept: "application/nostr+json" },
|
||||
signal: AbortSignal.timeout(6000),
|
||||
});
|
||||
if (!resp.ok) return null;
|
||||
return await resp.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure WebSocket connection latency by opening a fresh connection,
|
||||
* sending a REQ for a single event, and timing how long until the first
|
||||
* EOSE or EVENT response. Falls back to just measuring connect time.
|
||||
*/
|
||||
export async function measureLatency(relayUrl: string): Promise<{ latencyMs: number; connected: boolean }> {
|
||||
return new Promise((resolve) => {
|
||||
const start = performance.now();
|
||||
const timeout = setTimeout(() => {
|
||||
try { ws.close(); } catch { /* ignore */ }
|
||||
resolve({ latencyMs: -1, connected: false });
|
||||
}, 8000);
|
||||
|
||||
let ws: WebSocket;
|
||||
try {
|
||||
ws = new WebSocket(relayUrl);
|
||||
} catch {
|
||||
clearTimeout(timeout);
|
||||
resolve({ latencyMs: -1, connected: false });
|
||||
return;
|
||||
}
|
||||
|
||||
ws.onopen = () => {
|
||||
// Send a minimal REQ to measure round-trip
|
||||
const subId = "health_" + Math.random().toString(36).slice(2, 8);
|
||||
try {
|
||||
ws.send(JSON.stringify(["REQ", subId, { kinds: [0], limit: 1 }]));
|
||||
} catch {
|
||||
clearTimeout(timeout);
|
||||
const elapsed = Math.round(performance.now() - start);
|
||||
try { ws.close(); } catch { /* ignore */ }
|
||||
resolve({ latencyMs: elapsed, connected: true });
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = () => {
|
||||
clearTimeout(timeout);
|
||||
const elapsed = Math.round(performance.now() - start);
|
||||
try { ws.close(); } catch { /* ignore */ }
|
||||
resolve({ latencyMs: elapsed, connected: true });
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
clearTimeout(timeout);
|
||||
try { ws.close(); } catch { /* ignore */ }
|
||||
resolve({ latencyMs: -1, connected: false });
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
clearTimeout(timeout);
|
||||
resolve({ latencyMs: -1, connected: false });
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Full health check for a single relay: NIP-11 + latency probe.
|
||||
*/
|
||||
export async function checkRelayHealth(relayUrl: string): Promise<RelayHealthResult> {
|
||||
const [nip11, latency] = await Promise.all([
|
||||
fetchNip11(relayUrl),
|
||||
measureLatency(relayUrl),
|
||||
]);
|
||||
|
||||
let status: RelayHealthResult["status"];
|
||||
if (!latency.connected) {
|
||||
status = "offline";
|
||||
} else if (latency.latencyMs > 3000) {
|
||||
status = "slow";
|
||||
} else {
|
||||
status = "online";
|
||||
}
|
||||
|
||||
return {
|
||||
url: relayUrl,
|
||||
status,
|
||||
latencyMs: latency.connected ? latency.latencyMs : null,
|
||||
nip11,
|
||||
checkedAt: Date.now(),
|
||||
error: !latency.connected ? "Connection failed" : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check all relays in parallel.
|
||||
*/
|
||||
export async function checkAllRelays(relayUrls: string[]): Promise<RelayHealthResult[]> {
|
||||
return Promise.all(relayUrls.map(checkRelayHealth));
|
||||
}
|
||||
171
src/lib/parsing.test.ts
Normal file
171
src/lib/parsing.test.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseContent } from "./parsing";
|
||||
|
||||
describe("parseContent", () => {
|
||||
it("returns plain text as a single text segment", () => {
|
||||
const result = parseContent("Hello world");
|
||||
expect(result).toEqual([{ type: "text", value: "Hello world" }]);
|
||||
});
|
||||
|
||||
it("parses URLs as link segments with shortened display", () => {
|
||||
const result = parseContent("Check https://example.com/path out");
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0]).toEqual({ type: "text", value: "Check " });
|
||||
expect(result[1].type).toBe("link");
|
||||
expect(result[1].value).toBe("https://example.com/path");
|
||||
expect(result[1].display).toBe("example.com/path");
|
||||
expect(result[2]).toEqual({ type: "text", value: " out" });
|
||||
});
|
||||
|
||||
it("parses image URLs", () => {
|
||||
const result = parseContent("Look https://example.com/photo.jpg");
|
||||
expect(result[1]).toEqual({ type: "image", value: "https://example.com/photo.jpg" });
|
||||
});
|
||||
|
||||
it("parses image URLs with query params", () => {
|
||||
const result = parseContent("https://example.com/photo.png?w=800");
|
||||
expect(result[0].type).toBe("image");
|
||||
});
|
||||
|
||||
it("parses various image extensions", () => {
|
||||
for (const ext of ["jpg", "jpeg", "png", "gif", "webp", "svg"]) {
|
||||
const result = parseContent(`https://example.com/img.${ext}`);
|
||||
expect(result[0].type).toBe("image");
|
||||
}
|
||||
});
|
||||
|
||||
it("parses video URLs", () => {
|
||||
const result = parseContent("https://example.com/video.mp4");
|
||||
expect(result[0]).toEqual({ type: "video", value: "https://example.com/video.mp4" });
|
||||
});
|
||||
|
||||
it("parses various video extensions", () => {
|
||||
for (const ext of ["mp4", "webm", "mov"]) {
|
||||
const result = parseContent(`https://example.com/vid.${ext}`);
|
||||
expect(result[0].type).toBe("video");
|
||||
}
|
||||
});
|
||||
|
||||
it("parses audio URLs", () => {
|
||||
const result = parseContent("https://example.com/song.mp3");
|
||||
expect(result[0]).toEqual({ type: "audio", value: "https://example.com/song.mp3" });
|
||||
});
|
||||
|
||||
it("parses various audio extensions", () => {
|
||||
for (const ext of ["mp3", "wav", "flac", "aac"]) {
|
||||
const result = parseContent(`https://example.com/audio.${ext}`);
|
||||
expect(result[0].type).toBe("audio");
|
||||
}
|
||||
});
|
||||
|
||||
it("parses YouTube watch URLs", () => {
|
||||
const result = parseContent("https://youtube.com/watch?v=dQw4w9WgXcQ");
|
||||
expect(result[0].type).toBe("youtube");
|
||||
expect(result[0].mediaId).toBe("dQw4w9WgXcQ");
|
||||
});
|
||||
|
||||
it("parses YouTube shorts URLs", () => {
|
||||
const result = parseContent("https://youtube.com/shorts/dQw4w9WgXcQ");
|
||||
expect(result[0].type).toBe("youtube");
|
||||
expect(result[0].mediaId).toBe("dQw4w9WgXcQ");
|
||||
});
|
||||
|
||||
it("parses youtu.be short URLs", () => {
|
||||
const result = parseContent("https://youtu.be/dQw4w9WgXcQ");
|
||||
expect(result[0].type).toBe("youtube");
|
||||
expect(result[0].mediaId).toBe("dQw4w9WgXcQ");
|
||||
});
|
||||
|
||||
it("parses Spotify track URLs", () => {
|
||||
const result = parseContent("https://open.spotify.com/track/abc123");
|
||||
expect(result[0].type).toBe("spotify");
|
||||
expect(result[0].mediaType).toBe("track");
|
||||
expect(result[0].mediaId).toBe("abc123");
|
||||
});
|
||||
|
||||
it("parses Spotify album URLs", () => {
|
||||
const result = parseContent("https://open.spotify.com/album/xyz789");
|
||||
expect(result[0].type).toBe("spotify");
|
||||
expect(result[0].mediaType).toBe("album");
|
||||
});
|
||||
|
||||
it("parses Spotify playlist URLs", () => {
|
||||
const result = parseContent("https://open.spotify.com/playlist/pl123");
|
||||
expect(result[0].type).toBe("spotify");
|
||||
expect(result[0].mediaType).toBe("playlist");
|
||||
});
|
||||
|
||||
it("parses Vimeo URLs", () => {
|
||||
const result = parseContent("https://vimeo.com/123456789");
|
||||
expect(result[0].type).toBe("vimeo");
|
||||
expect(result[0].mediaId).toBe("123456789");
|
||||
});
|
||||
|
||||
it("parses Tidal track URLs", () => {
|
||||
const result = parseContent("https://tidal.com/track/12345");
|
||||
expect(result[0].type).toBe("tidal");
|
||||
expect(result[0].mediaType).toBe("track");
|
||||
expect(result[0].mediaId).toBe("12345");
|
||||
});
|
||||
|
||||
it("parses Tidal browse URLs", () => {
|
||||
const result = parseContent("https://tidal.com/browse/album/67890");
|
||||
expect(result[0].type).toBe("tidal");
|
||||
expect(result[0].mediaType).toBe("album");
|
||||
expect(result[0].mediaId).toBe("67890");
|
||||
});
|
||||
|
||||
it("parses nostr:npub mentions", () => {
|
||||
// Use a valid npub (bech32-encoded)
|
||||
const npub = "npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqsd2pfy7";
|
||||
const result = parseContent(`Hello nostr:${npub}`);
|
||||
const mention = result.find((s) => s.type === "mention");
|
||||
expect(mention).toBeDefined();
|
||||
expect(mention!.value).toBe(npub);
|
||||
});
|
||||
|
||||
it("parses hashtags", () => {
|
||||
const result = parseContent("Hello #bitcoin world");
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[1]).toEqual({ type: "hashtag", value: "bitcoin", display: "#bitcoin" });
|
||||
});
|
||||
|
||||
it("does not parse single-char hashtags", () => {
|
||||
const result = parseContent("Hello #a world");
|
||||
// #a has only 1 char after #, should not match (regex requires 2+)
|
||||
expect(result.find((s) => s.type === "hashtag")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles mixed content with multiple types", () => {
|
||||
const content = "Check https://example.com and #nostr";
|
||||
const result = parseContent(content);
|
||||
const types = result.map((s) => s.type);
|
||||
expect(types).toContain("text");
|
||||
expect(types).toContain("link");
|
||||
expect(types).toContain("hashtag");
|
||||
});
|
||||
|
||||
it("cleans trailing punctuation from URLs", () => {
|
||||
const result = parseContent("See https://example.com/page.");
|
||||
const link = result.find((s) => s.type === "link");
|
||||
expect(link!.value).toBe("https://example.com/page");
|
||||
});
|
||||
|
||||
it("cleans trailing parenthesis from URLs", () => {
|
||||
const result = parseContent("(https://example.com/page)");
|
||||
const link = result.find((s) => s.type === "link");
|
||||
expect(link!.value).toBe("https://example.com/page");
|
||||
});
|
||||
|
||||
it("shortens long display URLs", () => {
|
||||
const longPath = "/a".repeat(30);
|
||||
const result = parseContent(`https://example.com${longPath}`);
|
||||
const link = result.find((s) => s.type === "link");
|
||||
expect(link!.display!.length).toBeLessThanOrEqual(50);
|
||||
});
|
||||
|
||||
it("returns empty for empty input", () => {
|
||||
const result = parseContent("");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
297
src/lib/search.ts
Normal file
297
src/lib/search.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* Advanced search query parser — inspired by ants (dergigi/ants).
|
||||
*
|
||||
* Supported modifiers:
|
||||
* by:<nip05|npub|name> — filter by author
|
||||
* mentions:<npub|name> — notes that tag a specific pubkey
|
||||
* kind:<number|alias> — filter by event kind
|
||||
* is:<alias> — shorthand for kind (article, note, highlight, etc.)
|
||||
* has:<media> — notes containing specific media (image, video, link)
|
||||
* since:<date> — events after date (YYYY-MM-DD)
|
||||
* until:<date> — events before date (YYYY-MM-DD)
|
||||
* #hashtag — hashtag search
|
||||
* "quoted phrase" — exact phrase (passed to NIP-50 search)
|
||||
*
|
||||
* Boolean:
|
||||
* OR between terms — runs multiple queries (client-side merge)
|
||||
*
|
||||
* Everything else is passed as NIP-50 full-text search.
|
||||
*/
|
||||
|
||||
import { nip19 } from "@nostr-dev-kit/ndk";
|
||||
|
||||
export interface ParsedSearch {
|
||||
/** Text terms for NIP-50 search field */
|
||||
searchTerms: string[];
|
||||
/** Hashtags to filter by (#t tag) */
|
||||
hashtags: string[];
|
||||
/** Author pubkeys (hex) to filter by */
|
||||
authors: string[];
|
||||
/** Pubkeys mentioned in events (#p tag) */
|
||||
mentions: string[];
|
||||
/** Event kinds to search */
|
||||
kinds: number[];
|
||||
/** Media content filters (applied client-side) */
|
||||
hasFilters: string[];
|
||||
/** Unix timestamp — events after this */
|
||||
since: number | null;
|
||||
/** Unix timestamp — events before this */
|
||||
until: number | null;
|
||||
/** Original raw query for display */
|
||||
raw: string;
|
||||
/** Whether this is an OR query (multiple sub-queries) */
|
||||
orQueries: ParsedSearch[] | null;
|
||||
/** Unresolved NIP-05 identifiers that need async resolution */
|
||||
unresolvedNip05: string[];
|
||||
}
|
||||
|
||||
const KIND_ALIASES: Record<string, number> = {
|
||||
note: 1,
|
||||
text: 1,
|
||||
article: 30023,
|
||||
"long-form": 30023,
|
||||
longform: 30023,
|
||||
reaction: 7,
|
||||
repost: 6,
|
||||
dm: 4,
|
||||
highlight: 9802,
|
||||
bookmark: 10003,
|
||||
profile: 0,
|
||||
metadata: 0,
|
||||
contacts: 3,
|
||||
relay: 10002,
|
||||
zap: 9735,
|
||||
};
|
||||
|
||||
const IS_ALIASES: Record<string, number> = {
|
||||
...KIND_ALIASES,
|
||||
code: 1, // client-side filter for code blocks
|
||||
};
|
||||
|
||||
const MEDIA_PATTERNS: Record<string, RegExp> = {
|
||||
image: /\.(jpg|jpeg|png|gif|webp|avif|svg|apng)/i,
|
||||
video: /\.(mp4|webm|mov|m4v|ogg)/i,
|
||||
audio: /\.(mp3|wav|flac|m4a|ogg)/i,
|
||||
link: /https?:\/\//i,
|
||||
youtube: /youtu(\.be|be\.com)/i,
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse a date string (YYYY-MM-DD) to unix timestamp.
|
||||
* Returns null on invalid date.
|
||||
*/
|
||||
function parseDateToUnix(dateStr: string): number | null {
|
||||
const match = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
if (!match) return null;
|
||||
const d = new Date(`${match[1]}-${match[2]}-${match[3]}T00:00:00Z`);
|
||||
if (isNaN(d.getTime())) return null;
|
||||
return Math.floor(d.getTime() / 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to resolve an npub to hex pubkey.
|
||||
* Returns hex pubkey or null.
|
||||
*/
|
||||
function resolveNpub(input: string): string | null {
|
||||
if (input.startsWith("npub1")) {
|
||||
try {
|
||||
const decoded = nip19.decode(input);
|
||||
if (decoded.type === "npub") return decoded.data;
|
||||
} catch { /* not a valid npub */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokenize a query string, respecting quoted phrases.
|
||||
*/
|
||||
function tokenize(query: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
let current = "";
|
||||
let inQuote = false;
|
||||
|
||||
for (let i = 0; i < query.length; i++) {
|
||||
const char = query[i];
|
||||
if (char === '"') {
|
||||
if (inQuote) {
|
||||
tokens.push(`"${current}"`);
|
||||
current = "";
|
||||
inQuote = false;
|
||||
} else {
|
||||
if (current.trim()) tokens.push(current.trim());
|
||||
current = "";
|
||||
inQuote = true;
|
||||
}
|
||||
} else if (char === " " && !inQuote) {
|
||||
if (current.trim()) tokens.push(current.trim());
|
||||
current = "";
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
if (current.trim()) tokens.push(current.trim());
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a search query into structured search parameters.
|
||||
*/
|
||||
export function parseSearchQuery(raw: string): ParsedSearch {
|
||||
const trimmed = raw.trim();
|
||||
|
||||
// Handle OR queries — split on top-level OR
|
||||
if (/\bOR\b/i.test(trimmed)) {
|
||||
// Simple OR split (doesn't handle OR inside quotes, good enough)
|
||||
const parts = trimmed.split(/\s+OR\s+/i).map((p) => p.trim()).filter(Boolean);
|
||||
if (parts.length > 1) {
|
||||
return {
|
||||
searchTerms: [],
|
||||
hashtags: [],
|
||||
authors: [],
|
||||
mentions: [],
|
||||
kinds: [],
|
||||
hasFilters: [],
|
||||
since: null,
|
||||
until: null,
|
||||
raw: trimmed,
|
||||
orQueries: parts.map(parseSearchQuery),
|
||||
unresolvedNip05: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const tokens = tokenize(trimmed);
|
||||
const result: ParsedSearch = {
|
||||
searchTerms: [],
|
||||
hashtags: [],
|
||||
authors: [],
|
||||
mentions: [],
|
||||
kinds: [],
|
||||
hasFilters: [],
|
||||
since: null,
|
||||
until: null,
|
||||
raw: trimmed,
|
||||
orQueries: null,
|
||||
unresolvedNip05: [],
|
||||
};
|
||||
|
||||
for (const token of tokens) {
|
||||
const lower = token.toLowerCase();
|
||||
|
||||
// by:<author>
|
||||
if (lower.startsWith("by:")) {
|
||||
const value = token.slice(3);
|
||||
const hex = resolveNpub(value);
|
||||
if (hex) {
|
||||
result.authors.push(hex);
|
||||
} else if (value.includes(".") || value.includes("@")) {
|
||||
// Looks like a NIP-05 — needs async resolution
|
||||
result.unresolvedNip05.push(value);
|
||||
} else {
|
||||
// Treat as a search term for now (name-based lookup needs profile search)
|
||||
result.unresolvedNip05.push(value);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// mentions:<pubkey>
|
||||
if (lower.startsWith("mentions:")) {
|
||||
const value = token.slice(9);
|
||||
const hex = resolveNpub(value);
|
||||
if (hex) {
|
||||
result.mentions.push(hex);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// kind:<number|alias>
|
||||
if (lower.startsWith("kind:")) {
|
||||
const value = token.slice(5).toLowerCase();
|
||||
const num = parseInt(value);
|
||||
if (!isNaN(num)) {
|
||||
result.kinds.push(num);
|
||||
} else if (KIND_ALIASES[value] !== undefined) {
|
||||
result.kinds.push(KIND_ALIASES[value]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// is:<alias>
|
||||
if (lower.startsWith("is:")) {
|
||||
const value = token.slice(3).toLowerCase();
|
||||
if (IS_ALIASES[value] !== undefined) {
|
||||
result.kinds.push(IS_ALIASES[value]);
|
||||
}
|
||||
if (value === "code") {
|
||||
result.hasFilters.push("code");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// has:<media>
|
||||
if (lower.startsWith("has:")) {
|
||||
const value = token.slice(4).toLowerCase();
|
||||
result.hasFilters.push(value);
|
||||
continue;
|
||||
}
|
||||
|
||||
// since:<date>
|
||||
if (lower.startsWith("since:")) {
|
||||
const ts = parseDateToUnix(token.slice(6));
|
||||
if (ts) result.since = ts;
|
||||
continue;
|
||||
}
|
||||
|
||||
// until:<date>
|
||||
if (lower.startsWith("until:")) {
|
||||
const ts = parseDateToUnix(token.slice(6));
|
||||
if (ts) result.until = ts;
|
||||
continue;
|
||||
}
|
||||
|
||||
// #hashtag
|
||||
if (token.startsWith("#") && token.length > 1) {
|
||||
result.hashtags.push(token.slice(1).toLowerCase());
|
||||
continue;
|
||||
}
|
||||
|
||||
// Quoted phrase — keep quotes for NIP-50
|
||||
if (token.startsWith('"') && token.endsWith('"')) {
|
||||
result.searchTerms.push(token);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular search term
|
||||
result.searchTerms.push(token);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an event's content matches a "has:" filter.
|
||||
*/
|
||||
export function matchesHasFilter(content: string, filter: string): boolean {
|
||||
if (filter === "code") {
|
||||
return content.includes("```") || content.includes("`");
|
||||
}
|
||||
const pattern = MEDIA_PATTERNS[filter];
|
||||
if (pattern) return pattern.test(content);
|
||||
// Generic: just check if the filter text appears in content
|
||||
return content.toLowerCase().includes(filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a ParsedSearch back into a human-readable hint.
|
||||
*/
|
||||
export function describeSearch(parsed: ParsedSearch): string {
|
||||
const parts: string[] = [];
|
||||
if (parsed.searchTerms.length > 0) parts.push(parsed.searchTerms.join(" "));
|
||||
if (parsed.hashtags.length > 0) parts.push(parsed.hashtags.map((h) => `#${h}`).join(" "));
|
||||
if (parsed.authors.length > 0) parts.push(`by ${parsed.authors.length} author(s)`);
|
||||
if (parsed.kinds.length > 0) parts.push(`kind: ${parsed.kinds.join(", ")}`);
|
||||
if (parsed.hasFilters.length > 0) parts.push(`has: ${parsed.hasFilters.join(", ")}`);
|
||||
if (parsed.since) parts.push(`since ${new Date(parsed.since * 1000).toLocaleDateString()}`);
|
||||
if (parsed.until) parts.push(`until ${new Date(parsed.until * 1000).toLocaleDateString()}`);
|
||||
return parts.join(" · ") || "empty search";
|
||||
}
|
||||
49
src/lib/utils.test.ts
Normal file
49
src/lib/utils.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { timeAgo, shortenPubkey } from "./utils";
|
||||
|
||||
describe("timeAgo", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns seconds for < 60s", () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
expect(timeAgo(now - 30)).toBe("30s");
|
||||
});
|
||||
|
||||
it("returns minutes for < 1h", () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
expect(timeAgo(now - 300)).toBe("5m");
|
||||
});
|
||||
|
||||
it("returns hours for < 1d", () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
expect(timeAgo(now - 7200)).toBe("2h");
|
||||
});
|
||||
|
||||
it("returns days for < 1w", () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
expect(timeAgo(now - 86400 * 3)).toBe("3d");
|
||||
});
|
||||
|
||||
it("returns weeks for >= 1w", () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
expect(timeAgo(now - 604800 * 2)).toBe("2w");
|
||||
});
|
||||
|
||||
it("returns 0s for current timestamp", () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
expect(timeAgo(now)).toBe("0s");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shortenPubkey", () => {
|
||||
it("shortens a standard hex pubkey", () => {
|
||||
const key = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
|
||||
expect(shortenPubkey(key)).toBe("abcdef12…7890");
|
||||
});
|
||||
|
||||
it("handles short strings gracefully", () => {
|
||||
expect(shortenPubkey("abcd")).toBe("abcd…abcd");
|
||||
});
|
||||
});
|
||||
109
src/stores/feed.test.ts
Normal file
109
src/stores/feed.test.ts
Normal 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
28
src/stores/relayHealth.ts
Normal 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
100
src/stores/ui.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
27
src/test/setup.ts
Normal file
27
src/test/setup.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import { vi } from "vitest";
|
||||
|
||||
// Ensure localStorage is available (jsdom may not provide it in all configurations)
|
||||
const store = new Map<string, string>();
|
||||
const localStorageShim: Storage = {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => { store.set(key, value); },
|
||||
removeItem: (key: string) => { store.delete(key); },
|
||||
clear: () => { store.clear(); },
|
||||
get length() { return store.size; },
|
||||
key: (index: number) => [...store.keys()][index] ?? null,
|
||||
};
|
||||
|
||||
if (typeof globalThis.localStorage === "undefined" || !globalThis.localStorage?.getItem) {
|
||||
Object.defineProperty(globalThis, "localStorage", { value: localStorageShim, writable: true });
|
||||
}
|
||||
|
||||
// Mock @tauri-apps/api/core
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock @tauri-apps/plugin-opener
|
||||
vi.mock("@tauri-apps/plugin-opener", () => ({
|
||||
openUrl: vi.fn(),
|
||||
}));
|
||||
11
vitest.config.ts
Normal file
11
vitest.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
setupFiles: ["./src/test/setup.ts"],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user