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