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