20 changed files with 683 additions and 232 deletions
@ -0,0 +1,62 @@
@@ -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 @@
@@ -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 @@
@@ -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