diff --git a/package-lock.json b/package-lock.json index 146a245b..87c50f48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index c5b3fd4f..9ff0241b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/ConnectedRelays/active-relays-display.ts b/src/components/ConnectedRelays/active-relays-display.ts index d10b17bc..e67a0ca3 100644 --- a/src/components/ConnectedRelays/active-relays-display.ts +++ b/src/components/ConnectedRelays/active-relays-display.ts @@ -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) { 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 } diff --git a/src/components/RelayIcon/index.tsx b/src/components/RelayIcon/index.tsx index 9af94e91..3dcc7e20 100644 --- a/src/components/RelayIcon/index.tsx +++ b/src/components/RelayIcon/index.tsx @@ -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({ 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({ const fallbackGlyph = useMemo(() => getRelayIconFallbackGlyph(url), [url]) return ( - + {iconUrl && !iconLoadFailed && ( { + 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] + ) +} diff --git a/src/lib/relay-strikes.test.ts b/src/lib/relay-strikes.test.ts index cb19252d..bb3e29b2 100644 --- a/src/lib/relay-strikes.test.ts +++ b/src/lib/relay-strikes.test.ts @@ -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( diff --git a/src/lib/relay-strikes.ts b/src/lib/relay-strikes.ts index 22fb8a0b..204ff15d 100644 --- a/src/lib/relay-strikes.ts +++ b/src/lib/relay-strikes.ts @@ -93,6 +93,30 @@ function sessionKey(url: string): string { class RelaySessionStrikes { private byKey = new Map() private cacheRelayKeys = new Set() + 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 { 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 { 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 { e.readLastStrikeIncrementAt = 0 e.slowSignals = 0 e.slowParkUntil = 0 + this.emitChange() } /** @@ -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 { // 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 { 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 { e.publishFailures = 0 e.publishStrikeSkipUntil = 0 e.publishLastStrikeIncrementAt = 0 + this.emitChange() } filterPublishUrls(urls: readonly string[]): string[] { @@ -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() } }