From 7606c62b630e875d71b4498bb1f8c8d5830bc98b Mon Sep 17 00:00:00 2001 From: codytseng Date: Thu, 11 Sep 2025 22:07:56 +0800 Subject: [PATCH] feat: improve search user experience --- src/components/SearchBar/index.tsx | 279 ++++++++++++++++++++--------- src/types/index.d.ts | 2 +- 2 files changed, 199 insertions(+), 82 deletions(-) diff --git a/src/components/SearchBar/index.tsx b/src/components/SearchBar/index.tsx index d694ea0..63a43e6 100644 --- a/src/components/SearchBar/index.tsx +++ b/src/components/SearchBar/index.tsx @@ -7,8 +7,8 @@ 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 { TSearchParams } from '@/types' +import { Hash, Notebook, Search, Server } from 'lucide-react' import { nip19 } from 'nostr-tools' import { forwardRef, @@ -38,6 +38,8 @@ const SearchBar = forwardRef< 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)) { @@ -81,28 +83,26 @@ const SearchBar = forwardRef< searchInputRef.current?.blur() } - const list = useMemo(() => { - const search = input.trim() - if (!search) return null + const updateSearch = (params: TSearchParams) => { + blur() - const updateSearch = (params: TSearchParams) => { - blur() + if (params.type === 'note') { + push(toNote(params.search)) + } else { onSearch(params) } + } + + useEffect(() => { + const search = input.trim() + if (!search) return if (/^[0-9a-f]{64}$/.test(search)) { - return ( - <> - { - blur() - push(toNote(search)) - }} - /> - updateSearch({ type: 'profile', search })} /> - - ) + setSelectableOptions([ + { type: 'note', search }, + { type: 'profile', search } + ]) + return } try { @@ -112,60 +112,111 @@ const SearchBar = forwardRef< } const { type } = nip19.decode(id) if (['nprofile', 'npub'].includes(type)) { - return ( - updateSearch({ type: 'profile', search: id })} /> - ) + setSelectableOptions([{ type: 'profile', search: id }]) + return } if (['nevent', 'naddr', 'note'].includes(type)) { - return ( - { - blur() - push(toNote(id)) - }} - /> - ) + 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 }, + { type: 'hashtag', search: hashtag, input: `#${hashtag}` }, + ...(normalizedUrl ? [{ type: 'relay', search: normalizedUrl, input: normalizedUrl }] : []), + ...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 ( <> - 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 }) - } - /> - ))} + {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 === 'profiles') { + return ( + updateSearch(option)} + > +
{t('Show more...')}
+
+ ) + } + return null + })} {isFetchingProfiles && profiles.length < 5 && (
)} - {profiles.length >= 5 && ( - updateSearch({ type: 'profiles', search })}> -
{t('Show more...')}
-
- )} ) - }, [input, debouncedInput, profiles]) + }, [selectableOptions, selectedIndex, isFetchingProfiles, profiles]) useEffect(() => { setDisplayList(searching && !!input) @@ -185,11 +236,38 @@ const SearchBar = forwardRef< (e: React.KeyboardEvent) => { if (e.key === 'Enter') { e.stopPropagation() - onSearch({ type: 'notes', search: input.trim() }) + 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] + [input, onSearch, selectableOptions, selectedIndex] ) return ( @@ -234,65 +312,104 @@ export type TSearchBarRef = { blur: () => void } -function NormalItem({ search, onClick }: { search: string; onClick?: () => void }) { +function NormalItem({ + search, + onClick, + selected +}: { + search: string + onClick?: () => void + selected?: boolean +}) { return ( - +
{search}
) } -function HashtagItem({ search, onClick }: { search: string; onClick?: () => void }) { - const hashtag = search.match(/[\p{L}\p{N}\p{M}]+/u)?.[0].toLowerCase() +function HashtagItem({ + hashtag, + onClick, + selected +}: { + hashtag: string + onClick?: () => void + selected?: boolean +}) { return ( - +
{hashtag}
) } -function NoteItem({ id, onClick }: { id: string; onClick?: () => void }) { +function NoteItem({ + id, + onClick, + selected +}: { + id: string + onClick?: () => void + selected?: boolean +}) { return ( - +
{id}
) } -function ProfileIdItem({ id, onClick }: { id: string; onClick?: () => void }) { +function ProfileItem({ + userId, + onClick, + selected +}: { + userId: string + onClick?: () => void + selected?: boolean +}) { return ( - - -
{id}
-
- ) -} - -function ProfileItem({ profile, onClick }: { profile: TProfile; onClick?: () => void }) { - return ( -
- +
+
) } -function RelayItem({ url, onClick }: { url: string; onClick?: () => void }) { +function RelayItem({ + url, + onClick, + selected +}: { + url: string + onClick?: () => void + selected?: boolean +}) { return ( - +
{url}
) } -function Item({ className, children, ...props }: HTMLAttributes) { +function Item({ + className, + children, + selected, + ...props +}: HTMLAttributes & { selected?: boolean }) { return (