import SearchInput from '@/components/SearchInput' import { useSearchProfiles } from '@/hooks' import { toNote } from '@/lib/link' import { randomString } from '@/lib/random' import { normalizeUrl } from '@/lib/url' import { cn } from '@/lib/utils' import { useSecondaryPage } from '@/PageManager' import { useScreenSize } from '@/providers/ScreenSizeProvider' import modalManager from '@/services/modal-manager.service' import { TProfile, TSearchParams } from '@/types' import { Hash, Notebook, Search, Server, UserRound } from 'lucide-react' import { nip19 } from 'nostr-tools' import { forwardRef, HTMLAttributes, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import UserItem, { UserItemSkeleton } from '../UserItem' const SearchBar = forwardRef< TSearchBarRef, { input: string setInput: (input: string) => void onSearch: (params: TSearchParams | null) => void } >(({ input, setInput, onSearch }, ref) => { const { t } = useTranslation() const { push } = useSecondaryPage() const { isSmallScreen } = useScreenSize() const [debouncedInput, setDebouncedInput] = useState(input) const { profiles, isFetching: isFetchingProfiles } = useSearchProfiles(debouncedInput, 5) const [searching, setSearching] = useState(false) const [displayList, setDisplayList] = useState(false) const searchInputRef = useRef(null) const normalizedUrl = useMemo(() => { if (['w', 'ws', 'ws:', 'ws:/', 'wss', 'wss:', 'wss:/'].includes(input)) { return undefined } try { return normalizeUrl(input) } catch { return undefined } }, [input]) const id = useMemo(() => `search-${randomString()}`, []) useImperativeHandle(ref, () => ({ focus: () => { searchInputRef.current?.focus() }, blur: () => { searchInputRef.current?.blur() } })) useEffect(() => { if (!input) { onSearch(null) } }, [input]) useEffect(() => { const handler = setTimeout(() => { setDebouncedInput(input) }, 500) return () => { clearTimeout(handler) } }, [input]) const blur = () => { setSearching(false) searchInputRef.current?.blur() } const list = useMemo(() => { const search = input.trim() if (!search) return null const updateSearch = (params: TSearchParams) => { blur() onSearch(params) } if (/^[0-9a-f]{64}$/.test(search)) { return ( <> { blur() push(toNote(search)) }} /> updateSearch({ type: 'profile', search })} /> ) } try { let id = search if (id.startsWith('nostr:')) { id = id.slice(6) } const { type } = nip19.decode(id) if (['nprofile', 'npub'].includes(type)) { return ( updateSearch({ type: 'profile', search: id })} /> ) } if (['nevent', 'naddr', 'note'].includes(type)) { return ( { blur() push(toNote(id)) }} /> ) } } catch { // ignore } return ( <> updateSearch({ type: 'notes', search })} /> updateSearch({ type: 'hashtag', search, input: `#${search}` })} /> {!!normalizedUrl && ( updateSearch({ type: 'relay', search, input: normalizedUrl })} /> )} {profiles.map((profile) => ( updateSearch({ type: 'profile', search: profile.npub, input: profile.username }) } /> ))} {isFetchingProfiles && profiles.length < 5 && (
)} {profiles.length >= 5 && ( updateSearch({ type: 'profiles', search })}>
{t('Show more...')}
)} ) }, [input, debouncedInput, profiles]) useEffect(() => { setDisplayList(searching && !!input) }, [searching, input]) useEffect(() => { if (displayList && list) { modalManager.register(id, () => { setDisplayList(false) }) } else { modalManager.unregister(id) } }, [displayList, list]) const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === 'Enter') { e.stopPropagation() onSearch({ type: 'notes', search: input.trim() }) blur() } }, [input, onSearch] ) return (
{displayList && list && ( <>
e.preventDefault()} >
{list}
blur()} /> )} setInput(e.target.value)} onKeyDown={handleKeyDown} onFocus={() => setSearching(true)} onBlur={() => setSearching(false)} />
) }) SearchBar.displayName = 'SearchBar' export default SearchBar export type TSearchBarRef = { focus: () => void blur: () => void } function NormalItem({ search, onClick }: { search: string; onClick?: () => void }) { return (
{search}
) } function HashtagItem({ search, onClick }: { search: string; onClick?: () => void }) { const hashtag = search.match(/[\p{L}\p{N}\p{M}]+/u)?.[0].toLowerCase() return (
{hashtag}
) } function NoteItem({ id, onClick }: { id: string; onClick?: () => void }) { return (
{id}
) } function ProfileIdItem({ id, onClick }: { id: string; onClick?: () => void }) { return (
{id}
) } function ProfileItem({ profile, onClick }: { profile: TProfile; onClick?: () => void }) { return (
) } function RelayItem({ url, onClick }: { url: string; onClick?: () => void }) { return (
{url}
) } function Item({ className, children, ...props }: HTMLAttributes) { return (
{children}
) }