mirror of
https://github.com/hoornet/vega.git
synced 2026-05-11 14:41:18 -07:00
Bump to v0.7.1 — relay health checker, advanced search
This commit is contained in:
76
src/lib/language.test.ts
Normal file
76
src/lib/language.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { detectScript, getEventLanguageTag } from "./language";
|
||||
|
||||
describe("detectScript", () => {
|
||||
it("detects Latin script", () => {
|
||||
expect(detectScript("Hello world")).toBe("Latin");
|
||||
});
|
||||
|
||||
it("detects CJK script", () => {
|
||||
expect(detectScript("你好世界")).toBe("CJK");
|
||||
});
|
||||
|
||||
it("detects Cyrillic script", () => {
|
||||
expect(detectScript("Привет мир")).toBe("Cyrillic");
|
||||
});
|
||||
|
||||
it("detects Arabic script", () => {
|
||||
expect(detectScript("مرحبا بالعالم")).toBe("Arabic");
|
||||
});
|
||||
|
||||
it("detects Korean script", () => {
|
||||
expect(detectScript("안녕하세요")).toBe("Korean");
|
||||
});
|
||||
|
||||
it("returns dominant script for mixed content", () => {
|
||||
expect(detectScript("Hello 你好世界中文测试")).toBe("CJK");
|
||||
});
|
||||
|
||||
it("strips URLs before detection", () => {
|
||||
expect(detectScript("https://example.com 你好世界")).toBe("CJK");
|
||||
});
|
||||
|
||||
it("strips nostr mentions before detection", () => {
|
||||
expect(detectScript("nostr:npub1abc123 Привет")).toBe("Cyrillic");
|
||||
});
|
||||
|
||||
it("strips hashtags before detection", () => {
|
||||
expect(detectScript("#bitcoin Hola mundo")).toBe("Latin");
|
||||
});
|
||||
|
||||
it("returns Unknown for empty input", () => {
|
||||
expect(detectScript("")).toBe("Unknown");
|
||||
});
|
||||
|
||||
it("returns Unknown for whitespace-only input", () => {
|
||||
expect(detectScript(" ")).toBe("Unknown");
|
||||
});
|
||||
|
||||
it("returns Unknown for only URLs/mentions", () => {
|
||||
expect(detectScript("https://example.com nostr:npub1abc")).toBe("Unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEventLanguageTag", () => {
|
||||
it("finds ISO-639-1 language tag", () => {
|
||||
const tags = [
|
||||
["t", "bitcoin"],
|
||||
["l", "en", "ISO-639-1"],
|
||||
];
|
||||
expect(getEventLanguageTag(tags)).toBe("en");
|
||||
});
|
||||
|
||||
it("returns null when no language tag exists", () => {
|
||||
const tags = [["t", "nostr"], ["p", "abc123"]];
|
||||
expect(getEventLanguageTag(tags)).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores l tags without ISO-639-1 namespace", () => {
|
||||
const tags = [["l", "en", "some-other-namespace"]];
|
||||
expect(getEventLanguageTag(tags)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for empty tags", () => {
|
||||
expect(getEventLanguageTag([])).toBeNull();
|
||||
});
|
||||
});
|
||||
46
src/lib/lightning/nwc.test.ts
Normal file
46
src/lib/lightning/nwc.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseNwcUri, isValidNwcUri } from "./nwc";
|
||||
|
||||
describe("parseNwcUri", () => {
|
||||
it("parses a valid NWC URI", () => {
|
||||
const uri = "nostr+walletconnect://abc123?relay=wss://relay.example.com&secret=mysecret";
|
||||
const result = parseNwcUri(uri);
|
||||
expect(result.walletPubkey).toBe("abc123");
|
||||
expect(result.relayUrl).toBe("wss://relay.example.com");
|
||||
expect(result.secret).toBe("mysecret");
|
||||
});
|
||||
|
||||
it("throws on missing relay", () => {
|
||||
const uri = "nostr+walletconnect://abc123?secret=mysecret";
|
||||
expect(() => parseNwcUri(uri)).toThrow("Invalid NWC URI");
|
||||
});
|
||||
|
||||
it("throws on missing secret", () => {
|
||||
const uri = "nostr+walletconnect://abc123?relay=wss://relay.example.com";
|
||||
expect(() => parseNwcUri(uri)).toThrow("Invalid NWC URI");
|
||||
});
|
||||
|
||||
it("throws on completely invalid URI", () => {
|
||||
expect(() => parseNwcUri("not-a-uri")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidNwcUri", () => {
|
||||
it("returns true for valid NWC URI", () => {
|
||||
const uri = "nostr+walletconnect://abc123?relay=wss://relay.example.com&secret=mysecret";
|
||||
expect(isValidNwcUri(uri)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for invalid URI", () => {
|
||||
expect(isValidNwcUri("not-valid")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for wrong prefix", () => {
|
||||
const uri = "https://abc123?relay=wss://relay.example.com&secret=mysecret";
|
||||
expect(isValidNwcUri(uri)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for missing fields", () => {
|
||||
expect(isValidNwcUri("nostr+walletconnect://abc123")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import NDK, { NDKEvent, NDKFilter, NDKKind, NDKRelay, NDKRelaySet, NDKSubscriptionCacheUsage, nip19, giftWrap, giftUnwrap } from "@nostr-dev-kit/ndk";
|
||||
import NDK, { NDKEvent, NDKFilter, NDKKind, NDKRelay, NDKRelaySet, NDKSubscriptionCacheUsage, NDKUser, nip19, giftWrap, giftUnwrap } from "@nostr-dev-kit/ndk";
|
||||
import { type ParsedSearch, matchesHasFilter } from "../search";
|
||||
|
||||
const RELAY_STORAGE_KEY = "wrystr_relays";
|
||||
|
||||
@@ -783,3 +784,130 @@ export async function fetchMentions(pubkey: string, since: number, limit = 50):
|
||||
);
|
||||
return Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0));
|
||||
}
|
||||
|
||||
// ── NIP-05 Resolution ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function resolveNip05(identifier: string): Promise<string | null> {
|
||||
const instance = getNDK();
|
||||
try {
|
||||
const user = new NDKUser({ nip05: identifier });
|
||||
user.ndk = instance;
|
||||
await user.fetchProfile();
|
||||
return user.pubkey || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Advanced Search ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface AdvancedSearchResults {
|
||||
notes: NDKEvent[];
|
||||
articles: NDKEvent[];
|
||||
users: NDKEvent[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an advanced search using a ParsedSearch query.
|
||||
* Resolves NIP-05 identifiers, builds filters, runs queries,
|
||||
* and applies client-side filters (has:image, has:code, etc.).
|
||||
*/
|
||||
export async function advancedSearch(parsed: ParsedSearch, limit = 50): Promise<AdvancedSearchResults> {
|
||||
const instance = getNDK();
|
||||
|
||||
// Handle OR queries — run each sub-query and merge
|
||||
if (parsed.orQueries && parsed.orQueries.length > 0) {
|
||||
const subResults = await Promise.all(parsed.orQueries.map((q) => advancedSearch(q, limit)));
|
||||
const seenNotes = new Set<string>();
|
||||
const seenArticles = new Set<string>();
|
||||
const seenUsers = new Set<string>();
|
||||
const notes: NDKEvent[] = [];
|
||||
const articles: NDKEvent[] = [];
|
||||
const users: NDKEvent[] = [];
|
||||
for (const r of subResults) {
|
||||
for (const e of r.notes) { if (!seenNotes.has(e.id!)) { seenNotes.add(e.id!); notes.push(e); } }
|
||||
for (const e of r.articles) { if (!seenArticles.has(e.id!)) { seenArticles.add(e.id!); articles.push(e); } }
|
||||
for (const e of r.users) { if (!seenUsers.has(e.pubkey)) { seenUsers.add(e.pubkey); users.push(e); } }
|
||||
}
|
||||
return {
|
||||
notes: notes.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)).slice(0, limit),
|
||||
articles: articles.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)).slice(0, limit),
|
||||
users,
|
||||
};
|
||||
}
|
||||
|
||||
// Resolve any NIP-05 or name-based author identifiers
|
||||
const resolvedAuthors = [...parsed.authors];
|
||||
for (const nip05 of parsed.unresolvedNip05) {
|
||||
const resolved = await resolveNip05(nip05.includes("@") || nip05.includes(".") ? nip05 : `_@${nip05}`);
|
||||
if (resolved) {
|
||||
resolvedAuthors.push(resolved);
|
||||
} else {
|
||||
const nameResults = await searchUsers(nip05, 1);
|
||||
if (nameResults.length > 0) {
|
||||
resolvedAuthors.push(nameResults[0].pubkey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine which kinds to search
|
||||
const hasKindFilter = parsed.kinds.length > 0;
|
||||
const noteKinds = hasKindFilter
|
||||
? parsed.kinds.filter((k) => k === 1)
|
||||
: [1];
|
||||
const articleKinds = hasKindFilter
|
||||
? parsed.kinds.filter((k) => k === 30023)
|
||||
: [30023];
|
||||
|
||||
const searchText = parsed.searchTerms.join(" ").trim();
|
||||
const hasSearch = searchText.length > 0;
|
||||
const hasHashtags = parsed.hashtags.length > 0;
|
||||
|
||||
const buildFilter = (kinds: number[]): (NDKFilter & { search?: string }) | null => {
|
||||
if (kinds.length === 0 && hasKindFilter) return null;
|
||||
const filter: NDKFilter & { search?: string } = {
|
||||
kinds: kinds.map((k) => k as NDKKind),
|
||||
limit,
|
||||
};
|
||||
if (hasSearch) filter.search = searchText;
|
||||
if (hasHashtags) filter["#t"] = parsed.hashtags;
|
||||
if (resolvedAuthors.length > 0) filter.authors = resolvedAuthors;
|
||||
if (parsed.mentions.length > 0) filter["#p"] = parsed.mentions;
|
||||
if (parsed.since) filter.since = parsed.since;
|
||||
if (parsed.until) filter.until = parsed.until;
|
||||
if (!hasSearch && !hasHashtags && resolvedAuthors.length === 0 && parsed.mentions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return filter;
|
||||
};
|
||||
|
||||
const opts = { cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY };
|
||||
|
||||
const noteFilter = noteKinds.length > 0 ? buildFilter(noteKinds) : null;
|
||||
const articleFilter = articleKinds.length > 0 ? buildFilter(articleKinds) : null;
|
||||
const shouldSearchUsers = (!hasKindFilter || parsed.kinds.includes(0)) && hasSearch && !hasHashtags;
|
||||
|
||||
const [noteEvents, articleEvents, userEvents] = await Promise.all([
|
||||
noteFilter ? instance.fetchEvents(noteFilter, opts) : Promise.resolve(new Set<NDKEvent>()),
|
||||
articleFilter ? instance.fetchEvents(articleFilter, opts) : Promise.resolve(new Set<NDKEvent>()),
|
||||
shouldSearchUsers ? instance.fetchEvents({ kinds: [NDKKind.Metadata], search: searchText, limit: 20 } as NDKFilter & { search: string }, opts) : Promise.resolve(new Set<NDKEvent>()),
|
||||
]);
|
||||
|
||||
let notes = Array.from(noteEvents);
|
||||
let articles = Array.from(articleEvents);
|
||||
const users = Array.from(userEvents);
|
||||
|
||||
// Client-side filters: has:image, has:video, has:code, etc.
|
||||
if (parsed.hasFilters.length > 0) {
|
||||
const applyHas = (events: NDKEvent[]) =>
|
||||
events.filter((e) => parsed.hasFilters.every((f) => matchesHasFilter(e.content, f)));
|
||||
notes = applyHas(notes);
|
||||
articles = applyHas(articles);
|
||||
}
|
||||
|
||||
return {
|
||||
notes: notes.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)),
|
||||
articles: articles.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)),
|
||||
users,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { getNDK, connectToRelays, fetchGlobalFeed, fetchFollowFeed, fetchReplies, publishNote, publishArticle, publishProfile, publishReaction, publishRepost, publishQuote, publishReply, publishContactList, fetchBatchEngagement, fetchReactionCount, fetchReplyCount, fetchZapCount, fetchNoteById, fetchUserNotes, fetchProfile, fetchArticle, fetchAuthorArticles, fetchArticleFeed, searchArticles, fetchZapsReceived, fetchZapsSent, fetchDMConversations, fetchDMThread, sendDM, decryptDM, fetchBookmarkList, publishBookmarkList, fetchBookmarkListFull, publishBookmarkListFull, fetchByAddr, fetchMuteList, publishMuteList, getStoredRelayUrls, addRelay, removeRelay, searchNotes, searchUsers, fetchUserRelayList, publishRelayList, fetchUserNotesNIP65, fetchFollowSuggestions, fetchMentions } from "./client";
|
||||
export type { UserRelayList } from "./client";
|
||||
export { getNDK, connectToRelays, fetchGlobalFeed, fetchFollowFeed, fetchReplies, publishNote, publishArticle, publishProfile, publishReaction, publishRepost, publishQuote, publishReply, publishContactList, fetchBatchEngagement, fetchReactionCount, fetchReplyCount, fetchZapCount, fetchNoteById, fetchUserNotes, fetchProfile, fetchArticle, fetchAuthorArticles, fetchArticleFeed, searchArticles, fetchZapsReceived, fetchZapsSent, fetchDMConversations, fetchDMThread, sendDM, decryptDM, fetchBookmarkList, publishBookmarkList, fetchBookmarkListFull, publishBookmarkListFull, fetchByAddr, fetchMuteList, publishMuteList, getStoredRelayUrls, addRelay, removeRelay, searchNotes, searchUsers, fetchUserRelayList, publishRelayList, fetchUserNotesNIP65, fetchFollowSuggestions, fetchMentions, resolveNip05, advancedSearch } from "./client";
|
||||
export type { UserRelayList, AdvancedSearchResults } from "./client";
|
||||
|
||||
130
src/lib/nostr/relayHealth.ts
Normal file
130
src/lib/nostr/relayHealth.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Relay health checking — NIP-11 info, latency measurement, connection probe.
|
||||
*/
|
||||
|
||||
export interface RelayNip11Info {
|
||||
name?: string;
|
||||
description?: string;
|
||||
pubkey?: string;
|
||||
contact?: string;
|
||||
supported_nips?: number[];
|
||||
software?: string;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export interface RelayHealthResult {
|
||||
url: string;
|
||||
status: "online" | "slow" | "offline";
|
||||
latencyMs: number | null;
|
||||
nip11: RelayNip11Info | null;
|
||||
checkedAt: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch NIP-11 relay information document.
|
||||
* Converts wss:// to https:// and requests with application/nostr+json.
|
||||
*/
|
||||
export async function fetchNip11(relayUrl: string): Promise<RelayNip11Info | null> {
|
||||
const httpUrl = relayUrl.replace(/^wss:\/\//, "https://").replace(/^ws:\/\//, "http://");
|
||||
try {
|
||||
const resp = await fetch(httpUrl, {
|
||||
headers: { Accept: "application/nostr+json" },
|
||||
signal: AbortSignal.timeout(6000),
|
||||
});
|
||||
if (!resp.ok) return null;
|
||||
return await resp.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure WebSocket connection latency by opening a fresh connection,
|
||||
* sending a REQ for a single event, and timing how long until the first
|
||||
* EOSE or EVENT response. Falls back to just measuring connect time.
|
||||
*/
|
||||
export async function measureLatency(relayUrl: string): Promise<{ latencyMs: number; connected: boolean }> {
|
||||
return new Promise((resolve) => {
|
||||
const start = performance.now();
|
||||
const timeout = setTimeout(() => {
|
||||
try { ws.close(); } catch { /* ignore */ }
|
||||
resolve({ latencyMs: -1, connected: false });
|
||||
}, 8000);
|
||||
|
||||
let ws: WebSocket;
|
||||
try {
|
||||
ws = new WebSocket(relayUrl);
|
||||
} catch {
|
||||
clearTimeout(timeout);
|
||||
resolve({ latencyMs: -1, connected: false });
|
||||
return;
|
||||
}
|
||||
|
||||
ws.onopen = () => {
|
||||
// Send a minimal REQ to measure round-trip
|
||||
const subId = "health_" + Math.random().toString(36).slice(2, 8);
|
||||
try {
|
||||
ws.send(JSON.stringify(["REQ", subId, { kinds: [0], limit: 1 }]));
|
||||
} catch {
|
||||
clearTimeout(timeout);
|
||||
const elapsed = Math.round(performance.now() - start);
|
||||
try { ws.close(); } catch { /* ignore */ }
|
||||
resolve({ latencyMs: elapsed, connected: true });
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = () => {
|
||||
clearTimeout(timeout);
|
||||
const elapsed = Math.round(performance.now() - start);
|
||||
try { ws.close(); } catch { /* ignore */ }
|
||||
resolve({ latencyMs: elapsed, connected: true });
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
clearTimeout(timeout);
|
||||
try { ws.close(); } catch { /* ignore */ }
|
||||
resolve({ latencyMs: -1, connected: false });
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
clearTimeout(timeout);
|
||||
resolve({ latencyMs: -1, connected: false });
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Full health check for a single relay: NIP-11 + latency probe.
|
||||
*/
|
||||
export async function checkRelayHealth(relayUrl: string): Promise<RelayHealthResult> {
|
||||
const [nip11, latency] = await Promise.all([
|
||||
fetchNip11(relayUrl),
|
||||
measureLatency(relayUrl),
|
||||
]);
|
||||
|
||||
let status: RelayHealthResult["status"];
|
||||
if (!latency.connected) {
|
||||
status = "offline";
|
||||
} else if (latency.latencyMs > 3000) {
|
||||
status = "slow";
|
||||
} else {
|
||||
status = "online";
|
||||
}
|
||||
|
||||
return {
|
||||
url: relayUrl,
|
||||
status,
|
||||
latencyMs: latency.connected ? latency.latencyMs : null,
|
||||
nip11,
|
||||
checkedAt: Date.now(),
|
||||
error: !latency.connected ? "Connection failed" : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check all relays in parallel.
|
||||
*/
|
||||
export async function checkAllRelays(relayUrls: string[]): Promise<RelayHealthResult[]> {
|
||||
return Promise.all(relayUrls.map(checkRelayHealth));
|
||||
}
|
||||
171
src/lib/parsing.test.ts
Normal file
171
src/lib/parsing.test.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseContent } from "./parsing";
|
||||
|
||||
describe("parseContent", () => {
|
||||
it("returns plain text as a single text segment", () => {
|
||||
const result = parseContent("Hello world");
|
||||
expect(result).toEqual([{ type: "text", value: "Hello world" }]);
|
||||
});
|
||||
|
||||
it("parses URLs as link segments with shortened display", () => {
|
||||
const result = parseContent("Check https://example.com/path out");
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0]).toEqual({ type: "text", value: "Check " });
|
||||
expect(result[1].type).toBe("link");
|
||||
expect(result[1].value).toBe("https://example.com/path");
|
||||
expect(result[1].display).toBe("example.com/path");
|
||||
expect(result[2]).toEqual({ type: "text", value: " out" });
|
||||
});
|
||||
|
||||
it("parses image URLs", () => {
|
||||
const result = parseContent("Look https://example.com/photo.jpg");
|
||||
expect(result[1]).toEqual({ type: "image", value: "https://example.com/photo.jpg" });
|
||||
});
|
||||
|
||||
it("parses image URLs with query params", () => {
|
||||
const result = parseContent("https://example.com/photo.png?w=800");
|
||||
expect(result[0].type).toBe("image");
|
||||
});
|
||||
|
||||
it("parses various image extensions", () => {
|
||||
for (const ext of ["jpg", "jpeg", "png", "gif", "webp", "svg"]) {
|
||||
const result = parseContent(`https://example.com/img.${ext}`);
|
||||
expect(result[0].type).toBe("image");
|
||||
}
|
||||
});
|
||||
|
||||
it("parses video URLs", () => {
|
||||
const result = parseContent("https://example.com/video.mp4");
|
||||
expect(result[0]).toEqual({ type: "video", value: "https://example.com/video.mp4" });
|
||||
});
|
||||
|
||||
it("parses various video extensions", () => {
|
||||
for (const ext of ["mp4", "webm", "mov"]) {
|
||||
const result = parseContent(`https://example.com/vid.${ext}`);
|
||||
expect(result[0].type).toBe("video");
|
||||
}
|
||||
});
|
||||
|
||||
it("parses audio URLs", () => {
|
||||
const result = parseContent("https://example.com/song.mp3");
|
||||
expect(result[0]).toEqual({ type: "audio", value: "https://example.com/song.mp3" });
|
||||
});
|
||||
|
||||
it("parses various audio extensions", () => {
|
||||
for (const ext of ["mp3", "wav", "flac", "aac"]) {
|
||||
const result = parseContent(`https://example.com/audio.${ext}`);
|
||||
expect(result[0].type).toBe("audio");
|
||||
}
|
||||
});
|
||||
|
||||
it("parses YouTube watch URLs", () => {
|
||||
const result = parseContent("https://youtube.com/watch?v=dQw4w9WgXcQ");
|
||||
expect(result[0].type).toBe("youtube");
|
||||
expect(result[0].mediaId).toBe("dQw4w9WgXcQ");
|
||||
});
|
||||
|
||||
it("parses YouTube shorts URLs", () => {
|
||||
const result = parseContent("https://youtube.com/shorts/dQw4w9WgXcQ");
|
||||
expect(result[0].type).toBe("youtube");
|
||||
expect(result[0].mediaId).toBe("dQw4w9WgXcQ");
|
||||
});
|
||||
|
||||
it("parses youtu.be short URLs", () => {
|
||||
const result = parseContent("https://youtu.be/dQw4w9WgXcQ");
|
||||
expect(result[0].type).toBe("youtube");
|
||||
expect(result[0].mediaId).toBe("dQw4w9WgXcQ");
|
||||
});
|
||||
|
||||
it("parses Spotify track URLs", () => {
|
||||
const result = parseContent("https://open.spotify.com/track/abc123");
|
||||
expect(result[0].type).toBe("spotify");
|
||||
expect(result[0].mediaType).toBe("track");
|
||||
expect(result[0].mediaId).toBe("abc123");
|
||||
});
|
||||
|
||||
it("parses Spotify album URLs", () => {
|
||||
const result = parseContent("https://open.spotify.com/album/xyz789");
|
||||
expect(result[0].type).toBe("spotify");
|
||||
expect(result[0].mediaType).toBe("album");
|
||||
});
|
||||
|
||||
it("parses Spotify playlist URLs", () => {
|
||||
const result = parseContent("https://open.spotify.com/playlist/pl123");
|
||||
expect(result[0].type).toBe("spotify");
|
||||
expect(result[0].mediaType).toBe("playlist");
|
||||
});
|
||||
|
||||
it("parses Vimeo URLs", () => {
|
||||
const result = parseContent("https://vimeo.com/123456789");
|
||||
expect(result[0].type).toBe("vimeo");
|
||||
expect(result[0].mediaId).toBe("123456789");
|
||||
});
|
||||
|
||||
it("parses Tidal track URLs", () => {
|
||||
const result = parseContent("https://tidal.com/track/12345");
|
||||
expect(result[0].type).toBe("tidal");
|
||||
expect(result[0].mediaType).toBe("track");
|
||||
expect(result[0].mediaId).toBe("12345");
|
||||
});
|
||||
|
||||
it("parses Tidal browse URLs", () => {
|
||||
const result = parseContent("https://tidal.com/browse/album/67890");
|
||||
expect(result[0].type).toBe("tidal");
|
||||
expect(result[0].mediaType).toBe("album");
|
||||
expect(result[0].mediaId).toBe("67890");
|
||||
});
|
||||
|
||||
it("parses nostr:npub mentions", () => {
|
||||
// Use a valid npub (bech32-encoded)
|
||||
const npub = "npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqsd2pfy7";
|
||||
const result = parseContent(`Hello nostr:${npub}`);
|
||||
const mention = result.find((s) => s.type === "mention");
|
||||
expect(mention).toBeDefined();
|
||||
expect(mention!.value).toBe(npub);
|
||||
});
|
||||
|
||||
it("parses hashtags", () => {
|
||||
const result = parseContent("Hello #bitcoin world");
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[1]).toEqual({ type: "hashtag", value: "bitcoin", display: "#bitcoin" });
|
||||
});
|
||||
|
||||
it("does not parse single-char hashtags", () => {
|
||||
const result = parseContent("Hello #a world");
|
||||
// #a has only 1 char after #, should not match (regex requires 2+)
|
||||
expect(result.find((s) => s.type === "hashtag")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles mixed content with multiple types", () => {
|
||||
const content = "Check https://example.com and #nostr";
|
||||
const result = parseContent(content);
|
||||
const types = result.map((s) => s.type);
|
||||
expect(types).toContain("text");
|
||||
expect(types).toContain("link");
|
||||
expect(types).toContain("hashtag");
|
||||
});
|
||||
|
||||
it("cleans trailing punctuation from URLs", () => {
|
||||
const result = parseContent("See https://example.com/page.");
|
||||
const link = result.find((s) => s.type === "link");
|
||||
expect(link!.value).toBe("https://example.com/page");
|
||||
});
|
||||
|
||||
it("cleans trailing parenthesis from URLs", () => {
|
||||
const result = parseContent("(https://example.com/page)");
|
||||
const link = result.find((s) => s.type === "link");
|
||||
expect(link!.value).toBe("https://example.com/page");
|
||||
});
|
||||
|
||||
it("shortens long display URLs", () => {
|
||||
const longPath = "/a".repeat(30);
|
||||
const result = parseContent(`https://example.com${longPath}`);
|
||||
const link = result.find((s) => s.type === "link");
|
||||
expect(link!.display!.length).toBeLessThanOrEqual(50);
|
||||
});
|
||||
|
||||
it("returns empty for empty input", () => {
|
||||
const result = parseContent("");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
297
src/lib/search.ts
Normal file
297
src/lib/search.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* Advanced search query parser — inspired by ants (dergigi/ants).
|
||||
*
|
||||
* Supported modifiers:
|
||||
* by:<nip05|npub|name> — filter by author
|
||||
* mentions:<npub|name> — notes that tag a specific pubkey
|
||||
* kind:<number|alias> — filter by event kind
|
||||
* is:<alias> — shorthand for kind (article, note, highlight, etc.)
|
||||
* has:<media> — notes containing specific media (image, video, link)
|
||||
* since:<date> — events after date (YYYY-MM-DD)
|
||||
* until:<date> — events before date (YYYY-MM-DD)
|
||||
* #hashtag — hashtag search
|
||||
* "quoted phrase" — exact phrase (passed to NIP-50 search)
|
||||
*
|
||||
* Boolean:
|
||||
* OR between terms — runs multiple queries (client-side merge)
|
||||
*
|
||||
* Everything else is passed as NIP-50 full-text search.
|
||||
*/
|
||||
|
||||
import { nip19 } from "@nostr-dev-kit/ndk";
|
||||
|
||||
export interface ParsedSearch {
|
||||
/** Text terms for NIP-50 search field */
|
||||
searchTerms: string[];
|
||||
/** Hashtags to filter by (#t tag) */
|
||||
hashtags: string[];
|
||||
/** Author pubkeys (hex) to filter by */
|
||||
authors: string[];
|
||||
/** Pubkeys mentioned in events (#p tag) */
|
||||
mentions: string[];
|
||||
/** Event kinds to search */
|
||||
kinds: number[];
|
||||
/** Media content filters (applied client-side) */
|
||||
hasFilters: string[];
|
||||
/** Unix timestamp — events after this */
|
||||
since: number | null;
|
||||
/** Unix timestamp — events before this */
|
||||
until: number | null;
|
||||
/** Original raw query for display */
|
||||
raw: string;
|
||||
/** Whether this is an OR query (multiple sub-queries) */
|
||||
orQueries: ParsedSearch[] | null;
|
||||
/** Unresolved NIP-05 identifiers that need async resolution */
|
||||
unresolvedNip05: string[];
|
||||
}
|
||||
|
||||
const KIND_ALIASES: Record<string, number> = {
|
||||
note: 1,
|
||||
text: 1,
|
||||
article: 30023,
|
||||
"long-form": 30023,
|
||||
longform: 30023,
|
||||
reaction: 7,
|
||||
repost: 6,
|
||||
dm: 4,
|
||||
highlight: 9802,
|
||||
bookmark: 10003,
|
||||
profile: 0,
|
||||
metadata: 0,
|
||||
contacts: 3,
|
||||
relay: 10002,
|
||||
zap: 9735,
|
||||
};
|
||||
|
||||
const IS_ALIASES: Record<string, number> = {
|
||||
...KIND_ALIASES,
|
||||
code: 1, // client-side filter for code blocks
|
||||
};
|
||||
|
||||
const MEDIA_PATTERNS: Record<string, RegExp> = {
|
||||
image: /\.(jpg|jpeg|png|gif|webp|avif|svg|apng)/i,
|
||||
video: /\.(mp4|webm|mov|m4v|ogg)/i,
|
||||
audio: /\.(mp3|wav|flac|m4a|ogg)/i,
|
||||
link: /https?:\/\//i,
|
||||
youtube: /youtu(\.be|be\.com)/i,
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse a date string (YYYY-MM-DD) to unix timestamp.
|
||||
* Returns null on invalid date.
|
||||
*/
|
||||
function parseDateToUnix(dateStr: string): number | null {
|
||||
const match = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
if (!match) return null;
|
||||
const d = new Date(`${match[1]}-${match[2]}-${match[3]}T00:00:00Z`);
|
||||
if (isNaN(d.getTime())) return null;
|
||||
return Math.floor(d.getTime() / 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to resolve an npub to hex pubkey.
|
||||
* Returns hex pubkey or null.
|
||||
*/
|
||||
function resolveNpub(input: string): string | null {
|
||||
if (input.startsWith("npub1")) {
|
||||
try {
|
||||
const decoded = nip19.decode(input);
|
||||
if (decoded.type === "npub") return decoded.data;
|
||||
} catch { /* not a valid npub */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokenize a query string, respecting quoted phrases.
|
||||
*/
|
||||
function tokenize(query: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
let current = "";
|
||||
let inQuote = false;
|
||||
|
||||
for (let i = 0; i < query.length; i++) {
|
||||
const char = query[i];
|
||||
if (char === '"') {
|
||||
if (inQuote) {
|
||||
tokens.push(`"${current}"`);
|
||||
current = "";
|
||||
inQuote = false;
|
||||
} else {
|
||||
if (current.trim()) tokens.push(current.trim());
|
||||
current = "";
|
||||
inQuote = true;
|
||||
}
|
||||
} else if (char === " " && !inQuote) {
|
||||
if (current.trim()) tokens.push(current.trim());
|
||||
current = "";
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
if (current.trim()) tokens.push(current.trim());
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a search query into structured search parameters.
|
||||
*/
|
||||
export function parseSearchQuery(raw: string): ParsedSearch {
|
||||
const trimmed = raw.trim();
|
||||
|
||||
// Handle OR queries — split on top-level OR
|
||||
if (/\bOR\b/i.test(trimmed)) {
|
||||
// Simple OR split (doesn't handle OR inside quotes, good enough)
|
||||
const parts = trimmed.split(/\s+OR\s+/i).map((p) => p.trim()).filter(Boolean);
|
||||
if (parts.length > 1) {
|
||||
return {
|
||||
searchTerms: [],
|
||||
hashtags: [],
|
||||
authors: [],
|
||||
mentions: [],
|
||||
kinds: [],
|
||||
hasFilters: [],
|
||||
since: null,
|
||||
until: null,
|
||||
raw: trimmed,
|
||||
orQueries: parts.map(parseSearchQuery),
|
||||
unresolvedNip05: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const tokens = tokenize(trimmed);
|
||||
const result: ParsedSearch = {
|
||||
searchTerms: [],
|
||||
hashtags: [],
|
||||
authors: [],
|
||||
mentions: [],
|
||||
kinds: [],
|
||||
hasFilters: [],
|
||||
since: null,
|
||||
until: null,
|
||||
raw: trimmed,
|
||||
orQueries: null,
|
||||
unresolvedNip05: [],
|
||||
};
|
||||
|
||||
for (const token of tokens) {
|
||||
const lower = token.toLowerCase();
|
||||
|
||||
// by:<author>
|
||||
if (lower.startsWith("by:")) {
|
||||
const value = token.slice(3);
|
||||
const hex = resolveNpub(value);
|
||||
if (hex) {
|
||||
result.authors.push(hex);
|
||||
} else if (value.includes(".") || value.includes("@")) {
|
||||
// Looks like a NIP-05 — needs async resolution
|
||||
result.unresolvedNip05.push(value);
|
||||
} else {
|
||||
// Treat as a search term for now (name-based lookup needs profile search)
|
||||
result.unresolvedNip05.push(value);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// mentions:<pubkey>
|
||||
if (lower.startsWith("mentions:")) {
|
||||
const value = token.slice(9);
|
||||
const hex = resolveNpub(value);
|
||||
if (hex) {
|
||||
result.mentions.push(hex);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// kind:<number|alias>
|
||||
if (lower.startsWith("kind:")) {
|
||||
const value = token.slice(5).toLowerCase();
|
||||
const num = parseInt(value);
|
||||
if (!isNaN(num)) {
|
||||
result.kinds.push(num);
|
||||
} else if (KIND_ALIASES[value] !== undefined) {
|
||||
result.kinds.push(KIND_ALIASES[value]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// is:<alias>
|
||||
if (lower.startsWith("is:")) {
|
||||
const value = token.slice(3).toLowerCase();
|
||||
if (IS_ALIASES[value] !== undefined) {
|
||||
result.kinds.push(IS_ALIASES[value]);
|
||||
}
|
||||
if (value === "code") {
|
||||
result.hasFilters.push("code");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// has:<media>
|
||||
if (lower.startsWith("has:")) {
|
||||
const value = token.slice(4).toLowerCase();
|
||||
result.hasFilters.push(value);
|
||||
continue;
|
||||
}
|
||||
|
||||
// since:<date>
|
||||
if (lower.startsWith("since:")) {
|
||||
const ts = parseDateToUnix(token.slice(6));
|
||||
if (ts) result.since = ts;
|
||||
continue;
|
||||
}
|
||||
|
||||
// until:<date>
|
||||
if (lower.startsWith("until:")) {
|
||||
const ts = parseDateToUnix(token.slice(6));
|
||||
if (ts) result.until = ts;
|
||||
continue;
|
||||
}
|
||||
|
||||
// #hashtag
|
||||
if (token.startsWith("#") && token.length > 1) {
|
||||
result.hashtags.push(token.slice(1).toLowerCase());
|
||||
continue;
|
||||
}
|
||||
|
||||
// Quoted phrase — keep quotes for NIP-50
|
||||
if (token.startsWith('"') && token.endsWith('"')) {
|
||||
result.searchTerms.push(token);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular search term
|
||||
result.searchTerms.push(token);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an event's content matches a "has:" filter.
|
||||
*/
|
||||
export function matchesHasFilter(content: string, filter: string): boolean {
|
||||
if (filter === "code") {
|
||||
return content.includes("```") || content.includes("`");
|
||||
}
|
||||
const pattern = MEDIA_PATTERNS[filter];
|
||||
if (pattern) return pattern.test(content);
|
||||
// Generic: just check if the filter text appears in content
|
||||
return content.toLowerCase().includes(filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a ParsedSearch back into a human-readable hint.
|
||||
*/
|
||||
export function describeSearch(parsed: ParsedSearch): string {
|
||||
const parts: string[] = [];
|
||||
if (parsed.searchTerms.length > 0) parts.push(parsed.searchTerms.join(" "));
|
||||
if (parsed.hashtags.length > 0) parts.push(parsed.hashtags.map((h) => `#${h}`).join(" "));
|
||||
if (parsed.authors.length > 0) parts.push(`by ${parsed.authors.length} author(s)`);
|
||||
if (parsed.kinds.length > 0) parts.push(`kind: ${parsed.kinds.join(", ")}`);
|
||||
if (parsed.hasFilters.length > 0) parts.push(`has: ${parsed.hasFilters.join(", ")}`);
|
||||
if (parsed.since) parts.push(`since ${new Date(parsed.since * 1000).toLocaleDateString()}`);
|
||||
if (parsed.until) parts.push(`until ${new Date(parsed.until * 1000).toLocaleDateString()}`);
|
||||
return parts.join(" · ") || "empty search";
|
||||
}
|
||||
49
src/lib/utils.test.ts
Normal file
49
src/lib/utils.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { timeAgo, shortenPubkey } from "./utils";
|
||||
|
||||
describe("timeAgo", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns seconds for < 60s", () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
expect(timeAgo(now - 30)).toBe("30s");
|
||||
});
|
||||
|
||||
it("returns minutes for < 1h", () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
expect(timeAgo(now - 300)).toBe("5m");
|
||||
});
|
||||
|
||||
it("returns hours for < 1d", () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
expect(timeAgo(now - 7200)).toBe("2h");
|
||||
});
|
||||
|
||||
it("returns days for < 1w", () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
expect(timeAgo(now - 86400 * 3)).toBe("3d");
|
||||
});
|
||||
|
||||
it("returns weeks for >= 1w", () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
expect(timeAgo(now - 604800 * 2)).toBe("2w");
|
||||
});
|
||||
|
||||
it("returns 0s for current timestamp", () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
expect(timeAgo(now)).toBe("0s");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shortenPubkey", () => {
|
||||
it("shortens a standard hex pubkey", () => {
|
||||
const key = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
|
||||
expect(shortenPubkey(key)).toBe("abcdef12…7890");
|
||||
});
|
||||
|
||||
it("handles short strings gracefully", () => {
|
||||
expect(shortenPubkey("abcd")).toBe("abcd…abcd");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user