From c121cbd36f12ebef544f74bbb525f8dbc4ee24db Mon Sep 17 00:00:00 2001 From: highperfocused Date: Thu, 27 Nov 2025 22:22:22 +0100 Subject: [PATCH] Add search functionality with NIP-05 verification and user navigation --- src/hooks/useSearch.ts | 44 +++++++ src/lib/nip05.ts | 43 +++++++ src/pages/Search.tsx | 275 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 359 insertions(+), 3 deletions(-) create mode 100644 src/hooks/useSearch.ts create mode 100644 src/lib/nip05.ts diff --git a/src/hooks/useSearch.ts b/src/hooks/useSearch.ts new file mode 100644 index 0000000..94f9f66 --- /dev/null +++ b/src/hooks/useSearch.ts @@ -0,0 +1,44 @@ +import { useNostr } from '@nostrify/react'; +import { useQuery } from '@tanstack/react-query'; +import type { NostrEvent } from '@nostrify/nostrify'; + +export interface SearchOptions { + query: string; + kinds?: number[]; + limit?: number; +} + +/** + * Hook for NIP-50 search functionality + * Searches for Nostr events using relay search capabilities + */ +export function useSearch({ query, kinds = [0, 1], limit = 20 }: SearchOptions) { + const { nostr } = useNostr(); + + return useQuery({ + queryKey: ['search', query, kinds, limit], + queryFn: async ({ signal }) => { + if (!query || query.trim().length === 0) { + return []; + } + + try { + const events = await nostr.query( + [{ + kinds, + search: query.trim(), + limit, + }], + { signal: AbortSignal.any([signal, AbortSignal.timeout(3000)]) } + ); + + return events; + } catch (error) { + console.error('Search error:', error); + return []; + } + }, + enabled: !!query && query.trim().length > 0, + staleTime: 30 * 1000, // Cache for 30 seconds + }); +} diff --git a/src/lib/nip05.ts b/src/lib/nip05.ts new file mode 100644 index 0000000..3c905e1 --- /dev/null +++ b/src/lib/nip05.ts @@ -0,0 +1,43 @@ +/** + * Verify and fetch pubkey from NIP-05 identifier + */ +export async function verifyAndGetPubkey(nip05: string): Promise { + try { + // Parse the NIP-05 identifier (name@domain.com) + const [name, domain] = nip05.split('@'); + if (!name || !domain) { + return null; + } + + // Fetch the .well-known/nostr.json file + const url = `https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(name)}`; + const response = await fetch(url, { + signal: AbortSignal.timeout(5000), + }); + + if (!response.ok) { + return null; + } + + const data = await response.json(); + + // Get the pubkey + const pubkey = data.names?.[name]; + if (!pubkey) { + return null; + } + + return pubkey; + } catch { + return null; + } +} + +/** + * Check if a string looks like a NIP-05 identifier + */ +export function isNIP05Format(input: string): boolean { + // Simple check: contains @ and at least one dot after @ + const parts = input.split('@'); + return parts.length === 2 && parts[0].length > 0 && parts[1].includes('.'); +} diff --git a/src/pages/Search.tsx b/src/pages/Search.tsx index 394fcc5..2b2b210 100644 --- a/src/pages/Search.tsx +++ b/src/pages/Search.tsx @@ -1,12 +1,281 @@ +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; import { Layout } from '@/components/Layout'; +import { Input } from '@/components/ui/input'; +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Badge } from '@/components/ui/badge'; +import { Search, Hash, AtSign, User, Loader2, AlertCircle } from 'lucide-react'; +import { useSearch } from '@/hooks/useSearch'; +import { useAuthor } from '@/hooks/useAuthor'; +import { genUserName } from '@/lib/genUserName'; +import { verifyAndGetPubkey, isNIP05Format } from '@/lib/nip05'; +import { nip19 } from 'nostr-tools'; +import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify'; + +interface SearchResultItemProps { + event: NostrEvent; + onClick: () => void; +} + +function SearchResultItem({ event, onClick }: SearchResultItemProps) { + const author = useAuthor(event.pubkey); + const metadata: NostrMetadata | undefined = author.data?.metadata; + const displayName = metadata?.name || genUserName(event.pubkey); + const npub = nip19.npubEncode(event.pubkey); + + return ( + + +
+ + + + + + +
+
+

{displayName}

+ {metadata?.nip05 && ( + + {metadata.nip05} + + )} +
+

+ {npub.substring(0, 16)}... +

+ {metadata?.about && ( +

+ {metadata.about} +

+ )} +
+
+
+
+ ); +} export function SearchPage() { + const [searchInput, setSearchInput] = useState(''); + const [debouncedQuery, setDebouncedQuery] = useState(''); + const [isVerifyingNIP05, setIsVerifyingNIP05] = useState(false); + const navigate = useNavigate(); + + // Debounce search input + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedQuery(searchInput); + }, 500); + + return () => clearTimeout(timer); + }, [searchInput]); + + // Handle NIP-05 verification and redirect + useEffect(() => { + async function handleNIP05() { + if (!debouncedQuery || !isNIP05Format(debouncedQuery)) { + return; + } + + setIsVerifyingNIP05(true); + const pubkey = await verifyAndGetPubkey(debouncedQuery); + setIsVerifyingNIP05(false); + + if (pubkey) { + const npub = nip19.npubEncode(pubkey); + navigate(`/${npub}`); + } + } + + handleNIP05(); + }, [debouncedQuery, navigate]); + + // Handle hashtag redirect + useEffect(() => { + if (debouncedQuery.startsWith('#') && debouncedQuery.length > 1) { + const tag = debouncedQuery.substring(1); + navigate(`/tag/${encodeURIComponent(tag)}`); + } + }, [debouncedQuery, navigate]); + + // Determine search mode + const isHashtag = searchInput.startsWith('#'); + const isNIP05Search = isNIP05Format(searchInput); + const isUsernameSearch = searchInput.startsWith('@') && searchInput.length > 1; + + // Only do NIP-50 search for @username queries + const shouldSearch = isUsernameSearch && !isHashtag && !isNIP05Search; + const searchQuery = isUsernameSearch ? searchInput.substring(1) : searchInput; + + const { data: searchResults, isLoading } = useSearch({ + query: shouldSearch ? searchQuery : '', + kinds: [0], // Search profiles for @username + limit: 20, + }); + + const handleResultClick = (pubkey: string) => { + const npub = nip19.npubEncode(pubkey); + navigate(`/${npub}`); + }; + return (
-
-

Search

-

Search page - coming soon

+
+ {/* Header */} +
+

+ + Search +

+

+ Search for users, hashtags, or NIP-05 identifiers +

+
+ + {/* Search Input */} +
+ + setSearchInput(e.target.value)} + className="pl-10 pr-4 h-12 text-base" + autoFocus + /> +
+ + {/* Search Type Hints */} + {searchInput.length > 0 && ( +
+ {isHashtag && ( + + + Hashtag - will redirect to tag page + + )} + {isNIP05Search && ( + + + NIP-05 - verifying and redirecting... + + )} + {isUsernameSearch && !isNIP05Search && ( + + + Username search (NIP-50) + + )} +
+ )} + + {/* Loading State for NIP-05 */} + {isVerifyingNIP05 && ( + + + +

+ Verifying NIP-05 identifier... +

+
+
+ )} + + {/* Search Results */} + {shouldSearch && !isVerifyingNIP05 && ( + <> + {isLoading && ( +
+ {[...Array(5)].map((_, i) => ( + + +
+ +
+ + + +
+
+
+
+ ))} +
+ )} + + {!isLoading && searchResults && searchResults.length === 0 && ( + + + +

+ No users found matching "{searchQuery}". Try a different search term or check your relay connections. +

+
+
+ )} + + {!isLoading && searchResults && searchResults.length > 0 && ( +
+

+ Found {searchResults.length} result{searchResults.length !== 1 ? 's' : ''} +

+ {searchResults.map((event) => ( + handleResultClick(event.pubkey)} + /> + ))} +
+ )} + + )} + + {/* Instructions */} + {!searchInput && ( + + +

Search Tips

+
+ +
+ +
+

@username

+

+ Search for users by name or display name using NIP-50 relay search +

+
+
+
+ +
+

#hashtag

+

+ Browse content tagged with a specific hashtag +

+
+
+
+ +
+

name@domain.com

+

+ Verify and navigate to a user's profile via NIP-05 identifier +

+
+
+
+
+ )}