import SearchInput from '@/components/SearchInput' import { useSearchProfiles } from '@/hooks' import { toExternalContent, toNote } from '@/lib/link' import { formatFeedRequest, parseNakReqCommand } from '@/lib/nak-parser' 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 { TSearchParams } from '@/types' import { Hash, MessageSquare, Notebook, Search, Server, Terminal } 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 [selectableOptions, setSelectableOptions] = useState([]) const [selectedIndex, setSelectedIndex] = useState(-1) const searchInputRef = useRef(null) const normalizedUrl = useMemo(() => { if (['w', 'ws', 'ws:', 'ws:/', 'wss', 'wss:', 'wss:/'].includes(input)) { return undefined } if (!input.includes('.')) { 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) } setSelectedIndex(-1) }, [input]) useEffect(() => { const handler = setTimeout(() => { setDebouncedInput(input) }, 500) return () => { clearTimeout(handler) } }, [input]) const blur = () => { setSearching(false) searchInputRef.current?.blur() } const updateSearch = (params: TSearchParams) => { blur() if (params.type === 'note') { push(toNote(params.search)) } else if (params.type === 'externalContent') { push(toExternalContent(params.search)) } else { onSearch(params) } } useEffect(() => { const search = input.trim() if (!search) return // Check if input is a nak req command const request = parseNakReqCommand(search) if (request) { setSelectableOptions([ { type: 'nak', search: formatFeedRequest(request), request, input: search } ]) return } if (/^[0-9a-f]{64}$/.test(search)) { setSelectableOptions([ { type: 'note', search }, { type: 'profile', search } ]) 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 }]) 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() ?? '' setSelectableOptions([ { type: 'notes', search }, ...(normalizedUrl ? [{ type: 'relay', search: normalizedUrl, input: normalizedUrl }] : []), { type: 'externalContent', search, input }, { type: 'hashtag', search: hashtag, input: `#${hashtag}` }, ...profiles.map((profile) => ({ type: 'profile', search: profile.npub, input: profile.username })), ...(profiles.length >= 5 ? [{ type: 'profiles', search }] : []) ] as TSearchParams[]) }, [input, debouncedInput, 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 === 'hashtag') { return ( updateSearch(option)} /> ) } if (option.type === 'relay') { return ( updateSearch(option)} /> ) } if (option.type === 'externalContent') { return ( updateSearch(option)} /> ) } if (option.type === 'nak') { return ( updateSearch(option)} /> ) } if (option.type === 'profiles') { return ( updateSearch(option)} >
{t('Show more...')}
) } return null })} {isFetchingProfiles && profiles.length < 5 && (
)} ) }, [selectableOptions, selectedIndex, isFetchingProfiles, 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() 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] ) return (
{displayList && list && ( <>
e.preventDefault()} >
{list}
blur()} /> )} setInput(e.target.value)} onKeyDown={handleKeyDown} onFocus={() => setSearching(true)} onBlur={() => setSearching(false)} onQrScan={(value) => { setInput(value) // Automatically search after scanning let id = value if (id.startsWith('nostr:')) { id = id.slice(6) } try { const { type } = nip19.decode(id) if (['nprofile', 'npub'].includes(type)) { updateSearch({ type: 'profile', search: id }) return } if (['nevent', 'naddr', 'note'].includes(type)) { updateSearch({ type: 'note', search: id }) return } } catch { // Not a valid nip19 identifier, just set input } }} />
) }) SearchBar.displayName = 'SearchBar' export default SearchBar export type TSearchBarRef = { focus: () => void blur: () => void } function NormalItem({ search, onClick, selected }: { search: string onClick?: () => void selected?: boolean }) { const { t } = useTranslation() return (
{search}
{t('Search for notes')}
) } function HashtagItem({ hashtag, onClick, selected }: { hashtag: string onClick?: () => void selected?: boolean }) { const { t } = useTranslation() return (
#{hashtag}
{t('Search for hashtag')}
) } function NoteItem({ id, onClick, selected }: { id: string onClick?: () => void selected?: boolean }) { const { t } = useTranslation() return (
{id}
{t('Go to note')}
) } function ProfileItem({ userId, onClick, selected }: { userId: string onClick?: () => void selected?: boolean }) { return (
) } function RelayItem({ url, onClick, selected }: { url: string onClick?: () => void selected?: boolean }) { const { t } = useTranslation() return (
{url}
{t('Go to relay')}
) } function ExternalContentItem({ search, onClick, selected }: { search: string onClick?: () => void selected?: boolean }) { const { t } = useTranslation() return (
{search}
{t('View discussions about this')}
) } function NakItem({ description, onClick, selected }: { description: string onClick?: () => void selected?: boolean }) { return (
REQ
{description}
) } function Item({ className, children, selected, ...props }: HTMLAttributes & { selected?: boolean }) { return (
{children}
) }