feat: improve search user experience
This commit is contained in:
@@ -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}
|
||||||
|
|||||||
2
src/types/index.d.ts
vendored
2
src/types/index.d.ts
vendored
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user