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

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

View File

@@ -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. > **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.0Writer Tools & Upload Fix ### New in v0.7.1Relay Health Checker & Advanced Search
- **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 - **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
- **Markdown toolbar** — bold, italic, heading, link, image, quote, code, list buttons above the article editor; keyboard shortcuts Ctrl+B/I/K - **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
- **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 ### Previous: v0.7.0 — Writer Tools & Upload Fix
- **Article bookmarks** — NIP-51 `a` tag support; Notes/Articles tab toggle in bookmark view - NIP-98 HTTP Auth uploads with fallback services
- **Upload moved to TypeScript** — removed Rust upload command; dropped `reqwest`/`mime_guess` deps; lighter binary - Markdown toolbar with keyboard shortcuts (Ctrl+B/I/K)
- **Upload spinner** — animated spinner during image uploads in compose and editor - Multi-draft management with draft list
- **Draft count badge** — sidebar shows how many drafts you have - 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 ### Previous: v0.6.1 — Media Upload & Feed Polish
- Native file picker, file path paste upload, mention name resolution, connection stability - Native file picker, file path paste upload, mention name resolution, connection stability

View File

@@ -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 **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/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/nostr/` — NDK wrapper (`client.ts` + `index.ts`); all Nostr calls go through here
- `src/lib/lightning/` — NWC client (`nwc.ts`); Lightning payment logic - `src/lib/lightning/` — NWC client (`nwc.ts`); Lightning payment logic
- `src/hooks/``useProfile.ts`, `useReactionCount.ts` - `src/hooks/``useProfile.ts`, `useReactionCount.ts`
- `src/components/feed/` — Feed, NoteCard, NoteContent, ComposeBox - `src/components/feed/` — Feed, NoteCard, NoteContent, ComposeBox
- `src/components/profile/` — ProfileView (own + others, edit form) - `src/components/profile/` — ProfileView (own + others, edit form)
- `src/components/thread/` — ThreadView - `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/article/` — ArticleEditor, ArticleView, ArticleFeed, ArticleCard, MarkdownToolbar (NIP-23)
- `src/components/bookmark/` — BookmarkView - `src/components/bookmark/` — BookmarkView
- `src/components/zap/` — ZapModal - `src/components/zap/` — ZapModal
- `src/components/onboarding/` — OnboardingFlow (welcome, create key, backup, login) - `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 - `src/components/sidebar/` — Sidebar navigation
**Backend** (`src-tauri/`): Rust + Tauri 2.0 **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 - **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) - **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 ## 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 - **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) - **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 - 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 - Search: NIP-50 full-text, hashtag (#t filter), people, articles
- Settings: relay add/remove (persisted to localStorage), NWC URI, npub copy - 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 - OS keychain integration — nsec persists across restarts via `keyring` crate
- SQLite note + profile cache - SQLite note + profile cache
- Direct messages (NIP-04 + NIP-17 gift wrap) - Direct messages (NIP-04 + NIP-17 gift wrap)

View File

@@ -1,6 +1,6 @@
# Maintainer: hoornet <hoornet@users.noreply.github.com> # Maintainer: hoornet <hoornet@users.noreply.github.com>
pkgname=wrystr pkgname=wrystr
pkgver=0.7.0 pkgver=0.7.1
pkgrel=1 pkgrel=1
pkgdesc="Cross-platform Nostr desktop client with Lightning integration" pkgdesc="Cross-platform Nostr desktop client with Lightning integration"
arch=('x86_64') arch=('x86_64')

View File

