Auto-search after QR scan in search bar. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
558 lines
15 KiB
TypeScript
558 lines
15 KiB
TypeScript
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<TSearchParams[]>([])
|
|
const [selectedIndex, setSelectedIndex] = useState(-1)
|
|
const searchInputRef = useRef<HTMLInputElement>(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 (
|
|
<NoteItem
|
|
key={index}
|
|
selected={selectedIndex === index}
|
|
id={option.search}
|
|
onClick={() => updateSearch(option)}
|
|
/>
|
|
)
|
|
}
|
|
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 === 'externalContent') {
|
|
return (
|
|
<ExternalContentItem
|
|
key={index}
|
|
selected={selectedIndex === index}
|
|
search={option.search}
|
|
onClick={() => updateSearch(option)}
|
|
/>
|
|
)
|
|
}
|
|
if (option.type === 'nak') {
|
|
return (
|
|
<NakItem
|
|
key={index}
|
|
selected={selectedIndex === index}
|
|
description={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 && (
|
|
<div className="px-2">
|
|
<UserItemSkeleton hideFollowButton />
|
|
</div>
|
|
)}
|
|
</>
|
|
)
|
|
}, [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 (
|
|
<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-2 inset-x-0 pt-3.5 pb-1 border px-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-transparent',
|
|
searching ? 'z-50' : ''
|
|
)}
|
|
placeholder={t('People, keywords, or relays')}
|
|
value={input}
|
|
onChange={(e) => 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
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
)
|
|
})
|
|
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 (
|
|
<Item onClick={onClick} selected={selected}>
|
|
<div className="size-10 flex justify-center items-center">
|
|
<Search className="text-muted-foreground flex-shrink-0" />
|
|
</div>
|
|
<div className="flex flex-col min-w-0 flex-1">
|
|
<div className="font-semibold truncate">{search}</div>
|
|
<div className="text-sm text-muted-foreground">{t('Search for notes')}</div>
|
|
</div>
|
|
</Item>
|
|
)
|
|
}
|
|
|
|
function HashtagItem({
|
|
hashtag,
|
|
onClick,
|
|
selected
|
|
}: {
|
|
hashtag: string
|
|
onClick?: () => void
|
|
selected?: boolean
|
|
}) {
|
|
const { t } = useTranslation()
|
|
return (
|
|
<Item onClick={onClick} selected={selected}>
|
|
<div className="size-10 flex justify-center items-center">
|
|
<Hash className="text-muted-foreground flex-shrink-0" />
|
|
</div>
|
|
<div className="flex flex-col min-w-0 flex-1">
|
|
<div className="font-semibold truncate">#{hashtag}</div>
|
|
<div className="text-sm text-muted-foreground">{t('Search for hashtag')}</div>
|
|
</div>
|
|
</Item>
|
|
)
|
|
}
|
|
|
|
function NoteItem({
|
|
id,
|
|
onClick,
|
|
selected
|
|
}: {
|
|
id: string
|
|
onClick?: () => void
|
|
selected?: boolean
|
|
}) {
|
|
const { t } = useTranslation()
|
|
return (
|
|
<Item onClick={onClick} selected={selected}>
|
|
<div className="size-10 flex justify-center items-center">
|
|
<Notebook className="text-muted-foreground flex-shrink-0" />
|
|
</div>
|
|
<div className="flex flex-col min-w-0 flex-1">
|
|
<div className="font-semibold truncate font-mono text-sm">{id}</div>
|
|
<div className="text-sm text-muted-foreground">{t('Go to note')}</div>
|
|
</div>
|
|
</Item>
|
|
)
|
|
}
|
|
|
|
function ProfileItem({
|
|
userId,
|
|
onClick,
|
|
selected
|
|
}: {
|
|
userId: string
|
|
onClick?: () => void
|
|
selected?: boolean
|
|
}) {
|
|
return (
|
|
<div
|
|
className={cn('px-2 hover:bg-accent rounded-md cursor-pointer', selected && 'bg-accent')}
|
|
onClick={onClick}
|
|
>
|
|
<UserItem
|
|
userId={userId}
|
|
className="pointer-events-none"
|
|
hideFollowButton
|
|
showFollowingBadge
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function RelayItem({
|
|
url,
|
|
onClick,
|
|
selected
|
|
}: {
|
|
url: string
|
|
onClick?: () => void
|
|
selected?: boolean
|
|
}) {
|
|
const { t } = useTranslation()
|
|
return (
|
|
<Item onClick={onClick} selected={selected}>
|
|
<div className="size-10 flex justify-center items-center">
|
|
<Server className="text-muted-foreground flex-shrink-0" />
|
|
</div>
|
|
<div className="flex flex-col min-w-0 flex-1">
|
|
<div className="font-semibold truncate">{url}</div>
|
|
<div className="text-sm text-muted-foreground">{t('Go to relay')}</div>
|
|
</div>
|
|
</Item>
|
|
)
|
|
}
|
|
|
|
function ExternalContentItem({
|
|
search,
|
|
onClick,
|
|
selected
|
|
}: {
|
|
search: string
|
|
onClick?: () => void
|
|
selected?: boolean
|
|
}) {
|
|
const { t } = useTranslation()
|
|
return (
|
|
<Item onClick={onClick} selected={selected}>
|
|
<div className="size-10 flex justify-center items-center">
|
|
<MessageSquare className="text-muted-foreground flex-shrink-0" />
|
|
</div>
|
|
<div className="flex flex-col min-w-0 flex-1">
|
|
<div className="font-semibold truncate">{search}</div>
|
|
<div className="text-sm text-muted-foreground">{t('View discussions about this')}</div>
|
|
</div>
|
|
</Item>
|
|
)
|
|
}
|
|
|
|
function NakItem({
|
|
description,
|
|
onClick,
|
|
selected
|
|
}: {
|
|
description: string
|
|
onClick?: () => void
|
|
selected?: boolean
|
|
}) {
|
|
return (
|
|
<Item onClick={onClick} selected={selected}>
|
|
<div className="size-10 flex justify-center items-center">
|
|
<Terminal className="text-muted-foreground flex-shrink-0" />
|
|
</div>
|
|
<div className="flex flex-col min-w-0 flex-1">
|
|
<div className="font-semibold truncate">REQ</div>
|
|
<div className="text-sm text-muted-foreground truncate">{description}</div>
|
|
</div>
|
|
</Item>
|
|
)
|
|
}
|
|
|
|
function Item({
|
|
className,
|
|
children,
|
|
selected,
|
|
...props
|
|
}: HTMLAttributes<HTMLDivElement> & { selected?: boolean }) {
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'flex gap-2 items-center px-2 py-1.5 hover:bg-accent rounded-md cursor-pointer',
|
|
selected ? 'bg-accent' : '',
|
|
className
|
|
)}
|
|
{...props}
|
|
>
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|