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, Clock, 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 const [expirationSeconds, setExpirationSeconds] = useState(0)
50
51 const EXPIRATION_OPTIONS = [
52 { label: 'No expiry', seconds: 0 },
53 { label: '1 day', seconds: 86400 },
54 { label: '1 week', seconds: 604800 },
55 { label: '1 month', seconds: 2592000 },
56 ] as const
57
58 // Fetch partner's relay list when modal opens
59 useEffect(() => {
60 if (!open || !partnerPubkey) return
61
62 const fetchPartnerRelays = async () => {
63 setIsLoading(true)
64 try {
65 const relayList = await client.fetchRelayList(partnerPubkey)
66 setPartnerRelayList(relayList)
67 } catch (error) {
68 console.error('Failed to fetch partner relay list:', error)
69 } finally {
70 setIsLoading(false)
71 }
72 }
73
74 fetchPartnerRelays()
75 }, [open, partnerPubkey])
76
77 // Load encryption + expiration preferences when modal opens
78 useEffect(() => {
79 if (!open || !partnerPubkey || !pubkey) return
80
81 const loadPreferences = async () => {
82 const savedEnc = await indexedDb.getConversationEncryptionPreference(pubkey, partnerPubkey)
83 setEncryptionPreference(savedEnc || 'auto')
84 const savedExp = await indexedDb.getConversationExpirationPreference(pubkey, partnerPubkey)
85 setExpirationSeconds(savedExp)
86 }
87 loadPreferences()
88 }, [open, partnerPubkey, pubkey])
89
90 // Save encryption preference when it changes
91 const handleEncryptionChange = async (value: EncryptionPreference) => {
92 setEncryptionPreference(value)
93 if (pubkey && partnerPubkey) {
94 await indexedDb.putConversationEncryptionPreference(pubkey, partnerPubkey, value)
95 }
96 }
97
98 // Save expiration preference when it changes
99 const handleExpirationChange = async (seconds: number) => {
100 setExpirationSeconds(seconds)
101 if (pubkey && partnerPubkey) {
102 await indexedDb.putConversationExpirationPreference(pubkey, partnerPubkey, seconds)
103 }
104 }
105
106 // Build relay list when data is available
107 useEffect(() => {
108 if (!myRelayList || !partnerRelayList) return
109
110 const myWriteRelays = new Set(myRelayList.write.map((r) => r.replace(/\/$/, '')))
111 const theirReadRelays = new Set(partnerRelayList.read.map((r) => r.replace(/\/$/, '')))
112
113 // Combine all relays
114 const allRelayUrls = new Set<string>()
115 myRelayList.write.forEach((r) => allRelayUrls.add(r.replace(/\/$/, '')))
116 partnerRelayList.read.forEach((r) => allRelayUrls.add(r.replace(/\/$/, '')))
117
118 const relayInfos: RelayInfo[] = Array.from(allRelayUrls).map((url) => {
119 const normalizedUrl = url.replace(/\/$/, '')
120 const isYours = myWriteRelays.has(normalizedUrl)
121 const isTheirs = theirReadRelays.has(normalizedUrl)
122 return {
123 url,
124 isYours,
125 isTheirs,
126 isShared: isYours && isTheirs
127 }
128 })
129
130 // Sort: shared first, then yours, then theirs
131 relayInfos.sort((a, b) => {
132 if (a.isShared && !b.isShared) return -1
133 if (!a.isShared && b.isShared) return 1
134 if (a.isYours && !b.isYours) return -1
135 if (!a.isYours && b.isYours) return 1
136 return a.url.localeCompare(b.url)
137 })
138
139 setRelays(relayInfos)
140
141 // If no relays selected yet, default to shared relays
142 if (selectedRelays.length === 0) {
143 const sharedRelays = relayInfos.filter((r) => r.isShared).map((r) => r.url)
144 if (sharedRelays.length > 0) {
145 onSelectedRelaysChange(sharedRelays)
146 }
147 }
148 }, [myRelayList, partnerRelayList])
149
150 const toggleRelay = (url: string) => {
151 if (selectedRelays.includes(url)) {
152 onSelectedRelaysChange(selectedRelays.filter((r) => r !== url))
153 } else {
154 onSelectedRelaysChange([...selectedRelays, url])
155 }
156 }
157
158 const selectAllShared = () => {
159 const sharedUrls = relays.filter((r) => r.isShared).map((r) => r.url)
160 onSelectedRelaysChange(sharedUrls)
161 }
162
163 const selectAll = () => {
164 onSelectedRelaysChange(relays.map((r) => r.url))
165 }
166
167 const formatRelayUrl = (url: string) => {
168 try {
169 const parsed = new URL(url)
170 return parsed.hostname + (parsed.pathname !== '/' ? parsed.pathname : '')
171 } catch {
172 return url
173 }
174 }
175
176 if (!partnerPubkey || !pubkey) return null
177
178 return (
179 <Dialog open={open} onOpenChange={onOpenChange}>
180 <DialogContent className="max-w-md max-h-[80vh] flex flex-col">
181 <DialogHeader>
182 <DialogTitle>{t('Conversation Settings')}</DialogTitle>
183 </DialogHeader>
184
185 <div className="flex-1 overflow-hidden flex flex-col gap-4">
186 {/* Encryption Preference */}
187 <div className="space-y-2">
188 <Label className="text-sm font-medium">{t('Encryption')}</Label>
189 <RadioGroup
190 value={encryptionPreference}
191 onValueChange={(value) => handleEncryptionChange(value as EncryptionPreference)}
192 className="grid grid-cols-3 gap-2"
193 >
194 <div className="flex items-center space-x-2">
195 <RadioGroupItem value="auto" id="enc-auto" />
196 <Label
197 htmlFor="enc-auto"
198 className="flex items-center gap-1 text-xs cursor-pointer"
199 >
200 <Zap className="size-3" />
201 {t('Auto')}
202 </Label>
203 </div>
204 <div className="flex items-center space-x-2">
205 <RadioGroupItem value="nip04" id="enc-nip04" />
206 <Label
207 htmlFor="enc-nip04"
208 className="flex items-center gap-1 text-xs cursor-pointer"
209 >
210 <LockOpen className="size-3" />
211 NIP-04
212 </Label>
213 </div>
214 <div className="flex items-center space-x-2">
215 <RadioGroupItem
216 value="nip17"
217 id="enc-nip17"
218 disabled={!hasNip44Support}
219 />
220 <Label
221 htmlFor="enc-nip17"
222 className={`flex items-center gap-1 text-xs cursor-pointer ${!hasNip44Support ? 'opacity-50' : ''}`}
223 >
224 <Lock className="size-3" />
225 NIP-17
226 </Label>
227 </div>
228 </RadioGroup>
229 <p className="text-xs text-muted-foreground">
230 {encryptionPreference === 'auto'
231 ? t('Matches existing conversation encryption, or sends both on first message')
232 : encryptionPreference === 'nip04'
233 ? t('Classic encryption (NIP-04) - compatible with all clients')
234 : t('Modern encryption (NIP-17) - more private with metadata protection')}
235 </p>
236 </div>
237
238 {/* Message Expiration */}
239 <div className="space-y-2">
240 <Label className="text-sm font-medium flex items-center gap-1">
241 <Clock className="size-3.5" />
242 {t('Message Expiration')}
243 </Label>
244 <div className="flex flex-wrap gap-2">
245 {EXPIRATION_OPTIONS.map((opt) => (
246 <Button
247 key={opt.seconds}
248 variant={expirationSeconds === opt.seconds ? 'default' : 'outline'}
249 size="sm"
250 className="text-xs"
251 onClick={() => handleExpirationChange(opt.seconds)}
252 >
253 {t(opt.label)}
254 </Button>
255 ))}
256 </div>
257 <p className="text-xs text-muted-foreground">
258 {expirationSeconds === 0
259 ? t('Messages will not expire')
260 : t('Messages will include an expiration tag for relay garbage collection')}
261 </p>
262 </div>
263
264 <div className="border-t pt-4">
265 <Label className="text-sm font-medium">{t('Relays')}</Label>
266 </div>
267
268 {/* Legend */}
269 <div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
270 <div className="flex items-center gap-1">
271 <User className="size-3" />
272 <span>{t('You')}</span>
273 </div>
274 <div className="flex items-center gap-1">
275 <Users className="size-3" />
276 <span>{t('Them')}</span>
277 </div>
278 <div className="flex items-center gap-1">
279 <div className="size-3 rounded bg-green-500/20 border border-green-500/50" />
280 <span>{t('Shared')}</span>
281 </div>
282 <div className="flex items-center gap-1">
283 <Check className="size-3" />
284 <span>{t('Selected for sending')}</span>
285 </div>
286 </div>
287
288 {/* Quick actions */}
289 <div className="flex gap-2">
290 <Button variant="outline" size="sm" onClick={selectAllShared}>
291 {t('Select shared')}
292 </Button>
293 <Button variant="outline" size="sm" onClick={selectAll}>
294 {t('Select all')}
295 </Button>
296 </div>
297
298 {/* Relay list */}
299 <div className="flex-1 overflow-y-auto space-y-1 min-h-0">
300 {isLoading ? (
301 <div className="flex items-center justify-center py-8">
302 <Loader2 className="size-6 animate-spin text-muted-foreground" />
303 </div>
304 ) : relays.length === 0 ? (
305 <p className="text-sm text-muted-foreground text-center py-4">
306 {t('No relay information available')}
307 </p>
308 ) : (
309 relays.map((relay) => (
310 <div
311 key={relay.url}
312 className={`flex items-center gap-2 p-2 rounded-lg cursor-pointer hover:bg-accent/50 transition-colors ${
313 relay.isShared ? 'bg-green-500/10 border border-green-500/30' : 'bg-muted/50'
314 }`}
315 onClick={() => toggleRelay(relay.url)}
316 >
317 <Checkbox
318 checked={selectedRelays.includes(relay.url)}
319 onCheckedChange={() => toggleRelay(relay.url)}
320 />
321 <div className="flex-1 min-w-0">
322 <span className="text-sm font-mono truncate block" title={relay.url}>
323 {formatRelayUrl(relay.url)}
324 </span>
325 </div>
326 <div className="flex items-center gap-1 flex-shrink-0">
327 {relay.isYours && (
328 <span
329 className="text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-600 dark:text-blue-400"
330 title={t('Your write relay')}
331 >
332 <User className="size-3" />
333 </span>
334 )}
335 {relay.isTheirs && (
336 <span
337 className="text-xs px-1.5 py-0.5 rounded bg-purple-500/20 text-purple-600 dark:text-purple-400"
338 title={t('Their read relay')}
339 >
340 <Users className="size-3" />
341 </span>
342 )}
343 </div>
344 </div>
345 ))
346 )}
347 </div>
348
349 {/* Info text */}
350 <p className="text-xs text-muted-foreground">
351 {t('Selected relays will be used when sending new messages in this conversation.')}
352 </p>
353 </div>
354 </DialogContent>
355 </Dialog>
356 )
357 }
358