useMenuActions.tsx raw

   1  import { Pubkey } from '@/domain'
   2  import { isProtectedEvent } from '@/lib/event'
   3  import { getNoteBech32Id } from '@/lib/event-service'
   4  import { toNjump } from '@/lib/link'
   5  import { simplifyUrl } from '@/lib/url'
   6  import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
   7  import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
   8  import { useMuteList } from '@/providers/MuteListProvider'
   9  import { useNostr } from '@/providers/NostrProvider'
  10  import { usePinList } from '@/providers/PinListProvider'
  11  import client from '@/services/client.service'
  12  import {
  13    Bell,
  14    BellOff,
  15    Code,
  16    Copy,
  17    Link,
  18    Pin,
  19    PinOff,
  20    SatelliteDish,
  21    Trash2,
  22    TriangleAlert
  23  } from 'lucide-react'
  24  import { Event, kinds } from 'nostr-tools'
  25  import { useMemo } from 'react'
  26  import { useTranslation } from 'react-i18next'
  27  import { toast } from 'sonner'
  28  import RelayIcon from '../RelayIcon'
  29  
  30  export interface SubMenuAction {
  31    label: React.ReactNode
  32    onClick: () => void
  33    className?: string
  34    separator?: boolean
  35  }
  36  
  37  export interface MenuAction {
  38    icon: React.ComponentType
  39    label: string
  40    onClick?: () => void
  41    className?: string
  42    separator?: boolean
  43    subMenu?: SubMenuAction[]
  44  }
  45  
  46  interface UseMenuActionsProps {
  47    event: Event
  48    closeDrawer: () => void
  49    showSubMenuActions: (subMenu: SubMenuAction[], title: string) => void
  50    setIsRawEventDialogOpen: (open: boolean) => void
  51    setIsReportDialogOpen: (open: boolean) => void
  52    isSmallScreen: boolean
  53  }
  54  
  55  export function useMenuActions({
  56    event,
  57    closeDrawer,
  58    showSubMenuActions,
  59    setIsRawEventDialogOpen,
  60    setIsReportDialogOpen,
  61    isSmallScreen
  62  }: UseMenuActionsProps) {
  63    const { t } = useTranslation()
  64    const { pubkey, attemptDelete } = useNostr()
  65    const { relayUrls: currentBrowsingRelayUrls } = useCurrentRelays()
  66    const { relaySets, favoriteRelays } = useFavoriteRelays()
  67    const relayUrls = useMemo(() => {
  68      return Array.from(new Set(currentBrowsingRelayUrls.concat(favoriteRelays)))
  69    }, [currentBrowsingRelayUrls, favoriteRelays])
  70    const { mutePubkeyPublicly, mutePubkeyPrivately, unmutePubkey, mutePubkeySet } = useMuteList()
  71    const { pinnedEventHexIdSet, pin, unpin } = usePinList()
  72    const isMuted = useMemo(() => mutePubkeySet.has(event.pubkey), [mutePubkeySet, event])
  73  
  74    const broadcastSubMenu: SubMenuAction[] = useMemo(() => {
  75      const items = []
  76      if (pubkey && event.pubkey === pubkey) {
  77        items.push({
  78          label: <div className="text-left"> {t('Optimal relays')}</div>,
  79          onClick: async () => {
  80            closeDrawer()
  81            const promise = async () => {
  82              const relays = await client.determineTargetRelays(event)
  83              if (relays?.length) {
  84                await client.publishEvent(relays, event)
  85              }
  86            }
  87            toast.promise(promise, {
  88              loading: t('Republishing...'),
  89              success: () => {
  90                return t(
  91                  "Successfully republish to optimal relays (your write relays and mentioned users' read relays)"
  92                )
  93              },
  94              error: (err) => {
  95                return t('Failed to republish to optimal relays: {{error}}', {
  96                  error: err.message
  97                })
  98              }
  99            })
 100          }
 101        })
 102      }
 103  
 104      if (relaySets.length) {
 105        items.push(
 106          ...relaySets
 107            .filter((set) => set.relayUrls.length)
 108            .map((set, index) => ({
 109              label: <div className="text-left truncate">{set.name}</div>,
 110              onClick: async () => {
 111                closeDrawer()
 112                const promise = client.publishEvent(set.relayUrls, event)
 113                toast.promise(promise, {
 114                  loading: t('Republishing...'),
 115                  success: () => {
 116                    return t('Successfully republish to relay set: {{name}}', { name: set.name })
 117                  },
 118                  error: (err) => {
 119                    return t('Failed to republish to relay set: {{name}}. Error: {{error}}', {
 120                      name: set.name,
 121                      error: err.message
 122                    })
 123                  }
 124                })
 125              },
 126              separator: index === 0
 127            }))
 128        )
 129      }
 130  
 131      if (relayUrls.length) {
 132        items.push(
 133          ...relayUrls.map((relay, index) => ({
 134            label: (
 135              <div className="flex items-center gap-2 w-full">
 136                <RelayIcon url={relay} />
 137                <div className="flex-1 truncate text-left">{simplifyUrl(relay)}</div>
 138              </div>
 139            ),
 140            onClick: async () => {
 141              closeDrawer()
 142              const promise = client.publishEvent([relay], event)
 143              toast.promise(promise, {
 144                loading: t('Republishing...'),
 145                success: () => {
 146                  return t('Successfully republish to relay: {{url}}', { url: simplifyUrl(relay) })
 147                },
 148                error: (err) => {
 149                  return t('Failed to republish to relay: {{url}}. Error: {{error}}', {
 150                    url: simplifyUrl(relay),
 151                    error: err.message
 152                  })
 153                }
 154              })
 155            },
 156            separator: index === 0
 157          }))
 158        )
 159      }
 160  
 161      return items
 162    }, [pubkey, relayUrls, relaySets])
 163  
 164    const menuActions: MenuAction[] = useMemo(() => {
 165      const actions: MenuAction[] = [
 166        {
 167          icon: Copy,
 168          label: t('Copy event ID'),
 169          onClick: () => {
 170            navigator.clipboard.writeText(getNoteBech32Id(event))
 171            closeDrawer()
 172          }
 173        },
 174        {
 175          icon: Copy,
 176          label: t('Copy user ID'),
 177          onClick: () => {
 178            navigator.clipboard.writeText(Pubkey.tryFromString(event.pubkey)?.npub ?? '')
 179            closeDrawer()
 180          }
 181        },
 182        {
 183          icon: Link,
 184          label: t('Copy share link'),
 185          onClick: () => {
 186            navigator.clipboard.writeText(toNjump(getNoteBech32Id(event)))
 187            closeDrawer()
 188          }
 189        },
 190        {
 191          icon: Code,
 192          label: t('View raw event'),
 193          onClick: () => {
 194            closeDrawer()
 195            setIsRawEventDialogOpen(true)
 196          },
 197          separator: true
 198        }
 199      ]
 200  
 201      const isProtected = isProtectedEvent(event)
 202      if (!isProtected || event.pubkey === pubkey) {
 203        actions.push({
 204          icon: SatelliteDish,
 205          label: t('Republish to ...'),
 206          onClick: isSmallScreen
 207            ? () => showSubMenuActions(broadcastSubMenu, t('Republish to ...'))
 208            : undefined,
 209          subMenu: isSmallScreen ? undefined : broadcastSubMenu,
 210          separator: true
 211        })
 212      }
 213  
 214      if (event.pubkey === pubkey && event.kind === kinds.ShortTextNote) {
 215        const pinned = pinnedEventHexIdSet.has(event.id)
 216        actions.push({
 217          icon: pinned ? PinOff : Pin,
 218          label: pinned ? t('Unpin from profile') : t('Pin to profile'),
 219          onClick: async () => {
 220            closeDrawer()
 221            await (pinned ? unpin(event) : pin(event))
 222          }
 223        })
 224      }
 225  
 226      if (pubkey && event.pubkey !== pubkey) {
 227        actions.push({
 228          icon: TriangleAlert,
 229          label: t('Report'),
 230          className: 'text-destructive focus:text-destructive',
 231          onClick: () => {
 232            closeDrawer()
 233            setIsReportDialogOpen(true)
 234          },
 235          separator: true
 236        })
 237      }
 238  
 239      if (pubkey && event.pubkey !== pubkey) {
 240        if (isMuted) {
 241          actions.push({
 242            icon: Bell,
 243            label: t('Unmute user'),
 244            onClick: () => {
 245              closeDrawer()
 246              unmutePubkey(event.pubkey)
 247            },
 248            className: 'text-destructive focus:text-destructive',
 249            separator: true
 250          })
 251        } else {
 252          actions.push(
 253            {
 254              icon: BellOff,
 255              label: t('Mute user privately'),
 256              onClick: () => {
 257                closeDrawer()
 258                mutePubkeyPrivately(event.pubkey)
 259              },
 260              className: 'text-destructive focus:text-destructive',
 261              separator: true
 262            },
 263            {
 264              icon: BellOff,
 265              label: t('Mute user publicly'),
 266              onClick: () => {
 267                closeDrawer()
 268                mutePubkeyPublicly(event.pubkey)
 269              },
 270              className: 'text-destructive focus:text-destructive'
 271            }
 272          )
 273        }
 274      }
 275  
 276      if (pubkey && event.pubkey === pubkey) {
 277        actions.push({
 278          icon: Trash2,
 279          label: t('Try deleting this note'),
 280          onClick: () => {
 281            closeDrawer()
 282            attemptDelete(event)
 283          },
 284          className: 'text-destructive focus:text-destructive',
 285          separator: true
 286        })
 287      }
 288  
 289      return actions
 290    }, [
 291      t,
 292      event,
 293      pubkey,
 294      isMuted,
 295      isSmallScreen,
 296      broadcastSubMenu,
 297      pinnedEventHexIdSet,
 298      closeDrawer,
 299      showSubMenuActions,
 300      setIsRawEventDialogOpen,
 301      mutePubkeyPrivately,
 302      mutePubkeyPublicly,
 303      unmutePubkey
 304    ])
 305  
 306    return menuActions
 307  }
 308