Add search functionality with NIP-05 verification and user navigation

This commit is contained in:
2025-11-27 22:22:22 +01:00
parent 7089275b8c
commit c121cbd36f
3 changed files with 359 additions and 3 deletions

44
src/hooks/useSearch.ts Normal file
View File

@@ -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<NostrEvent[]>({
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
});
}

43
src/lib/nip05.ts Normal file
View File

@@ -0,0 +1,43 @@
/**
* Verify and fetch pubkey from NIP-05 identifier
*/
export async function verifyAndGetPubkey(nip05: string): Promise<string | null> {
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('.');
}

View File

@@ -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 (
<Card
className="cursor-pointer hover:bg-accent/50 transition-colors"
onClick={onClick}
>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<Avatar className="h-12 w-12">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback>
<User className="h-6 w-6" />
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-semibold truncate">{displayName}</p>
{metadata?.nip05 && (
<Badge variant="outline" className="text-xs">
{metadata.nip05}
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground truncate">
{npub.substring(0, 16)}...
</p>
{metadata?.about && (
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
{metadata.about}
</p>
)}
</div>
</div>
</CardContent>
</Card>
);
}
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 (
<Layout>
<div className="container py-8">
<div className="max-w-2xl mx-auto">
<h1 className="text-3xl font-bold mb-4">Search</h1>
<p className="text-muted-foreground">Search page - coming soon</p>
<div className="max-w-2xl mx-auto space-y-6">
{/* Header */}
<div className="space-y-2">
<h1 className="text-3xl font-bold flex items-center gap-2">
<Search className="h-8 w-8" />
Search
</h1>
<p className="text-muted-foreground">
Search for users, hashtags, or NIP-05 identifiers
</p>
</div>
{/* Search Input */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground" />
<Input
type="text"
placeholder="@username, #hashtag, or name@domain.com"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="pl-10 pr-4 h-12 text-base"
autoFocus
/>
</div>
{/* Search Type Hints */}
{searchInput.length > 0 && (
<div className="flex flex-wrap gap-2">
{isHashtag && (
<Badge variant="secondary" className="gap-1">
<Hash className="h-3 w-3" />
Hashtag - will redirect to tag page
</Badge>
)}
{isNIP05Search && (
<Badge variant="secondary" className="gap-1">
<AtSign className="h-3 w-3" />
NIP-05 - verifying and redirecting...
</Badge>
)}
{isUsernameSearch && !isNIP05Search && (
<Badge variant="secondary" className="gap-1">
<User className="h-3 w-3" />
Username search (NIP-50)
</Badge>
)}
</div>
)}
{/* Loading State for NIP-05 */}
{isVerifyingNIP05 && (
<Card>
<CardContent className="py-12 px-8 text-center">
<Loader2 className="h-8 w-8 animate-spin mx-auto mb-4 text-primary" />
<p className="text-muted-foreground">
Verifying NIP-05 identifier...
</p>
</CardContent>
</Card>
)}
{/* Search Results */}
{shouldSearch && !isVerifyingNIP05 && (
<>
{isLoading && (
<div className="space-y-3">
{[...Array(5)].map((_, i) => (
<Card key={i}>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<Skeleton className="h-12 w-12 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-24" />
<Skeleton className="h-3 w-full" />
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
{!isLoading && searchResults && searchResults.length === 0 && (
<Card className="border-dashed">
<CardContent className="py-12 px-8 text-center">
<AlertCircle className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
<p className="text-muted-foreground">
No users found matching "{searchQuery}". Try a different search term or check your relay connections.
</p>
</CardContent>
</Card>
)}
{!isLoading && searchResults && searchResults.length > 0 && (
<div className="space-y-3">
<h2 className="text-lg font-semibold">
Found {searchResults.length} result{searchResults.length !== 1 ? 's' : ''}
</h2>
{searchResults.map((event) => (
<SearchResultItem
key={event.id}
event={event}
onClick={() => handleResultClick(event.pubkey)}
/>
))}
</div>
)}
</>
)}
{/* Instructions */}
{!searchInput && (
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">Search Tips</h3>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-start gap-3">
<User className="h-5 w-5 text-primary mt-0.5" />
<div>
<p className="font-medium">@username</p>
<p className="text-sm text-muted-foreground">
Search for users by name or display name using NIP-50 relay search
</p>
</div>
</div>
<div className="flex items-start gap-3">
<Hash className="h-5 w-5 text-primary mt-0.5" />
<div>
<p className="font-medium">#hashtag</p>
<p className="text-sm text-muted-foreground">
Browse content tagged with a specific hashtag
</p>
</div>
</div>
<div className="flex items-start gap-3">
<AtSign className="h-5 w-5 text-primary mt-0.5" />
<div>
<p className="font-medium">name@domain.com</p>
<p className="text-sm text-muted-foreground">
Verify and navigate to a user's profile via NIP-05 identifier
</p>
</div>
</div>
</CardContent>
</Card>
)}
</div>
</div>
</Layout>