index.tsx raw

   1  import { Button, ButtonProps } from '@/components/ui/button'
   2  import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/dialog'
   3  import { Drawer, DrawerContent, DrawerOverlay, DrawerTrigger } from '@/components/ui/drawer'
   4  import { Separator } from '@/components/ui/separator'
   5  import { ExtendedKind } from '@/constants'
   6  import { getReplaceableEventIdentifier, getNoteBech32Id } from '@/lib/event'
   7  import { toChachiChat } from '@/lib/link'
   8  import { useScreenSize } from '@/providers/ScreenSizeProvider'
   9  import clientService from '@/services/client.service'
  10  import { ExternalLink } from 'lucide-react'
  11  import { Event, kinds, nip19 } from 'nostr-tools'
  12  import { Dispatch, SetStateAction, useMemo, useState } from 'react'
  13  import { useTranslation } from 'react-i18next'
  14  
  15  const clients: Record<string, { name: string; getUrl: (id: string) => string }> = {
  16    nosta: {
  17      name: 'Nosta',
  18      getUrl: (id: string) => `https://nosta.me/${id}`
  19    },
  20    snort: {
  21      name: 'Snort',
  22      getUrl: (id: string) => `https://snort.social/${id}`
  23    },
  24    olas: {
  25      name: 'Olas',
  26      getUrl: (id: string) => `https://olas.app/e/${id}`
  27    },
  28    primal: {
  29      name: 'Primal',
  30      getUrl: (id: string) => `https://primal.net/e/${id}`
  31    },
  32    nostrudel: {
  33      name: 'Nostrudel',
  34      getUrl: (id: string) => `https://nostrudel.ninja/l/${id}`
  35    },
  36    nostter: {
  37      name: 'Nostter',
  38      getUrl: (id: string) => `https://nostter.app/${id}`
  39    },
  40    coracle: {
  41      name: 'Coracle',
  42      getUrl: (id: string) => `https://coracle.social/${id}`
  43    },
  44    iris: {
  45      name: 'Iris',
  46      getUrl: (id: string) => `https://iris.to/${id}`
  47    },
  48    lumilumi: {
  49      name: 'Lumilumi',
  50      getUrl: (id: string) => `https://lumilumi.app/${id}`
  51    },
  52    zapStream: {
  53      name: 'zap.stream',
  54      getUrl: (id: string) => `https://zap.stream/${id}`
  55    },
  56    yakihonne: {
  57      name: 'YakiHonne',
  58      getUrl: (id: string) => `https://yakihonne.com/${id}`
  59    },
  60    habla: {
  61      name: 'Habla',
  62      getUrl: (id: string) => `https://habla.news/a/${id}`
  63    },
  64    pareto: {
  65      name: 'Pareto',
  66      getUrl: (id: string) => `https://pareto.space/a/${id}`
  67    },
  68    njump: {
  69      name: 'Njump',
  70      getUrl: (id: string) => `https://njump.me/${id}`
  71    }
  72  }
  73  
  74  export default function ClientSelect({
  75    event,
  76    originalNoteId,
  77    ...props
  78  }: ButtonProps & {
  79    event?: Event
  80    originalNoteId?: string
  81  }) {
  82    const { isSmallScreen } = useScreenSize()
  83    const [open, setOpen] = useState(false)
  84    const { t } = useTranslation()
  85  
  86    const supportedClients = useMemo(() => {
  87      let kind: number | undefined
  88      if (event) {
  89        kind = event.kind
  90      } else if (originalNoteId) {
  91        try {
  92          const pointer = nip19.decode(originalNoteId)
  93          if (pointer.type === 'naddr') {
  94            kind = pointer.data.kind
  95          }
  96        } catch (error) {
  97          console.error('Failed to decode NIP-19 pointer:', error)
  98          return ['njump']
  99        }
 100      }
 101      if (!kind) {
 102        return ['njump']
 103      }
 104  
 105      switch (kind) {
 106        case kinds.LongFormArticle:
 107        case kinds.DraftLong:
 108          return ['yakihonne', 'coracle', 'habla', 'lumilumi', 'pareto', 'njump']
 109        case kinds.LiveEvent:
 110          return ['zapStream', 'nostrudel', 'njump']
 111        case kinds.Date:
 112        case kinds.Time:
 113          return ['coracle', 'njump']
 114        case kinds.CommunityDefinition:
 115          return ['coracle', 'snort', 'njump']
 116        default:
 117          return ['njump']
 118      }
 119    }, [event])
 120  
 121    if (!originalNoteId && !event) {
 122      return null
 123    }
 124  
 125    const content = (
 126      <div className="space-y-2">
 127        {event?.kind === ExtendedKind.GROUP_METADATA ? (
 128          <RelayBasedGroupChatSelector
 129            event={event}
 130            originalNoteId={originalNoteId}
 131            setOpen={setOpen}
 132          />
 133        ) : (
 134          supportedClients.map((clientId) => {
 135            const client = clients[clientId]
 136            if (!client) return null
 137  
 138            return (
 139              <ClientSelectItem
 140                key={clientId}
 141                onClick={() => setOpen(false)}
 142                href={client.getUrl(originalNoteId ?? getNoteBech32Id(event!))}
 143                name={client.name}
 144              />
 145            )
 146          })
 147        )}
 148        <Separator />
 149        <Button
 150          variant="ghost"
 151          className="w-full py-6 font-semibold"
 152          onClick={() => {
 153            navigator.clipboard.writeText(originalNoteId ?? getNoteBech32Id(event!))
 154            setOpen(false)
 155          }}
 156        >
 157          {t('Copy event ID')}
 158        </Button>
 159      </div>
 160    )
 161  
 162    const trigger = (
 163      <Button variant="outline" {...props}>
 164        <ExternalLink /> {t('Open in another client')}
 165      </Button>
 166    )
 167  
 168    if (isSmallScreen) {
 169      return (
 170        <div onClick={(e) => e.stopPropagation()}>
 171          <Drawer open={open} onOpenChange={setOpen}>
 172            <DrawerTrigger asChild>{trigger}</DrawerTrigger>
 173            <DrawerOverlay
 174              onClick={(e) => {
 175                e.stopPropagation()
 176                setOpen(false)
 177              }}
 178            />
 179            <DrawerContent hideOverlay>{content}</DrawerContent>
 180          </Drawer>
 181        </div>
 182      )
 183    }
 184  
 185    return (
 186      <div onClick={(e) => e.stopPropagation()}>
 187        <Dialog open={open} onOpenChange={setOpen}>
 188          <DialogTrigger asChild>{trigger}</DialogTrigger>
 189          <DialogContent className="px-8" onOpenAutoFocus={(e) => e.preventDefault()}>
 190            {content}
 191          </DialogContent>
 192        </Dialog>
 193      </div>
 194    )
 195  }
 196  
 197  function RelayBasedGroupChatSelector({
 198    event,
 199    originalNoteId,
 200    setOpen
 201  }: {
 202    event: Event
 203    setOpen: Dispatch<SetStateAction<boolean>>
 204    originalNoteId?: string
 205  }) {
 206    const { relay, id } = useMemo(() => {
 207      let relay: string | undefined
 208      if (originalNoteId) {
 209        const pointer = nip19.decode(originalNoteId)
 210        if (pointer.type === 'naddr' && pointer.data.relays?.length) {
 211          relay = pointer.data.relays[0]
 212        }
 213      }
 214      if (!relay) {
 215        relay = clientService.getEventHint(event.id)
 216      }
 217  
 218      return { relay, id: getReplaceableEventIdentifier(event) }
 219    }, [event, originalNoteId])
 220  
 221    return (
 222      <ClientSelectItem
 223        onClick={() => setOpen(false)}
 224        href={toChachiChat(relay, id)}
 225        name="Chachi Chat"
 226      />
 227    )
 228  }
 229  
 230  function ClientSelectItem({
 231    onClick,
 232    href,
 233    name
 234  }: {
 235    onClick: () => void
 236    href: string
 237    name: string
 238  }) {
 239    return (
 240      <Button asChild variant="ghost" className="w-full py-6 font-semibold" onClick={onClick}>
 241        <a href={href} target="_blank" rel="noopener noreferrer">
 242          {name}
 243        </a>
 244      </Button>
 245    )
 246  }
 247