import SearchInput from '@/components/SearchInput' import { useSearchProfiles } from '@/hooks' import { toNote, toNoteList } from '@/lib/link' import client from '@/services/client.service' import { eventService } from '@/services/client.service' import { randomString } from '@/lib/random' import { isKind10243HttpRelayTagUrl, isWebsocketUrl, looksLikeNostrBech32Identifier, looksLikeRelayUrlInput, normalizeAnyRelayUrl, normalizeHttpRelayUrl } from '@/lib/url' import { normalizeToDTag } from '@/lib/search-parser' import { cn } from '@/lib/utils' import { useSmartNoteNavigation, useSmartHashtagNavigation } from '@/PageManager' import { useScreenSize } from '@/providers/ScreenSizeProvider' import modalManager from '@/services/modal-manager.service' import { TSearchParams } from '@/types' import { Hash, Notebook, Search, Server, FileText, Users } from 'lucide-react' import { nip19 } from 'nostr-tools' import { forwardRef, HTMLAttributes, useCallback, useEffect, useImperativeHandle, useLayoutEffect, 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 { navigateToNote } = useSmartNoteNavigation() const { navigateToHashtag } = useSmartHashtagNavigation() const { isSmallScreen } = useScreenSize() const [searching, setSearching] = useState(false) const { profiles, isFetching: isFetchingProfiles, debouncedSearch } = useSearchProfiles( searching ? input : '', 5 ) const [displayList, setDisplayList] = useState(false) const [selectableOptions, setSelectableOptions] = useState([]) const [selectedIndex, setSelectedIndex] = useState(-1) const prevSelectableCountRef = useRef(0) const searchInputRef = useRef(null) const barContainerRef = useRef(null) const [suggestPanelTop, setSuggestPanelTop] = useState(0) const normalizedUrl = useMemo(() => { if (['w', 'ws', 'ws:', 'ws:/', 'wss', 'wss:', 'wss:/'].includes(input)) { return undefined } const trimmed = input.trim() if (!trimmed || looksLikeNostrBech32Identifier(trimmed) || !looksLikeRelayUrlInput(trimmed)) { return undefined } try { const n = normalizeAnyRelayUrl(trimmed) || normalizeHttpRelayUrl(trimmed) if (!n || (!isWebsocketUrl(n) && !isKind10243HttpRelayTagUrl(n))) return undefined return n } catch { return undefined } }, [input]) const id = useMemo(() => `search-${randomString()}`, []) useImperativeHandle(ref, () => ({ focus: () => { searchInputRef.current?.focus() }, blur: () => { searchInputRef.current?.blur() } })) useEffect(() => { if (!input) { onSearch(null) } setSelectedIndex(-1) }, [input]) const blur = () => { setSearching(false) searchInputRef.current?.blur() } const updateSearch = (params: TSearchParams) => { blur() if (params.type === 'note') { // Prime event cache so note page finds it without re-fetch eventService .fetchEvent(params.search) .then((ev) => { if (!ev) return const hex = /^[0-9a-f]{64}$/i.test(ev.id) ? ev.id.toLowerCase() : undefined eventService.addEventToCache(ev, hex ? { explicitNoteLookupHexId: hex } : undefined) }) .catch(() => {}) navigateToNote(toNote(params.search)) } else if (params.type === 'hashtag') { navigateToHashtag(toNoteList({ hashtag: params.search })) } else if (params.type === 'dtag') { // Navigate to d-tag search using same pattern as hashtag navigateToHashtag(toNoteList({ domain: params.search })) } else if (params.type === 'profile') { // Prime profile cache so profile page finds it without re-fetch client.fetchProfileEvent(params.search).catch(() => {}) onSearch(params) } else { onSearch(params) } } useEffect(() => { const search = input.trim() if (!search) { setSelectableOptions([]) setSelectedIndex(-1) setSearching(false) return } const hex64 = /^[0-9a-f]{64}$/i if (hex64.test(search)) { const normalized = search.toLowerCase() setSelectableOptions([ { type: 'note', search: normalized }, { type: 'profile', search: normalized }, { type: 'profiles', search: normalized } ]) return } try { let id = search if (id.startsWith('nostr:')) { id = id.slice(6) } const { type } = nip19.decode(id) if (['nprofile', 'npub'].includes(type)) { setSelectableOptions([ { type: 'profile', search: id }, { type: 'profiles', search: id } ]) return } if (['nevent', 'naddr', 'note'].includes(type)) { setSelectableOptions([{ type: 'note', search: id }]) return } } catch { // ignore } const hashtag = search.match(/[\p{L}\p{N}\p{M}]+/u)?.[0].toLowerCase() ?? '' const normalizedDTag = normalizeToDTag(search) setSelectableOptions([ { type: 'notes', search }, { type: 'profiles', search }, { type: 'hashtag', search: hashtag, input: `#${hashtag}` }, ...(normalizedDTag && normalizedDTag.length > 0 ? [{ type: 'dtag', search: normalizedDTag, input: search }] : []), ...(normalizedUrl ? [{ type: 'relay', search: normalizedUrl, input: normalizedUrl }] : []), ...profiles.map((profile) => ({ type: 'profile' as const, search: profile.npub, input: profile.username, profile })) ] as TSearchParams[]) }, [input, debouncedSearch, profiles]) const list = useMemo(() => { if (selectableOptions.length <= 0) { return null } return ( <> {selectableOptions.map((option, index) => { if (option.type === 'note') { return ( updateSearch(option)} /> ) } if (option.type === 'profile') { return ( updateSearch(option)} /> ) } if (option.type === 'notes') { return ( updateSearch(option)} /> ) } if (option.type === 'profiles') { return ( updateSearch(option)} /> ) } if (option.type === 'hashtag') { return ( updateSearch(option)} /> ) } if (option.type === 'dtag') { return ( updateSearch(option)} /> ) } if (option.type === 'relay') { return ( updateSearch(option)} /> ) } return null })} {isFetchingProfiles && profiles.length < 5 && (
)} ) }, [selectableOptions, selectedIndex, isFetchingProfiles, profiles]) useEffect(() => { setDisplayList(searching && !!input.trim()) }, [searching, input]) /** * Prefilled / parent-controlled `input` (e.g. URL sync) can have suggestions while the field never received * focus, so `searching` stays false and the dropdown never mounts. When options first appear, focus the input * once so `onFocus` runs and the list opens (mousedown on suggestions still prevents premature blur). */ useEffect(() => { const trimmed = input.trim() const len = selectableOptions.length if (!trimmed) { prevSelectableCountRef.current = 0 return } if (len > 0 && prevSelectableCountRef.current === 0) { const el = searchInputRef.current if (el && document.activeElement !== el) { queueMicrotask(() => { el.focus({ preventScroll: true }) }) } } prevSelectableCountRef.current = len }, [input, selectableOptions]) useEffect(() => { if (displayList && list) { modalManager.register(id, () => { setDisplayList(false) }) } else { modalManager.unregister(id) } }, [displayList, list]) const updateSuggestPanelGeometry = useCallback(() => { const el = barContainerRef.current if (!el) return setSuggestPanelTop(el.getBoundingClientRect().bottom) }, []) useLayoutEffect(() => { if (!displayList || !list || !isSmallScreen) return updateSuggestPanelGeometry() const onScrollOrResize = () => updateSuggestPanelGeometry() window.addEventListener('scroll', onScrollOrResize, true) window.addEventListener('resize', onScrollOrResize) return () => { window.removeEventListener('scroll', onScrollOrResize, true) window.removeEventListener('resize', onScrollOrResize) } }, [displayList, list, isSmallScreen, input, updateSuggestPanelGeometry]) const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === 'Enter') { e.stopPropagation() if (selectableOptions.length <= 0) { return } onSearch(selectableOptions[selectedIndex >= 0 ? selectedIndex : 0]) blur() return } if (e.key === 'ArrowDown') { e.preventDefault() if (selectableOptions.length <= 0) { return } setSelectedIndex((prev) => (prev + 1) % selectableOptions.length) return } if (e.key === 'ArrowUp') { e.preventDefault() if (selectableOptions.length <= 0) { return } setSelectedIndex((prev) => (prev - 1 + selectableOptions.length) % selectableOptions.length) return } if (e.key === 'Escape') { blur() return } }, [input, onSearch, selectableOptions, selectedIndex] ) const suggestTopPx = Math.max(0, suggestPanelTop - 4) const suggestionsPanel = list ? (
e.preventDefault()} >
{list}
) : null return (
{displayList && list && !isSmallScreen && ( <> {suggestionsPanel}
blur()} aria-hidden /> )} {displayList && list && isSmallScreen && ( <>
blur()} aria-hidden /> {suggestionsPanel} )} { setSearching(true) setInput(e.target.value) }} onPaste={() => { setSearching(true) }} 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, selected }: { search: string onClick?: () => void selected?: boolean }) { return (
FULL TEXT
{search}
) } function ProfilesSearchItem({ search, onClick, selected }: { search: string onClick?: () => void selected?: boolean }) { const { t } = useTranslation() return (
{t('Search dropdown profile search')}
{search}
) } function HashtagItem({ hashtag, onClick, selected }: { hashtag: string onClick?: () => void selected?: boolean }) { return (
HASHTAG
{hashtag}
) } function NoteItem({ id, onClick, selected }: { id: string onClick?: () => void selected?: boolean }) { return (
NOTE
{id}
) } function ProfileItem({ userId, prefetchedProfile, onClick, selected }: { userId: string prefetchedProfile?: TSearchParams['profile'] onClick?: () => void selected?: boolean }) { return (
) } function DTagItem({ dtag, onClick, selected }: { dtag: string onClick?: () => void selected?: boolean }) { return (
D-TAG
{dtag}
) } function RelayItem({ url, onClick, selected }: { url: string onClick?: () => void selected?: boolean }) { return (
RELAY
{url}
) } function Item({ className, children, selected, ...props }: HTMLAttributes & { selected?: boolean }) { return (
{children}
) }