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