@@ -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 - **Notifications** — mentions view (🔔 in sidebar) with unread badge; clears on open
**Relay & network** **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 - 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 - **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** **Discovery**
- **Discover people** — "follows of follows" suggestions on the Search page with mutual follow counts and one-click follow - **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) - Search: NIP-50 full-text, `#hashtag`, people search with inline follow, **article search** (kind 30023)
**Performance & UX** **Performance & UX**
@@ -109,7 +111,6 @@ npm run tauri build # production binary
See [ROADMAP.md](./ROADMAP.md) for the full prioritised next steps. See [ROADMAP.md](./ROADMAP.md) for the full prioritised next steps.
Up next: Up next:
- Relay health checker
- Web of Trust scoring - Web of Trust scoring
- NIP-46 remote signer support - NIP-46 remote signer support
- Custom feeds / lists - Custom feeds / lists

View File

@@ -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) ## 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) ### Web of Trust (WOT)
- Social graph distance for trust scoring - Social graph distance for trust scoring
- Could power: feed ranking, spam filtering, people search, follow suggestions - 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 ## 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 ### 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 - **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 - **Markdown toolbar** — bold, italic, heading, link, image, quote, code, list buttons above the article editor textarea

View File

@@ -1,7 +1,7 @@
{ {
"name": "wrystr", "name": "wrystr",
"private": true, "private": true,
"version": "0.7.0", "version": "0.7.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "wrystr" name = "wrystr"
version = "0.7.0" version = "0.7.1"
description = "Cross-platform Nostr desktop client with Lightning integration" description = "Cross-platform Nostr desktop client with Lightning integration"
authors = ["hoornet"] authors = ["hoornet"]
edition = "2021" edition = "2021"

View File

@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Wrystr", "productName": "Wrystr",
"version": "0.7.0", "version": "0.7.1",
"identifier": "com.hoornet.wrystr", "identifier": "com.hoornet.wrystr",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",

View File

@@ -1,6 +1,7 @@
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import { NDKEvent } from "@nostr-dev-kit/ndk"; 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 { getNip50Relays } from "../../lib/nostr/relayInfo";
import { useUserStore } from "../../stores/user"; import { useUserStore } from "../../stores/user";
import { useUIStore } from "../../stores/ui"; import { useUIStore } from "../../stores/ui";
@@ -135,7 +136,8 @@ export function SearchView() {
const [suggestionsLoading, setSuggestionsLoading] = useState(false); const [suggestionsLoading, setSuggestionsLoading] = useState(false);
const [suggestionsLoaded, setSuggestionsLoaded] = 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) // Check relay NIP-50 support once on mount (background, non-blocking)
useEffect(() => { useEffect(() => {
@@ -191,17 +193,23 @@ export function SearchView() {
if (overrideQuery) setQuery(overrideQuery); if (overrideQuery) setQuery(overrideQuery);
setLoading(true); setLoading(true);
setSearched(false); setSearched(false);
setSearchHint(null);
try { try {
const isTag = q.startsWith("#"); const parsed = parseSearchQuery(q);
const [notes, userEvents, articleEvents] = await Promise.all([ const isAdvanced = parsed.authors.length > 0 || parsed.unresolvedNip05.length > 0 ||
searchNotes(q), parsed.kinds.length > 0 || parsed.hasFilters.length > 0 ||
isTag ? Promise.resolve([]) : searchUsers(q), parsed.since !== null || parsed.until !== null || parsed.mentions.length > 0 ||
searchArticles(q), parsed.orQueries !== null;
]);
setNoteResults(notes); if (isAdvanced) {
setUserResults(userEvents.map(parseUserEvent)); setSearchHint(describeSearch(parsed));
setArticleResults(articleEvents); }
setActiveTab(notes.length > 0 ? "notes" : articleEvents.length > 0 ? "articles" : "people");
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 { } finally {
setLoading(false); setLoading(false);
setSearched(true); setSearched(true);
@@ -236,7 +244,7 @@ export function SearchView() {
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder="search notes, #hashtags, or people…" placeholder="search… try by:name, #tag, has:image, is:article, since:2026-01-01"
autoFocus autoFocus
className="flex-1 bg-transparent text-text text-[13px] placeholder:text-text-dim focus:outline-none" className="flex-1 bg-transparent text-text text-[13px] placeholder:text-text-dim focus:outline-none"
/> />
@@ -272,22 +280,59 @@ export function SearchView() {
</div> </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 */} {/* Results area */}
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{/* Idle / pre-search hint */} {/* Idle / pre-search hint */}
{!searched && !loading && ( {!searched && !loading && (
<div className="px-4 py-8 text-center space-y-2"> <div className="px-4 py-6 space-y-4">
<p className="text-text-dim text-[12px]"> <div className="text-center space-y-2">
Use <span className="text-accent">#hashtag</span> to browse topics, or type a keyword for full-text search. <p className="text-text-dim text-[12px]">
</p> Type a keyword, <span className="text-accent">#hashtag</span>, or use search modifiers.
{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> </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> </div>
)} )}

View File

@@ -1,36 +1,276 @@
import { getNDK } from "../../lib/nostr"; import { useEffect, useState } from "react";
import { getNDK, getStoredRelayUrls, removeRelay, publishRelayList } from "../../lib/nostr";
import { useRelayHealthStore } from "../../stores/relayHealth";
import { useUserStore } from "../../stores/user";
import type { RelayHealthResult } from "../../lib/nostr/relayHealth";
function statusColor(status: RelayHealthResult["status"]): string {
switch (status) {
case "online": return "bg-success";
case "slow": return "bg-warning";
case "offline": return "bg-danger";
}
}
function statusLabel(status: RelayHealthResult["status"]): string {
switch (status) {
case "online": return "online";
case "slow": return "slow";
case "offline": return "offline";
}
}
function formatLatency(ms: number | null): string {
if (ms === null || ms < 0) return "—";
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
function NipBadges({ nips }: { nips: number[] }) {
const notable = [1, 4, 11, 17, 23, 25, 50, 57, 65, 96, 98];
const supported = notable.filter((n) => nips.includes(n));
if (supported.length === 0) return null;
return (
<div className="flex flex-wrap gap-1 mt-1">
{supported.map((n) => (
<span key={n} className="px-1 py-0 text-[9px] border border-border text-text-dim rounded-sm">
NIP-{String(n).padStart(2, "0")}
</span>
))}
</div>
);
}
function RelayHealthCard({ result, poolConnected }: { result: RelayHealthResult; poolConnected: boolean }) {
const [expanded, setExpanded] = useState(false);
const nip11 = result.nip11;
return (
<div className="border border-border">
<div
className="flex items-center gap-3 px-3 py-2 text-[12px] cursor-pointer hover:bg-bg-hover transition-colors"
onClick={() => setExpanded((v) => !v)}
>
<span className={`w-2 h-2 rounded-full shrink-0 ${statusColor(result.status)}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-text truncate font-mono">{result.url}</span>
{nip11?.name && (
<span className="text-text-dim text-[10px] truncate">({nip11.name})</span>
)}
</div>
</div>
<div className="flex items-center gap-3 shrink-0">
{result.latencyMs !== null && (
<span className={`text-[10px] font-mono ${result.latencyMs > 2000 ? "text-warning" : "text-text-dim"}`}>
{formatLatency(result.latencyMs)}
</span>
)}
<span className={`text-[10px] ${result.status === "offline" ? "text-danger" : "text-text-dim"}`}>
{statusLabel(result.status)}
</span>
{poolConnected && (
<span className="w-1.5 h-1.5 rounded-full bg-accent shrink-0" title="NDK connected" />
)}
<span className="text-text-dim text-[10px]">{expanded ? "▾" : "▸"}</span>
</div>
</div>
{expanded && (
<div className="px-3 py-2 border-t border-border bg-bg-raised text-[11px] space-y-1.5">
{nip11 ? (
<>
{nip11.description && (
<p className="text-text-muted">{nip11.description}</p>
)}
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-[10px]">
{nip11.software && (
<div>
<span className="text-text-dim">Software: </span>
<span className="text-text">{nip11.software}{nip11.version ? ` ${nip11.version}` : ""}</span>
</div>
)}
{nip11.contact && (
<div>
<span className="text-text-dim">Contact: </span>
<span className="text-text">{nip11.contact}</span>
</div>
)}
{nip11.pubkey && (
<div className="col-span-2">
<span className="text-text-dim">Pubkey: </span>
<span className="text-text font-mono">{nip11.pubkey.slice(0, 16)}</span>
</div>
)}
</div>
{nip11.supported_nips && nip11.supported_nips.length > 0 && (
<div>
<span className="text-text-dim text-[10px]">
{nip11.supported_nips.length} NIPs supported
</span>
<NipBadges nips={nip11.supported_nips} />
</div>
)}
</>
) : (
<p className="text-text-dim">No NIP-11 info available{result.error ? `${result.error}` : ""}</p>
)}
<div className="text-text-dim text-[9px]">
Checked {new Date(result.checkedAt).toLocaleTimeString()}
</div>
</div>
)}
</div>
);
}
/** Fallback row for relays not yet health-checked */
function RelayPoolRow({ url, connected }: { url: string; connected: boolean }) {
return (
<div className="flex items-center gap-3 px-3 py-2 border border-border text-[12px]">
<span className={`w-2 h-2 rounded-full shrink-0 ${connected ? "bg-success" : "bg-text-dim"}`} />
<span className="text-text truncate flex-1 font-mono">{url}</span>
<span className="text-text-dim text-[10px]">{connected ? "connected" : "—"}</span>
</div>
);
}
export function RelaysView() { export function RelaysView() {
const { results, checking, lastChecked, checkAll } = useRelayHealthStore();
const { loggedIn } = useUserStore();
const ndk = getNDK(); 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 ( return (
<div className="h-full flex flex-col"> <div className="h-full flex flex-col">
<header className="border-b border-border px-4 py-2.5 shrink-0"> <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> </header>
<div className="flex-1 overflow-y-auto p-4"> {/* Actions bar — show when there are dead relays */}
{relays.length === 0 ? ( {deadRelays.length > 0 && (
<p className="text-text-dim text-[12px]">No relays configured.</p> <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]">
<div className="space-y-1"> {deadRelays.length} relay{deadRelays.length > 1 ? "s" : ""} offline
{relays.map((relay) => ( </span>
<div <div className="flex items-center gap-2">
key={relay.url} <button
className="flex items-center gap-3 px-3 py-2 border border-border text-[12px]" 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 {republishing ? "publishing…" : "republish list"}
className={`w-1.5 h-1.5 rounded-full shrink-0 ${ </button>
relay.connected ? "bg-success" : "bg-danger" )}
}`} </div>
/> </div>
<span className="text-text truncate flex-1 font-mono">{relay.url}</span> )}
<span className="text-text-dim shrink-0">
{relay.connected ? "connected" : "disconnected"} <div className="flex-1 overflow-y-auto p-4">
</span> {results.length === 0 && !checking && poolRelays.length === 0 && (
</div> <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>
)} )}
</div> </div>

View File

@@ -13,6 +13,7 @@
--color-accent-hover: #7c3aed; --color-accent-hover: #7c3aed;
--color-zap: #f59e0b; --color-zap: #f59e0b;
--color-danger: #ef4444; --color-danger: #ef4444;
--color-warning: #f59e0b;
--color-success: #22c55e; --color-success: #22c55e;
--font-mono: "JetBrains Mono", "Fira Code", "SF Mono", monospace; --font-mono: "JetBrains Mono", "Fira Code", "SF Mono", monospace;
} }

76
src/lib/language.test.ts Normal file
View 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();
});
});

View 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);
});
});

View File

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

View File

@@ -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 { 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 } from "./client"; export type { UserRelayList, AdvancedSearchResults } from "./client";

View 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
View 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
View 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
View 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
View File

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

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

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

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

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

27
src/test/setup.ts Normal file
View 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
View 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"],
},
});