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