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

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";
@@ -783,3 +784,130 @@ export async function fetchMentions(pubkey: string, since: number, limit = 50):
);
return Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0));
}
// ── NIP-05 Resolution ─────────────────────────────────────────────────────────
export async function resolveNip05(identifier: string): Promise<string | null> {
const instance = getNDK();
try {
const user = new NDKUser({ nip05: identifier });
user.ndk = instance;
await user.fetchProfile();
return user.pubkey || null;
} catch {
return null;
}
}
// ── Advanced Search ───────────────────────────────────────────────────────────
export interface AdvancedSearchResults {
notes: NDKEvent[];
articles: NDKEvent[];
users: NDKEvent[];
}
/**
* Execute an advanced search using a ParsedSearch query.
* Resolves NIP-05 identifiers, builds filters, runs queries,
* and applies client-side filters (has:image, has:code, etc.).
*/
export async function advancedSearch(parsed: ParsedSearch, limit = 50): Promise<AdvancedSearchResults> {
const instance = getNDK();
// Handle OR queries — run each sub-query and merge
if (parsed.orQueries && parsed.orQueries.length > 0) {
const subResults = await Promise.all(parsed.orQueries.map((q) => advancedSearch(q, limit)));
const seenNotes = new Set<string>();
const seenArticles = new Set<string>();
const seenUsers = new Set<string>();
const notes: NDKEvent[] = [];
const articles: NDKEvent[] = [];
const users: NDKEvent[] = [];
for (const r of subResults) {
for (const e of r.notes) { if (!seenNotes.has(e.id!)) { seenNotes.add(e.id!); notes.push(e); } }
for (const e of r.articles) { if (!seenArticles.has(e.id!)) { seenArticles.add(e.id!); articles.push(e); } }
for (const e of r.users) { if (!seenUsers.has(e.pubkey)) { seenUsers.add(e.pubkey); users.push(e); } }
}
return {
notes: notes.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)).slice(0, limit),
articles: articles.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)).slice(0, limit),
users,
};
}
// Resolve any NIP-05 or name-based author identifiers
const resolvedAuthors = [...parsed.authors];
for (const nip05 of parsed.unresolvedNip05) {
const resolved = await resolveNip05(nip05.includes("@") || nip05.includes(".") ? nip05 : `_@${nip05}`);
if (resolved) {
resolvedAuthors.push(resolved);
} else {
const nameResults = await searchUsers(nip05, 1);
if (nameResults.length > 0) {
resolvedAuthors.push(nameResults[0].pubkey);
}
}
}
// Determine which kinds to search
const hasKindFilter = parsed.kinds.length > 0;
const noteKinds = hasKindFilter
? parsed.kinds.filter((k) => k === 1)
: [1];
const articleKinds = hasKindFilter
? parsed.kinds.filter((k) => k === 30023)
: [30023];
const searchText = parsed.searchTerms.join(" ").trim();
const hasSearch = searchText.length > 0;
const hasHashtags = parsed.hashtags.length > 0;
const buildFilter = (kinds: number[]): (NDKFilter & { search?: string }) | null => {
if (kinds.length === 0 && hasKindFilter) return null;
const filter: NDKFilter & { search?: string } = {
kinds: kinds.map((k) => k as NDKKind),
limit,
};
if (hasSearch) filter.search = searchText;
if (hasHashtags) filter["#t"] = parsed.hashtags;
if (resolvedAuthors.length > 0) filter.authors = resolvedAuthors;
if (parsed.mentions.length > 0) filter["#p"] = parsed.mentions;
if (parsed.since) filter.since = parsed.since;
if (parsed.until) filter.until = parsed.until;
if (!hasSearch && !hasHashtags && resolvedAuthors.length === 0 && parsed.mentions.length === 0) {
return null;
}
return filter;
};
const opts = { cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY };
const noteFilter = noteKinds.length > 0 ? buildFilter(noteKinds) : null;
const articleFilter = articleKinds.length > 0 ? buildFilter(articleKinds) : null;
const shouldSearchUsers = (!hasKindFilter || parsed.kinds.includes(0)) && hasSearch && !hasHashtags;
const [noteEvents, articleEvents, userEvents] = await Promise.all([
noteFilter ? instance.fetchEvents(noteFilter, opts) : Promise.resolve(new Set<NDKEvent>()),
articleFilter ? instance.fetchEvents(articleFilter, opts) : Promise.resolve(new Set<NDKEvent>()),
shouldSearchUsers ? instance.fetchEvents({ kinds: [NDKKind.Metadata], search: searchText, limit: 20 } as NDKFilter & { search: string }, opts) : Promise.resolve(new Set<NDKEvent>()),
]);
let notes = Array.from(noteEvents);
let articles = Array.from(articleEvents);
const users = Array.from(userEvents);
// Client-side filters: has:image, has:video, has:code, etc.
if (parsed.hasFilters.length > 0) {
const applyHas = (events: NDKEvent[]) =>
events.filter((e) => parsed.hasFilters.every((f) => matchesHasFilter(e.content, f)));
notes = applyHas(notes);
articles = applyHas(articles);
}
return {
notes: notes.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)),
articles: articles.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)),
users,
};
}

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 type { UserRelayList } from "./client";
export { getNDK, connectToRelays, fetchGlobalFeed, fetchFollowFeed, fetchReplies, publishNote, publishArticle, publishProfile, publishReaction, publishRepost, publishQuote, publishReply, publishContactList, fetchBatchEngagement, fetchReactionCount, fetchReplyCount, fetchZapCount, fetchNoteById, fetchUserNotes, fetchProfile, fetchArticle, fetchAuthorArticles, fetchArticleFeed, searchArticles, fetchZapsReceived, fetchZapsSent, fetchDMConversations, fetchDMThread, sendDM, decryptDM, fetchBookmarkList, publishBookmarkList, fetchBookmarkListFull, publishBookmarkListFull, fetchByAddr, fetchMuteList, publishMuteList, getStoredRelayUrls, addRelay, removeRelay, searchNotes, searchUsers, fetchUserRelayList, publishRelayList, fetchUserNotesNIP65, fetchFollowSuggestions, fetchMentions, resolveNip05, advancedSearch } from "./client";
export type { UserRelayList, AdvancedSearchResults } from "./client";

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