ConversationSettingsModal.tsx raw
1 import {
2 Dialog,
3 DialogContent,
4 DialogHeader,
5 DialogTitle
6 } from '@/components/ui/dialog'
7 import { Button } from '@/components/ui/button'
8 import { Checkbox } from '@/components/ui/checkbox'
9 import { Label } from '@/components/ui/label'
10 import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
11 import { useNostr } from '@/providers/NostrProvider'
12 import client from '@/services/client.service'
13 import indexedDb from '@/services/indexed-db.service'
14 import { TRelayList } from '@/types'
15 import { Check, Loader2, Lock, LockOpen, User, Users, Zap } from 'lucide-react'
16 import { useEffect, useState } from 'react'
17 import { useTranslation } from 'react-i18next'
18
19 type EncryptionPreference = 'auto' | 'nip04' | 'nip17'
20
21 interface ConversationSettingsModalProps {
22 partnerPubkey: string | null
23 open: boolean
24 onOpenChange: (open: boolean) => void
25 selectedRelays: string[]
26 onSelectedRelaysChange: (relays: string[]) => void
27 }
28
29 type RelayInfo = {
30 url: string
31 isYours: boolean
32 isTheirs: boolean
33 isShared: boolean
34 }
35
36 export default function ConversationSettingsModal({
37 partnerPubkey,
38 open,
39 onOpenChange,
40 selectedRelays,
41 onSelectedRelaysChange
42 }: ConversationSettingsModalProps) {
43 const { t } = useTranslation()
44 const { pubkey, relayList: myRelayList, hasNip44Support } = useNostr()
45 const [partnerRelayList, setPartnerRelayList] = useState<TRelayList | null>(null)
46 const [isLoading, setIsLoading] = useState(false)
47 const [relays, setRelays] = useState<RelayInfo[]>([])
48 const [encryptionPreference, setEncryptionPreference] = useState<EncryptionPreference>('auto')
49
50 // Fetch partner's relay list when modal opens
51 useEffect(() => {
52 if (!open || !partnerPubkey) return
53
54 const fetchPartnerRelays = async () => {
55 setIsLoading(true)
56 try {
57 const relayList = await client.fetchRelayList(partnerPubkey)
58 setPartnerRelayList(relayList)
59 } catch (error) {
60 console.error('Failed to fetch partner relay list:', error)
61 } finally {
62 setIsLoading(false)
63 }
64 }
65
66 fetchPartnerRelays()
67 }, [open, partnerPubkey])
68
69 // Load encryption preference when modal opens
70 useEffect(() => {
71 if (!open || !partnerPubkey || !pubkey) return
72
73 const loadEncryptionPreference = async () => {
74 const saved = await indexedDb.getConversationEncryptionPreference(pubkey, partnerPubkey)
75 setEncryptionPreference(saved || 'auto')
76 }
77 loadEncryptionPreference()
78 }, [open, partnerPubkey, pubkey])
79
80 // Save encryption preference when it changes
81 const handleEncryptionChange = async (value: EncryptionPreference) => {
82 setEncryptionPreference(value)
83 if (pubkey && partnerPubkey) {
84 await indexedDb.putConversationEncryptionPreference(pubkey, partnerPubkey, value)
85 }
86 }
87
88 // Build relay list when data is available
89 useEffect(() => {
90 if (!myRelayList || !partnerRelayList) return
91
92 const myWriteRelays = new Set(myRelayList.write.map((r) => r.replace(/\/$/, '')))
93 const theirReadRelays = new Set(partnerRelayList.read.map((r) => r.replace(/\/$/, '')))
94
95 // Combine all relays
96 const allRelayUrls = new Set<string>()
97 myRelayList.write.forEach((r) => allRelayUrls.add(r.replace(/\/$/, '')))
98 partnerRelayList.read.forEach((r) => allRelayUrls.add(r.replace(/\/$/, '')))
99
100 const relayInfos: RelayInfo[] = Array.from(allRelayUrls).map((url) => {
101 const normalizedUrl = url.replace(/\/$/, '')
102 const isYours = myWriteRelays.has(normalizedUrl)
103 const isTheirs = theirReadRelays.has(normalizedUrl)
104 return {
105 url,
106 isYours,
107 isTheirs,
108 isShared: isYours && isTheirs
109 }
110 })
111
112 // Sort: shared first, then yours, then theirs
113 relayInfos.sort((a, b) => {
114 if (a.isShared && !b.isShared) return -1
115 if (!a.isShared && b.isShared) return 1
116 if (a.isYours && !b.isYours) return -1
117 if (!a.isYours && b.isYours) return 1
118 return a.url.localeCompare(b.url)
119 })
120
121 setRelays(relayInfos)
122
123 // If no relays selected yet, default to shared relays
124 if (selectedRelays.length === 0) {
125 const sharedRelays = relayInfos.filter((r) => r.isShared).map((r) => r.url)
126 if (sharedRelays.length > 0) {
127 onSelectedRelaysChange(sharedRelays)
128 }
129 }
130 }, [myRelayList, partnerRelayList])
131
132 const toggleRelay = (url: string) => {
133 if (selectedRelays.includes(url)) {
134 onSelectedRelaysChange(selectedRelays.filter((r) => r !== url))
135 } else {
136 onSelectedRelaysChange([...selectedRelays, url])
137 }
138 }
139
140 const selectAllShared = () => {
141 const sharedUrls = relays.filter((r) => r.isShared).map((r) => r.url)
142 onSelectedRelaysChange(sharedUrls)
143 }
144
145 const selectAll = () => {
146 onSelectedRelaysChange(relays.map((r) => r.url))
147 }
148
149 const formatRelayUrl = (url: string) => {
150 try {
151 const parsed = new URL(url)
152 return parsed.hostname + (parsed.pathname !== '/' ? parsed.pathname : '')
153 } catch {
154 return url
155 }
156 }
157
158 if (!partnerPubkey || !pubkey) return null
159
160 return (
161 <Dialog open={open} onOpenChange={onOpenChange}>
162 <DialogContent className="max-w-md max-h-[80vh] flex flex-col">
163 <DialogHeader>
164 <DialogTitle>{t('Conversation Settings')}</DialogTitle>
165 </DialogHeader>
166
167 <div className="flex-1 overflow-hidden flex flex-col gap-4">
168 {/* Encryption Preference */}
169 <div className="space-y-2">
170 <Label className="text-sm font-medium">{t('Encryption')}</Label>
171 <RadioGroup
172 value={encryptionPreference}
173 onValueChange={(value) => handleEncryptionChange(value as EncryptionPreference)}
174 className="grid grid-cols-3 gap-2"
175 >
176 <div className="flex items-center space-x-2">
177 <RadioGroupItem value="auto" id="enc-auto" />
178 <Label
179 htmlFor="enc-auto"
180 className="flex items-center gap-1 text-xs cursor-pointer"
181 >
182 <Zap className="size-3" />
183 {t('Auto')}
184 </Label>
185 </div>
186 <div className="flex items-center space-x-2">
187 <RadioGroupItem value="nip04" id="enc-nip04" />
188 <Label
189 htmlFor="enc-nip04"
190 className="flex items-center gap-1 text-xs cursor-pointer"
191 >
192 <LockOpen className="size-3" />
193 NIP-04
194 </Label>
195 </div>
196 <div className="flex items-center space-x-2">
197 <RadioGroupItem
198 value="nip17"
199 id="enc-nip17"
200 disabled={!hasNip44Support}
201 />
202 <Label
203 htmlFor="enc-nip17"
204 className={`flex items-center gap-1 text-xs cursor-pointer ${!hasNip44Support ? 'opacity-50' : ''}`}
205 >
206 <Lock className="size-3" />
207 NIP-17
208 </Label>
209 </div>
210 </RadioGroup>
211 <p className="text-xs text-muted-foreground">
212 {encryptionPreference === 'auto'
213 ? t('Matches existing conversation encryption, or sends both on first message')
214 : encryptionPreference === 'nip04'
215 ? t('Classic encryption (NIP-04) - compatible with all clients')
216 : t('Modern encryption (NIP-17) - more private with metadata protection')}
217 </p>
218 </div>
219
220 <div className="border-t pt-4">
221 <Label className="text-sm font-medium">{t('Relays')}</Label>
222 </div>
223
224 {/* Legend */}
225 <div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
226 <div className="flex items-center gap-1">
227 <User className="size-3" />
228 <span>{t('You')}</span>
229 </div>
230 <div className="flex items-center gap-1">
231 <Users className="size-3" />
232 <span>{t('Them')}</span>
233 </div>
234 <div className="flex items-center gap-1">
235 <div className="size-3 rounded bg-green-500/20 border border-green-500/50" />
236 <span>{t('Shared')}</span>
237 </div>
238 <div className="flex items-center gap-1">
239 <Check className="size-3" />
240 <span>{t('Selected for sending')}</span>
241 </div>
242 </div>
243
244 {/* Quick actions */}
245 <div className="flex gap-2">
246 <Button variant="outline" size="sm" onClick={selectAllShared}>
247 {t('Select shared')}
248 </Button>
249 <Button variant="outline" size="sm" onClick={selectAll}>
250 {t('Select all')}
251 </Button>
252 </div>
253
254 {/* Relay list */}
255 <div className="flex-1 overflow-y-auto space-y-1 min-h-0">
256 {isLoading ? (
257 <div className="flex items-center justify-center py-8">
258 <Loader2 className="size-6 animate-spin text-muted-foreground" />
259 </div>
260 ) : relays.length === 0 ? (
261 <p className="text-sm text-muted-foreground text-center py-4">
262 {t('No relay information available')}
263 </p>
264 ) : (
265 relays.map((relay) => (
266 <div
267 key={relay.url}
268 className={`flex items-center gap-2 p-2 rounded-lg cursor-pointer hover:bg-accent/50 transition-colors ${
269 relay.isShared ? 'bg-green-500/10 border border-green-500/30' : 'bg-muted/50'
270 }`}
271 onClick={() => toggleRelay(relay.url)}
272 >
273 <Checkbox
274 checked={selectedRelays.includes(relay.url)}
275 onCheckedChange={() => toggleRelay(relay.url)}
276 />
277 <div className="flex-1 min-w-0">
278 <span className="text-sm font-mono truncate block" title={relay.url}>
279 {formatRelayUrl(relay.url)}
280 </span>
281 </div>
282 <div className="flex items-center gap-1 flex-shrink-0">
283 {relay.isYours && (
284 <span
285 className="text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-600 dark:text-blue-400"
286 title={t('Your write relay')}
287 >
288 <User className="size-3" />
289 </span>
290 )}
291 {relay.isTheirs && (
292 <span
293 className="text-xs px-1.5 py-0.5 rounded bg-purple-500/20 text-purple-600 dark:text-purple-400"
294 title={t('Their read relay')}
295 >
296 <Users className="size-3" />
297 </span>
298 )}
299 </div>
300 </div>
301 ))
302 )}
303 </div>
304
305 {/* Info text */}
306 <p className="text-xs text-muted-foreground">
307 {t('Selected relays will be used when sending new messages in this conversation.')}
308 </p>
309 </div>
310 </DialogContent>
311 </Dialog>
312 )
313 }
314