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