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.
|
> **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
|
### New in v0.7.1 — Relay 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
|
||||||
|
|||||||
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
|
**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)
|
||||||
|
|||||||
2
PKGBUILD
2
PKGBUILD
@@ -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')
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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)
|
## 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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
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";
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
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