Add Nostr identifier detection in search and profile search, improve DomainFeedPage header

Detect NIP-19 and NIP-05 identifiers in the search bar and profile
search dropdown, navigating directly to the appropriate route instead
of performing a text search. Refactor DomainFeedPage header to use
back navigation, show user count inline, and match border styling.
This commit is contained in:
Chad Curtis
2026-02-19 18:13:17 -06:00
parent 223d8822a4
commit 8fe07e65cc
4 changed files with 96 additions and 14 deletions
+11 -1
View File
@@ -6,6 +6,7 @@ import { Input } from '@/components/ui/input';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { useSearchProfiles, type SearchProfile } from '@/hooks/useSearchProfiles';
import { genUserName } from '@/lib/genUserName';
import { getNostrIdentifierPath } from '@/lib/nostrIdentifier';
import { getProfileUrl } from '@/lib/profileUrl';
import { cn } from '@/lib/utils';
@@ -73,10 +74,19 @@ export function ProfileSearchDropdown({
}, [navigate, onSelect]);
const handleTextSearch = useCallback(() => {
if (!enableTextSearch || !query.trim()) return;
if (!query.trim()) return;
setOpen(false);
setQuery('');
inputRef.current?.blur();
// If the input is a Nostr identifier (NIP-19 or NIP-05), navigate directly
const identifierPath = getNostrIdentifierPath(query);
if (identifierPath) {
navigate(identifierPath);
return;
}
if (!enableTextSearch) return;
navigate(`/search?q=${encodeURIComponent(query.trim())}`);
}, [enableTextSearch, query, navigate]);
+57
View File
@@ -0,0 +1,57 @@
import { nip19 } from 'nostr-tools';
/**
* Known NIP-19 bech32 prefixes that the app can route to.
*/
const NIP19_PREFIXES = ['npub1', 'nprofile1', 'note1', 'nevent1', 'naddr1'];
/**
* Checks whether a string looks like a NIP-05 identifier (user@domain.com)
* or a bare domain (e.g. fiatjaf.com). Strips a leading `@` if present.
*/
function looksLikeNip05(value: string): boolean {
const cleaned = value.startsWith('@') ? value.slice(1) : value;
// user@domain.com — must have text on both sides of @
if (cleaned.includes('@')) {
const atIndex = cleaned.indexOf('@');
return atIndex > 0 && atIndex < cleaned.length - 1 && cleaned.slice(atIndex + 1).includes('.');
}
// bare domain — contains a dot but isn't a NIP-19 identifier
if (cleaned.includes('.') && !NIP19_PREFIXES.some((p) => cleaned.startsWith(p))) {
return true;
}
return false;
}
/**
* If `input` is a Nostr identifier (NIP-19 bech32 or NIP-05 address),
* returns the path the app should navigate to (e.g. `/npub1...` or `/user@domain.com`).
*
* Returns `null` if the input is a regular search query.
*/
export function getNostrIdentifierPath(input: string): string | null {
const trimmed = input.trim();
if (!trimmed) return null;
// Strip nostr: URI prefix if present
const value = trimmed.startsWith('nostr:') ? trimmed.slice(6) : trimmed;
// Try NIP-19 decode
if (NIP19_PREFIXES.some((p) => value.startsWith(p))) {
try {
nip19.decode(value); // throws if invalid
return `/${value}`;
} catch {
// Not a valid NIP-19 — fall through
}
}
// Try NIP-05
if (looksLikeNip05(value)) {
// Strip leading @ for the URL path
const cleaned = value.startsWith('@') ? value.slice(1) : value;
return `/${cleaned}`;
}
return null;
}
+17 -12
View File
@@ -1,7 +1,7 @@
import { useMemo } from 'react';
import { useSeoMeta } from '@unhead/react';
import { ArrowLeft } from 'lucide-react';
import { Link, useParams } from 'react-router-dom';
import { useNavigate, useParams } from 'react-router-dom';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { MainLayout } from '@/components/MainLayout';
@@ -56,6 +56,7 @@ function useDomainPubkeys(domain: string | undefined, corsProxy: string) {
export function DomainFeedPage() {
const { domain } = useParams<{ domain: string }>();
const navigate = useNavigate();
const { nostr } = useNostr();
const { config } = useAppContext();
const { feedSettings } = useFeedSettings();
@@ -96,23 +97,27 @@ export function DomainFeedPage() {
return (
<MainLayout>
<main className="flex-1 min-w-0 sidebar:max-w-[600px] sidebar:border-l xl:border-r border-border min-h-screen">
<div className={cn(STICKY_HEADER_CLASS, 'flex items-center gap-4 px-4 mt-4 mb-5 bg-background/80 backdrop-blur-md z-10')}>
<Link to="/" className="p-2 rounded-full hover:bg-secondary transition-colors sidebar:hidden">
<div className={cn(STICKY_HEADER_CLASS, 'flex items-center gap-3 px-4 py-4 border-b border-border bg-background/80 backdrop-blur-md z-10')}>
<button
onClick={() => navigate(-1)}
className="p-1.5 -ml-1.5 rounded-full hover:bg-secondary/60 transition-colors"
aria-label="Go back"
>
<ArrowLeft className="size-5" />
</Link>
</button>
<div className="flex items-center gap-2 min-w-0">
<DomainFavicon domain={domain ?? ''} size={20} />
<h1 className="text-xl font-bold truncate">{domain}</h1>
<div className="min-w-0">
<h1 className="text-lg font-bold truncate leading-tight">{domain}</h1>
{pubkeys && pubkeys.length > 0 && (
<p className="text-xs text-muted-foreground leading-tight">
{pubkeys.length} user{pubkeys.length !== 1 ? 's' : ''}
</p>
)}
</div>
</div>
</div>
{/* Domain info */}
{pubkeys && pubkeys.length > 0 && (
<div className="px-4 pb-4 text-sm text-muted-foreground">
{pubkeys.length} user{pubkeys.length !== 1 ? 's' : ''} on {domainLabel}
</div>
)}
{pubkeysError ? (
<div className="py-16 text-center text-muted-foreground">
<p>Could not fetch users from {domain}.</p>
+11 -1
View File
@@ -1,7 +1,7 @@
import { useSeoMeta } from '@unhead/react';
import { ChevronUp, ChevronDown, Search as SearchIcon, Flame, TrendingUp, Swords, Image, Video, Film, Languages } from 'lucide-react';
import { useState, useMemo, useEffect } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { MainLayout } from '@/components/MainLayout';
import { NoteCard } from '@/components/NoteCard';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
@@ -16,6 +16,7 @@ import { useSearchProfiles } from '@/hooks/useSearchProfiles';
import { useStreamPosts } from '@/hooks/useStreamPosts';
import { useTrendingTags, useSortedPosts, type SortMode } from '@/hooks/useTrending';
import { genUserName } from '@/lib/genUserName';
import { getNostrIdentifierPath } from '@/lib/nostrIdentifier';
import { cn, STICKY_HEADER_CLASS } from '@/lib/utils';
import { nip19 } from 'nostr-tools';
@@ -28,6 +29,7 @@ export function SearchPage() {
description: 'Search Nostr',
});
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const initialQuery = searchParams.get('q') ?? '';
const initialTab = searchParams.get('tab') as TabType | null;
@@ -37,6 +39,14 @@ export function SearchPage() {
const [filtersOpen, setFiltersOpen] = useState(true);
const [trendSort, setTrendSort] = useState<SortMode>('hot');
// If the search query is a Nostr identifier, redirect immediately
useEffect(() => {
const path = getNostrIdentifierPath(searchQuery);
if (path) {
navigate(path, { replace: true });
}
}, [searchQuery, navigate]);
// Sync search query and tab to URL params
useEffect(() => {
const params: Record<string, string> = {};