index.tsx raw
1 import { Button } from '@/components/ui/button'
2 import { Label } from '@/components/ui/label'
3 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
4 import { Switch } from '@/components/ui/switch'
5 import managedOutboxService from '@/services/managed-outbox.service'
6 import relayStatsService from '@/services/relay-stats.service'
7 import storage, { dispatchSettingsChanged } from '@/services/local-storage.service'
8 import type { TRelayEntry } from '@/types/relay-management'
9 import type { TOutboxMode } from '@/types/relay-management'
10 import { Check, ChevronDown, ChevronUp, Shield, ShieldAlert, ShieldOff, X } from 'lucide-react'
11 import { useCallback, useMemo, useState } from 'react'
12 import { useTranslation } from 'react-i18next'
13
14 type RelayTab = 'pending' | 'approved' | 'rejected'
15
16 function failureRateColor(rate: number): string {
17 if (rate >= 0.99) return 'text-red-900 dark:text-red-300'
18 if (rate > 0.5) return 'text-red-600 dark:text-red-400'
19 if (rate > 0.1) return 'text-yellow-600 dark:text-yellow-400'
20 return 'text-green-600 dark:text-green-400'
21 }
22
23 function RelayRow({ entry, onAction }: { entry: TRelayEntry; onAction: () => void }) {
24 const { t } = useTranslation()
25 const [expanded, setExpanded] = useState(false)
26 const failureRate = relayStatsService.getFailureRate(entry.url)
27 const autoDisabled = relayStatsService.isAutoDisabled(entry.url)
28
29 return (
30 <div className="border rounded-lg p-3 space-y-2">
31 <div className="flex items-center justify-between gap-2">
32 <div className="flex items-center gap-2 min-w-0 flex-1">
33 <button
34 type="button"
35 onClick={() => setExpanded(!expanded)}
36 className="text-muted-foreground hover:text-foreground shrink-0"
37 >
38 {expanded ? <ChevronUp className="size-4" /> : <ChevronDown className="size-4" />}
39 </button>
40 <span className="truncate text-sm font-mono">{entry.url}</span>
41 {autoDisabled && (
42 <span title={t('Auto-disabled')}><ShieldAlert className="size-4 text-red-500 shrink-0" /></span>
43 )}
44 {entry.manualExclude && (
45 <span title={t('Manually excluded')}><ShieldOff className="size-4 text-orange-500 shrink-0" /></span>
46 )}
47 </div>
48 <div className="flex items-center gap-1 shrink-0">
49 <span className="text-xs text-muted-foreground capitalize px-1.5 py-0.5 bg-muted rounded">
50 {entry.direction}
51 </span>
52 <span className={`text-xs font-mono ${failureRateColor(failureRate)}`}>
53 {(failureRate * 100).toFixed(0)}%
54 </span>
55 </div>
56 </div>
57 {expanded && (
58 <div className="pl-6 space-y-2">
59 {entry.reason && (
60 <div className="text-xs text-muted-foreground">{entry.reason}</div>
61 )}
62 {entry.relayIp && (
63 <div className="text-xs text-muted-foreground">IP: {entry.relayIp}</div>
64 )}
65 <div className="flex gap-2 flex-wrap">
66 {entry.status !== 'approved' && (
67 <Button
68 size="sm"
69 variant="outline"
70 onClick={() => { managedOutboxService.approve(entry.url); onAction() }}
71 >
72 <Check className="size-3 mr-1" /> {t('Approve')}
73 </Button>
74 )}
75 {entry.status !== 'rejected' && (
76 <Button
77 size="sm"
78 variant="outline"
79 onClick={() => { managedOutboxService.reject(entry.url); onAction() }}
80 >
81 <X className="size-3 mr-1" /> {t('Reject')}
82 </Button>
83 )}
84 {entry.status !== 'pending' && (
85 <Button
86 size="sm"
87 variant="outline"
88 onClick={() => { managedOutboxService.resetStatus(entry.url); onAction() }}
89 >
90 {t('Reset')}
91 </Button>
92 )}
93 <div className="flex items-center gap-1.5">
94 <Switch
95 checked={entry.manualExclude}
96 onCheckedChange={(checked) => {
97 managedOutboxService.setManualExclude(entry.url, checked)
98 onAction()
99 }}
100 />
101 <span className="text-xs text-muted-foreground">{t('Exclude')}</span>
102 </div>
103 </div>
104 </div>
105 )}
106 </div>
107 )
108 }
109
110 export default function ManagedOutboxSetting() {
111 const { t } = useTranslation()
112 const [outboxMode, setOutboxMode] = useState<TOutboxMode>(
113 storage.getOutboxMode() as TOutboxMode
114 )
115 const [tab, setTab] = useState<RelayTab>('pending')
116 const [refreshKey, setRefreshKey] = useState(0)
117
118 const refresh = useCallback(() => setRefreshKey((k) => k + 1), [])
119
120 const pending = useMemo(() => managedOutboxService.getPendingRelays(), [refreshKey])
121 const approved = useMemo(() => managedOutboxService.getApprovedRelays(), [refreshKey])
122 const rejected = useMemo(() => managedOutboxService.getRejectedRelays(), [refreshKey])
123 const excluded = useMemo(() => managedOutboxService.getExcludedRelays(), [refreshKey])
124 const autoDisabled = useMemo(() => managedOutboxService.getAutoDisabledRelays(), [refreshKey])
125
126 const currentList = tab === 'pending' ? pending : tab === 'approved' ? approved : rejected
127
128 const handleModeChange = (mode: string) => {
129 storage.setOutboxMode(mode)
130 setOutboxMode(mode as TOutboxMode)
131 dispatchSettingsChanged()
132 }
133
134 return (
135 <div className="space-y-4">
136 <div className="text-sm text-muted-foreground space-y-2">
137 <p>
138 {t('Relays discovered via outbox model (NIP-65) are tracked here with per-network failure stats. In')}
139 {' '}<strong>{t('Automatic')}</strong> {t('mode, all discovered relays are used unless manually excluded or auto-disabled. In')}
140 {' '}<strong>{t('Managed')}</strong> {t('mode, relays must be explicitly approved before use.')}
141 </p>
142 <p>
143 {t('Approve/Reject controls whether a relay is used in managed mode. Exclude is a manual override that blocks a relay in both modes, independent of approval status. Relays with 99%+ failure rate on your current network are auto-disabled.')}
144 </p>
145 <p>
146 {t('Failure rates are tracked per network (based on your external IP), so a relay that fails on one connection may work fine on another.')}
147 </p>
148 </div>
149 <div className="flex items-center justify-between">
150 <Label className="text-base font-normal">{t('Outbox mode')}</Label>
151 <Select value={outboxMode} onValueChange={handleModeChange}>
152 <SelectTrigger className="w-40">
153 <SelectValue />
154 </SelectTrigger>
155 <SelectContent>
156 <SelectItem value="automatic">{t('Automatic')}</SelectItem>
157 <SelectItem value="managed">{t('Managed')}</SelectItem>
158 </SelectContent>
159 </Select>
160 </div>
161
162 <div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
163 <span>{pending.length} {t('pending')}</span>
164 <span>·</span>
165 <span>{approved.length} {t('approved')}</span>
166 <span>·</span>
167 <span>{rejected.length} {t('rejected')}</span>
168 <span>·</span>
169 <span>{excluded.length} {t('excluded')}</span>
170 <span>·</span>
171 <span>{autoDisabled.length} {t('auto-disabled')}</span>
172 </div>
173
174 <div className="flex gap-1 border-b">
175 {(['pending', 'approved', 'rejected'] as const).map((t_) => (
176 <button
177 key={t_}
178 type="button"
179 onClick={() => setTab(t_)}
180 className={`px-3 py-1.5 text-sm capitalize border-b-2 transition-colors ${
181 tab === t_
182 ? 'border-primary text-foreground'
183 : 'border-transparent text-muted-foreground hover:text-foreground'
184 }`}
185 >
186 {t(t_)} ({t_ === 'pending' ? pending.length : t_ === 'approved' ? approved.length : rejected.length})
187 </button>
188 ))}
189 </div>
190
191 {tab === 'pending' && pending.length > 1 && (
192 <div className="flex gap-2">
193 <Button
194 size="sm"
195 variant="outline"
196 onClick={() => {
197 managedOutboxService.bulkApprove(pending.map((e) => e.url))
198 refresh()
199 }}
200 >
201 <Shield className="size-3 mr-1" /> {t('Approve all')}
202 </Button>
203 <Button
204 size="sm"
205 variant="outline"
206 onClick={() => {
207 managedOutboxService.bulkReject(pending.map((e) => e.url))
208 refresh()
209 }}
210 >
211 <ShieldOff className="size-3 mr-1" /> {t('Reject all')}
212 </Button>
213 </div>
214 )}
215
216 <div className="space-y-2 max-h-96 overflow-y-auto">
217 {currentList.length === 0 ? (
218 <div className="text-sm text-muted-foreground py-4 text-center">
219 {t('No relays')}
220 </div>
221 ) : (
222 currentList.map((entry) => (
223 <RelayRow key={entry.url} entry={entry} onAction={refresh} />
224 ))
225 )}
226 </div>
227 </div>
228 )
229 }
230