fix: make mentions list scrollable
This commit is contained in:
@@ -1,11 +1,19 @@
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
import { Drawer, DrawerContent, DrawerOverlay } from '@/components/ui/drawer'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
import { useMuteList } from '@/providers/MuteListProvider'
|
import { useMuteList } from '@/providers/MuteListProvider'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
|
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||||
import client from '@/services/client.service'
|
import client from '@/services/client.service'
|
||||||
import { Check } from 'lucide-react'
|
import { Check } from 'lucide-react'
|
||||||
import { Event, nip19 } from 'nostr-tools'
|
import { Event, nip19 } from 'nostr-tools'
|
||||||
import { HTMLAttributes, useEffect, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { SimpleUserAvatar } from '../UserAvatar'
|
import { SimpleUserAvatar } from '../UserAvatar'
|
||||||
import { SimpleUsername } from '../Username'
|
import { SimpleUsername } from '../Username'
|
||||||
@@ -22,6 +30,8 @@ export default function Mentions({
|
|||||||
parentEvent?: Event
|
parentEvent?: Event
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const { isSmallScreen } = useScreenSize()
|
||||||
|
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
|
||||||
const { pubkey } = useNostr()
|
const { pubkey } = useNostr()
|
||||||
const { mutePubkeySet } = useMuteList()
|
const { mutePubkeySet } = useMuteList()
|
||||||
const [potentialMentions, setPotentialMentions] = useState<string[]>([])
|
const [potentialMentions, setPotentialMentions] = useState<string[]>([])
|
||||||
@@ -56,26 +66,12 @@ export default function Mentions({
|
|||||||
setMentions(newMentions)
|
setMentions(newMentions)
|
||||||
}, [potentialMentions, removedPubkeys])
|
}, [potentialMentions, removedPubkeys])
|
||||||
|
|
||||||
return (
|
const items = useMemo(() => {
|
||||||
<Popover>
|
return potentialMentions.map((_, index) => {
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
className="px-3"
|
|
||||||
variant="ghost"
|
|
||||||
disabled={potentialMentions.length === 0}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{t('Mentions')}{' '}
|
|
||||||
{potentialMentions.length > 0 && `(${mentions.length}/${potentialMentions.length})`}
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-52 p-0 py-1">
|
|
||||||
<div className="space-y-1">
|
|
||||||
{potentialMentions.map((_, index) => {
|
|
||||||
const pubkey = potentialMentions[potentialMentions.length - 1 - index]
|
const pubkey = potentialMentions[potentialMentions.length - 1 - index]
|
||||||
const isParentPubkey = pubkey === parentEventPubkey
|
const isParentPubkey = pubkey === parentEventPubkey
|
||||||
return (
|
return (
|
||||||
<PopoverCheckboxItem
|
<MenuItem
|
||||||
key={`${pubkey}-${index}`}
|
key={`${pubkey}-${index}`}
|
||||||
checked={isParentPubkey ? true : mentions.includes(pubkey)}
|
checked={isParentPubkey ? true : mentions.includes(pubkey)}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
@@ -96,39 +92,101 @@ export default function Mentions({
|
|||||||
className="font-semibold text-sm truncate"
|
className="font-semibold text-sm truncate"
|
||||||
skeletonClassName="h-3"
|
skeletonClassName="h-3"
|
||||||
/>
|
/>
|
||||||
</PopoverCheckboxItem>
|
</MenuItem>
|
||||||
)
|
)
|
||||||
})}
|
})
|
||||||
|
}, [potentialMentions, parentEventPubkey, mentions])
|
||||||
|
|
||||||
|
if (isSmallScreen) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
className="px-3"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={potentialMentions.length === 0}
|
||||||
|
onClick={() => setIsDrawerOpen(true)}
|
||||||
|
>
|
||||||
|
{t('Mentions')}{' '}
|
||||||
|
{potentialMentions.length > 0 && `(${mentions.length}/${potentialMentions.length})`}
|
||||||
|
</Button>
|
||||||
|
<Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
|
||||||
|
<DrawerOverlay onClick={() => setIsDrawerOpen(false)} />
|
||||||
|
<DrawerContent className="max-h-[80vh]" hideOverlay>
|
||||||
|
<div
|
||||||
|
className="overflow-y-auto overscroll-contain py-2"
|
||||||
|
style={{ touchAction: 'pan-y' }}
|
||||||
|
>
|
||||||
|
{items}
|
||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</DrawerContent>
|
||||||
</Popover>
|
</Drawer>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function PopoverCheckboxItem({
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
className="px-3"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={potentialMentions.length === 0}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{t('Mentions')}{' '}
|
||||||
|
{potentialMentions.length > 0 && `(${mentions.length}/${potentialMentions.length})`}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="max-w-96 max-h-[50vh]" showScrollButtons>
|
||||||
|
{items}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuItem({
|
||||||
children,
|
children,
|
||||||
checked,
|
checked,
|
||||||
onCheckedChange,
|
|
||||||
disabled,
|
disabled,
|
||||||
...props
|
onCheckedChange
|
||||||
}: HTMLAttributes<HTMLButtonElement> & {
|
}: {
|
||||||
disabled?: boolean
|
children: React.ReactNode
|
||||||
checked: boolean
|
checked: boolean
|
||||||
onCheckedChange?: (checked: boolean) => void
|
disabled?: boolean
|
||||||
|
onCheckedChange: (checked: boolean) => void
|
||||||
}) {
|
}) {
|
||||||
|
const { isSmallScreen } = useScreenSize()
|
||||||
|
|
||||||
|
if (isSmallScreen) {
|
||||||
return (
|
return (
|
||||||
<div className="px-1">
|
<div
|
||||||
<Button
|
onClick={() => {
|
||||||
variant="ghost"
|
if (disabled) return
|
||||||
className="w-full rounded-md justify-start px-2"
|
onCheckedChange(!checked)
|
||||||
onClick={() => onCheckedChange?.(!checked)}
|
}}
|
||||||
disabled={disabled}
|
className={cn(
|
||||||
{...props}
|
'flex items-center gap-2 px-4 py-3 clickable',
|
||||||
|
disabled ? 'opacity-50 pointer-events-none' : ''
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{checked ? <Check className="shrink-0" /> : <div className="w-4 shrink-0" />}
|
<div className="flex items-center justify-center size-4 shrink-0">
|
||||||
{children}
|
{checked && <Check className="size-4" />}
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
checked={checked}
|
||||||
|
disabled={disabled}
|
||||||
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
onCheckedChange={onCheckedChange}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const DropdownMenu = ({
|
|||||||
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false)
|
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false)
|
||||||
const isControlled = controlledOpen !== undefined
|
const isControlled = controlledOpen !== undefined
|
||||||
const open = isControlled ? controlledOpen : uncontrolledOpen
|
const open = isControlled ? controlledOpen : uncontrolledOpen
|
||||||
|
const backdropRef = React.useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const handleOpenChange = React.useCallback(
|
const handleOpenChange = React.useCallback(
|
||||||
(newOpen: boolean) => {
|
(newOpen: boolean) => {
|
||||||
@@ -24,11 +25,29 @@ const DropdownMenu = ({
|
|||||||
[isControlled, controlledOnOpenChange]
|
[isControlled, controlledOnOpenChange]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
const preventScroll = (e: Event) => e.preventDefault()
|
||||||
|
|
||||||
|
document.addEventListener('wheel', preventScroll, { passive: false })
|
||||||
|
document.addEventListener('touchmove', preventScroll, { passive: false })
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('wheel', preventScroll)
|
||||||
|
document.removeEventListener('touchmove', preventScroll)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{open &&
|
{open &&
|
||||||
createPortal(
|
createPortal(
|
||||||
<div className="fixed inset-0 z-50" onClick={() => handleOpenChange(false)} />,
|
<div
|
||||||
|
ref={backdropRef}
|
||||||
|
className="fixed inset-0 z-50"
|
||||||
|
onClick={() => handleOpenChange(false)}
|
||||||
|
/>,
|
||||||
document.body
|
document.body
|
||||||
)}
|
)}
|
||||||
<DropdownMenuPrimitive.Root
|
<DropdownMenuPrimitive.Root
|
||||||
@@ -224,6 +243,7 @@ const DropdownMenuContent = React.forwardRef<
|
|||||||
ref={scrollAreaRef}
|
ref={scrollAreaRef}
|
||||||
className={cn('p-1 overflow-y-auto', className)}
|
className={cn('p-1 overflow-y-auto', className)}
|
||||||
onScroll={checkScrollability}
|
onScroll={checkScrollability}
|
||||||
|
onWheel={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user