From c10acb0a5e5737ac87bed805dec179f0ac04c613 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 6 Sep 2026 23:00:42 +0200 Subject: [PATCH] Add unified Nostr search app (#44) * Initial plan * feat: add unified Nostr search app Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com> * Fix search profile lookup review feedback Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com> --- src/apps/search/index.tsx | 164 ++++++++++++++++++++++++++++ src/apps/search/searchUtils.test.ts | 28 +++++ src/apps/search/searchUtils.ts | 30 +++++ src/os/registry.ts | 12 +- 4 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 src/apps/search/index.tsx create mode 100644 src/apps/search/searchUtils.test.ts create mode 100644 src/apps/search/searchUtils.ts diff --git a/src/apps/search/index.tsx b/src/apps/search/index.tsx new file mode 100644 index 0000000..745e8bc --- /dev/null +++ b/src/apps/search/index.tsx @@ -0,0 +1,164 @@ +import { useEffect, useState } from 'react'; +import { useNostr } from '@nostrify/react'; +import { useQuery } from '@tanstack/react-query'; +import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify'; +import { nip05 } from 'nostr-tools'; +import { Hash, Loader2, Search, UserRound } from 'lucide-react'; +import { AppBody, AppLayout, AppToolbar, EmptyState } from '@/components/os/AppChrome'; +import { NoteCard } from '@/components/nostr/NoteCard'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Skeleton } from '@/components/ui/skeleton'; +import { displayName, sanitizeUrl } from '@/lib/nostrUtils'; +import { useWindowManager } from '@/os/useWindowManager'; +import type { AppProps } from '@/os/types'; +import { parseSearchInput, type SearchInput } from './searchUtils'; + +const LIMIT = 50; + +interface SearchResults { + notes: NostrEvent[]; + profiles: NostrEvent[]; +} + +function profileMatches(event: NostrEvent, term: string): boolean { + try { + const metadata = JSON.parse(event.content) as NostrMetadata; + const query = term.toLowerCase(); + return [metadata.name, metadata.display_name, metadata.nip05] + .some((value) => value?.toLowerCase()?.includes(query)); + } catch { + return false; + } +} + +function useSearch(input: SearchInput) { + const { nostr } = useNostr(); + + return useQuery({ + queryKey: ['nostr', 'search', input], + enabled: input.type !== 'empty', + queryFn: async ({ signal }) => { + const options = { signal: AbortSignal.any([signal, AbortSignal.timeout(6000)]) }; + if (input.type === 'profile') { + const events = await nostr.query( + [ + { kinds: [0], authors: [input.pubkey], limit: LIMIT }, + { kinds: [1], authors: [input.pubkey], limit: LIMIT }, + ], + { ...options, relays: input.relays }, + ); + return { notes: events.filter((event) => event.kind === 1), profiles: events.filter((event) => event.kind === 0) }; + } + + if (input.type === 'nip05') { + const pointer = await nip05.queryProfile(input.value); + if (!pointer || !/^[0-9a-f]{64}$/.test(pointer.pubkey)) return { notes: [], profiles: [] }; + const events = await nostr.query( + [ + { kinds: [0], authors: [pointer.pubkey], limit: LIMIT }, + { kinds: [1], authors: [pointer.pubkey], limit: LIMIT }, + ], + options, + ); + return { notes: events.filter((event) => event.kind === 1), profiles: events.filter((event) => event.kind === 0) }; + } + + if (input.type === 'empty') return { notes: [], profiles: [] }; + const filters = input.type === 'hashtag' + ? [{ kinds: [1], '#t': [input.value], limit: LIMIT }] + : [{ kinds: [1], search: input.value, limit: LIMIT }, { kinds: [0], search: input.value, limit: LIMIT }]; + const events = await nostr.query(filters, options); + const profiles = events.filter((event) => event.kind === 0); + return { + notes: events.filter((event) => event.kind === 1), + profiles: input.type === 'text' ? profiles.filter((event) => profileMatches(event, input.value)) : profiles, + }; + }, + staleTime: 30_000, + }); +} + +export default function SearchApp({ setTitle }: AppProps) { + const [term, setTerm] = useState(''); + const [submitted, setSubmitted] = useState({ type: 'empty' }); + const results = useSearch(submitted); + + useEffect(() => setTitle('Search'), [setTitle]); + + const submit = (event: React.FormEvent) => { + event.preventDefault(); + setSubmitted(parseSearchInput(term)); + }; + + return ( + + +
+ setTerm(event.target.value)} + placeholder="Search notes, people, #hashtags, npub, or name@domain" + className="h-8 min-w-0" + /> + +
+
+ + {submitted.type === 'empty' ? ( + + ) : results.isLoading ? ( + + ) : results.isError ? ( + results.refetch()}>Try again} /> + ) : (results.data?.notes.length ?? 0) + (results.data?.profiles.length ?? 0) === 0 ? ( + + ) : ( + <> + {results.data && results.data.profiles.length > 0 && ( +
+ }>People + {results.data.profiles.map((event) => )} +
+ )} + {results.data && results.data.notes.length > 0 && ( +
+ : }> + {submitted.type === 'hashtag' ? `Posts tagged #${submitted.value}` : 'Notes & replies'} + + {results.data.notes.map((event) => )} +
+ )} + + )} +
+
+ ); +} + +function ResultHeading({ id, icon, children }: { id: string; icon: React.ReactNode; children: React.ReactNode }) { + return

{icon}{children}

; +} + +function ProfileResult({ event }: { event: NostrEvent }) { + const { openApp } = useWindowManager(); + let metadata: NostrMetadata | undefined; + try { metadata = JSON.parse(event.content) as NostrMetadata; } catch { /* invalid metadata is rendered safely with a fallback */ } + const name = displayName(event.pubkey, metadata); + const picture = sanitizeUrl(metadata?.picture); + return ( + + ); +} + +function SearchSkeleton() { + return
{Array.from({ length: 4 }).map((_, index) =>
)}
; +} diff --git a/src/apps/search/searchUtils.test.ts b/src/apps/search/searchUtils.test.ts new file mode 100644 index 0000000..1f0ed67 --- /dev/null +++ b/src/apps/search/searchUtils.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { nip19 } from 'nostr-tools'; +import { parseSearchInput } from './searchUtils'; + +describe('parseSearchInput', () => { + it('recognizes a hashtag without passing its hash to the relay filter', () => { + expect(parseSearchInput(' #Nostr ')).toEqual({ type: 'hashtag', value: 'nostr' }); + }); + + it('recognizes npubs as profile lookups', () => { + const pubkey = 'f'.repeat(64); + expect(parseSearchInput(nip19.npubEncode(pubkey))).toEqual({ type: 'profile', pubkey }); + }); + + it('preserves relay hints from nprofiles', () => { + const pubkey = 'f'.repeat(64); + const relays = ['wss://relay.example']; + expect(parseSearchInput(nip19.nprofileEncode({ pubkey, relays }))).toEqual({ type: 'profile', pubkey, relays }); + }); + + it('recognizes NIP-05 addresses', () => { + expect(parseSearchInput('Alice@Example.com')).toEqual({ type: 'nip05', value: 'alice@example.com' }); + }); + + it('leaves ordinary words as a text search', () => { + expect(parseSearchInput('open source')).toEqual({ type: 'text', value: 'open source' }); + }); +}); diff --git a/src/apps/search/searchUtils.ts b/src/apps/search/searchUtils.ts new file mode 100644 index 0000000..1211bf5 --- /dev/null +++ b/src/apps/search/searchUtils.ts @@ -0,0 +1,30 @@ +import { nip19 } from 'nostr-tools'; + +export type SearchInput = + | { type: 'empty' } + | { type: 'hashtag'; value: string } + | { type: 'profile'; pubkey: string; relays?: string[] } + | { type: 'nip05'; value: string } + | { type: 'text'; value: string }; + +const NIP05_RE = /^(?:[a-z0-9._-]+)@(?:[a-z0-9-]+(?:\.[a-z0-9-]+)+)$/i; +const HASHTAG_RE = /^#([\p{L}\p{N}_-]+)$/u; + +export function parseSearchInput(input: string): SearchInput { + const value = input.trim(); + if (!value) return { type: 'empty' }; + + const hashtag = value.match(HASHTAG_RE); + if (hashtag) return { type: 'hashtag', value: hashtag[1].toLowerCase() }; + + try { + const decoded = nip19.decode(value); + if (decoded.type === 'npub') return { type: 'profile', pubkey: decoded.data }; + if (decoded.type === 'nprofile') return { type: 'profile', pubkey: decoded.data.pubkey, relays: decoded.data.relays }; + } catch { + // A normal text search need not be a NIP-19 identifier. + } + + if (NIP05_RE.test(value)) return { type: 'nip05', value: value.toLowerCase() }; + return { type: 'text', value }; +} diff --git a/src/os/registry.ts b/src/os/registry.ts index 688345c..c9c8473 100644 --- a/src/os/registry.ts +++ b/src/os/registry.ts @@ -1,5 +1,5 @@ import { lazy } from 'react'; -import { Activity, Bookmark, BookOpen, CalendarDays, FileText, Info, Link2, Radio, Rss, Settings, Sparkles, User } from 'lucide-react'; +import { Activity, Bookmark, BookOpen, CalendarDays, FileText, Info, Link2, Radio, Rss, Search, Settings, Sparkles, User } from 'lucide-react'; import type { AppDefinition } from './types'; /** @@ -28,6 +28,16 @@ export const APPS: AppDefinition[] = [ defaultSize: { width: 620, height: 700 }, minSize: { width: 360, height: 320 }, }, + { + id: 'search', + title: 'Search', + description: 'Find notes, replies, hashtags and people across Nostr', + icon: Search, + category: 'social', + component: lazy(() => import('@/apps/search')), + defaultSize: { width: 680, height: 700 }, + minSize: { width: 360, height: 320 }, + }, { id: 'notes', title: 'Note',