MentionList.tsx raw

   1  import FollowingBadge from '@/components/FollowingBadge'
   2  import { ScrollArea } from '@/components/ui/scroll-area'
   3  import { Pubkey } from '@/domain'
   4  import { cn } from '@/lib/utils'
   5  import { SuggestionKeyDownProps } from '@tiptap/suggestion'
   6  import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
   7  import Nip05 from '../../../Nip05'
   8  import { SimpleUserAvatar } from '../../../UserAvatar'
   9  import { SimpleUsername } from '../../../Username'
  10  
  11  export interface MentionListProps {
  12    items: string[]
  13    command: (payload: { id: string; label?: string }) => void
  14  }
  15  
  16  export interface MentionListHandle {
  17    onKeyDown: (args: SuggestionKeyDownProps) => boolean
  18  }
  19  
  20  const MentionList = forwardRef<MentionListHandle, MentionListProps>((props, ref) => {
  21    const [selectedIndex, setSelectedIndex] = useState<number>(0)
  22  
  23    const selectItem = (index: number) => {
  24      const item = props.items[index]
  25  
  26      if (item) {
  27        props.command({ id: item, label: Pubkey.tryFromString(item)?.formatNpub(12) ?? item.slice(0, 12) })
  28      }
  29    }
  30  
  31    const upHandler = () => {
  32      setSelectedIndex((selectedIndex + props.items.length - 1) % props.items.length)
  33    }
  34  
  35    const downHandler = () => {
  36      setSelectedIndex((selectedIndex + 1) % props.items.length)
  37    }
  38  
  39    const enterHandler = () => {
  40      selectItem(selectedIndex)
  41    }
  42  
  43    useEffect(() => {
  44      setSelectedIndex(props.items.length ? 0 : -1)
  45    }, [props.items])
  46  
  47    useImperativeHandle(ref, () => ({
  48      onKeyDown: ({ event }: SuggestionKeyDownProps) => {
  49        if (event.key === 'ArrowUp') {
  50          upHandler()
  51          return true
  52        }
  53  
  54        if (event.key === 'ArrowDown') {
  55          downHandler()
  56          return true
  57        }
  58  
  59        if (event.key === 'Enter' && selectedIndex >= 0) {
  60          enterHandler()
  61          return true
  62        }
  63  
  64        return false
  65      }
  66    }))
  67  
  68    if (!props.items?.length) {
  69      return null
  70    }
  71  
  72    return (
  73      <ScrollArea
  74        className="border rounded-lg bg-background z-50 pointer-events-auto flex flex-col max-h-80 overflow-y-auto"
  75        onWheel={(e) => e.stopPropagation()}
  76        onTouchMove={(e) => e.stopPropagation()}
  77      >
  78        {props.items.map((item, index) => (
  79          <button
  80            className={cn(
  81              'cursor-pointer text-start items-center m-1 p-2 outline-none transition-colors [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 rounded-md',
  82              selectedIndex === index && 'bg-accent text-accent-foreground'
  83            )}
  84            key={item}
  85            onClick={() => selectItem(index)}
  86            onMouseEnter={() => setSelectedIndex(index)}
  87          >
  88            <div className="flex gap-2 w-80 items-center truncate pointer-events-none">
  89              <SimpleUserAvatar userId={item} />
  90              <div className="flex-1 w-0">
  91                <div className="flex items-center gap-2">
  92                  <SimpleUsername userId={item} className="font-semibold truncate" />
  93                  <FollowingBadge userId={item} />
  94                </div>
  95                <Nip05 pubkey={Pubkey.tryFromString(item)?.hex ?? item} />
  96              </div>
  97            </div>
  98          </button>
  99        ))}
 100      </ScrollArea>
 101    )
 102  })
 103  MentionList.displayName = 'MentionList'
 104  export default MentionList
 105