import type { Event } from "@nostr/tools/core"; import type { Filter } from "@nostr/tools/filter"; import { queryForum } from "$lib/relay"; import { GROUP_ID, MODE } from "$lib/config"; import { groupsStore } from "$lib/groups.svelte"; export type SearchResult = { threadId: string; title: string; snippet: string; matchKind: "thread" | "reply"; createdAt: number; }; // Window the snippet around the first term match so the highlight is visible // even when the match sits deep in a long post. function snippetOf(content: string, query: string): string { const flat = content.replace(/\s+/g, " ").trim(); const MAX = 140; if (flat.length <= MAX) return flat; const lower = flat.toLowerCase(); let idx = -1; for (const t of query.toLowerCase().split(/\s+/).filter(Boolean)) { const i = lower.indexOf(t); if (i !== -1 && (idx === -1 || i < idx)) idx = i; } if (idx <= 40) return flat.slice(0, MAX) + "…"; const start = idx - 40; const end = Math.min(flat.length, start + MAX); return "…" + flat.slice(start, end) + (end < flat.length ? "…" : ""); } // NIP-50 search over the forum relay: thread OPs (kind 11) and replies // (kind 1111), deduped by thread. Simple mode scopes to the single group; // full mode spans every visible room. The group scope is applied client-side // on the `h` tag: pyramid returns nothing when `search` is combined with a // `#h` filter. export async function searchThreads(query: string): Promise { const filter: Filter = { kinds: [11, 1111], search: query, limit: 30 }; const groups = MODE === "simple" ? new Set([GROUP_ID]) : new Set(groupsStore.list.map((g) => g.id)); const all = await queryForum(filter, { label: "search" }); const events = all.filter((e) => { const h = e.tags.find((t) => t[0] === "h")?.[1]; return h !== undefined && (groups.size === 0 || groups.has(h)); }); const byThread = new Map(); const opless: SearchResult[] = []; for (const e of events) { if (e.kind === 11) { const existing = byThread.get(e.id); // An OP match wins over a reply match on the same thread if (existing && existing.matchKind === "thread") continue; byThread.set(e.id, { threadId: e.id, title: e.tags.find((t) => t[0] === "title")?.[1] ?? "(untitled)", snippet: snippetOf(e.content, query), matchKind: "thread", createdAt: e.created_at, }); } else { const root = e.tags.find((t) => t[0] === "E")?.[1]; if (!root || byThread.has(root)) continue; const r: SearchResult = { threadId: root, title: "", snippet: snippetOf(e.content, query), matchKind: "reply", createdAt: e.created_at, }; byThread.set(root, r); opless.push(r); } } // Backfill titles for reply-only matches; the group relay truncates // multi-id queries, so fetch each OP on its own await Promise.all( opless.map(async (r) => { const ops = await queryForum({ kinds: [11], ids: [r.threadId] }); const op: Event | undefined = ops[0]; r.title = op?.tags.find((t) => t[0] === "title")?.[1] ?? "(untitled)"; }), ); // Keep the relay's relevance order (arrival order); a thread keeps the // position of its best-ranked match return [...byThread.values()]; }