index.tsx raw
1 import SearchInput from '@/components/SearchInput'
2 import { useSearchProfiles } from '@/hooks'
3 import { toExternalContent, toNote } from '@/lib/link'
4 import { formatFeedRequest, parseNakReqCommand } from '@/lib/nak-parser'
5 import { randomString } from '@/lib/random'
6 import { normalizeUrl } from '@/lib/url'
7 import { cn } from '@/lib/utils'
8 import { useSecondaryPage } from '@/PageManager'
9 import { useScreenSize } from '@/providers/ScreenSizeProvider'
10 import modalManager from '@/services/modal-manager.service'
11 import { TSearchParams } from '@/types'
12 import { Hash, MessageSquare, Notebook, Search, Server, Terminal } from 'lucide-react'
13 import { nip19 } from 'nostr-tools'
14 import {
15 forwardRef,
16 HTMLAttributes,
17 useCallback,
18 useEffect,
19 useImperativeHandle,
20 useMemo,
21 useRef,
22 useState
23 } from 'react'
24 import { useTranslation } from 'react-i18next'
25 import UserItem, { UserItemSkeleton } from '../UserItem'
26
27 const SearchBar = forwardRef<
28 TSearchBarRef,
29 {
30 input: string
31 setInput: (input: string) => void
32 onSearch: (params: TSearchParams | null) => void
33 }
34 >(({ input, setInput, onSearch }, ref) => {
35 const { t } = useTranslation()
36 const { push } = useSecondaryPage()
37 const { isSmallScreen } = useScreenSize()
38 const [debouncedInput, setDebouncedInput] = useState(input)
39 const { profiles, isFetching: isFetchingProfiles } = useSearchProfiles(debouncedInput, 5)
40 const [searching, setSearching] = useState(false)
41 const [displayList, setDisplayList] = useState(false)
42 const [selectableOptions, setSelectableOptions] = useState<TSearchParams[]>([])
43 const [selectedIndex, setSelectedIndex] = useState(-1)
44 const searchInputRef = useRef<HTMLInputElement>(null)
45 const normalizedUrl = useMemo(() => {
46 if (['w', 'ws', 'ws:', 'ws:/', 'wss', 'wss:', 'wss:/'].includes(input)) {
47 return undefined
48 }
49 if (!input.includes('.')) {
50 return undefined
51 }
52 try {
53 return normalizeUrl(input)
54 } catch {
55 return undefined
56 }
57 }, [input])
58 const id = useMemo(() => `search-${randomString()}`, [])
59
60 useImperativeHandle(ref, () => ({
61 focus: () => {
62 searchInputRef.current?.focus()
63 },
64 blur: () => {
65 searchInputRef.current?.blur()
66 }
67 }))
68
69 useEffect(() => {
70 if (!input) {
71 onSearch(null)
72 }
73 setSelectedIndex(-1)
74 }, [input])
75
76 useEffect(() => {
77 const handler = setTimeout(() => {
78 setDebouncedInput(input)
79 }, 500)
80
81 return () => {
82 clearTimeout(handler)
83 }
84 }, [input])
85
86 const blur = () => {
87 setSearching(false)
88 searchInputRef.current?.blur()
89 }
90
91 const updateSearch = (params: TSearchParams) => {
92 blur()
93
94 if (params.type === 'note') {
95 push(toNote(params.search))
96 } else if (params.type === 'externalContent') {
97 push(toExternalContent(params.search))
98 } else {
99 onSearch(params)
100 }
101 }
102
103 useEffect(() => {
104 const search = input.trim()
105 if (!search) return
106
107 // Check if input is a nak req command
108 const request = parseNakReqCommand(search)
109 if (request) {
110 setSelectableOptions([
111 {
112 type: 'nak',
113 search: formatFeedRequest(request),
114 request,
115 input: search
116 }
117 ])
118 return
119 }
120
121 if (/^[0-9a-f]{64}$/.test(search)) {
122 setSelectableOptions([
123 { type: 'note', search },
124 { type: 'profile', search }
125 ])
126 return
127 }
128
129 try {
130 let id = search
131 if (id.startsWith('nostr:')) {
132 id = id.slice(6)
133 }
134 const { type } = nip19.decode(id)
135 if (['nprofile', 'npub'].includes(type)) {
136 setSelectableOptions([{ type: 'profile', search: id }])
137 return
138 }
139 if (['nevent', 'naddr', 'note'].includes(type)) {
140 setSelectableOptions([{ type: 'note', search: id }])
141 return
142 }
143 } catch {
144 // ignore
145 }
146
147 const hashtag = search.match(/[\p{L}\p{N}\p{M}]+/u)?.[0].toLowerCase() ?? ''
148
149 setSelectableOptions([
150 { type: 'notes', search },
151 ...(normalizedUrl ? [{ type: 'relay', search: normalizedUrl, input: normalizedUrl }] : []),
152 { type: 'externalContent', search, input },
153 { type: 'hashtag', search: hashtag, input: `#${hashtag}` },
154 ...profiles.map((profile) => ({
155 type: 'profile',
156 search: profile.npub,
157 input: profile.username
158 })),
159 ...(profiles.length >= 5 ? [{ type: 'profiles', search }] : [])
160 ] as TSearchParams[])
161 }, [input, debouncedInput, profiles])
162
163 const list = useMemo(() => {
164 if (selectableOptions.length <= 0) {
165 return null
166 }
167
168 return (
169 <>
170 {selectableOptions.map((option, index) => {
171 if (option.type === 'note') {
172 return (
173 <NoteItem
174 key={index}
175 selected={selectedIndex === index}
176 id={option.search}
177 onClick={() => updateSearch(option)}
178 />
179 )
180 }
181 if (option.type === 'profile') {
182 return (
183 <ProfileItem
184 key={index}
185 selected={selectedIndex === index}
186 userId={option.search}
187 onClick={() => updateSearch(option)}
188 />
189 )
190 }
191 if (option.type === 'notes') {
192 return (
193 <NormalItem
194 key={index}
195 selected={selectedIndex === index}
196 search={option.search}
197 onClick={() => updateSearch(option)}
198 />
199 )
200 }
201 if (option.type === 'hashtag') {
202 return (
203 <HashtagItem
204 key={index}
205 selected={selectedIndex === index}
206 hashtag={option.search}
207 onClick={() => updateSearch(option)}
208 />
209 )
210 }
211 if (option.type === 'relay') {
212 return (
213 <RelayItem
214 key={index}
215 selected={selectedIndex === index}
216 url={option.search}
217 onClick={() => updateSearch(option)}
218 />
219 )
220 }
221 if (option.type === 'externalContent') {
222 return (
223 <ExternalContentItem
224 key={index}
225 selected={selectedIndex === index}
226 search={option.search}
227 onClick={() => updateSearch(option)}
228 />
229 )
230 }
231 if (option.type === 'nak') {
232 return (
233 <NakItem
234 key={index}
235 selected={selectedIndex === index}
236 description={option.search}
237 onClick={() => updateSearch(option)}
238 />
239 )
240 }
241 if (option.type === 'profiles') {
242 return (
243 <Item
244 key={index}
245 selected={selectedIndex === index}
246 onClick={() => updateSearch(option)}
247 >
248 <div className="font-semibold">{t('Show more...')}</div>
249 </Item>
250 )
251 }
252 return null
253 })}
254 {isFetchingProfiles && profiles.length < 5 && (
255 <div className="px-2">
256 <UserItemSkeleton hideFollowButton />
257 </div>
258 )}
259 </>
260 )
261 }, [selectableOptions, selectedIndex, isFetchingProfiles, profiles])
262
263 useEffect(() => {
264 setDisplayList(searching && !!input)
265 }, [searching, input])
266
267 useEffect(() => {
268 if (displayList && list) {
269 modalManager.register(id, () => {
270 setDisplayList(false)
271 })
272 } else {
273 modalManager.unregister(id)
274 }
275 }, [displayList, list])
276
277 const handleKeyDown = useCallback(
278 (e: React.KeyboardEvent) => {
279 if (e.key === 'Enter') {
280 e.stopPropagation()
281 if (selectableOptions.length <= 0) {
282 return
283 }
284 onSearch(selectableOptions[selectedIndex >= 0 ? selectedIndex : 0])
285 blur()
286 return
287 }
288
289 if (e.key === 'ArrowDown') {
290 e.preventDefault()
291 if (selectableOptions.length <= 0) {
292 return
293 }
294 setSelectedIndex((prev) => (prev + 1) % selectableOptions.length)
295 return
296 }
297
298 if (e.key === 'ArrowUp') {
299 e.preventDefault()
300 if (selectableOptions.length <= 0) {
301 return
302 }
303 setSelectedIndex((prev) => (prev - 1 + selectableOptions.length) % selectableOptions.length)
304 return
305 }
306
307 if (e.key === 'Escape') {
308 blur()
309 return
310 }
311 },
312 [input, onSearch, selectableOptions, selectedIndex]
313 )
314
315 return (
316 <div className="relative flex gap-1 items-center h-full w-full">
317 {displayList && list && (
318 <>
319 <div
320 className={cn(
321 'bg-surface-background rounded-b-lg shadow-lg z-50',
322 isSmallScreen
323 ? 'fixed top-12 inset-x-0'
324 : 'absolute top-full -translate-y-2 inset-x-0 pt-3.5 pb-1 border px-1'
325 )}
326 onMouseDown={(e) => e.preventDefault()}
327 >
328 <div className="h-fit">{list}</div>
329 </div>
330 <div className="fixed inset-0 w-full h-full" onClick={() => blur()} />
331 </>
332 )}
333 <SearchInput
334 ref={searchInputRef}
335 className={cn(
336 'bg-surface-background shadow-inner h-full border-transparent',
337 searching ? 'z-50' : ''
338 )}
339 placeholder={t('People, keywords, or relays')}
340 value={input}
341 onChange={(e) => setInput(e.target.value)}
342 onKeyDown={handleKeyDown}
343 onFocus={() => setSearching(true)}
344 onBlur={() => setSearching(false)}
345 onQrScan={(value) => {
346 setInput(value)
347 // Automatically search after scanning
348 let id = value
349 if (id.startsWith('nostr:')) {
350 id = id.slice(6)
351 }
352 try {
353 const { type } = nip19.decode(id)
354 if (['nprofile', 'npub'].includes(type)) {
355 updateSearch({ type: 'profile', search: id })
356 return
357 }
358 if (['nevent', 'naddr', 'note'].includes(type)) {
359 updateSearch({ type: 'note', search: id })
360 return
361 }
362 } catch {
363 // Not a valid nip19 identifier, just set input
364 }
365 }}
366 />
367 </div>
368 )
369 })
370 SearchBar.displayName = 'SearchBar'
371 export default SearchBar
372
373 export type TSearchBarRef = {
374 focus: () => void
375 blur: () => void
376 }
377
378 function NormalItem({
379 search,
380 onClick,
381 selected
382 }: {
383 search: string
384 onClick?: () => void
385 selected?: boolean
386 }) {
387 const { t } = useTranslation()
388 return (
389 <Item onClick={onClick} selected={selected}>
390 <div className="size-10 flex justify-center items-center">
391 <Search className="text-muted-foreground flex-shrink-0" />
392 </div>
393 <div className="flex flex-col min-w-0 flex-1">
394 <div className="font-semibold truncate">{search}</div>
395 <div className="text-sm text-muted-foreground">{t('Search for notes')}</div>
396 </div>
397 </Item>
398 )
399 }
400
401 function HashtagItem({
402 hashtag,
403 onClick,
404 selected
405 }: {
406 hashtag: string
407 onClick?: () => void
408 selected?: boolean
409 }) {
410 const { t } = useTranslation()
411 return (
412 <Item onClick={onClick} selected={selected}>
413 <div className="size-10 flex justify-center items-center">
414 <Hash className="text-muted-foreground flex-shrink-0" />
415 </div>
416 <div className="flex flex-col min-w-0 flex-1">
417 <div className="font-semibold truncate">#{hashtag}</div>
418 <div className="text-sm text-muted-foreground">{t('Search for hashtag')}</div>
419 </div>
420 </Item>
421 )
422 }
423
424 function NoteItem({
425 id,
426 onClick,
427 selected
428 }: {
429 id: string
430 onClick?: () => void
431 selected?: boolean
432 }) {
433 const { t } = useTranslation()
434 return (
435 <Item onClick={onClick} selected={selected}>
436 <div className="size-10 flex justify-center items-center">
437 <Notebook className="text-muted-foreground flex-shrink-0" />
438 </div>
439 <div className="flex flex-col min-w-0 flex-1">
440 <div className="font-semibold truncate font-mono text-sm">{id}</div>
441 <div className="text-sm text-muted-foreground">{t('Go to note')}</div>
442 </div>
443 </Item>
444 )
445 }
446
447 function ProfileItem({
448 userId,
449 onClick,
450 selected
451 }: {
452 userId: string
453 onClick?: () => void
454 selected?: boolean
455 }) {
456 return (
457 <div
458 className={cn('px-2 hover:bg-accent rounded-md cursor-pointer', selected && 'bg-accent')}
459 onClick={onClick}
460 >
461 <UserItem
462 userId={userId}
463 className="pointer-events-none"
464 hideFollowButton
465 showFollowingBadge
466 />
467 </div>
468 )
469 }
470
471 function RelayItem({
472 url,
473 onClick,
474 selected
475 }: {
476 url: string
477 onClick?: () => void
478 selected?: boolean
479 }) {
480 const { t } = useTranslation()
481 return (
482 <Item onClick={onClick} selected={selected}>
483 <div className="size-10 flex justify-center items-center">
484 <Server className="text-muted-foreground flex-shrink-0" />
485 </div>
486 <div className="flex flex-col min-w-0 flex-1">
487 <div className="font-semibold truncate">{url}</div>
488 <div className="text-sm text-muted-foreground">{t('Go to relay')}</div>
489 </div>
490 </Item>
491 )
492 }
493
494 function ExternalContentItem({
495 search,
496 onClick,
497 selected
498 }: {
499 search: string
500 onClick?: () => void
501 selected?: boolean
502 }) {
503 const { t } = useTranslation()
504 return (
505 <Item onClick={onClick} selected={selected}>
506 <div className="size-10 flex justify-center items-center">
507 <MessageSquare className="text-muted-foreground flex-shrink-0" />
508 </div>
509 <div className="flex flex-col min-w-0 flex-1">
510 <div className="font-semibold truncate">{search}</div>
511 <div className="text-sm text-muted-foreground">{t('View discussions about this')}</div>
512 </div>
513 </Item>
514 )
515 }
516
517 function NakItem({
518 description,
519 onClick,
520 selected
521 }: {
522 description: string
523 onClick?: () => void
524 selected?: boolean
525 }) {
526 return (
527 <Item onClick={onClick} selected={selected}>
528 <div className="size-10 flex justify-center items-center">
529 <Terminal className="text-muted-foreground flex-shrink-0" />
530 </div>
531 <div className="flex flex-col min-w-0 flex-1">
532 <div className="font-semibold truncate">REQ</div>
533 <div className="text-sm text-muted-foreground truncate">{description}</div>
534 </div>
535 </Item>
536 )
537 }
538
539 function Item({
540 className,
541 children,
542 selected,
543 ...props
544 }: HTMLAttributes<HTMLDivElement> & { selected?: boolean }) {
545 return (
546 <div
547 className={cn(
548 'flex gap-2 items-center px-2 py-1.5 hover:bg-accent rounded-md cursor-pointer',
549 selected ? 'bg-accent' : '',
550 className
551 )}
552 {...props}
553 >
554 {children}
555 </div>
556 )
557 }
558