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