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:
@@ -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]);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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> = {};
|
||||
|
||||
Reference in New Issue
Block a user