Files
smesh/src/components/SearchBar/index.tsx
codytseng 8655fd1840 fix: 🐛
2025-09-11 10:09:32 +08:00

304 lines
8.1 KiB
TypeScript

import SearchInput from '@/components/SearchInput'
import { useSearchProfiles } from '@/hooks'
import { toNote } from '@/lib/link'
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 { TProfile, TSearchParams } from '@/types'
import { Hash, Notebook, Search, Server, UserRound } 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 searchInputRef = useRef<HTMLInputElement>(null)
const normalizedUrl = useMemo(() => {
if (['w', 'ws', 'ws:', 'ws:/', 'wss', 'wss:', 'wss:/'].includes(input)) {
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)
}
}, [input])
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedInput(input)
}, 500)
return () => {
clearTimeout(handler)
}
}, [input])
const blur = () => {
setSearching(false)
searchInputRef.current?.blur()
}
const list = useMemo(() => {
const search = input.trim()
if (!search) return null
const updateSearch = (params: TSearchParams) => {
blur()
onSearch(params)
}
if (/^[0-9a-f]{64}$/.test(search)) {
return (
<>
<NoteItem
id={search}
onClick={() => {
blur()
push(toNote(search))
}}
/>
<ProfileIdItem id={search} onClick={() => updateSearch({ type: 'profile', search })} />
</>
)
}
try {
let id = search
if (id.startsWith('nostr:')) {
id = id.slice(6)
}
const { type } = nip19.decode(id)
if (['nprofile', 'npub'].includes(type)) {
return (
<ProfileIdItem id={id} onClick={() => updateSearch({ type: 'profile', search: id })} />
)
}
if (['nevent', 'naddr', 'note'].includes(type)) {
return (
<NoteItem
id={id}
onClick={() => {
blur()
push(toNote(id))
}}
/>
)
}
} catch {
// ignore
}
return (
<>
<NormalItem search={search} onClick={() => updateSearch({ type: 'notes', search })} />
<HashtagItem
search={search}
onClick={() => updateSearch({ type: 'hashtag', search, input: `#${search}` })}
/>
{!!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 })
}
/>
))}
{isFetchingProfiles && profiles.length < 5 && (
<div className="px-2">
<UserItemSkeleton hideFollowButton />
</div>
)}
{profiles.length >= 5 && (
<Item onClick={() => updateSearch({ type: 'profiles', search })}>
<div className="font-semibold">{t('Show more...')}</div>
</Item>
)}
</>
)
}, [input, debouncedInput, 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()
onSearch({ type: 'notes', search: input.trim() })
blur()
}
},
[input, onSearch]
)
return (
<div className="relative flex gap-1 items-center h-full w-full">
{displayList && list && (
<>
<div
className={cn(
'bg-surface-background rounded-b-lg shadow-lg z-50',
isSmallScreen
? 'fixed top-12 inset-x-0'
: 'absolute top-full -translate-y-1 inset-x-0 pt-1 '
)}
onMouseDown={(e) => e.preventDefault()}
>
<div className="h-fit">{list}</div>
</div>
<div className="fixed inset-0 w-full h-full" onClick={() => blur()} />
</>
)}
<SearchInput
ref={searchInputRef}
className={cn(
'bg-surface-background shadow-inner h-full border-none',
searching ? 'z-50' : ''
)}
placeholder={t('Type searching for people, keywords, or relays')}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={() => setSearching(true)}
onBlur={() => setSearching(false)}
/>
</div>
)
})
SearchBar.displayName = 'SearchBar'
export default SearchBar
export type TSearchBarRef = {
focus: () => void
blur: () => void
}
function NormalItem({ search, onClick }: { search: string; onClick?: () => void }) {
return (
<Item onClick={onClick}>
<Search className="text-muted-foreground" />
<div className="font-semibold truncate">{search}</div>
</Item>
)
}
function HashtagItem({ search, onClick }: { search: string; onClick?: () => void }) {
const hashtag = search.match(/[\p{L}\p{N}\p{M}]+/u)?.[0].toLowerCase()
return (
<Item onClick={onClick}>
<Hash className="text-muted-foreground" />
<div className="font-semibold truncate">{hashtag}</div>
</Item>
)
}
function NoteItem({ id, onClick }: { id: string; onClick?: () => void }) {
return (
<Item onClick={onClick}>
<Notebook className="text-muted-foreground" />
<div className="font-semibold truncate">{id}</div>
</Item>
)
}
function ProfileIdItem({ id, onClick }: { id: string; onClick?: () => void }) {
return (
<Item onClick={onClick}>
<UserRound className="text-muted-foreground" />
<div className="font-semibold truncate">{id}</div>
</Item>
)
}
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>
)
}
function RelayItem({ url, onClick }: { url: string; onClick?: () => void }) {
return (
<Item onClick={onClick}>
<Server className="text-muted-foreground" />
<div className="font-semibold truncate">{url}</div>
</Item>
)
}
function Item({ className, children, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
'flex gap-2 items-center px-2 py-3 hover:bg-accent rounded-md cursor-pointer',
className
)}
{...props}
>
{children}
</div>
)
}