Revert "refactor: responsive menu"

This reverts commit 1dc18645b2.
This commit is contained in:
codytseng
2025-11-29 12:06:13 +08:00
parent 019dbc073c
commit 3eb018f39f
16 changed files with 1097 additions and 1324 deletions

View File

@@ -1,15 +1,18 @@
import { useSecondaryPage } from '@/PageManager'
import { Button } from '@/components/ui/button'
import { Drawer, DrawerContent, DrawerOverlay } from '@/components/ui/drawer'
import {
ResponsiveMenu,
ResponsiveMenuContent,
ResponsiveMenuItem,
ResponsiveMenuTrigger
} from '@/components/ui/responsive-menu'
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { toExternalContent } from '@/lib/link'
import { truncateUrl } from '@/lib/url'
import { cn } from '@/lib/utils'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { ExternalLink as ExternalLinkIcon, MessageSquare } from 'lucide-react'
import { useMemo } from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
export default function ExternalLink({
@@ -22,9 +25,29 @@ export default function ExternalLink({
justOpenLink?: boolean
}) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const { push } = useSecondaryPage()
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
const displayUrl = useMemo(() => truncateUrl(url), [url])
const handleOpenLink = (e: React.MouseEvent) => {
e.stopPropagation()
if (isSmallScreen) {
setIsDrawerOpen(false)
}
window.open(url, '_blank', 'noreferrer')
}
const handleViewDiscussions = (e: React.MouseEvent) => {
e.stopPropagation()
if (isSmallScreen) {
setIsDrawerOpen(false)
setTimeout(() => push(toExternalContent(url)), 100) // wait for drawer to close
return
}
push(toExternalContent(url))
}
if (justOpenLink) {
return (
<a
@@ -39,38 +62,74 @@ export default function ExternalLink({
)
}
const handleOpenLink = (e?: React.MouseEvent) => {
e?.stopPropagation()
window.open(url, '_blank', 'noreferrer')
}
const trigger = (
<span
className={cn('cursor-pointer text-primary hover:underline', className)}
onClick={(e) => {
e.stopPropagation()
if (isSmallScreen) {
setIsDrawerOpen(true)
}
}}
title={url}
>
{displayUrl}
</span>
)
const handleViewDiscussions = (e?: React.MouseEvent) => {
e?.stopPropagation()
setTimeout(() => push(toExternalContent(url)), 100) // wait for menu to close
if (isSmallScreen) {
return (
<>
{trigger}
<Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
<DrawerOverlay
onClick={(e) => {
e.stopPropagation()
setIsDrawerOpen(false)
}}
/>
<DrawerContent hideOverlay>
<div className="py-2">
<Button
onClick={handleOpenLink}
className="w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5"
variant="ghost"
>
<ExternalLinkIcon />
{t('Open link')}
</Button>
<Button
onClick={handleViewDiscussions}
className="w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5"
variant="ghost"
>
<MessageSquare />
{t('View Nostr discussions')}
</Button>
</div>
</DrawerContent>
</Drawer>
</>
)
}
return (
<div className="inline-block" onClick={(e) => e.stopPropagation()}>
<ResponsiveMenu>
<ResponsiveMenuTrigger asChild>
<span
className={cn('cursor-pointer text-primary hover:underline', className)}
title={url}
>
{displayUrl}
</span>
</ResponsiveMenuTrigger>
<ResponsiveMenuContent align="start">
<ResponsiveMenuItem onClick={handleOpenLink}>
<ExternalLinkIcon />
{t('Open link')}
</ResponsiveMenuItem>
<ResponsiveMenuItem onClick={handleViewDiscussions}>
<MessageSquare />
{t('View Nostr discussions')}
</ResponsiveMenuItem>
</ResponsiveMenuContent>
</ResponsiveMenu>
</div>
<DropdownMenu>
<DropdownMenuTrigger>
<span className={cn('cursor-pointer text-primary hover:underline', className)} title={url}>
{displayUrl}
</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" onClick={(e) => e.stopPropagation()}>
<DropdownMenuItem onClick={handleOpenLink}>
<ExternalLinkIcon />
{t('Open link')}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleViewDiscussions}>
<MessageSquare />
{t('View Nostr discussions')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -1,12 +1,14 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer'
import {
ResponsiveMenu,
ResponsiveMenuContent,
ResponsiveMenuItem,
ResponsiveMenuTrigger
} from '@/components/ui/responsive-menu'
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { TRelaySet } from '@/types'
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
@@ -22,6 +24,7 @@ import {
} from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import DrawerMenuItem from '../DrawerMenuItem'
import RelayUrls from './RelayUrl'
import { useRelaySetsSettingComponent } from './provider'
@@ -136,9 +139,16 @@ function RelayUrlsExpandToggle({
function RelaySetOptions({ relaySet }: { relaySet: TRelaySet }) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const { deleteRelaySet } = useFavoriteRelays()
const { setRenamingRelaySetId } = useRelaySetsSettingComponent()
const trigger = (
<Button variant="ghost" size="icon">
<EllipsisVertical />
</Button>
)
const rename = () => {
setRenamingRelaySetId(relaySet.id)
}
@@ -149,30 +159,53 @@ function RelaySetOptions({ relaySet }: { relaySet: TRelaySet }) {
)
}
if (isSmallScreen) {
return (
<Drawer>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent>
<div className="py-2">
<DrawerMenuItem onClick={rename}>
<Edit />
{t('Rename')}
</DrawerMenuItem>
<DrawerMenuItem onClick={copyShareLink}>
<Link />
{t('Copy share link')}
</DrawerMenuItem>
<DrawerMenuItem
className="text-destructive focus:text-destructive"
onClick={() => deleteRelaySet(relaySet.id)}
>
<Trash2 />
{t('Delete')}
</DrawerMenuItem>
</div>
</DrawerContent>
</Drawer>
)
}
return (
<ResponsiveMenu>
<ResponsiveMenuTrigger asChild>
<Button variant="ghost" size="icon">
<EllipsisVertical />
</Button>
</ResponsiveMenuTrigger>
<ResponsiveMenuContent>
<ResponsiveMenuItem onClick={rename}>
<DropdownMenu>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={rename}>
<Edit />
{t('Rename')}
</ResponsiveMenuItem>
<ResponsiveMenuItem onClick={copyShareLink}>
</DropdownMenuItem>
<DropdownMenuItem onClick={copyShareLink}>
<Link />
{t('Copy share link')}
</ResponsiveMenuItem>
<ResponsiveMenuItem
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => deleteRelaySet(relaySet.id)}
>
<Trash2 />
{t('Delete')}
</ResponsiveMenuItem>
</ResponsiveMenuContent>
</ResponsiveMenu>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -1,12 +1,14 @@
import { Button } from '@/components/ui/button'
import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer'
import {
ResponsiveMenu,
ResponsiveMenuContent,
ResponsiveMenuItem,
ResponsiveMenuTrigger
} from '@/components/ui/responsive-menu'
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { useMuteList } from '@/providers/MuteListProvider'
import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { BellOff, Loader } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -14,6 +16,7 @@ import { toast } from 'sonner'
export default function MuteButton({ pubkey }: { pubkey: string }) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const { pubkey: accountPubkey, checkLogin } = useNostr()
const { mutePubkeySet, changing, mutePubkeyPrivately, mutePubkeyPublicly, unmutePubkey } =
useMuteList()
@@ -71,34 +74,63 @@ export default function MuteButton({ pubkey }: { pubkey: string }) {
)
}
return (
<ResponsiveMenu>
<ResponsiveMenuTrigger asChild>
<Button
variant="destructive"
className="w-20 min-w-20 rounded-full"
disabled={updating || changing}
>
{updating ? <Loader className="animate-spin" /> : t('Mute')}
</Button>
</ResponsiveMenuTrigger>
const trigger = (
<Button
variant="destructive"
className="w-20 min-w-20 rounded-full"
disabled={updating || changing}
>
{updating ? <Loader className="animate-spin" /> : t('Mute')}
</Button>
)
<ResponsiveMenuContent>
<ResponsiveMenuItem
if (isSmallScreen) {
return (
<Drawer>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent>
<div className="py-2">
<Button
className="w-full p-6 justify-start text-destructive text-lg gap-4 [&_svg]:size-5 focus:text-destructive"
variant="ghost"
onClick={(e) => handleMute(e, true)}
disabled={updating || changing}
>
{updating ? <Loader className="animate-spin" /> : t('Mute user privately')}
</Button>
<Button
className="w-full p-6 justify-start text-destructive text-lg gap-4 [&_svg]:size-5 focus:text-destructive"
variant="ghost"
onClick={(e) => handleMute(e, false)}
disabled={updating || changing}
>
{updating ? <Loader className="animate-spin" /> : t('Mute user publicly')}
</Button>
</div>
</DrawerContent>
</Drawer>
)
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
onClick={(e) => handleMute(e, true)}
className="text-destructive focus:text-destructive"
>
<BellOff />
{t('Mute user privately')}
</ResponsiveMenuItem>
<ResponsiveMenuItem
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => handleMute(e, false)}
className="text-destructive focus:text-destructive"
>
<BellOff />
{t('Mute user publicly')}
</ResponsiveMenuItem>
</ResponsiveMenuContent>
</ResponsiveMenu>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,64 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { cn } from '@/lib/utils'
import { MenuAction } from './useMenuActions'
interface DesktopMenuProps {
menuActions: MenuAction[]
trigger: React.ReactNode
}
export function DesktopMenu({ menuActions, trigger }: DesktopMenuProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
<DropdownMenuContent className="max-h-[50vh] overflow-y-auto">
{menuActions.map((action, index) => {
const Icon = action.icon
return (
<div key={index}>
{action.separator && index > 0 && <DropdownMenuSeparator />}
{action.subMenu ? (
<DropdownMenuSub>
<DropdownMenuSubTrigger className={action.className}>
<Icon />
{action.label}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent
className="max-h-[50vh] overflow-y-auto"
showScrollButtons
>
{action.subMenu.map((subAction, subIndex) => (
<div key={subIndex}>
{subAction.separator && subIndex > 0 && <DropdownMenuSeparator />}
<DropdownMenuItem
onClick={subAction.onClick}
className={cn('w-64', subAction.className)}
>
{subAction.label}
</DropdownMenuItem>
</div>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
) : (
<DropdownMenuItem onClick={action.onClick} className={action.className}>
<Icon />
{action.label}
</DropdownMenuItem>
)}
</div>
)
})}
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,79 @@
import { Button } from '@/components/ui/button'
import { Drawer, DrawerContent, DrawerOverlay } from '@/components/ui/drawer'
import { ArrowLeft } from 'lucide-react'
import { MenuAction, SubMenuAction } from './useMenuActions'
interface MobileMenuProps {
menuActions: MenuAction[]
trigger: React.ReactNode
isDrawerOpen: boolean
setIsDrawerOpen: (open: boolean) => void
showSubMenu: boolean
activeSubMenu: SubMenuAction[]
subMenuTitle: string
closeDrawer: () => void
goBackToMainMenu: () => void
}
export function MobileMenu({
menuActions,
trigger,
isDrawerOpen,
setIsDrawerOpen,
showSubMenu,
activeSubMenu,
subMenuTitle,
closeDrawer,
goBackToMainMenu
}: MobileMenuProps) {
return (
<>
{trigger}
<Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
<DrawerOverlay onClick={closeDrawer} />
<DrawerContent hideOverlay className="max-h-[80vh]">
<div className="overflow-y-auto overscroll-contain py-2" style={{ touchAction: 'pan-y' }}>
{!showSubMenu ? (
menuActions.map((action, index) => {
const Icon = action.icon
return (
<Button
key={index}
onClick={action.onClick}
className={`w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5 ${action.className || ''}`}
variant="ghost"
>
<Icon />
{action.label}
</Button>
)
})
) : (
<>
<Button
onClick={goBackToMainMenu}
className="w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5 mb-2"
variant="ghost"
>
<ArrowLeft />
{subMenuTitle}
</Button>
<div className="border-t border-border mb-2" />
{activeSubMenu.map((subAction, index) => (
<Button
key={index}
onClick={subAction.onClick}
className={`w-full p-6 justify-start text-lg gap-4 ${subAction.className || ''}`}
variant="ghost"
>
{subAction.label}
</Button>
))}
</>
)}
</div>
</DrawerContent>
</Drawer>
</>
)
}

View File

@@ -1,76 +1,72 @@
import {
ResponsiveMenu,
ResponsiveMenuContent,
ResponsiveMenuItem,
ResponsiveMenuSeparator,
ResponsiveMenuSub,
ResponsiveMenuSubContent,
ResponsiveMenuSubTrigger,
ResponsiveMenuTrigger
} from '@/components/ui/responsive-menu'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Ellipsis } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useState } from 'react'
import { DesktopMenu } from './DesktopMenu'
import { MobileMenu } from './MobileMenu'
import RawEventDialog from './RawEventDialog'
import ReportDialog from './ReportDialog'
import { useMenuActions } from './useMenuActions'
import { SubMenuAction, useMenuActions } from './useMenuActions'
export default function NoteOptions({ event, className }: { event: Event; className?: string }) {
const { isSmallScreen } = useScreenSize()
const [isRawEventDialogOpen, setIsRawEventDialogOpen] = useState(false)
const [isReportDialogOpen, setIsReportDialogOpen] = useState(false)
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
const [showSubMenu, setShowSubMenu] = useState(false)
const [activeSubMenu, setActiveSubMenu] = useState<SubMenuAction[]>([])
const [subMenuTitle, setSubMenuTitle] = useState('')
const closeDrawer = () => {
setIsDrawerOpen(false)
setShowSubMenu(false)
}
const goBackToMainMenu = () => {
setShowSubMenu(false)
}
const showSubMenuActions = (subMenu: SubMenuAction[], title: string) => {
setActiveSubMenu(subMenu)
setSubMenuTitle(title)
setShowSubMenu(true)
}
const menuActions = useMenuActions({
event,
closeDrawer,
showSubMenuActions,
setIsRawEventDialogOpen,
setIsReportDialogOpen
setIsReportDialogOpen,
isSmallScreen
})
const trigger = (
<button
className="flex items-center text-muted-foreground hover:text-foreground pl-2 h-full"
onClick={() => setIsDrawerOpen(true)}
>
<Ellipsis />
</button>
)
return (
<div className={className} onClick={(e) => e.stopPropagation()}>
<ResponsiveMenu>
<ResponsiveMenuTrigger asChild>
<button className="flex items-center text-muted-foreground hover:text-foreground pl-2 h-full">
<Ellipsis />
</button>
</ResponsiveMenuTrigger>
<ResponsiveMenuContent showScrollButtons>
{menuActions.map((action, index) => {
const Icon = action.icon
return (
<div key={index}>
{action.separator && index > 0 && <ResponsiveMenuSeparator />}
{action.subMenu ? (
<ResponsiveMenuSub>
<ResponsiveMenuSubTrigger className={action.className}>
<Icon />
{action.label}
</ResponsiveMenuSubTrigger>
<ResponsiveMenuSubContent showScrollButtons>
{action.subMenu.map((subAction, subIndex) => (
<div key={subIndex}>
{subAction.separator && subIndex > 0 && <ResponsiveMenuSeparator />}
<ResponsiveMenuItem
onClick={subAction.onClick}
className={subAction.className}
>
{subAction.label}
</ResponsiveMenuItem>
</div>
))}
</ResponsiveMenuSubContent>
</ResponsiveMenuSub>
) : (
<ResponsiveMenuItem onClick={action.onClick} className={action.className}>
<Icon />
{action.label}
</ResponsiveMenuItem>
)}
</div>
)
})}
</ResponsiveMenuContent>
</ResponsiveMenu>
{isSmallScreen ? (
<MobileMenu
menuActions={menuActions}
trigger={trigger}
isDrawerOpen={isDrawerOpen}
setIsDrawerOpen={setIsDrawerOpen}
showSubMenu={showSubMenu}
activeSubMenu={activeSubMenu}
subMenuTitle={subMenuTitle}
closeDrawer={closeDrawer}
goBackToMainMenu={goBackToMainMenu}
/>
) : (
<DesktopMenu menuActions={menuActions} trigger={trigger} />
)}
<RawEventDialog
event={event}

View File

@@ -44,14 +44,20 @@ export interface MenuAction {
interface UseMenuActionsProps {
event: Event
closeDrawer: () => void
showSubMenuActions: (subMenu: SubMenuAction[], title: string) => void
setIsRawEventDialogOpen: (open: boolean) => void
setIsReportDialogOpen: (open: boolean) => void
isSmallScreen: boolean
}
export function useMenuActions({
event,
closeDrawer,
showSubMenuActions,
setIsRawEventDialogOpen,
setIsReportDialogOpen
setIsReportDialogOpen,
isSmallScreen
}: UseMenuActionsProps) {
const { t } = useTranslation()
const { pubkey, attemptDelete } = useNostr()
@@ -70,6 +76,7 @@ export function useMenuActions({
items.push({
label: <div className="text-left"> {t('Optimal relays')}</div>,
onClick: async () => {
closeDrawer()
const promise = async () => {
const relays = await client.determineTargetRelays(event)
if (relays?.length) {
@@ -100,6 +107,7 @@ export function useMenuActions({
.map((set, index) => ({
label: <div className="text-left truncate">{set.name}</div>,
onClick: async () => {
closeDrawer()
const promise = client.publishEvent(set.relayUrls, event)
toast.promise(promise, {
loading: t('Republishing...'),
@@ -129,6 +137,7 @@ export function useMenuActions({
</div>
),
onClick: async () => {
closeDrawer()
const promise = client.publishEvent([relay], event)
toast.promise(promise, {
loading: t('Republishing...'),
@@ -149,7 +158,7 @@ export function useMenuActions({
}
return items
}, [pubkey, relayUrls, relaySets, event, t])
}, [pubkey, relayUrls, relaySets])
const menuActions: MenuAction[] = useMemo(() => {
const actions: MenuAction[] = [
@@ -158,6 +167,7 @@ export function useMenuActions({
label: t('Copy event ID'),
onClick: () => {
navigator.clipboard.writeText(getNoteBech32Id(event))
closeDrawer()
}
},
{
@@ -165,6 +175,7 @@ export function useMenuActions({
label: t('Copy user ID'),
onClick: () => {
navigator.clipboard.writeText(pubkeyToNpub(event.pubkey) ?? '')
closeDrawer()
}
},
{
@@ -172,12 +183,14 @@ export function useMenuActions({
label: t('Copy share link'),
onClick: () => {
navigator.clipboard.writeText(toNjump(getNoteBech32Id(event)))
closeDrawer()
}
},
{
icon: Code,
label: t('View raw event'),
onClick: () => {
closeDrawer()
setIsRawEventDialogOpen(true)
},
separator: true
@@ -189,7 +202,10 @@ export function useMenuActions({
actions.push({
icon: SatelliteDish,
label: t('Republish to ...'),
subMenu: broadcastSubMenu,
onClick: isSmallScreen
? () => showSubMenuActions(broadcastSubMenu, t('Republish to ...'))
: undefined,
subMenu: isSmallScreen ? undefined : broadcastSubMenu,
separator: true
})
}
@@ -200,6 +216,7 @@ export function useMenuActions({
icon: pinned ? PinOff : Pin,
label: pinned ? t('Unpin from profile') : t('Pin to profile'),
onClick: async () => {
closeDrawer()
await (pinned ? unpin(event) : pin(event))
}
})
@@ -211,6 +228,7 @@ export function useMenuActions({
label: t('Report'),
className: 'text-destructive focus:text-destructive',
onClick: () => {
closeDrawer()
setIsReportDialogOpen(true)
},
separator: true
@@ -223,6 +241,7 @@ export function useMenuActions({
icon: Bell,
label: t('Unmute user'),
onClick: () => {
closeDrawer()
unmutePubkey(event.pubkey)
},
className: 'text-destructive focus:text-destructive',
@@ -234,6 +253,7 @@ export function useMenuActions({
icon: BellOff,
label: t('Mute user privately'),
onClick: () => {
closeDrawer()
mutePubkeyPrivately(event.pubkey)
},
className: 'text-destructive focus:text-destructive',
@@ -243,6 +263,7 @@ export function useMenuActions({
icon: BellOff,
label: t('Mute user publicly'),
onClick: () => {
closeDrawer()
mutePubkeyPublicly(event.pubkey)
},
className: 'text-destructive focus:text-destructive'
@@ -256,6 +277,7 @@ export function useMenuActions({
icon: Trash2,
label: t('Try deleting this note'),
onClick: () => {
closeDrawer()
attemptDelete(event)
},
className: 'text-destructive focus:text-destructive',
@@ -269,16 +291,15 @@ export function useMenuActions({
event,
pubkey,
isMuted,
isSmallScreen,
broadcastSubMenu,
pinnedEventHexIdSet,
closeDrawer,
showSubMenuActions,
setIsRawEventDialogOpen,
setIsReportDialogOpen,
mutePubkeyPrivately,
mutePubkeyPublicly,
unmutePubkey,
unpin,
pin,
attemptDelete
unmutePubkey
])
return menuActions

View File

@@ -1,15 +1,19 @@
import { Button } from '@/components/ui/button'
import { Drawer, DrawerContent, DrawerOverlay } from '@/components/ui/drawer'
import {
ResponsiveMenu,
ResponsiveMenuCheckboxItem,
ResponsiveMenuContent,
ResponsiveMenuTrigger
} from '@/components/ui/responsive-menu'
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { cn } from '@/lib/utils'
import { useMuteList } from '@/providers/MuteListProvider'
import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import client from '@/services/client.service'
import { Check } from 'lucide-react'
import { Event, nip19 } from 'nostr-tools'
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SimpleUserAvatar } from '../UserAvatar'
import { SimpleUsername } from '../Username'
@@ -26,6 +30,8 @@ export default function Mentions({
parentEvent?: Event
}) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
const { pubkey } = useNostr()
const { mutePubkeySet } = useMuteList()
const [potentialMentions, setPotentialMentions] = useState<string[]>([])
@@ -58,11 +64,69 @@ export default function Mentions({
useEffect(() => {
const newMentions = potentialMentions.filter((pubkey) => !removedPubkeys.includes(pubkey))
setMentions(newMentions)
}, [potentialMentions, removedPubkeys, setMentions])
}, [potentialMentions, removedPubkeys])
const items = useMemo(() => {
return potentialMentions.map((_, index) => {
const pubkey = potentialMentions[potentialMentions.length - 1 - index]
const isParentPubkey = pubkey === parentEventPubkey
return (
<MenuItem
key={`${pubkey}-${index}`}
checked={isParentPubkey ? true : mentions.includes(pubkey)}
onCheckedChange={(checked) => {
if (isParentPubkey) {
return
}
if (checked) {
setRemovedPubkeys((pubkeys) => pubkeys.filter((p) => p !== pubkey))
} else {
setRemovedPubkeys((pubkeys) => [...pubkeys, pubkey])
}
}}
disabled={isParentPubkey}
>
<SimpleUserAvatar userId={pubkey} size="small" />
<SimpleUsername
userId={pubkey}
className="font-semibold text-sm truncate"
skeletonClassName="h-3"
/>
</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>
</DrawerContent>
</Drawer>
</>
)
}
return (
<ResponsiveMenu>
<ResponsiveMenuTrigger asChild>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className="px-3"
variant="ghost"
@@ -72,38 +136,57 @@ export default function Mentions({
{t('Mentions')}{' '}
{potentialMentions.length > 0 && `(${mentions.length}/${potentialMentions.length})`}
</Button>
</ResponsiveMenuTrigger>
<ResponsiveMenuContent align="start" className="max-w-96" showScrollButtons>
{potentialMentions.map((_, index) => {
const pubkey = potentialMentions[potentialMentions.length - 1 - index]
const isParentPubkey = pubkey === parentEventPubkey
return (
<ResponsiveMenuCheckboxItem
key={`${pubkey}-${index}`}
checked={isParentPubkey ? true : mentions.includes(pubkey)}
onCheckedChange={(checked) => {
if (isParentPubkey) {
return
}
if (checked) {
setRemovedPubkeys((pubkeys) => pubkeys.filter((p) => p !== pubkey))
} else {
setRemovedPubkeys((pubkeys) => [...pubkeys, pubkey])
}
}}
disabled={isParentPubkey}
>
<SimpleUserAvatar userId={pubkey} size="small" />
<SimpleUsername
userId={pubkey}
className="font-semibold text-sm truncate"
skeletonClassName="h-3"
/>
</ResponsiveMenuCheckboxItem>
)
})}
</ResponsiveMenuContent>
</ResponsiveMenu>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-w-96 max-h-[50vh]" showScrollButtons>
{items}
</DropdownMenuContent>
</DropdownMenu>
)
}
function MenuItem({
children,
checked,
disabled,
onCheckedChange
}: {
children: React.ReactNode
checked: boolean
disabled?: boolean
onCheckedChange: (checked: boolean) => void
}) {
const { isSmallScreen } = useScreenSize()
if (isSmallScreen) {
return (
<div
onClick={() => {
if (disabled) return
onCheckedChange(!checked)
}}
className={cn(
'flex items-center gap-2 px-4 py-3 clickable',
disabled ? 'opacity-50 pointer-events-none' : ''
)}
>
<div className="flex items-center justify-center size-4 shrink-0">
{checked && <Check className="size-4" />}
</div>
{children}
</div>
)
}
return (
<DropdownMenuCheckboxItem
checked={checked}
disabled={disabled}
onSelect={(e) => e.preventDefault()}
onCheckedChange={onCheckedChange}
className="flex items-center gap-2"
>
{children}
</DropdownMenuCheckboxItem>
)
}

View File

@@ -1,17 +1,21 @@
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Drawer, DrawerContent, DrawerOverlay } from '@/components/ui/drawer'
import {
ResponsiveMenu,
ResponsiveMenuCheckboxItem,
ResponsiveMenuContent,
ResponsiveMenuSeparator,
ResponsiveMenuTrigger
} from '@/components/ui/responsive-menu'
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import { isProtectedEvent } from '@/lib/event'
import { simplifyUrl } from '@/lib/url'
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import client from '@/services/client.service'
import { Check } from 'lucide-react'
import { NostrEvent } from 'nostr-tools'
import { Dispatch, SetStateAction, useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -43,6 +47,8 @@ export default function PostRelaySelector({
setAdditionalRelayUrls: Dispatch<SetStateAction<string[]>>
}) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
const { relayUrls } = useCurrentRelays()
const { relaySets, favoriteRelays } = useFavoriteRelays()
const [postTargetItems, setPostTargetItems] = useState<TPostTargetItem[]>([])
@@ -73,10 +79,8 @@ export default function PostRelaySelector({
: simplifyUrl(item.urls[0])
}
}
const hasWriteRelays = postTargetItems.some(
(item: TPostTargetItem) => item.type === 'writeRelays'
)
const relayCount = postTargetItems.reduce((count: number, item: TPostTargetItem) => {
const hasWriteRelays = postTargetItems.some((item) => item.type === 'writeRelays')
const relayCount = postTargetItems.reduce((count, item) => {
if (item.type === 'relay') {
return count + 1
}
@@ -89,7 +93,7 @@ export default function PostRelaySelector({
return t('Optimal relays and {{count}} other relays', { count: relayCount })
}
return t('{{count}} relays', { count: relayCount })
}, [postTargetItems, t])
}, [postTargetItems])
useEffect(() => {
if (openFrom && openFrom.length) {
@@ -104,10 +108,8 @@ export default function PostRelaySelector({
}, [openFrom, parentEventSeenOnRelays])
useEffect(() => {
const isProtected = postTargetItems.every(
(item: TPostTargetItem) => item.type !== 'writeRelays'
)
const relayUrls = postTargetItems.flatMap((item: TPostTargetItem) => {
const isProtectedEvent = postTargetItems.every((item) => item.type !== 'writeRelays')
const relayUrls = postTargetItems.flatMap((item) => {
if (item.type === 'relay') {
return [item.url]
}
@@ -117,26 +119,24 @@ export default function PostRelaySelector({
return []
})
setIsProtectedEvent(isProtected)
setIsProtectedEvent(isProtectedEvent)
setAdditionalRelayUrls(relayUrls)
}, [postTargetItems, setIsProtectedEvent, setAdditionalRelayUrls])
}, [postTargetItems])
const handleWriteRelaysCheckedChange = useCallback((checked: boolean) => {
if (checked) {
setPostTargetItems((prev: TPostTargetItem[]) => [...prev, { type: 'writeRelays' }])
setPostTargetItems((prev) => [...prev, { type: 'writeRelays' }])
} else {
setPostTargetItems((prev: TPostTargetItem[]) =>
prev.filter((item: TPostTargetItem) => item.type !== 'writeRelays')
)
setPostTargetItems((prev) => prev.filter((item) => item.type !== 'writeRelays'))
}
}, [])
const handleRelayCheckedChange = useCallback((checked: boolean, url: string) => {
if (checked) {
setPostTargetItems((prev: TPostTargetItem[]) => [...prev, { type: 'relay', url }])
setPostTargetItems((prev) => [...prev, { type: 'relay', url }])
} else {
setPostTargetItems((prev: TPostTargetItem[]) =>
prev.filter((item: TPostTargetItem) => !(item.type === 'relay' && item.url === url))
setPostTargetItems((prev) =>
prev.filter((item) => !(item.type === 'relay' && item.url === url))
)
}
}, [])
@@ -144,74 +144,152 @@ export default function PostRelaySelector({
const handleRelaySetCheckedChange = useCallback(
(checked: boolean, id: string, urls: string[]) => {
if (checked) {
setPostTargetItems((prev: TPostTargetItem[]) => [...prev, { type: 'relaySet', id, urls }])
setPostTargetItems((prev) => [...prev, { type: 'relaySet', id, urls }])
} else {
setPostTargetItems((prev: TPostTargetItem[]) =>
prev.filter((item: TPostTargetItem) => !(item.type === 'relaySet' && item.id === id))
setPostTargetItems((prev) =>
prev.filter((item) => !(item.type === 'relaySet' && item.id === id))
)
}
},
[]
)
return (
<ResponsiveMenu>
<ResponsiveMenuTrigger asChild>
<div className="flex items-center gap-2 w-fit">
<Label>{t('Post to')}</Label>
<Button variant="outline" className="px-2 flex-1 max-w-fit justify-start">
<div className="truncate">{description}</div>
</Button>
</div>
</ResponsiveMenuTrigger>
<ResponsiveMenuContent align="start" className="max-w-96" showScrollButtons>
<ResponsiveMenuCheckboxItem
checked={postTargetItems.some((item: TPostTargetItem) => item.type === 'writeRelays')}
const content = useMemo(() => {
return (
<>
<MenuItem
checked={postTargetItems.some((item) => item.type === 'writeRelays')}
onCheckedChange={handleWriteRelaysCheckedChange}
>
{t('Write relays')}
</ResponsiveMenuCheckboxItem>
</MenuItem>
{relaySets.length > 0 && (
<>
<ResponsiveMenuSeparator />
<MenuSeparator />
{relaySets
.filter(({ relayUrls }) => relayUrls.length)
.map(({ id, name, relayUrls }) => (
<ResponsiveMenuCheckboxItem
<MenuItem
key={id}
checked={postTargetItems.some(
(item: TPostTargetItem) => item.type === 'relaySet' && item.id === id
(item) => item.type === 'relaySet' && item.id === id
)}
onCheckedChange={(checked) => handleRelaySetCheckedChange(checked, id, relayUrls)}
>
<div className="truncate">
{name} ({relayUrls.length})
</div>
</ResponsiveMenuCheckboxItem>
</MenuItem>
))}
</>
)}
{selectableRelays.length > 0 && (
<>
<ResponsiveMenuSeparator />
<MenuSeparator />
{selectableRelays.map((url) => (
<ResponsiveMenuCheckboxItem
<MenuItem
key={url}
checked={postTargetItems.some(
(item: TPostTargetItem) => item.type === 'relay' && item.url === url
)}
checked={postTargetItems.some((item) => item.type === 'relay' && item.url === url)}
onCheckedChange={(checked) => handleRelayCheckedChange(checked, url)}
>
<div className="flex items-center gap-2">
<RelayIcon url={url} />
<div className="truncate">{simplifyUrl(url)}</div>
</div>
</ResponsiveMenuCheckboxItem>
</MenuItem>
))}
</>
)}
</ResponsiveMenuContent>
</ResponsiveMenu>
</>
)
}, [postTargetItems, relaySets, selectableRelays])
if (isSmallScreen) {
return (
<>
<div className="flex items-center gap-2">
<Label>{t('Post to')}</Label>
<Button
variant="outline"
className="px-2 flex-1 max-w-fit justify-start"
onClick={() => setIsDrawerOpen(true)}
>
<div className="truncate">{description}</div>
</Button>
</div>
<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' }}
>
{content}
</div>
</DrawerContent>
</Drawer>
</>
)
}
return (
<DropdownMenu>
<div className="flex items-center gap-2">
<Label>{t('Post to')}</Label>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="px-2 flex-1 max-w-fit justify-start">
<div className="truncate">{description}</div>
</Button>
</DropdownMenuTrigger>
</div>
<DropdownMenuContent align="start" className="max-w-96 max-h-[50vh]" showScrollButtons>
{content}
</DropdownMenuContent>
</DropdownMenu>
)
}
function MenuSeparator() {
const { isSmallScreen } = useScreenSize()
if (isSmallScreen) {
return <Separator />
}
return <DropdownMenuSeparator />
}
function MenuItem({
children,
checked,
onCheckedChange
}: {
children: React.ReactNode
checked: boolean
onCheckedChange: (checked: boolean) => void
}) {
const { isSmallScreen } = useScreenSize()
if (isSmallScreen) {
return (
<div
onClick={() => onCheckedChange(!checked)}
className="flex items-center gap-2 px-4 py-3 clickable"
>
<div className="flex items-center justify-center size-4 shrink-0">
{checked && <Check className="size-4" />}
</div>
{children}
</div>
)
}
return (
<DropdownMenuCheckboxItem
checked={checked}
onSelect={(e) => e.preventDefault()}
onCheckedChange={onCheckedChange}
className="flex items-center gap-2"
>
{children}
</DropdownMenuCheckboxItem>
)
}

View File

@@ -1,67 +1,147 @@
import { Button } from '@/components/ui/button'
import { Drawer, DrawerContent, DrawerOverlay } from '@/components/ui/drawer'
import {
ResponsiveMenu,
ResponsiveMenuContent,
ResponsiveMenuItem,
ResponsiveMenuTrigger
} from '@/components/ui/responsive-menu'
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { pubkeyToNpub } from '@/lib/pubkey'
import { useMuteList } from '@/providers/MuteListProvider'
import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Bell, BellOff, Copy, Ellipsis } from 'lucide-react'
import { useMemo } from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
export default function ProfileOptions({ pubkey }: { pubkey: string }) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const { pubkey: accountPubkey } = useNostr()
const { mutePubkeySet, mutePubkeyPrivately, mutePubkeyPublicly, unmutePubkey } = useMuteList()
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
const isMuted = useMemo(() => mutePubkeySet.has(pubkey), [mutePubkeySet, pubkey])
if (pubkey === accountPubkey) return null
return (
<ResponsiveMenu>
<ResponsiveMenuTrigger asChild>
<Button variant="secondary" size="icon" className="rounded-full">
<Ellipsis />
</Button>
</ResponsiveMenuTrigger>
const trigger = (
<Button
variant="secondary"
size="icon"
className="rounded-full"
onClick={() => {
if (isSmallScreen) {
setIsDrawerOpen(true)
}
}}
>
<Ellipsis />
</Button>
)
<ResponsiveMenuContent>
<ResponsiveMenuItem onClick={() => navigator.clipboard.writeText(pubkeyToNpub(pubkey) ?? '')}>
if (isSmallScreen) {
return (
<>
{trigger}
<Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
<DrawerOverlay onClick={() => setIsDrawerOpen(false)} />
<DrawerContent hideOverlay>
<div className="py-2">
<Button
onClick={() => {
setIsDrawerOpen(false)
navigator.clipboard.writeText(pubkeyToNpub(pubkey) ?? '')
}}
className="w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5"
variant="ghost"
>
<Copy />
{t('Copy user ID')}
</Button>
{accountPubkey ? (
isMuted ? (
<Button
onClick={() => {
setIsDrawerOpen(false)
unmutePubkey(pubkey)
}}
className="w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5 text-destructive focus:text-destructive"
variant="ghost"
>
<Bell />
{t('Unmute user')}
</Button>
) : (
<>
<Button
onClick={() => {
setIsDrawerOpen(false)
mutePubkeyPrivately(pubkey)
}}
className="w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5 text-destructive focus:text-destructive"
variant="ghost"
>
<BellOff />
{t('Mute user privately')}
</Button>
<Button
onClick={() => {
setIsDrawerOpen(false)
mutePubkeyPublicly(pubkey)
}}
className="w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5 text-destructive focus:text-destructive"
variant="ghost"
>
<BellOff />
{t('Mute user publicly')}
</Button>
</>
)
) : null}
</div>
</DrawerContent>
</Drawer>
</>
)
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => navigator.clipboard.writeText(pubkeyToNpub(pubkey) ?? '')}>
<Copy />
{t('Copy user ID')}
</ResponsiveMenuItem>
</DropdownMenuItem>
{accountPubkey ? (
isMuted ? (
<ResponsiveMenuItem
<DropdownMenuItem
onClick={() => unmutePubkey(pubkey)}
className="text-destructive focus:text-destructive"
>
<Bell />
{t('Unmute user')}
</ResponsiveMenuItem>
</DropdownMenuItem>
) : (
<>
<ResponsiveMenuItem
<DropdownMenuItem
onClick={() => mutePubkeyPrivately(pubkey)}
className="text-destructive focus:text-destructive"
>
<BellOff />
{t('Mute user privately')}
</ResponsiveMenuItem>
<ResponsiveMenuItem
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => mutePubkeyPublicly(pubkey)}
className="text-destructive focus:text-destructive"
>
<BellOff />
{t('Mute user publicly')}
</ResponsiveMenuItem>
</DropdownMenuItem>
</>
)
) : null}
</ResponsiveMenuContent>
</ResponsiveMenu>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -1,19 +1,29 @@
import { Button } from '@/components/ui/button'
import {
ResponsiveMenu,
ResponsiveMenuContent,
ResponsiveMenuItem,
ResponsiveMenuLabel,
ResponsiveMenuSeparator,
ResponsiveMenuTrigger
} from '@/components/ui/responsive-menu'
Drawer,
DrawerContent,
DrawerHeader,
DrawerOverlay,
DrawerTitle
} from '@/components/ui/drawer'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Separator } from '@/components/ui/separator'
import { normalizeUrl } from '@/lib/url'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { TRelaySet } from '@/types'
import { Check, FolderPlus, Plus, Star } from 'lucide-react'
import { useMemo } from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import DrawerMenuItem from '../DrawerMenuItem'
export default function SaveRelayDropdownMenu({
urls,
@@ -23,6 +33,7 @@ export default function SaveRelayDropdownMenu({
bigButton?: boolean
}) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const { favoriteRelays, relaySets } = useFavoriteRelays()
const normalizedUrls = useMemo(() => urls.map((url) => normalizeUrl(url)).filter(Boolean), [urls])
const alreadySaved = useMemo(() => {
@@ -30,39 +41,73 @@ export default function SaveRelayDropdownMenu({
normalizedUrls.every((url) => favoriteRelays.includes(url)) ||
relaySets.some((set) => normalizedUrls.every((url) => set.relayUrls.includes(url)))
)
}, [relaySets, normalizedUrls, favoriteRelays])
}, [relaySets, normalizedUrls])
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
const trigger = bigButton ? (
<Button variant="ghost" size="titlebar-icon" onClick={() => setIsDrawerOpen(true)}>
<Star className={alreadySaved ? 'fill-primary stroke-primary' : ''} />
</Button>
) : (
<button
className="enabled:hover:text-primary [&_svg]:size-5 pr-0 pt-0.5"
onClick={(e) => {
e.stopPropagation()
setIsDrawerOpen(true)
}}
>
<Star className={alreadySaved ? 'fill-primary stroke-primary' : ''} />
</button>
)
if (isSmallScreen) {
return (
<div>
{trigger}
<div onClick={(e) => e.stopPropagation()}>
<Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
<DrawerOverlay onClick={() => setIsDrawerOpen(false)} />
<DrawerContent hideOverlay>
<DrawerHeader>
<DrawerTitle>{t('Save to')} ...</DrawerTitle>
</DrawerHeader>
<div className="py-2">
<RelayItem urls={normalizedUrls} />
{relaySets.map((set) => (
<RelaySetItem key={set.id} set={set} urls={normalizedUrls} />
))}
<Separator />
<SaveToNewSet urls={normalizedUrls} />
</div>
</DrawerContent>
</Drawer>
</div>
</div>
)
}
return (
<div onClick={(e) => e.stopPropagation()}>
<ResponsiveMenu>
<ResponsiveMenuTrigger asChild>
{bigButton ? (
<Button variant="ghost" size="titlebar-icon">
<Star className={alreadySaved ? 'fill-primary stroke-primary' : ''} />
</Button>
) : (
<button className="enabled:hover:text-primary [&_svg]:size-5 pr-0 pt-0.5">
<Star className={alreadySaved ? 'fill-primary stroke-primary' : ''} />
</button>
)}
</ResponsiveMenuTrigger>
<ResponsiveMenuContent>
<ResponsiveMenuLabel>{t('Save to')} ...</ResponsiveMenuLabel>
<ResponsiveMenuSeparator />
<RelayItem urls={normalizedUrls} />
{relaySets.map((set) => (
<RelaySetItem key={set.id} set={set} urls={normalizedUrls} />
))}
<ResponsiveMenuSeparator />
<SaveToNewSet urls={normalizedUrls} />
</ResponsiveMenuContent>
</ResponsiveMenu>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild className="px-2">
{trigger}
</DropdownMenuTrigger>
<DropdownMenuContent onClick={(e) => e.stopPropagation()}>
<DropdownMenuLabel>{t('Save to')} ...</DropdownMenuLabel>
<DropdownMenuSeparator />
<RelayItem urls={normalizedUrls} />
{relaySets.map((set) => (
<RelaySetItem key={set.id} set={set} urls={normalizedUrls} />
))}
<DropdownMenuSeparator />
<SaveToNewSet urls={normalizedUrls} />
</DropdownMenuContent>
</DropdownMenu>
)
}
function RelayItem({ urls }: { urls: string[] }) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const { favoriteRelays, addFavoriteRelays, deleteFavoriteRelays } = useFavoriteRelays()
const saved = useMemo(
() => urls.every((url) => favoriteRelays.includes(url)),
@@ -77,15 +122,25 @@ function RelayItem({ urls }: { urls: string[] }) {
}
}
if (isSmallScreen) {
return (
<DrawerMenuItem onClick={handleClick}>
{saved ? <Check /> : <Plus />}
{saved ? t('Unfavorite') : t('Favorite')}
</DrawerMenuItem>
)
}
return (
<ResponsiveMenuItem onClick={handleClick}>
<DropdownMenuItem className="flex gap-2" onClick={handleClick}>
{saved ? <Check /> : <Plus />}
{saved ? t('Unfavorite') : t('Favorite')}
</ResponsiveMenuItem>
</DropdownMenuItem>
)
}
function RelaySetItem({ set, urls }: { set: TRelaySet; urls: string[] }) {
const { isSmallScreen } = useScreenSize()
const { pubkey, startLogin } = useNostr()
const { updateRelaySet } = useFavoriteRelays()
const saved = urls.every((url) => set.relayUrls.includes(url))
@@ -108,16 +163,26 @@ function RelaySetItem({ set, urls }: { set: TRelaySet; urls: string[] }) {
}
}
if (isSmallScreen) {
return (
<DrawerMenuItem onClick={handleClick}>
{saved ? <Check /> : <Plus />}
{set.name}
</DrawerMenuItem>
)
}
return (
<ResponsiveMenuItem onClick={handleClick}>
<DropdownMenuItem key={set.id} className="flex gap-2" onClick={handleClick}>
{saved ? <Check /> : <Plus />}
{set.name}
</ResponsiveMenuItem>
</DropdownMenuItem>
)
}
function SaveToNewSet({ urls }: { urls: string[] }) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const { pubkey, startLogin } = useNostr()
const { createRelaySet } = useFavoriteRelays()
@@ -132,10 +197,19 @@ function SaveToNewSet({ urls }: { urls: string[] }) {
}
}
if (isSmallScreen) {
return (
<DrawerMenuItem onClick={handleSave}>
<FolderPlus />
{t('Save to a new relay set')}
</DrawerMenuItem>
)
}
return (
<ResponsiveMenuItem onClick={handleSave}>
<DropdownMenuItem onClick={handleSave}>
<FolderPlus />
{t('Save to a new relay set')}
</ResponsiveMenuItem>
</DropdownMenuItem>
)
}

View File

@@ -1,12 +1,12 @@
import { Button } from '@/components/ui/button'
import {
ResponsiveMenu,
ResponsiveMenuContent,
ResponsiveMenuItem,
ResponsiveMenuLabel,
ResponsiveMenuSeparator,
ResponsiveMenuTrigger
} from '@/components/ui/responsive-menu'
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { toWallet } from '@/lib/link'
import { cn } from '@/lib/utils'
import { useSecondaryPage } from '@/PageManager'
@@ -41,8 +41,8 @@ function ProfileButton({ collapse }: { collapse: boolean }) {
if (!pubkey) return null
return (
<ResponsiveMenu>
<ResponsiveMenuTrigger asChild>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className={cn(
@@ -57,16 +57,16 @@ function ProfileButton({ collapse }: { collapse: boolean }) {
)}
</div>
</Button>
</ResponsiveMenuTrigger>
<ResponsiveMenuContent side="top" className="w-72 max-h-[80vh]">
<ResponsiveMenuItem onClick={() => push(toWallet())}>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" className="w-72">
<DropdownMenuItem onClick={() => push(toWallet())}>
<Wallet />
{t('Wallet')}
</ResponsiveMenuItem>
<ResponsiveMenuSeparator />
<ResponsiveMenuLabel>{t('Switch account')}</ResponsiveMenuLabel>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>{t('Switch account')}</DropdownMenuLabel>
{accounts.map((act) => (
<ResponsiveMenuItem
<DropdownMenuItem
className={act.pubkey === pubkey ? 'cursor-default focus:bg-background' : ''}
key={`${act.pubkey}:${act.signerType}`}
onClick={() => {
@@ -92,17 +92,18 @@ function ProfileButton({ collapse }: { collapse: boolean }) {
act.pubkey === pubkey && 'size-4 border-4 border-primary'
)}
/>
</ResponsiveMenuItem>
</DropdownMenuItem>
))}
<div className="border border-dashed m-2 rounded-md">
<ResponsiveMenuItem onClick={() => setLoginDialogOpen(true)}>
<div className="flex gap-2 items-center justify-center w-full py-2">
<Plus />
{t('Add an Account')}
</div>
</ResponsiveMenuItem>
</div>
<ResponsiveMenuItem
<DropdownMenuItem
onClick={() => setLoginDialogOpen(true)}
className="border border-dashed m-2 focus:border-muted-foreground focus:bg-background"
>
<div className="flex gap-2 items-center justify-center w-full py-2">
<Plus />
{t('Add an Account')}
</div>
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setLogoutDialogOpen(true)}
>
@@ -110,13 +111,13 @@ function ProfileButton({ collapse }: { collapse: boolean }) {
<span className="shrink-0">{t('Logout')}</span>
<SimpleUsername
userId={pubkey}
className="text-muted-foreground border border-muted-foreground px-1 rounded-md text-xs truncate ml-auto"
className="text-muted-foreground border border-muted-foreground px-1 rounded-md text-xs truncate"
/>
</ResponsiveMenuItem>
</ResponsiveMenuContent>
</DropdownMenuItem>
</DropdownMenuContent>
<LoginDialog open={loginDialogOpen} setOpen={setLoginDialogOpen} />
<LogoutDialog open={logoutDialogOpen} setOpen={setLogoutDialogOpen} />
</ResponsiveMenu>
</DropdownMenu>
)
}

View File

@@ -1,15 +1,18 @@
import { Button } from '@/components/ui/button'
import { Drawer, DrawerContent, DrawerOverlay } from '@/components/ui/drawer'
import {
ResponsiveMenu,
ResponsiveMenuContent,
ResponsiveMenuItem,
ResponsiveMenuTrigger
} from '@/components/ui/responsive-menu'
import { useStuff } from '@/hooks/useStuff'
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { useStuffStatsById } from '@/hooks/useStuffStatsById'
import { useStuff } from '@/hooks/useStuff'
import { createRepostDraftEvent } from '@/lib/draft-event'
import { getNoteBech32Id } from '@/lib/event'
import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import stuffStatsService from '@/services/stuff-stats.service'
import { Loader, PencilLine, Repeat } from 'lucide-react'
@@ -21,13 +24,14 @@ import { formatCount } from './utils'
export default function RepostButton({ stuff }: { stuff: Event | string }) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const { publish, checkLogin, pubkey } = useNostr()
const { event, stuffKey } = useStuff(stuff)
const noteStats = useStuffStatsById(stuffKey)
const [reposting, setReposting] = useState(false)
const [isPostDialogOpen, setIsPostDialogOpen] = useState(false)
const [open, setOpen] = useState(false)
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
const { repostCount, hasReposted } = useMemo(() => {
// external content
if (!event) return { repostCount: 0, hasReposted: false }
@@ -70,39 +74,95 @@ export default function RepostButton({ stuff }: { stuff: Event | string }) {
})
}
const trigger = (
<button
className={cn(
'flex gap-1 items-center px-3 h-full enabled:hover:text-lime-500 disabled:text-muted-foreground/40',
hasReposted ? 'text-lime-500' : 'text-muted-foreground'
)}
disabled={!event}
title={t('Repost')}
onClick={() => {
if (!event) return
if (isSmallScreen) {
setIsDrawerOpen(true)
}
}}
>
{reposting ? <Loader className="animate-spin" /> : <Repeat />}
{!!repostCount && <div className="text-sm">{formatCount(repostCount)}</div>}
</button>
)
if (!event) {
return trigger
}
const postEditor = (
<PostEditor
open={isPostDialogOpen}
setOpen={setIsPostDialogOpen}
defaultContent={'\nnostr:' + getNoteBech32Id(event)}
/>
)
if (isSmallScreen) {
return (
<>
{trigger}
<Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
<DrawerOverlay onClick={() => setIsDrawerOpen(false)} />
<DrawerContent hideOverlay>
<div className="py-2">
<Button
onClick={(e) => {
e.stopPropagation()
setIsDrawerOpen(false)
repost()
}}
disabled={!canRepost}
className="w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5"
variant="ghost"
>
<Repeat /> {t('Repost')}
</Button>
<Button
onClick={(e) => {
e.stopPropagation()
setIsDrawerOpen(false)
checkLogin(() => {
setIsPostDialogOpen(true)
})
}}
className="w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5"
variant="ghost"
>
<PencilLine /> {t('Quote')}
</Button>
</div>
</DrawerContent>
</Drawer>
{postEditor}
</>
)
}
return (
<>
<ResponsiveMenu open={open} onOpenChange={setOpen}>
<ResponsiveMenuTrigger asChild>
<button
className={cn(
'flex gap-1 items-center px-3 h-full enabled:hover:text-lime-500 disabled:text-muted-foreground/40',
hasReposted ? 'text-lime-500' : 'text-muted-foreground'
)}
disabled={!event}
title={t('Repost')}
<DropdownMenu>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
if (!event) return
setOpen(true)
}}
>
{reposting ? <Loader className="animate-spin" /> : <Repeat />}
{!!repostCount && <div className="text-sm">{formatCount(repostCount)}</div>}
</button>
</ResponsiveMenuTrigger>
<ResponsiveMenuContent>
<ResponsiveMenuItem
onClick={(e) => {
e?.stopPropagation()
repost()
}}
disabled={!canRepost}
>
<Repeat /> {t('Repost')}
</ResponsiveMenuItem>
<ResponsiveMenuItem
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
checkLogin(() => {
@@ -111,15 +171,10 @@ export default function RepostButton({ stuff }: { stuff: Event | string }) {
}}
>
<PencilLine /> {t('Quote')}
</ResponsiveMenuItem>
</ResponsiveMenuContent>
</ResponsiveMenu>
<PostEditor
open={isPostDialogOpen}
setOpen={setIsPostDialogOpen}
defaultContent={event ? '\nnostr:' + getNoteBech32Id(event) : undefined}
/>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{postEditor}
</>
)
}

View File

@@ -1,15 +1,18 @@
import { useSecondaryPage } from '@/PageManager'
import { Button } from '@/components/ui/button'
import { Drawer, DrawerContent, DrawerOverlay } from '@/components/ui/drawer'
import {
ResponsiveMenu,
ResponsiveMenuContent,
ResponsiveMenuItem,
ResponsiveMenuLabel,
ResponsiveMenuSeparator,
ResponsiveMenuTrigger
} from '@/components/ui/responsive-menu'
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { useStuff } from '@/hooks/useStuff'
import { toRelay } from '@/lib/link'
import { simplifyUrl } from '@/lib/url'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import client from '@/services/client.service'
import { Server } from 'lucide-react'
import { Event } from 'nostr-tools'
@@ -19,56 +22,84 @@ import RelayIcon from '../RelayIcon'
export default function SeenOnButton({ stuff }: { stuff: Event | string }) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const { push } = useSecondaryPage()
const { event } = useStuff(stuff)
const [relays, setRelays] = useState<string[]>([])
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
useEffect(() => {
if (!event) return
const seenOn = client.getSeenEventRelayUrls(event.id)
setRelays(seenOn)
}, [event])
}, [])
const trigger = (
<button
className="flex gap-1 items-center text-muted-foreground enabled:hover:text-primary pl-3 h-full disabled:text-muted-foreground/40"
title={t('Seen on')}
disabled={relays.length === 0}
onClick={() => {
if (!event) return
if (isSmallScreen) {
setIsDrawerOpen(true)
}
}}
>
<Server />
{relays.length > 0 && <div className="text-sm">{relays.length}</div>}
</button>
)
if (relays.length === 0) {
return trigger
}
if (isSmallScreen) {
return (
<button
className="flex gap-1 items-center text-muted-foreground enabled:hover:text-primary pl-3 h-full disabled:text-muted-foreground/40"
title={t('Seen on')}
disabled
>
<Server />
</button>
<>
{trigger}
<Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
<DrawerOverlay onClick={() => setIsDrawerOpen(false)} />
<DrawerContent hideOverlay>
<div className="py-2">
{relays.map((relay) => (
<Button
className="w-full p-6 justify-start text-lg gap-4"
variant="ghost"
key={relay}
onClick={() => {
setIsDrawerOpen(false)
setTimeout(() => {
push(toRelay(relay))
}, 50) // Timeout to allow the drawer to close before navigating
}}
>
<RelayIcon url={relay} /> {simplifyUrl(relay)}
</Button>
))}
</div>
</DrawerContent>
</Drawer>
</>
)
}
return (
<ResponsiveMenu>
<ResponsiveMenuTrigger asChild>
<button
className="flex gap-1 items-center text-muted-foreground enabled:hover:text-primary pl-3 h-full"
title={t('Seen on')}
>
<Server />
<div className="text-sm">{relays.length}</div>
</button>
</ResponsiveMenuTrigger>
<ResponsiveMenuContent>
<ResponsiveMenuLabel>{t('Seen on')}</ResponsiveMenuLabel>
<ResponsiveMenuSeparator />
<DropdownMenu>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>{t('Seen on')}</DropdownMenuLabel>
<DropdownMenuSeparator />
{relays.map((relay) => (
<ResponsiveMenuItem
key={relay}
onClick={() => {
setTimeout(() => push(toRelay(relay)), 100) // slight delay to allow menu to close
}}
>
<DropdownMenuItem key={relay} onClick={() => push(toRelay(relay))} className="min-w-52">
<RelayIcon url={relay} />
{simplifyUrl(relay)}
</ResponsiveMenuItem>
</DropdownMenuItem>
))}
</ResponsiveMenuContent>
</ResponsiveMenu>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -1,433 +0,0 @@
/**
* ResponsiveMenu 组合式 API 使用示例
*
* 这个版本采用组合式 API与 DropdownMenu/Drawer 的使用方式一致
*/
import * as React from 'react'
import {
ResponsiveMenu,
ResponsiveMenuTrigger,
ResponsiveMenuContent,
ResponsiveMenuItem,
ResponsiveMenuSeparator,
ResponsiveMenuLabel,
ResponsiveMenuSub,
ResponsiveMenuSubTrigger,
ResponsiveMenuSubContent
} from './responsive-menu'
import { Button } from './button'
import { Avatar, AvatarFallback, AvatarImage } from './avatar'
import { Badge } from './badge'
import {
Menu,
Copy,
Share2,
Trash2,
Settings,
User,
Bell,
Wallet,
Plus,
LogOut,
SatelliteDish
} from 'lucide-react'
import { toast } from 'sonner'
// ============================================================================
// 1. Basic Example
// ============================================================================
export function BasicExample() {
return (
<ResponsiveMenu>
<ResponsiveMenuTrigger asChild>
<Button variant="outline">
<Menu />
Actions
</Button>
</ResponsiveMenuTrigger>
<ResponsiveMenuContent>
<ResponsiveMenuItem onClick={() => toast.success('Copied!')}>
<Copy />
Copy
</ResponsiveMenuItem>
<ResponsiveMenuItem onClick={() => toast.success('Shared!')}>
<Share2 />
Share
</ResponsiveMenuItem>
<ResponsiveMenuSeparator />
<ResponsiveMenuItem
onClick={() => toast.error('Deleted!')}
className="text-destructive focus:text-destructive"
>
<Trash2 />
Delete
</ResponsiveMenuItem>
</ResponsiveMenuContent>
</ResponsiveMenu>
)
}
// ============================================================================
// 2. With Sub Menu Example
// ============================================================================
export function WithSubMenuExample() {
return (
<ResponsiveMenu>
<ResponsiveMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Menu />
</Button>
</ResponsiveMenuTrigger>
<ResponsiveMenuContent>
<ResponsiveMenuItem onClick={() => toast.success('Profile')}>
<User />
Profile
</ResponsiveMenuItem>
<ResponsiveMenuSub>
<ResponsiveMenuSubTrigger>
<Settings />
Settings
</ResponsiveMenuSubTrigger>
<ResponsiveMenuSubContent>
<ResponsiveMenuItem onClick={() => toast.success('General settings')}>
General
</ResponsiveMenuItem>
<ResponsiveMenuSeparator />
<ResponsiveMenuItem onClick={() => toast.success('Privacy settings')}>
Privacy
</ResponsiveMenuItem>
<ResponsiveMenuItem onClick={() => toast.success('Advanced settings')}>
Advanced
</ResponsiveMenuItem>
</ResponsiveMenuSubContent>
</ResponsiveMenuSub>
<ResponsiveMenuSub>
<ResponsiveMenuSubTrigger>
<Bell />
Notifications
</ResponsiveMenuSubTrigger>
<ResponsiveMenuSubContent>
<ResponsiveMenuItem onClick={() => toast.success('Enabled all')}>
Enable all
</ResponsiveMenuItem>
<ResponsiveMenuItem
onClick={() => toast.success('Disabled all')}
className="text-destructive focus:text-destructive"
>
Disable all
</ResponsiveMenuItem>
</ResponsiveMenuSubContent>
</ResponsiveMenuSub>
</ResponsiveMenuContent>
</ResponsiveMenu>
)
}
// ============================================================================
// 3. Controlled Example
// ============================================================================
export function ControlledExample() {
const [open, setOpen] = React.useState(false)
return (
<div>
<ResponsiveMenu open={open} onOpenChange={setOpen}>
<ResponsiveMenuTrigger asChild>
<Button>Open Menu</Button>
</ResponsiveMenuTrigger>
<ResponsiveMenuContent>
<ResponsiveMenuItem onClick={() => toast.success('Copied!')}>
<Copy />
Copy
</ResponsiveMenuItem>
</ResponsiveMenuContent>
</ResponsiveMenu>
<p className="mt-2 text-sm text-muted-foreground">Menu is {open ? 'open' : 'closed'}</p>
</div>
)
}
// ============================================================================
// 4. Account Switcher Example
// ============================================================================
export function AccountButtonExample() {
const accounts = [
{
pubkey: 'npub1abc...',
name: 'Alice',
avatar: 'https://i.pravatar.cc/150?img=1',
signerType: 'extension'
},
{
pubkey: 'npub1def...',
name: 'Bob',
avatar: 'https://i.pravatar.cc/150?img=2',
signerType: 'nsec'
}
]
const [currentAccount, setCurrentAccount] = React.useState(accounts[0])
return (
<ResponsiveMenu>
<ResponsiveMenuTrigger asChild>
<Button variant="ghost" className="w-full justify-start gap-2">
<Avatar className="h-8 w-8">
<AvatarImage src={currentAccount.avatar} />
<AvatarFallback>{currentAccount.name[0]}</AvatarFallback>
</Avatar>
<span className="flex-1 text-left truncate">{currentAccount.name}</span>
</Button>
</ResponsiveMenuTrigger>
<ResponsiveMenuContent side="top" className="w-72">
<ResponsiveMenuItem onClick={() => toast.success('Wallet')}>
<Wallet />
Wallet
</ResponsiveMenuItem>
<ResponsiveMenuSeparator />
<ResponsiveMenuLabel>Switch account</ResponsiveMenuLabel>
{accounts.map((account) => (
<ResponsiveMenuItem
key={account.pubkey}
onClick={() => setCurrentAccount(account)}
className={
account.pubkey === currentAccount.pubkey ? 'cursor-default focus:bg-background' : ''
}
>
<div className="flex gap-2 items-center flex-1">
<Avatar className="h-8 w-8">
<AvatarImage src={account.avatar} />
<AvatarFallback>{account.name[0]}</AvatarFallback>
</Avatar>
<div className="flex-1 w-0">
<div className="font-medium truncate">{account.name}</div>
<Badge variant="outline" className="text-xs">
{account.signerType}
</Badge>
</div>
</div>
<div
className={`border border-muted-foreground rounded-full h-3.5 w-3.5 ${
account.pubkey === currentAccount.pubkey && 'h-4 w-4 border-4 border-primary'
}`}
/>
</ResponsiveMenuItem>
))}
<div className="border border-dashed m-2 rounded-md">
<ResponsiveMenuItem onClick={() => toast.success('Add account')}>
<div className="flex gap-2 items-center justify-center w-full py-2">
<Plus />
Add an Account
</div>
</ResponsiveMenuItem>
</div>
<ResponsiveMenuItem
onClick={() => toast.error('Logout')}
className="text-destructive focus:text-destructive"
>
<LogOut />
<span className="shrink-0">Logout</span>
<span className="text-muted-foreground border border-muted-foreground px-1 rounded-md text-xs truncate ml-auto">
{currentAccount.name}
</span>
</ResponsiveMenuItem>
</ResponsiveMenuContent>
</ResponsiveMenu>
)
}
// ============================================================================
// 5. Complex Sub Menu Example
// ============================================================================
export function ComplexSubMenuExample() {
const relays = [
{ url: 'wss://relay1.example.com', name: 'Relay 1', status: 'connected' },
{ url: 'wss://relay2.example.com', name: 'Relay 2', status: 'connecting' },
{ url: 'wss://relay3.example.com', name: 'Relay 3', status: 'disconnected' }
]
return (
<ResponsiveMenu>
<ResponsiveMenuTrigger asChild>
<Button variant="outline">
<Menu />
Actions
</Button>
</ResponsiveMenuTrigger>
<ResponsiveMenuContent showScrollButtons>
<ResponsiveMenuItem onClick={() => toast.success('Copied ID')}>
<Copy />
Copy ID
</ResponsiveMenuItem>
<ResponsiveMenuItem onClick={() => toast.success('Copied user ID')}>
<Copy />
Copy user ID
</ResponsiveMenuItem>
<ResponsiveMenuItem onClick={() => toast.success('Copied link')}>
<Share2 />
Copy share link
</ResponsiveMenuItem>
<ResponsiveMenuSeparator />
<ResponsiveMenuSub>
<ResponsiveMenuSubTrigger>
<SatelliteDish />
Republish to ...
</ResponsiveMenuSubTrigger>
<ResponsiveMenuSubContent showScrollButtons>
<ResponsiveMenuItem onClick={() => toast.success('Republishing to optimal relays')}>
<div className="text-left">Optimal relays</div>
</ResponsiveMenuItem>
<ResponsiveMenuSeparator />
{relays.map((relay) => (
<ResponsiveMenuItem
key={relay.url}
onClick={() => toast.success(`Republishing to ${relay.name}`)}
>
<div className="flex items-center gap-2 w-full">
<SatelliteDish className="h-4 w-4" />
<div className="flex-1 truncate text-left">{relay.name}</div>
<Badge
variant={relay.status === 'connected' ? 'default' : 'secondary'}
className="text-xs"
>
{relay.status}
</Badge>
</div>
</ResponsiveMenuItem>
))}
</ResponsiveMenuSubContent>
</ResponsiveMenuSub>
<ResponsiveMenuSeparator />
<ResponsiveMenuItem
onClick={() => toast.error('Deleting...')}
className="text-destructive focus:text-destructive"
>
<Trash2 />
Delete
</ResponsiveMenuItem>
</ResponsiveMenuContent>
</ResponsiveMenu>
)
}
// ============================================================================
// 6. Dynamic Content Example
// ============================================================================
export function DynamicContentExample() {
const [canDelete, setCanDelete] = React.useState(true)
const [isPinned, setIsPinned] = React.useState(false)
return (
<div className="space-y-4">
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => setCanDelete(!canDelete)}>
Toggle Delete Permission
</Button>
<Button variant="outline" size="sm" onClick={() => setIsPinned(!isPinned)}>
Toggle Pin Status
</Button>
</div>
<ResponsiveMenu>
<ResponsiveMenuTrigger asChild>
<Button>Menu</Button>
</ResponsiveMenuTrigger>
<ResponsiveMenuContent>
<ResponsiveMenuItem onClick={() => toast.success('Copied')}>
<Copy />
Copy
</ResponsiveMenuItem>
<ResponsiveMenuItem onClick={() => toast.success('Shared')}>
<Share2 />
Share
</ResponsiveMenuItem>
{/* 条件渲染 */}
{isPinned && (
<>
<ResponsiveMenuSeparator />
<ResponsiveMenuItem onClick={() => setIsPinned(false)}>Unpin</ResponsiveMenuItem>
</>
)}
{canDelete && (
<>
<ResponsiveMenuSeparator />
<ResponsiveMenuItem
onClick={() => toast.error('Deleted')}
className="text-destructive focus:text-destructive"
>
<Trash2 />
Delete
</ResponsiveMenuItem>
</>
)}
</ResponsiveMenuContent>
</ResponsiveMenu>
</div>
)
}
// ============================================================================
// 7. Custom Style Example
// ============================================================================
export function CustomStyleExample() {
return (
<ResponsiveMenu>
<ResponsiveMenuTrigger asChild>
<Button>Custom Menu</Button>
</ResponsiveMenuTrigger>
<ResponsiveMenuContent
className="w-80"
align="end"
side="bottom"
sideOffset={8}
showScrollButtons
>
<ResponsiveMenuItem onClick={() => toast.success('Copied')}>
<Copy />
Copy
</ResponsiveMenuItem>
<ResponsiveMenuItem onClick={() => toast.success('Shared')}>
<Share2 />
Share
</ResponsiveMenuItem>
</ResponsiveMenuContent>
</ResponsiveMenu>
)
}

View File

@@ -1,480 +0,0 @@
import { Button } from '@/components/ui/button'
import { Drawer, DrawerContent, DrawerOverlay, DrawerTrigger } from '@/components/ui/drawer'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Separator } from '@/components/ui/separator'
import { cn } from '@/lib/utils'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { ArrowLeft, Check, ChevronRight } from 'lucide-react'
import * as React from 'react'
// ============================================================================
// Context
// ============================================================================
interface ResponsiveMenuContextValue {
isSmallScreen: boolean
closeMenu: () => void
openSubMenu: (title: React.ReactNode, content: React.ReactNode) => void
goBack: () => void
}
const ResponsiveMenuContext = React.createContext<ResponsiveMenuContextValue | undefined>(undefined)
function useResponsiveMenuContext() {
const context = React.useContext(ResponsiveMenuContext)
if (!context) {
throw new Error('ResponsiveMenu components must be used within ResponsiveMenu')
}
return context
}
// ============================================================================
// Root Component
// ============================================================================
interface ResponsiveMenuProps {
children: React.ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}
export function ResponsiveMenu({
children,
open: controlledOpen,
onOpenChange
}: ResponsiveMenuProps) {
const { isSmallScreen } = useScreenSize()
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false)
const [subMenuContent, setSubMenuContent] = React.useState<React.ReactNode>(null)
const [subMenuTitle, setSubMenuTitle] = React.useState<React.ReactNode>('')
const isOpen = controlledOpen !== undefined ? controlledOpen : uncontrolledOpen
const handleOpenChange = React.useCallback(
(open: boolean) => {
if (controlledOpen === undefined) {
setUncontrolledOpen(open)
}
onOpenChange?.(open)
// Reset submenu when closing
if (!open) {
setSubMenuContent(null)
setSubMenuTitle('')
}
},
[controlledOpen, onOpenChange]
)
const closeMenu = React.useCallback(() => {
handleOpenChange(false)
}, [handleOpenChange])
const openSubMenu = React.useCallback((title: React.ReactNode, content: React.ReactNode) => {
setSubMenuTitle(title)
setSubMenuContent(content)
}, [])
const goBack = React.useCallback(() => {
setSubMenuContent(null)
setSubMenuTitle('')
}, [])
const contextValue = React.useMemo(
() => ({
isSmallScreen,
closeMenu,
openSubMenu,
goBack
}),
[isSmallScreen, closeMenu, openSubMenu, goBack]
)
if (isSmallScreen) {
return (
<ResponsiveMenuContext.Provider value={contextValue}>
<Drawer open={isOpen} onOpenChange={handleOpenChange}>
{React.Children.map(children, (child) => {
if (React.isValidElement(child) && child.type === ResponsiveMenuTrigger) {
const props = child.props as { children: React.ReactNode }
return <DrawerTrigger asChild>{props.children}</DrawerTrigger>
}
if (React.isValidElement(child) && child.type === ResponsiveMenuContent) {
const props = child.props as { children: React.ReactNode; className?: string }
return (
<>
<DrawerOverlay onClick={closeMenu} />
<DrawerContent hideOverlay className={cn('max-h-[80vh]', props.className)}>
<div
className="overflow-y-auto overscroll-contain py-2"
style={{ touchAction: 'pan-y' }}
>
{subMenuContent ? (
<>
<Button
onClick={goBack}
className="w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5 mb-2"
variant="ghost"
>
<ArrowLeft />
{subMenuTitle}
</Button>
<div className="border-t border-border mb-2" />
{subMenuContent}
</>
) : (
props.children
)}
</div>
</DrawerContent>
</>
)
}
return null
})}
</Drawer>
</ResponsiveMenuContext.Provider>
)
}
return (
<ResponsiveMenuContext.Provider value={contextValue}>
<DropdownMenu open={isOpen} onOpenChange={handleOpenChange}>
{children}
</DropdownMenu>
</ResponsiveMenuContext.Provider>
)
}
// ============================================================================
// Trigger Component
// ============================================================================
interface ResponsiveMenuTriggerProps {
children: React.ReactNode
asChild?: boolean
}
export function ResponsiveMenuTrigger({ children, asChild }: ResponsiveMenuTriggerProps) {
const { isSmallScreen } = useResponsiveMenuContext()
if (isSmallScreen) {
// Trigger is handled in ResponsiveMenu root
return <>{children}</>
}
return <DropdownMenuTrigger asChild={asChild}>{children}</DropdownMenuTrigger>
}
// ============================================================================
// Content Component
// ============================================================================
interface ResponsiveMenuContentProps {
children: React.ReactNode
className?: string
align?: 'start' | 'center' | 'end'
side?: 'top' | 'right' | 'bottom' | 'left'
sideOffset?: number
showScrollButtons?: boolean
}
export function ResponsiveMenuContent({
children,
className,
align,
side,
sideOffset,
showScrollButtons = true
}: ResponsiveMenuContentProps) {
const { isSmallScreen } = useResponsiveMenuContext()
if (isSmallScreen) {
// Content is handled in ResponsiveMenu root
return <>{children}</>
}
return (
<DropdownMenuContent
className={cn('max-h-[50vh]', className)}
align={align}
side={side}
sideOffset={sideOffset}
showScrollButtons={showScrollButtons}
>
{children}
</DropdownMenuContent>
)
}
// ============================================================================
// Item Component
// ============================================================================
interface ResponsiveMenuItemProps {
children: React.ReactNode
onClick?: React.MouseEventHandler<HTMLDivElement | HTMLButtonElement>
className?: string
disabled?: boolean
}
export function ResponsiveMenuItem({
children,
onClick,
className,
disabled
}: ResponsiveMenuItemProps) {
const { isSmallScreen, closeMenu } = useResponsiveMenuContext()
if (isSmallScreen) {
return (
<Button
onClick={(e) => {
onClick?.(e)
closeMenu()
}}
disabled={disabled}
className={cn('w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5', className)}
variant="ghost"
>
{children}
</Button>
)
}
return (
<DropdownMenuItem
onClick={(e) => {
onClick?.(e)
closeMenu()
}}
disabled={disabled}
className={className}
>
{children}
</DropdownMenuItem>
)
}
// ============================================================================
// CheckboxItem Component
// ============================================================================
interface ResponsiveMenuCheckboxItemProps {
children: React.ReactNode
checked: boolean
onCheckedChange: (checked: boolean) => void
className?: string
disabled?: boolean
}
export function ResponsiveMenuCheckboxItem({
children,
checked,
onCheckedChange,
className,
disabled
}: ResponsiveMenuCheckboxItemProps) {
const { isSmallScreen } = useResponsiveMenuContext()
if (isSmallScreen) {
return (
<div
onClick={() => {
if (disabled) return
onCheckedChange(!checked)
}}
className={cn(
'flex items-center gap-2 px-4 py-3 clickable',
disabled && 'opacity-50 pointer-events-none',
className
)}
>
<div className="flex items-center justify-center size-4 shrink-0">
{checked && <Check className="size-4" />}
</div>
{children}
</div>
)
}
return (
<DropdownMenuCheckboxItem
checked={checked}
onSelect={(e) => e.preventDefault()}
onCheckedChange={onCheckedChange}
disabled={disabled}
className={cn('flex items-center gap-2', className)}
>
{children}
</DropdownMenuCheckboxItem>
)
}
// ============================================================================
// Separator Component
// ============================================================================
interface ResponsiveMenuSeparatorProps {
className?: string
}
export function ResponsiveMenuSeparator({ className }: ResponsiveMenuSeparatorProps) {
const { isSmallScreen } = useResponsiveMenuContext()
if (isSmallScreen) {
return <Separator className={className} />
}
return <DropdownMenuSeparator className={className} />
}
// ============================================================================
// Label Component
// ============================================================================
interface ResponsiveMenuLabelProps {
children: React.ReactNode
className?: string
}
export function ResponsiveMenuLabel({ children, className }: ResponsiveMenuLabelProps) {
const { isSmallScreen } = useResponsiveMenuContext()
if (isSmallScreen) {
return (
<div className={cn('px-6 py-3 text-sm font-semibold text-muted-foreground', className)}>
{children}
</div>
)
}
return <DropdownMenuLabel className={className}>{children}</DropdownMenuLabel>
}
// ============================================================================
// Sub Menu Components
// ============================================================================
const ResponsiveMenuSubContext = React.createContext<{
registerSubContent: (content: React.ReactNode) => void
} | null>(null)
interface ResponsiveMenuSubProps {
children: React.ReactNode
}
export function ResponsiveMenuSub({ children }: ResponsiveMenuSubProps) {
const { isSmallScreen, openSubMenu } = useResponsiveMenuContext()
const [subContent, setSubContent] = React.useState<React.ReactNode>(null)
const [title, setTitle] = React.useState<React.ReactNode>('')
const registerSubContent = React.useCallback((content: React.ReactNode) => {
setSubContent(content)
}, [])
const registerTitle = React.useCallback((titleContent: React.ReactNode) => {
setTitle(titleContent)
}, [])
const handleTriggerClick = React.useCallback(() => {
if (isSmallScreen && subContent) {
openSubMenu(title, subContent)
}
}, [isSmallScreen, subContent, openSubMenu, title])
if (isSmallScreen) {
return (
<ResponsiveMenuSubContext.Provider value={{ registerSubContent }}>
<ResponsiveMenuSubTitleContext.Provider
value={{ registerTitle, onTriggerClick: handleTriggerClick }}
>
{children}
</ResponsiveMenuSubTitleContext.Provider>
</ResponsiveMenuSubContext.Provider>
)
}
return <DropdownMenuSub>{children}</DropdownMenuSub>
}
const ResponsiveMenuSubTitleContext = React.createContext<{
registerTitle: (title: React.ReactNode) => void
onTriggerClick: () => void
} | null>(null)
interface ResponsiveMenuSubTriggerProps {
children: React.ReactNode
className?: string
}
export function ResponsiveMenuSubTrigger({ children, className }: ResponsiveMenuSubTriggerProps) {
const { isSmallScreen } = useResponsiveMenuContext()
const subTitleContext = React.useContext(ResponsiveMenuSubTitleContext)
React.useEffect(() => {
if (isSmallScreen && subTitleContext) {
subTitleContext.registerTitle(children)
}
}, [isSmallScreen, children, subTitleContext])
if (isSmallScreen) {
return (
<Button
onClick={subTitleContext?.onTriggerClick}
className={cn('w-full p-6 justify-between text-lg gap-4 [&_svg]:size-5', className)}
variant="ghost"
>
<div className="flex items-center gap-2">{children}</div>
<ChevronRight />
</Button>
)
}
return <DropdownMenuSubTrigger className={className}>{children}</DropdownMenuSubTrigger>
}
interface ResponsiveMenuSubContentProps {
children: React.ReactNode
className?: string
showScrollButtons?: boolean
}
export function ResponsiveMenuSubContent({
children,
className,
showScrollButtons = true
}: ResponsiveMenuSubContentProps) {
const { isSmallScreen } = useResponsiveMenuContext()
const subContext = React.useContext(ResponsiveMenuSubContext)
React.useEffect(() => {
if (isSmallScreen && subContext) {
subContext.registerSubContent(children)
}
}, [isSmallScreen, children, subContext])
if (isSmallScreen) {
return null // Content will be shown via context
}
return (
<DropdownMenuSubContent
className={cn('max-h-[50vh]', className)}
showScrollButtons={showScrollButtons}
>
{children}
</DropdownMenuSubContent>
)
}