feat: improve search user experience

This commit is contained in:
codytseng
2025-09-11 22:07:56 +08:00
parent 8ba9d872e2
commit 7606c62b63
2 changed files with 199 additions and 82 deletions

View File

@@ -7,8 +7,8 @@ import { cn } from '@/lib/utils'
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import modalManager from '@/services/modal-manager.service' import modalManager from '@/services/modal-manager.service'
import { TProfile, TSearchParams } from '@/types' import { TSearchParams } from '@/types'
import { Hash, Notebook, Search, Server, UserRound } from 'lucide-react' import { Hash, Notebook, Search, Server } from 'lucide-react'
import { nip19 } from 'nostr-tools' import { nip19 } from 'nostr-tools'
import { import {
forwardRef, forwardRef,
@@ -38,6 +38,8 @@ const SearchBar = forwardRef<
const { profiles, isFetching: isFetchingProfiles } = useSearchProfiles(debouncedInput, 5) const { profiles, isFetching: isFetchingProfiles } = useSearchProfiles(debouncedInput, 5)
const [searching, setSearching] = useState(false) const [searching, setSearching] = useState(false)
const [displayList, setDisplayList] = useState(false) const [displayList, setDisplayList] = useState(false)
const [selectableOptions, setSelectableOptions] = useState<TSearchParams[]>([])
const [selectedIndex, setSelectedIndex] = useState(-1)
const searchInputRef = useRef<HTMLInputElement>(null) const searchInputRef = useRef<HTMLInputElement>(null)
const normalizedUrl = useMemo(() => { const normalizedUrl = useMemo(() => {
if (['w', 'ws', 'ws:', 'ws:/', 'wss', 'wss:', 'wss:/'].includes(input)) { if (['w', 'ws', 'ws:', 'ws:/', 'wss', 'wss:', 'wss:/'].includes(input)) {
@@ -81,28 +83,26 @@ const SearchBar = forwardRef<
searchInputRef.current?.blur() searchInputRef.current?.blur()
} }
const list = useMemo(() => {
const search = input.trim()
if (!search) return null
const updateSearch = (params: TSearchParams) => { const updateSearch = (params: TSearchParams) => {
blur() blur()
if (params.type === 'note') {
push(toNote(params.search))
} else {
onSearch(params) onSearch(params)
} }
}
useEffect(() => {
const search = input.trim()
if (!search) return
if (/^[0-9a-f]{64}$/.test(search)) { if (/^[0-9a-f]{64}$/.test(search)) {
return ( setSelectableOptions([
<> { type: 'note', search },
<NoteItem { type: 'profile', search }
id={search} ])
onClick={() => { return
blur()
push(toNote(search))
}}
/>
<ProfileIdItem id={search} onClick={() => updateSearch({ type: 'profile', search })} />
</>
)
} }
try { try {
@@ -112,60 +112,111 @@ const SearchBar = forwardRef<
} }
const { type } = nip19.decode(id) const { type } = nip19.decode(id)
if (['nprofile', 'npub'].includes(type)) { if (['nprofile', 'npub'].includes(type)) {
return ( setSelectableOptions([{ type: 'profile', search: id }])
<ProfileIdItem id={id} onClick={() => updateSearch({ type: 'profile', search: id })} /> return
)
} }
if (['nevent', 'naddr', 'note'].includes(type)) { if (['nevent', 'naddr', 'note'].includes(type)) {
return ( setSelectableOptions([{ type: 'note', search: id }])
<NoteItem return
id={id}
onClick={() => {
blur()
push(toNote(id))
}}
/>
)
} }
} catch { } catch {
// ignore // 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 ( return (
<> <>
<NormalItem search={search} onClick={() => updateSearch({ type: 'notes', search })} /> {selectableOptions.map((option, index) => {
<HashtagItem if (option.type === 'note') {
search={search} return (
onClick={() => updateSearch({ type: 'hashtag', search, input: `#${search}` })} <NoteItem
key={index}
selected={selectedIndex === index}
id={option.search}
onClick={() => updateSearch(option)}
/> />
{!!normalizedUrl && ( )
<RelayItem
url={normalizedUrl}
onClick={() => updateSearch({ type: 'relay', search, input: normalizedUrl })}
/>
)}
{profiles.map((profile) => (
<ProfileItem
key={profile.pubkey}
profile={profile}
onClick={() =>
updateSearch({ type: 'profile', search: profile.npub, input: profile.username })
} }
if (option.type === 'profile') {
return (
<ProfileItem
key={index}
selected={selectedIndex === index}
userId={option.search}
onClick={() => updateSearch(option)}
/> />
))} )
}
if (option.type === 'notes') {
return (
<NormalItem
key={index}
selected={selectedIndex === index}
search={option.search}
onClick={() => updateSearch(option)}
/>
)
}
if (option.type === 'hashtag') {
return (
<HashtagItem
key={index}
selected={selectedIndex === index}
hashtag={option.search}
onClick={() => updateSearch(option)}
/>
)
}
if (option.type === 'relay') {
return (
<RelayItem
key={index}
selected={selectedIndex === index}
url={option.search}
onClick={() => updateSearch(option)}
/>
)
}
if (option.type === 'profiles') {
return (
<Item
key={index}
selected={selectedIndex === index}
onClick={() => updateSearch(option)}
>
<div className="font-semibold">{t('Show more...')}</div>
</Item>
)
}
return null
})}
{isFetchingProfiles && profiles.length < 5 && ( {isFetchingProfiles && profiles.length < 5 && (
<div className="px-2"> <div className="px-2">
<UserItemSkeleton hideFollowButton /> <UserItemSkeleton hideFollowButton />
</div> </div>
)} )}
{profiles.length >= 5 && (
<Item onClick={() => updateSearch({ type: 'profiles', search })}>
<div className="font-semibold">{t('Show more...')}</div>
</Item>
)}
</> </>
) )
}, [input, debouncedInput, profiles]) }, [selectableOptions, selectedIndex, isFetchingProfiles, profiles])
useEffect(() => { useEffect(() => {
setDisplayList(searching && !!input) setDisplayList(searching && !!input)
@@ -185,11 +236,38 @@ const SearchBar = forwardRef<
(e: React.KeyboardEvent) => { (e: React.KeyboardEvent) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
e.stopPropagation() e.stopPropagation()
onSearch({ type: 'notes', search: input.trim() }) if (selectableOptions.length <= 0) {
return
}
onSearch(selectableOptions[selectedIndex >= 0 ? selectedIndex : 0])
blur() 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 ( return (
@@ -234,65 +312,104 @@ export type TSearchBarRef = {
blur: () => void blur: () => void
} }
function NormalItem({ search, onClick }: { search: string; onClick?: () => void }) { function NormalItem({
search,
onClick,
selected
}: {
search: string
onClick?: () => void
selected?: boolean
}) {
return ( return (
<Item onClick={onClick}> <Item onClick={onClick} selected={selected}>
<Search className="text-muted-foreground" /> <Search className="text-muted-foreground" />
<div className="font-semibold truncate">{search}</div> <div className="font-semibold truncate">{search}</div>
</Item> </Item>
) )
} }
function HashtagItem({ search, onClick }: { search: string; onClick?: () => void }) { function HashtagItem({
const hashtag = search.match(/[\p{L}\p{N}\p{M}]+/u)?.[0].toLowerCase() hashtag,
onClick,
selected
}: {
hashtag: string
onClick?: () => void
selected?: boolean
}) {
return ( return (
<Item onClick={onClick}> <Item onClick={onClick} selected={selected}>
<Hash className="text-muted-foreground" /> <Hash className="text-muted-foreground" />
<div className="font-semibold truncate">{hashtag}</div> <div className="font-semibold truncate">{hashtag}</div>
</Item> </Item>
) )
} }
function NoteItem({ id, onClick }: { id: string; onClick?: () => void }) { function NoteItem({
id,
onClick,
selected
}: {
id: string
onClick?: () => void
selected?: boolean
}) {
return ( return (
<Item onClick={onClick}> <Item onClick={onClick} selected={selected}>
<Notebook className="text-muted-foreground" /> <Notebook className="text-muted-foreground" />
<div className="font-semibold truncate">{id}</div> <div className="font-semibold truncate">{id}</div>
</Item> </Item>
) )
} }
function ProfileIdItem({ id, onClick }: { id: string; onClick?: () => void }) { function ProfileItem({
userId,
onClick,
selected
}: {
userId: string
onClick?: () => void
selected?: boolean
}) {
return ( return (
<Item onClick={onClick}> <div
<UserRound className="text-muted-foreground" /> className={cn('px-2 hover:bg-accent rounded-md cursor-pointer', selected && 'bg-accent')}
<div className="font-semibold truncate">{id}</div> onClick={onClick}
</Item> >
) <UserItem pubkey={userId} hideFollowButton className="pointer-events-none" />
}
function ProfileItem({ profile, onClick }: { profile: TProfile; onClick?: () => void }) {
return (
<div className="px-2 hover:bg-accent rounded-md cursor-pointer" onClick={onClick}>
<UserItem pubkey={profile.pubkey} hideFollowButton className="pointer-events-none" />
</div> </div>
) )
} }
function RelayItem({ url, onClick }: { url: string; onClick?: () => void }) { function RelayItem({
url,
onClick,
selected
}: {
url: string
onClick?: () => void
selected?: boolean
}) {
return ( return (
<Item onClick={onClick}> <Item onClick={onClick} selected={selected}>
<Server className="text-muted-foreground" /> <Server className="text-muted-foreground" />
<div className="font-semibold truncate">{url}</div> <div className="font-semibold truncate">{url}</div>
</Item> </Item>
) )
} }
function Item({ className, children, ...props }: HTMLAttributes<HTMLDivElement>) { function Item({
className,
children,
selected,
...props
}: HTMLAttributes<HTMLDivElement> & { selected?: boolean }) {
return ( return (
<div <div
className={cn( className={cn(
'flex gap-2 items-center px-2 py-3 hover:bg-accent rounded-md cursor-pointer', 'flex gap-2 items-center px-2 py-3 hover:bg-accent rounded-md cursor-pointer',
selected ? 'bg-accent' : '',
className className
)} )}
{...props} {...props}

View File

@@ -169,7 +169,7 @@ export type TPollCreateData = {
endsAt?: number endsAt?: number
} }
export type TSearchType = 'profile' | 'profiles' | 'notes' | 'hashtag' | 'relay' export type TSearchType = 'profile' | 'profiles' | 'notes' | 'note' | 'hashtag' | 'relay'
export type TSearchParams = { export type TSearchParams = {
type: TSearchType type: TSearchType