index.tsx raw

   1  /**
   2   * Cache Relays Setting Component
   3   *
   4   * Configure NRC connections as cache relays for faster event loading.
   5   * Cache relays are queried first (400ms timeout) before regular relays,
   6   * and loaded events are pushed to them in background.
   7   */
   8  
   9  import { useState, useCallback } from 'react'
  10  import { useTranslation } from 'react-i18next'
  11  import { useNostr } from '@/providers/NostrProvider'
  12  import nrcCacheRelayService, { NRCCacheRelayConfig } from '@/services/nrc/nrc-cache-relay.service'
  13  import { Button } from '@/components/ui/button'
  14  import { Input } from '@/components/ui/input'
  15  import { Label } from '@/components/ui/label'
  16  import { Switch } from '@/components/ui/switch'
  17  import {
  18    Dialog,
  19    DialogContent,
  20    DialogDescription,
  21    DialogFooter,
  22    DialogHeader,
  23    DialogTitle
  24  } from '@/components/ui/dialog'
  25  import {
  26    AlertDialog,
  27    AlertDialogAction,
  28    AlertDialogCancel,
  29    AlertDialogContent,
  30    AlertDialogDescription,
  31    AlertDialogFooter,
  32    AlertDialogHeader,
  33    AlertDialogTitle,
  34    AlertDialogTrigger
  35  } from '@/components/ui/alert-dialog'
  36  import { Plus, Trash2, Zap, Database } from 'lucide-react'
  37  
  38  export default function CacheRelaysSetting() {
  39    const { t } = useTranslation()
  40    const { pubkey } = useNostr()
  41  
  42    // Cache relay state
  43    const [cacheRelays, setCacheRelays] = useState<NRCCacheRelayConfig[]>(nrcCacheRelayService.getAll())
  44    const [isDialogOpen, setIsDialogOpen] = useState(false)
  45    const [uri, setUri] = useState('')
  46    const [label, setLabel] = useState('')
  47    const [queryFirst, setQueryFirst] = useState(true)
  48    const [pushEvents, setPushEvents] = useState(true)
  49    const [isLoading, setIsLoading] = useState(false)
  50    const [isTesting, setIsTesting] = useState<string | null>(null)
  51    const [error, setError] = useState<string | null>(null)
  52  
  53    const handleAdd = useCallback(async () => {
  54      if (!uri.trim() || !label.trim()) return
  55  
  56      setIsLoading(true)
  57      setError(null)
  58      try {
  59        // Test connection first
  60        await nrcCacheRelayService.testConnection(uri.trim())
  61  
  62        // Add the cache relay
  63        nrcCacheRelayService.add({
  64          uri: uri.trim(),
  65          label: label.trim(),
  66          enabled: true,
  67          queryFirst,
  68          pushEvents
  69        })
  70  
  71        setCacheRelays(nrcCacheRelayService.getAll())
  72        setIsDialogOpen(false)
  73        setUri('')
  74        setLabel('')
  75        setQueryFirst(true)
  76        setPushEvents(true)
  77      } catch (err) {
  78        console.error('Failed to add cache relay:', err)
  79        setError(err instanceof Error ? err.message : 'Connection failed')
  80      } finally {
  81        setIsLoading(false)
  82      }
  83    }, [uri, label, queryFirst, pushEvents])
  84  
  85    const handleRemove = useCallback((id: string) => {
  86      nrcCacheRelayService.remove(id)
  87      setCacheRelays(nrcCacheRelayService.getAll())
  88    }, [])
  89  
  90    const handleToggleEnabled = useCallback((id: string, enabled: boolean) => {
  91      nrcCacheRelayService.update(id, { enabled })
  92      setCacheRelays(nrcCacheRelayService.getAll())
  93    }, [])
  94  
  95    const handleToggleQueryFirst = useCallback((id: string, queryFirst: boolean) => {
  96      nrcCacheRelayService.update(id, { queryFirst })
  97      setCacheRelays(nrcCacheRelayService.getAll())
  98    }, [])
  99  
 100    const handleTogglePushEvents = useCallback((id: string, pushEvents: boolean) => {
 101      nrcCacheRelayService.update(id, { pushEvents })
 102      setCacheRelays(nrcCacheRelayService.getAll())
 103    }, [])
 104  
 105    const handleTest = useCallback(async (id: string, relayUri: string) => {
 106      setIsTesting(id)
 107      setError(null)
 108      try {
 109        await nrcCacheRelayService.testConnection(relayUri)
 110        nrcCacheRelayService.update(id, { lastConnected: Date.now(), lastError: undefined })
 111        setCacheRelays(nrcCacheRelayService.getAll())
 112      } catch (err) {
 113        const errorMsg = err instanceof Error ? err.message : 'Connection failed'
 114        nrcCacheRelayService.update(id, { lastError: errorMsg })
 115        setCacheRelays(nrcCacheRelayService.getAll())
 116      } finally {
 117        setIsTesting(null)
 118      }
 119    }, [])
 120  
 121    if (!pubkey) {
 122      return (
 123        <div className="text-muted-foreground text-sm p-4 text-center">
 124          {t('Login required to configure cache relays')}
 125        </div>
 126      )
 127    }
 128  
 129    return (
 130      <div className="space-y-4">
 131        <div className="p-3 bg-muted/30 rounded-lg">
 132          <p className="text-sm text-muted-foreground">
 133            {t('Cache relays store events via NRC for faster loading. They are queried first (400ms timeout) before regular relays.')}
 134          </p>
 135        </div>
 136  
 137        {/* Header with Add button */}
 138        <div className="flex items-center justify-between">
 139          <Label className="flex items-center gap-2">
 140            <Database className="w-4 h-4" />
 141            {t('Cache Relays')}
 142          </Label>
 143          <Button
 144            variant="outline"
 145            size="sm"
 146            onClick={() => setIsDialogOpen(true)}
 147            className="gap-1"
 148          >
 149            <Plus className="w-4 h-4" />
 150            {t('Add')}
 151          </Button>
 152        </div>
 153  
 154        {/* Cache Relays List */}
 155        {cacheRelays.length === 0 ? (
 156          <div className="text-sm text-muted-foreground p-4 text-center border border-dashed rounded-lg">
 157            {t('No cache relays configured')}
 158          </div>
 159        ) : (
 160          <div className="space-y-2">
 161            {cacheRelays.map((relay) => (
 162              <div
 163                key={relay.id}
 164                className="p-3 bg-muted/30 rounded-lg space-y-3"
 165              >
 166                <div className="flex items-center justify-between">
 167                  <div className="flex-1 min-w-0">
 168                    <div className="flex items-center gap-2">
 169                      <span className="font-medium truncate">{relay.label}</span>
 170                      {relay.lastError && (
 171                        <span className="text-xs text-destructive">({t('Error')})</span>
 172                      )}
 173                      {relay.lastConnected && !relay.lastError && (
 174                        <span className="text-xs text-green-500">({t('Connected')})</span>
 175                      )}
 176                    </div>
 177                    <div className="text-xs text-muted-foreground truncate">
 178                      {relay.uri.length > 60 ? relay.uri.substring(0, 60) + '...' : relay.uri}
 179                    </div>
 180                  </div>
 181                  <div className="flex items-center gap-1">
 182                    <Switch
 183                      checked={relay.enabled}
 184                      onCheckedChange={(checked) => handleToggleEnabled(relay.id, checked)}
 185                      title={t('Enable/Disable')}
 186                    />
 187                    <Button
 188                      variant="ghost"
 189                      size="icon"
 190                      onClick={() => handleTest(relay.id, relay.uri)}
 191                      disabled={isTesting === relay.id}
 192                      title={t('Test Connection')}
 193                    >
 194                      <Zap className={`w-4 h-4 ${isTesting === relay.id ? 'animate-pulse' : ''}`} />
 195                    </Button>
 196                    <AlertDialog>
 197                      <AlertDialogTrigger asChild>
 198                        <Button
 199                          variant="ghost"
 200                          size="icon"
 201                          className="text-destructive hover:text-destructive"
 202                          title={t('Remove')}
 203                        >
 204                          <Trash2 className="w-4 h-4" />
 205                        </Button>
 206                      </AlertDialogTrigger>
 207                      <AlertDialogContent>
 208                        <AlertDialogHeader>
 209                          <AlertDialogTitle>{t('Remove Cache Relay?')}</AlertDialogTitle>
 210                          <AlertDialogDescription>
 211                            {t('This will remove "{{label}}" from your cache relays.', {
 212                              label: relay.label
 213                            })}
 214                          </AlertDialogDescription>
 215                        </AlertDialogHeader>
 216                        <AlertDialogFooter>
 217                          <AlertDialogCancel>{t('Cancel')}</AlertDialogCancel>
 218                          <AlertDialogAction
 219                            onClick={() => handleRemove(relay.id)}
 220                            className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
 221                          >
 222                            {t('Remove')}
 223                          </AlertDialogAction>
 224                        </AlertDialogFooter>
 225                      </AlertDialogContent>
 226                    </AlertDialog>
 227                  </div>
 228                </div>
 229  
 230                {/* Options */}
 231                <div className="flex items-center gap-4 text-sm">
 232                  <label className="flex items-center gap-2">
 233                    <Switch
 234                      checked={relay.queryFirst}
 235                      onCheckedChange={(checked) => handleToggleQueryFirst(relay.id, checked)}
 236                      disabled={!relay.enabled}
 237                    />
 238                    <span className="text-muted-foreground">{t('Query first')}</span>
 239                  </label>
 240                  <label className="flex items-center gap-2">
 241                    <Switch
 242                      checked={relay.pushEvents}
 243                      onCheckedChange={(checked) => handleTogglePushEvents(relay.id, checked)}
 244                      disabled={!relay.enabled}
 245                    />
 246                    <span className="text-muted-foreground">{t('Push events')}</span>
 247                  </label>
 248                </div>
 249  
 250                {relay.lastError && (
 251                  <div className="text-xs text-destructive">
 252                    {relay.lastError}
 253                  </div>
 254                )}
 255              </div>
 256            ))}
 257          </div>
 258        )}
 259  
 260        {/* Add Cache Relay Dialog */}
 261        <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
 262          <DialogContent>
 263            <DialogHeader>
 264              <DialogTitle>{t('Add Cache Relay')}</DialogTitle>
 265              <DialogDescription>
 266                {t('Add an NRC connection as a cache relay for faster event loading')}
 267              </DialogDescription>
 268            </DialogHeader>
 269            <div className="space-y-4 py-4">
 270              <div className="space-y-2">
 271                <Label htmlFor="cache-relay-uri">{t('Connection URI')}</Label>
 272                <Input
 273                  id="cache-relay-uri"
 274                  value={uri}
 275                  onChange={(e) => setUri(e.target.value)}
 276                  placeholder="nostr+relayconnect://..."
 277                  className="font-mono text-xs"
 278                />
 279              </div>
 280              <div className="space-y-2">
 281                <Label htmlFor="cache-relay-label">{t('Label')}</Label>
 282                <Input
 283                  id="cache-relay-label"
 284                  value={label}
 285                  onChange={(e) => setLabel(e.target.value)}
 286                  placeholder={t('e.g., Home Relay, Personal Cache')}
 287                />
 288              </div>
 289              <div className="space-y-3">
 290                <div className="flex items-center justify-between">
 291                  <div>
 292                    <Label>{t('Query first')}</Label>
 293                    <p className="text-xs text-muted-foreground">
 294                      {t('Check cache relay before regular relays (400ms timeout)')}
 295                    </p>
 296                  </div>
 297                  <Switch
 298                    checked={queryFirst}
 299                    onCheckedChange={setQueryFirst}
 300                  />
 301                </div>
 302                <div className="flex items-center justify-between">
 303                  <div>
 304                    <Label>{t('Push events')}</Label>
 305                    <p className="text-xs text-muted-foreground">
 306                      {t('Store loaded events in cache relay in background')}
 307                    </p>
 308                  </div>
 309                  <Switch
 310                    checked={pushEvents}
 311                    onCheckedChange={setPushEvents}
 312                  />
 313                </div>
 314              </div>
 315              {error && (
 316                <div className="text-sm text-destructive">{error}</div>
 317              )}
 318            </div>
 319            <DialogFooter>
 320              <Button variant="outline" onClick={() => {
 321                setIsDialogOpen(false)
 322                setError(null)
 323              }}>
 324                {t('Cancel')}
 325              </Button>
 326              <Button
 327                onClick={handleAdd}
 328                disabled={!uri.trim() || !label.trim() || isLoading}
 329              >
 330                {isLoading ? t('Testing...') : t('Add')}
 331              </Button>
 332            </DialogFooter>
 333          </DialogContent>
 334        </Dialog>
 335      </div>
 336    )
 337  }
 338