Browse Source

render struck relays in grayscale

imwald
Silberengel 2 weeks ago
parent
commit
0a6a0a6357
  1. 4
      package-lock.json
  2. 2
      package.json
  3. 4
      src/components/ConnectedRelays/active-relays-display.ts
  4. 6
      src/components/RelayIcon/index.tsx
  5. 32
      src/hooks/useRelaySessionStrikeActive.ts
  6. 23
      src/lib/relay-strikes.test.ts
  7. 33
      src/lib/relay-strikes.ts

4
package-lock.json generated

@ -1,12 +1,12 @@ @@ -1,12 +1,12 @@
{
"name": "imwald",
"version": "23.18.2",
"version": "23.18.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "imwald",
"version": "23.18.2",
"version": "23.18.3",
"license": "MIT",
"dependencies": {
"@asciidoctor/core": "^3.0.4",

2
package.json

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
{
"name": "imwald",
"version": "23.18.2",
"version": "23.18.3",
"description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery",
"private": true,
"type": "module",

4
src/components/ConnectedRelays/active-relays-display.ts

@ -1,3 +1,4 @@ @@ -1,3 +1,4 @@
import { relaySessionStrikes } from '@/lib/relay-strikes'
import { simplifyUrl } from '@/lib/url'
export const ACTIVE_RELAYS_MAX_ICONS = 14
@ -9,5 +10,8 @@ export function activeRelayRowMuted(connected: boolean) { @@ -9,5 +10,8 @@ export function activeRelayRowMuted(connected: boolean) {
export function activeRelayRowTitle(url: string, connected: boolean, t: (k: string) => string) {
const base = simplifyUrl(url)
if (!connected) return `${base}${t('Not connected')}`
if (relaySessionStrikes.isSessionStrikeActiveForUrl(url)) {
return `${base}${t('Session relay strikes')}`
}
return base
}

6
src/components/RelayIcon/index.tsx

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { useFetchRelayInfo } from '@/hooks'
import { useRelaySessionStrikeActive } from '@/hooks/useRelaySessionStrikeActive'
import {
getRelayIconFallbackGlyph,
getRelayIconOverrideSrc,
@ -53,6 +54,7 @@ export default function RelayIcon({ @@ -53,6 +54,7 @@ export default function RelayIcon({
relayInfoProp !== undefined || skipRelayInfoFetch ? undefined : url
)
const relayInfo = relayInfoProp !== undefined ? relayInfoProp : fetchedRelayInfo
const sessionStrike = useRelaySessionStrikeActive(url)
const [iconLoadFailed, setIconLoadFailed] = useState(false)
useEffect(() => {
setIconLoadFailed(false)
@ -79,7 +81,9 @@ export default function RelayIcon({ @@ -79,7 +81,9 @@ export default function RelayIcon({
const fallbackGlyph = useMemo(() => getRelayIconFallbackGlyph(url), [url])
return (
<Avatar className={cn('w-6 h-6', className)}>
<Avatar
className={cn('w-6 h-6', sessionStrike && 'grayscale opacity-50', className)}
>
{iconUrl && !iconLoadFailed && (
<AvatarImage
src={iconUrl}

32
src/hooks/useRelaySessionStrikeActive.ts

@ -0,0 +1,32 @@ @@ -0,0 +1,32 @@
import { relaySessionStrikes } from '@/lib/relay-strikes'
import { useEffect, useMemo, useState } from 'react'
const COOLDOWN_TICK_MS = 5_000
/** Bump when session strike map changes or skip/cooldown windows may have expired. */
export function useRelaySessionStrikeRevision(): number {
const [revision, setRevision] = useState(0)
useEffect(() => {
const bump = () => setRevision((n) => n + 1)
const unsub = relaySessionStrikes.subscribe(bump)
const id = window.setInterval(bump, COOLDOWN_TICK_MS)
return () => {
unsub()
window.clearInterval(id)
}
}, [])
return revision
}
/**
* True when the relay has session strike / cooldown state (failures, skip windows, rate limit, slow park).
*/
export function useRelaySessionStrikeActive(url: string | undefined): boolean {
const revision = useRelaySessionStrikeRevision()
return useMemo(
() => (url ? relaySessionStrikes.isSessionStrikeActiveForUrl(url) : false),
[url, revision]
)
}

23
src/lib/relay-strikes.test.ts

@ -152,6 +152,29 @@ describe('relaySessionStrikes publish failures', () => { @@ -152,6 +152,29 @@ describe('relaySessionStrikes publish failures', () => {
})
})
describe('relaySessionStrikes.isSessionStrikeActiveForUrl', () => {
beforeEach(() => {
relaySessionStrikes.reset()
})
it('is false with no strike state', () => {
expect(relaySessionStrikes.isSessionStrikeActiveForUrl('wss://relay.example/')).toBe(false)
})
it('is true after read failures accrue', () => {
const url = 'wss://relay.example/'
relaySessionStrikes.recordReadFailure(url, 'http')
expect(relaySessionStrikes.isSessionStrikeActiveForUrl(url)).toBe(true)
})
it('is false after clearKey', () => {
const url = 'wss://relay.example/'
relaySessionStrikes.recordReadFailure(url, 'http')
relaySessionStrikes.clearKey(url)
expect(relaySessionStrikes.isSessionStrikeActiveForUrl(url)).toBe(false)
})
})
describe('isRelayStrikeEntryActive', () => {
it('is false for empty entry', () => {
expect(

33
src/lib/relay-strikes.ts

@ -93,6 +93,30 @@ function sessionKey(url: string): string { @@ -93,6 +93,30 @@ function sessionKey(url: string): string {
class RelaySessionStrikes {
private byKey = new Map<string, StrikeEntry>()
private cacheRelayKeys = new Set<string>()
private changeListeners = new Set<() => void>()
/** Subscribe to session strike map changes (for relay icon UI). */
subscribe(listener: () => void): () => void {
this.changeListeners.add(listener)
return () => {
this.changeListeners.delete(listener)
}
}
private emitChange(): void {
for (const listener of this.changeListeners) {
listener()
}
}
/** True when this URL has session strike / cooldown state (see {@link isRelayStrikeEntryActive}). */
isSessionStrikeActiveForUrl(url: string, now = Date.now()): boolean {
const key = sessionKey(url)
if (!key) return false
const e = this.byKey.get(key)
if (!e) return false
return isRelayStrikeEntryActive(e, now)
}
setSessionCacheRelayKeysFromKind10432(ev: Event | null | undefined): void {
this.cacheRelayKeys.clear()
@ -191,6 +215,7 @@ class RelaySessionStrikes { @@ -191,6 +215,7 @@ class RelaySessionStrikes {
private applyRateLimitCooldownKey(key: string, cooldownMs = RATE_LIMIT_COOLDOWN_MS): void {
const e = this.getEntry(key)
e.rateLimitUntil = Math.max(e.rateLimitUntil, Date.now() + cooldownMs)
this.emitChange()
}
/** WS connect failure, HTTP transport failure, etc. */
@ -230,6 +255,7 @@ class RelaySessionStrikes { @@ -230,6 +255,7 @@ class RelaySessionStrikes {
e.readStrikeSkipUntil = Math.max(e.readStrikeSkipUntil, now + STRIKE_COOLDOWN_MS)
logger.debug('[RelayStrikes] read path strike skip', { key, readFailures: e.readFailures, threshold })
}
this.emitChange()
}
private readStrikeThresholdForKey(
@ -254,6 +280,7 @@ class RelaySessionStrikes { @@ -254,6 +280,7 @@ class RelaySessionStrikes {
e.readLastStrikeIncrementAt = 0
e.slowSignals = 0
e.slowParkUntil = 0
this.emitChange()
}
/**
@ -299,6 +326,7 @@ class RelaySessionStrikes { @@ -299,6 +326,7 @@ class RelaySessionStrikes {
const e = this.byKey.get(key)
if (e && e.slowSignals > 0) {
e.slowSignals = Math.max(0, e.slowSignals - 1)
this.emitChange()
}
}
}
@ -312,6 +340,7 @@ class RelaySessionStrikes { @@ -312,6 +340,7 @@ class RelaySessionStrikes {
// Read-only index relays (aggr.nostr.land, search.nos.today, …) are intentionally slower than inbox relays.
if (url && isReadOnlyRelayUrl(url)) return false
e.slowSignals += 1
this.emitChange()
if (e.slowSignals < RELAY_SLOW_PARK_SIGNALS_THRESHOLD) return false
e.slowParkUntil = Math.max(e.slowParkUntil, now + RELAY_SLOW_PARK_COOLDOWN_MS)
logger.warn('[RelayStrikes] session-parked slow relay', {
@ -344,6 +373,7 @@ class RelaySessionStrikes { @@ -344,6 +373,7 @@ class RelaySessionStrikes {
e.publishStrikeSkipUntil = Math.max(e.publishStrikeSkipUntil, now + STRIKE_COOLDOWN_MS)
logger.warn('[RelayStrikes] publish path strike skip', { key, publishFailures: e.publishFailures })
}
this.emitChange()
}
/** Successful publish clears publish strikes (existing publish stats stay in ClientService). */
@ -355,6 +385,7 @@ class RelaySessionStrikes { @@ -355,6 +385,7 @@ class RelaySessionStrikes {
e.publishFailures = 0
e.publishStrikeSkipUntil = 0
e.publishLastStrikeIncrementAt = 0
this.emitChange()
}
filterPublishUrls(urls: readonly string[]): string[] {
@ -389,11 +420,13 @@ class RelaySessionStrikes { @@ -389,11 +420,13 @@ class RelaySessionStrikes {
const key = sessionKey(urlOrSessionKey) || urlOrSessionKey.trim()
if (!key) return
this.byKey.delete(key)
this.emitChange()
}
reset(): void {
this.byKey.clear()
this.cacheRelayKeys.clear()
this.emitChange()
}
}

Loading…
Cancel
Save