20 changed files with 683 additions and 232 deletions
@ -0,0 +1,62 @@ |
|||||||
|
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-screen 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-screen overflow-y-auto"> |
||||||
|
{action.subMenu.map((subAction, subIndex) => ( |
||||||
|
<> |
||||||
|
{subAction.separator && subIndex > 0 && <DropdownMenuSeparator />} |
||||||
|
<DropdownMenuItem |
||||||
|
key={subIndex} |
||||||
|
onClick={subAction.onClick} |
||||||
|
className={cn('w-64', subAction.className)} |
||||||
|
> |
||||||
|
{subAction.label} |
||||||
|
</DropdownMenuItem> |
||||||
|
</> |
||||||
|
))} |
||||||
|
</DropdownMenuSubContent> |
||||||
|
</DropdownMenuSub> |
||||||
|
) : ( |
||||||
|
<DropdownMenuItem onClick={action.onClick} className={action.className}> |
||||||
|
<Icon /> |
||||||
|
{action.label} |
||||||
|
</DropdownMenuItem> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
) |
||||||
|
})} |
||||||
|
</DropdownMenuContent> |
||||||
|
</DropdownMenu> |
||||||
|
) |
||||||
|
} |
||||||
@ -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-screen"> |
||||||
|
<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> |
||||||
|
</> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,255 @@ |
|||||||
|
import { getNoteBech32Id, isProtectedEvent } from '@/lib/event' |
||||||
|
import { toNjump } from '@/lib/link' |
||||||
|
import { pubkeyToNpub } from '@/lib/pubkey' |
||||||
|
import { simplifyUrl } from '@/lib/url' |
||||||
|
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' |
||||||
|
import { useMuteList } from '@/providers/MuteListProvider' |
||||||
|
import { useNostr } from '@/providers/NostrProvider' |
||||||
|
import client from '@/services/client.service' |
||||||
|
import { Bell, BellOff, Code, Copy, Globe, Link, Mail, Server } from 'lucide-react' |
||||||
|
import { Event } from 'nostr-tools' |
||||||
|
import { useMemo } from 'react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
import { toast } from 'sonner' |
||||||
|
import RelayIcon from '../RelayIcon' |
||||||
|
|
||||||
|
export interface SubMenuAction { |
||||||
|
label: React.ReactNode |
||||||
|
onClick: () => void |
||||||
|
className?: string |
||||||
|
separator?: boolean |
||||||
|
} |
||||||
|
|
||||||
|
export interface MenuAction { |
||||||
|
icon: React.ComponentType |
||||||
|
label: string |
||||||
|
onClick?: () => void |
||||||
|
className?: string |
||||||
|
separator?: boolean |
||||||
|
subMenu?: SubMenuAction[] |
||||||
|
} |
||||||
|
|
||||||
|
interface UseMenuActionsProps { |
||||||
|
event: Event |
||||||
|
closeDrawer: () => void |
||||||
|
showSubMenuActions: (subMenu: SubMenuAction[], title: string) => void |
||||||
|
setIsRawEventDialogOpen: (open: boolean) => void |
||||||
|
isSmallScreen: boolean |
||||||
|
} |
||||||
|
|
||||||
|
export function useMenuActions({ |
||||||
|
event, |
||||||
|
closeDrawer, |
||||||
|
showSubMenuActions, |
||||||
|
setIsRawEventDialogOpen, |
||||||
|
isSmallScreen |
||||||
|
}: UseMenuActionsProps) { |
||||||
|
const { t } = useTranslation() |
||||||
|
const { pubkey, relayList } = useNostr() |
||||||
|
const { relaySets, favoriteRelays } = useFavoriteRelays() |
||||||
|
const { mutePubkeyPublicly, mutePubkeyPrivately, unmutePubkey, mutePubkeys } = useMuteList() |
||||||
|
const isMuted = useMemo(() => mutePubkeys.includes(event.pubkey), [mutePubkeys, event]) |
||||||
|
|
||||||
|
const broadcastSubMenu: SubMenuAction[] = useMemo(() => { |
||||||
|
const items = [] |
||||||
|
if (pubkey) { |
||||||
|
items.push({ |
||||||
|
label: ( |
||||||
|
<div className="flex items-center gap-2 w-full pl-1"> |
||||||
|
<Mail /> |
||||||
|
<div className="flex-1 truncate text-left">{t('Write relays')}</div> |
||||||
|
</div> |
||||||
|
), |
||||||
|
onClick: async () => { |
||||||
|
closeDrawer() |
||||||
|
const relays = relayList?.write.slice(0, 10) |
||||||
|
if (relays?.length) { |
||||||
|
await client |
||||||
|
.publishEvent(relays, event) |
||||||
|
.then(() => { |
||||||
|
toast.success(t('Successfully broadcasted to your write relays')) |
||||||
|
}) |
||||||
|
.catch((error) => { |
||||||
|
toast.error( |
||||||
|
t('Failed to broadcast to your write relays: {{error}}', { error: error.message }) |
||||||
|
) |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
if (relaySets.length) { |
||||||
|
items.push( |
||||||
|
...relaySets |
||||||
|
.filter((set) => set.relayUrls.length) |
||||||
|
.map((set, index) => ({ |
||||||
|
label: ( |
||||||
|
<div className="flex items-center gap-2 w-full pl-1"> |
||||||
|
<Server /> |
||||||
|
<div className="flex-1 truncate text-left">{set.name}</div> |
||||||
|
</div> |
||||||
|
), |
||||||
|
onClick: async () => { |
||||||
|
closeDrawer() |
||||||
|
await client |
||||||
|
.publishEvent(set.relayUrls, event) |
||||||
|
.then(() => { |
||||||
|
toast.success( |
||||||
|
t('Successfully broadcasted to relay set: {{name}}', { name: set.name }) |
||||||
|
) |
||||||
|
}) |
||||||
|
.catch((error) => { |
||||||
|
toast.error( |
||||||
|
t('Failed to broadcast to relay set: {{name}}. Error: {{error}}', { |
||||||
|
name: set.name, |
||||||
|
error: error.message |
||||||
|
}) |
||||||
|
) |
||||||
|
}) |
||||||
|
}, |
||||||
|
separator: index === 0 |
||||||
|
})) |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
if (favoriteRelays.length) { |
||||||
|
items.push( |
||||||
|
...favoriteRelays.map((relay, index) => ({ |
||||||
|
label: ( |
||||||
|
<div className="flex items-center gap-2 w-full"> |
||||||
|
<RelayIcon url={relay} /> |
||||||
|
<div className="flex-1 truncate text-left">{simplifyUrl(relay)}</div> |
||||||
|
</div> |
||||||
|
), |
||||||
|
onClick: async () => { |
||||||
|
closeDrawer() |
||||||
|
await client |
||||||
|
.publishEvent([relay], event) |
||||||
|
.then(() => { |
||||||
|
toast.success( |
||||||
|
t('Successfully broadcasted to relay: {{url}}', { url: simplifyUrl(relay) }) |
||||||
|
) |
||||||
|
}) |
||||||
|
.catch((error) => { |
||||||
|
toast.error( |
||||||
|
t('Failed to broadcast to relay: {{url}}. Error: {{error}}', { |
||||||
|
url: simplifyUrl(relay), |
||||||
|
error: error.message |
||||||
|
}) |
||||||
|
) |
||||||
|
}) |
||||||
|
}, |
||||||
|
separator: index === 0 |
||||||
|
})) |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
return items |
||||||
|
}, [pubkey, favoriteRelays, relaySets]) |
||||||
|
|
||||||
|
const menuActions: MenuAction[] = useMemo(() => { |
||||||
|
const actions: MenuAction[] = [ |
||||||
|
{ |
||||||
|
icon: Copy, |
||||||
|
label: t('Copy event ID'), |
||||||
|
onClick: () => { |
||||||
|
navigator.clipboard.writeText(getNoteBech32Id(event)) |
||||||
|
closeDrawer() |
||||||
|
} |
||||||
|
}, |
||||||
|
{ |
||||||
|
icon: Copy, |
||||||
|
label: t('Copy user ID'), |
||||||
|
onClick: () => { |
||||||
|
navigator.clipboard.writeText(pubkeyToNpub(event.pubkey) ?? '') |
||||||
|
closeDrawer() |
||||||
|
} |
||||||
|
}, |
||||||
|
{ |
||||||
|
icon: Link, |
||||||
|
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 |
||||||
|
} |
||||||
|
] |
||||||
|
|
||||||
|
const isProtected = isProtectedEvent(event) |
||||||
|
if (!isProtected || event.pubkey === pubkey) { |
||||||
|
actions.push({ |
||||||
|
icon: Globe, |
||||||
|
label: t('Broadcast to ...'), |
||||||
|
onClick: isSmallScreen |
||||||
|
? () => showSubMenuActions(broadcastSubMenu, t('Broadcast to ...')) |
||||||
|
: undefined, |
||||||
|
subMenu: isSmallScreen ? undefined : broadcastSubMenu, |
||||||
|
separator: true |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
if (pubkey) { |
||||||
|
if (isMuted) { |
||||||
|
actions.push({ |
||||||
|
icon: Bell, |
||||||
|
label: t('Unmute user'), |
||||||
|
onClick: () => { |
||||||
|
closeDrawer() |
||||||
|
unmutePubkey(event.pubkey) |
||||||
|
}, |
||||||
|
className: 'text-destructive focus:text-destructive', |
||||||
|
separator: true |
||||||
|
}) |
||||||
|
} else { |
||||||
|
actions.push( |
||||||
|
{ |
||||||
|
icon: BellOff, |
||||||
|
label: t('Mute user privately'), |
||||||
|
onClick: () => { |
||||||
|
closeDrawer() |
||||||
|
mutePubkeyPrivately(event.pubkey) |
||||||
|
}, |
||||||
|
className: 'text-destructive focus:text-destructive', |
||||||
|
separator: true |
||||||
|
}, |
||||||
|
{ |
||||||
|
icon: BellOff, |
||||||
|
label: t('Mute user publicly'), |
||||||
|
onClick: () => { |
||||||
|
closeDrawer() |
||||||
|
mutePubkeyPublicly(event.pubkey) |
||||||
|
}, |
||||||
|
className: 'text-destructive focus:text-destructive' |
||||||
|
} |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return actions |
||||||
|
}, [ |
||||||
|
t, |
||||||
|
event, |
||||||
|
pubkey, |
||||||
|
isMuted, |
||||||
|
isSmallScreen, |
||||||
|
broadcastSubMenu, |
||||||
|
closeDrawer, |
||||||
|
showSubMenuActions, |
||||||
|
setIsRawEventDialogOpen, |
||||||
|
mutePubkeyPrivately, |
||||||
|
mutePubkeyPublicly, |
||||||
|
unmutePubkey |
||||||
|
]) |
||||||
|
|
||||||
|
return menuActions |
||||||
|
} |
||||||
Loading…
Reference in new issue