You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

142 lines
4.8 KiB

import type { Editor } from '@tiptap/core'
import { formatNpub, userIdToPubkey } from '@/lib/pubkey'
import { cn } from '@/lib/utils'
import { SuggestionKeyDownProps } from '@tiptap/suggestion'
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Nip05 from '../../../Nip05'
import { SimpleUserAvatar } from '../../../UserAvatar'
import { SimpleUsername } from '../../../Username'
import type { PickerSearchMode } from '@/services/mention-event-search.service'
import { NEVENT_NADDR_PICKER_ID } from './constants'
export type MentionListItem = string | { id: string; mode?: PickerSearchMode }
export interface MentionListProps {
items: MentionListItem[]
command: (payload: { id: string; label?: string; mode?: PickerSearchMode }) => void
/** When provided, selection is controlled by parent (e.g. for plain textarea @-mentions). */
selectedIndex?: number
onSelectIndex?: (index: number) => void
/** When provided, used to detect if we're inside a dialog (for z-index). */
editor?: Editor
}
export interface MentionListHandle {
onKeyDown: (args: SuggestionKeyDownProps) => boolean
}
const MentionList = forwardRef<MentionListHandle, MentionListProps>((props, ref) => {
const { t } = useTranslation()
const items = props.items ?? []
const inDialog = Boolean(props.editor?.view?.dom?.closest?.('[role="dialog"]'))
const [internalIndex, setInternalIndex] = useState<number>(0)
const isControlled = props.selectedIndex !== undefined
const selectedIndex = isControlled ? props.selectedIndex! : internalIndex
const setSelectedIndex = isControlled ? (n: number) => props.onSelectIndex?.(n) : setInternalIndex
const getItemId = (item: MentionListItem): string =>
typeof item === 'string' ? item : item.id
const getItemMode = (item: MentionListItem): PickerSearchMode | undefined =>
typeof item === 'object' && item && 'mode' in item ? item.mode : undefined
const selectItem = (index: number) => {
const item = items[index]
if (item) {
const id = getItemId(item)
const label =
id === NEVENT_NADDR_PICKER_ID
? t('Search for event or address…')
: formatNpub(id)
props.command({ id, label, mode: getItemMode(item) })
}
}
const upHandler = () => {
if (!items.length) return
setSelectedIndex((selectedIndex + items.length - 1) % items.length)
}
const downHandler = () => {
if (!items.length) return
setSelectedIndex((selectedIndex + 1) % items.length)
}
const enterHandler = () => {
selectItem(selectedIndex)
}
useEffect(() => {
if (!isControlled) {
setInternalIndex(items.length ? 0 : -1)
}
}, [items, isControlled])
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }: SuggestionKeyDownProps) => {
if (event.key === 'ArrowUp') {
upHandler()
return true
}
if (event.key === 'ArrowDown') {
downHandler()
return true
}
if (event.key === 'Enter' && selectedIndex >= 0) {
enterHandler()
return true
}
return false
}
}))
if (!items.length) {
return null
}
return (
<div
className={cn(
'border rounded-lg bg-background pointer-events-auto flex flex-col min-h-0 max-h-[min(85dvh,calc(100dvh-6rem))] max-w-[min(calc(100vw-1.5rem),28rem)] overflow-x-hidden overflow-y-auto overscroll-contain popover-scroll-y',
inDialog ? 'z-[290]' : 'z-[110]'
)}
onWheel={(e: React.WheelEvent) => e.stopPropagation()}
onTouchMove={(e: React.TouchEvent) => e.stopPropagation()}
>
{items.map((item, index) => (
<button
className={cn(
'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',
selectedIndex === index && 'bg-accent text-accent-foreground'
)}
key={getItemId(item)}
onClick={() => selectItem(index)}
onMouseEnter={() => setSelectedIndex(index)}
>
<div className="flex gap-2 w-80 items-center truncate pointer-events-none">
{getItemId(item) === NEVENT_NADDR_PICKER_ID ? (
<span className="text-muted-foreground text-sm">
{t('Search for event or address…')}
</span>
) : (
<>
<SimpleUserAvatar userId={getItemId(item)} deferRemoteAvatar={false} />
<div className="flex-1 w-0">
<SimpleUsername userId={getItemId(item)} className="font-semibold truncate" />
<Nip05 pubkey={userIdToPubkey(getItemId(item))} />
</div>
</>
)}
</div>
</button>
))}
</div>
)
})
MentionList.displayName = 'MentionList'
export default MentionList