feat(settings): add Relays section showing active and recently connected relays

- Add relayStatusService to track relay connections with 20-minute history
- Add useRelayStatus hook for polling relay status updates
- Create RelaySettings component to display active and recent relays
- Update Settings and ThreePaneLayout to integrate relay status display
- Shows relay connection status with visual indicators and timestamps
This commit is contained in:
Gigi
2025-10-09 12:09:53 +01:00
parent 5d379a280b
commit facdd36145
5 changed files with 275 additions and 1 deletions

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect, useRef } from 'react'
import { faTimes, faUndo } from '@fortawesome/free-solid-svg-icons'
import { RelayPool } from 'applesauce-relay'
import { UserSettings } from '../services/settingsService'
import IconButton from './IconButton'
import { loadFont } from '../utils/fontLoader'
@@ -7,6 +8,8 @@ import ReadingDisplaySettings from './Settings/ReadingDisplaySettings'
import LayoutNavigationSettings from './Settings/LayoutNavigationSettings'
import StartupPreferencesSettings from './Settings/StartupPreferencesSettings'
import ZapSettings from './Settings/ZapSettings'
import RelaySettings from './Settings/RelaySettings'
import { useRelayStatus } from '../hooks/useRelayStatus'
const DEFAULT_SETTINGS: UserSettings = {
collapseOnArticleOpen: true,
@@ -33,9 +36,10 @@ interface SettingsProps {
settings: UserSettings
onSave: (settings: UserSettings) => Promise<void>
onClose: () => void
relayPool: RelayPool | null
}
const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose, relayPool }) => {
const [localSettings, setLocalSettings] = useState<UserSettings>(() => {
// Migrate old settings format to new weight-based format
const migrated = { ...settings }
@@ -52,6 +56,8 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
const isInitialMount = useRef(true)
const saveTimeoutRef = useRef<number | null>(null)
const isLocallyUpdating = useRef(false)
const relayStatuses = useRelayStatus({ relayPool })
useEffect(() => {
// Don't update from external settings if we're currently making local changes
@@ -152,6 +158,7 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
<LayoutNavigationSettings settings={localSettings} onUpdate={handleUpdate} />
<StartupPreferencesSettings settings={localSettings} onUpdate={handleUpdate} />
<ZapSettings settings={localSettings} onUpdate={handleUpdate} />
<RelaySettings relayStatuses={relayStatuses} />
</div>
</div>
)

View File

@@ -0,0 +1,164 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faCheckCircle, faCircle, faClock } from '@fortawesome/free-solid-svg-icons'
import { RelayStatus } from '../../services/relayStatusService'
import { formatDistanceToNow } from 'date-fns'
interface RelaySettingsProps {
relayStatuses: RelayStatus[]
}
const RelaySettings: React.FC<RelaySettingsProps> = ({ relayStatuses }) => {
const activeRelays = relayStatuses.filter(r => r.isInPool)
const recentRelays = relayStatuses.filter(r => !r.isInPool)
const formatRelayUrl = (url: string) => {
return url.replace(/^wss?:\/\//, '')
}
const formatLastSeen = (timestamp: number) => {
try {
return formatDistanceToNow(timestamp, { addSuffix: true })
} catch {
return 'just now'
}
}
return (
<div className="settings-section">
<h3>Relays</h3>
<div className="relay-summary" style={{ marginBottom: '1rem' }}>
<p style={{ color: 'var(--text-secondary)', fontSize: '0.9rem' }}>
{activeRelays.length} active relay{activeRelays.length !== 1 ? 's' : ''}
{recentRelays.length > 0 &&
` · ${recentRelays.length} recently seen`
}
</p>
</div>
{activeRelays.length > 0 && (
<div className="relay-group" style={{ marginBottom: '1.5rem' }}>
<h4 style={{
fontSize: '0.85rem',
fontWeight: 600,
color: 'var(--text-secondary)',
marginBottom: '0.75rem',
textTransform: 'uppercase',
letterSpacing: '0.05em'
}}>
Active
</h4>
<div className="relay-list">
{activeRelays.map((relay) => (
<div
key={relay.url}
className="relay-item"
style={{
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
padding: '0.75rem',
background: 'var(--surface-secondary)',
borderRadius: '6px',
marginBottom: '0.5rem'
}}
>
<FontAwesomeIcon
icon={faCheckCircle}
style={{
color: 'var(--success, #22c55e)',
fontSize: '1rem'
}}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: '0.9rem',
fontFamily: 'var(--font-mono, monospace)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}>
{formatRelayUrl(relay.url)}
</div>
</div>
</div>
))}
</div>
</div>
)}
{recentRelays.length > 0 && (
<div className="relay-group">
<h4 style={{
fontSize: '0.85rem',
fontWeight: 600,
color: 'var(--text-secondary)',
marginBottom: '0.75rem',
textTransform: 'uppercase',
letterSpacing: '0.05em'
}}>
Recently Seen
</h4>
<div className="relay-list">
{recentRelays.map((relay) => (
<div
key={relay.url}
className="relay-item"
style={{
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
padding: '0.75rem',
background: 'var(--surface-secondary)',
borderRadius: '6px',
marginBottom: '0.5rem',
opacity: 0.7
}}
>
<FontAwesomeIcon
icon={faCircle}
style={{
color: 'var(--text-tertiary, #6b7280)',
fontSize: '0.7rem'
}}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: '0.9rem',
fontFamily: 'var(--font-mono, monospace)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}>
{formatRelayUrl(relay.url)}
</div>
</div>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
fontSize: '0.8rem',
color: 'var(--text-tertiary)',
whiteSpace: 'nowrap'
}}>
<FontAwesomeIcon icon={faClock} />
{formatLastSeen(relay.lastSeen)}
</div>
</div>
))}
</div>
</div>
)}
{relayStatuses.length === 0 && (
<p style={{ color: 'var(--text-secondary)', fontStyle: 'italic' }}>
No relay connections found
</p>
)}
</div>
)
}
export default RelaySettings

View File

@@ -97,6 +97,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
settings={props.settings}
onSave={props.onSaveSettings}
onClose={props.onCloseSettings}
relayPool={props.relayPool}
/>
) : (
<ContentPanel

View File

@@ -0,0 +1,37 @@
import { useState, useEffect } from 'react'
import { RelayPool } from 'applesauce-relay'
import { RelayStatus, updateAndGetRelayStatuses } from '../services/relayStatusService'
interface UseRelayStatusParams {
relayPool: RelayPool | null
pollingInterval?: number // in milliseconds
}
export function useRelayStatus({
relayPool,
pollingInterval = 5000
}: UseRelayStatusParams) {
const [relayStatuses, setRelayStatuses] = useState<RelayStatus[]>([])
useEffect(() => {
if (!relayPool) return
const updateStatuses = () => {
const statuses = updateAndGetRelayStatuses(relayPool)
setRelayStatuses(statuses)
}
// Initial update
updateStatuses()
// Poll for updates
const interval = setInterval(updateStatuses, pollingInterval)
return () => {
clearInterval(interval)
}
}, [relayPool, pollingInterval])
return relayStatuses
}

View File

@@ -0,0 +1,65 @@
import { RelayPool } from 'applesauce-relay'
export interface RelayStatus {
url: string
isInPool: boolean
lastSeen: number // timestamp
}
const RECENT_CONNECTION_WINDOW = 20 * 60 * 1000 // 20 minutes
// In-memory tracking of relay last seen times
const relayLastSeen = new Map<string, number>()
/**
* Updates and gets the current status of all relays
*/
export function updateAndGetRelayStatuses(relayPool: RelayPool): RelayStatus[] {
const statuses: RelayStatus[] = []
const now = Date.now()
const currentRelayUrls = new Set<string>()
// Update relays currently in the pool
for (const relay of relayPool.relays.values()) {
currentRelayUrls.add(relay.url)
relayLastSeen.set(relay.url, now)
statuses.push({
url: relay.url,
isInPool: true,
lastSeen: now
})
}
// Add recently seen relays that are no longer in the pool
const cutoffTime = now - RECENT_CONNECTION_WINDOW
for (const [url, lastSeen] of relayLastSeen.entries()) {
if (!currentRelayUrls.has(url) && lastSeen >= cutoffTime) {
statuses.push({
url,
isInPool: false,
lastSeen
})
}
}
// Clean up old entries
for (const [url, lastSeen] of relayLastSeen.entries()) {
if (lastSeen < cutoffTime) {
relayLastSeen.delete(url)
}
}
return statuses.sort((a, b) => {
if (a.isInPool !== b.isInPool) return a.isInPool ? -1 : 1
return b.lastSeen - a.lastSeen
})
}
/**
* Gets count of currently active relays
*/
export function getActiveCount(statuses: RelayStatus[]): number {
return statuses.filter(r => r.isInPool).length
}