push(toRelay(url))}
- className={cn('min-w-52 gap-2', rowClass(connected))}
+ className={cn('min-w-52 gap-2', rowClass(connected, sessionStriked))}
>
{simplifyUrl(url)}
diff --git a/src/components/ConnectedRelays/ConnectedRelaysSidebarStrip.tsx b/src/components/ConnectedRelays/ConnectedRelaysSidebarStrip.tsx
index 30df026b..feb846f9 100644
--- a/src/components/ConnectedRelays/ConnectedRelaysSidebarStrip.tsx
+++ b/src/components/ConnectedRelays/ConnectedRelaysSidebarStrip.tsx
@@ -17,8 +17,24 @@ import RelayIcon from '../RelayIcon'
const MAX_ICONS = 14
-function rowMenuClass(connected: boolean) {
- return cn(!connected && 'opacity-50 text-muted-foreground')
+function rowMuted(connected: boolean, sessionStriked: boolean) {
+ return !connected || sessionStriked
+}
+
+function rowMenuClass(connected: boolean, sessionStriked: boolean) {
+ return cn(rowMuted(connected, sessionStriked) && 'opacity-50 text-muted-foreground')
+}
+
+function rowTitle(
+ url: string,
+ connected: boolean,
+ sessionStriked: boolean,
+ t: (k: string) => string
+) {
+ const base = simplifyUrl(url)
+ if (sessionStriked) return `${base} — ${t('Relay session striked')}`
+ if (!connected) return `${base} — ${t('Not connected')}`
+ return base
}
/**
@@ -47,13 +63,11 @@ export function ConnectedRelaysSidebarStrip({ className }: { className?: string
{t('Active relays')}
- {shown.map(({ url, connected }) => (
+ {shown.map(({ url, connected, sessionStriked }) => (
@@ -77,11 +91,11 @@ export function ConnectedRelaysSidebarStrip({ className }: { className?: string
{t('More relays', { count: overflow })}
- {overflowRows.map(({ url, connected }) => (
+ {overflowRows.map(({ url, connected, sessionStriked }) => (
push(toRelay(url))}
>
diff --git a/src/hooks/useRelayConnectionRows.ts b/src/hooks/useRelayConnectionRows.ts
index de6e17c4..bb670c7e 100644
--- a/src/hooks/useRelayConnectionRows.ts
+++ b/src/hooks/useRelayConnectionRows.ts
@@ -2,7 +2,7 @@ import { DEFAULT_FAVORITE_RELAYS, FAST_READ_RELAY_URLS } from '@/constants'
import { normalizeAnyRelayUrl } from '@/lib/url'
import { useNostr } from '@/providers/NostrProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
-import client from '@/services/client.service'
+import client, { JUMBLE_SESSION_RELAY_STRIKES_CHANGED } from '@/services/client.service'
import { useEffect, useMemo, useState } from 'react'
const POLL_MS = 1500
@@ -27,14 +27,22 @@ function mergeUniquePreserveOrder(...lists: (readonly string[] | undefined)[]):
return out
}
-export type TRelayConnectionRow = { url: string; connected: boolean }
+export type TRelayConnectionRow = {
+ url: string
+ /** WebSocket in the pool is open. */
+ connected: boolean
+ /** Session strike threshold reached — app skips this relay for reads/publishes until cleared. */
+ sessionStriked: boolean
+}
/**
* Relays to show in “active relays” UI: favorites + NIP-65 read/write + defaults + fast-read,
- * then any pool-connected URL not already listed. {@link row.connected} reflects the live WebSocket.
+ * then any pool-connected URL not already listed. {@link row.connected} reflects the live WebSocket;
+ * {@link row.sessionStriked} reflects {@link client.isSessionRelayStrikedForReads}.
*/
export function useRelayConnectionRows(): {
rows: TRelayConnectionRow[]
+ /** Relays that are both socket-connected and not session-striked (usable for REQs this session). */
connectedCount: number
} {
const { relayList } = useNostr()
@@ -42,6 +50,7 @@ export function useRelayConnectionRows(): {
const [connectedCanon, setConnectedCanon] = useState>(() =>
new Set(client.getConnectedRelayUrls().map(canon))
)
+ const [strikesEpoch, setStrikesEpoch] = useState(0)
useEffect(() => {
const tick = () => setConnectedCanon(new Set(client.getConnectedRelayUrls().map(canon)))
@@ -50,6 +59,12 @@ export function useRelayConnectionRows(): {
return () => clearInterval(id)
}, [])
+ useEffect(() => {
+ const bump = () => setStrikesEpoch((n) => n + 1)
+ window.addEventListener(JUMBLE_SESSION_RELAY_STRIKES_CHANGED, bump)
+ return () => window.removeEventListener(JUMBLE_SESSION_RELAY_STRIKES_CHANGED, bump)
+ }, [])
+
return useMemo(() => {
const inbox = [...(relayList?.read ?? []), ...(relayList?.write ?? [])]
const base = mergeUniquePreserveOrder(
@@ -60,18 +75,23 @@ export function useRelayConnectionRows(): {
)
const baseCanon = new Set(base.map(canon))
- const rows: TRelayConnectionRow[] = base.map((url) => ({
+ const rowFor = (url: string, socketConnected: boolean): TRelayConnectionRow => ({
url,
- connected: connectedCanon.has(canon(url))
- }))
+ connected: socketConnected,
+ sessionStriked: client.isSessionRelayStrikedForReads(url)
+ })
+
+ const rows: TRelayConnectionRow[] = base.map((url) =>
+ rowFor(url, connectedCanon.has(canon(url)))
+ )
for (const url of client.getConnectedRelayUrls()) {
const k = canon(url)
if (baseCanon.has(k)) continue
- rows.push({ url, connected: true })
+ rows.push(rowFor(url, true))
}
- const connectedCount = rows.filter((r) => r.connected).length
+ const connectedCount = rows.filter((r) => r.connected && !r.sessionStriked).length
return { rows, connectedCount }
- }, [favoriteRelays, relayList?.read, relayList?.write, connectedCanon])
+ }, [favoriteRelays, relayList?.read, relayList?.write, connectedCanon, strikesEpoch])
}
diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts
index e9c672f0..83b2a2ca 100644
--- a/src/i18n/locales/de.ts
+++ b/src/i18n/locales/de.ts
@@ -533,6 +533,7 @@ export default {
'Seen on': 'Gesehen auf',
'Active relays': 'Aktive Relays',
'Not connected': 'Nicht verbunden',
+ 'Relay session striked': 'Diese Sitzung übersprungen (zu viele Verbindungsfehler)',
'More relays': '+{{count}} Relays',
'Temporarily display this reply': 'Antwort vorübergehend anzeigen',
'Note not found': 'Die Notiz wurde nicht gefunden',
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts
index b312561f..c92ef636 100644
--- a/src/i18n/locales/en.ts
+++ b/src/i18n/locales/en.ts
@@ -529,6 +529,7 @@ export default {
'Seen on': 'Seen on',
'Active relays': 'Active relays',
'Not connected': 'Not connected',
+ 'Relay session striked': 'Skipped this session (too many connection failures)',
'More relays': '+{{count}} relays',
'Temporarily display this reply': 'Temporarily display this reply',
'Note not found': 'Note not found',
diff --git a/src/lib/error-suppression.ts b/src/lib/error-suppression.ts
index 6355c0c8..6076e15f 100644
--- a/src/lib/error-suppression.ts
+++ b/src/lib/error-suppression.ts
@@ -6,18 +6,96 @@
// Track suppressed errors to avoid spam
const suppressedErrors = new Set()
+/** Flatten console args so URLs and Error messages are visible (join(' ') loses nested fields). */
+function formatConsoleArgs(args: readonly unknown[]): string {
+ const parts: string[] = []
+ for (const a of args) {
+ if (typeof a === 'string') {
+ parts.push(a)
+ } else if (a instanceof Error) {
+ parts.push(a.message, a.stack ?? '')
+ } else if (a !== null && typeof a === 'object') {
+ const o = a as Record
+ if (typeof o.message === 'string') parts.push(o.message)
+ if (typeof o.reason === 'string') parts.push(o.reason)
+ if (typeof o.url === 'string') parts.push(o.url)
+ } else {
+ parts.push(String(a))
+ }
+ }
+ return parts.join(' ')
+}
+
+function isExpectedFaviconNetworkNoise(message: string): boolean {
+ if (!message.includes('favicon.ico')) return false
+ return (
+ message.includes('NS_BINDING') ||
+ message.includes('aborted') ||
+ message.includes('ORB') ||
+ message.includes('CORRUPTED') ||
+ message.includes('404') ||
+ message.includes('403') ||
+ message.includes('blockiert') ||
+ message.includes('blocked') ||
+ message.includes('CORS') ||
+ message.includes('Failed to load') ||
+ message.includes('Laden fehlgeschlagen') ||
+ message.includes('net::ERR_')
+ )
+}
+
+function isExpectedRelayWebSocketNoise(message: string): boolean {
+ if (message.includes('WebSocket connection to') || message.includes('Close received after close')) {
+ return true
+ }
+ // Firefox EN (straight and curly apostrophe)
+ if (
+ (message.includes('establish a connection to the server') ||
+ message.includes('Firefox can') ||
+ message.includes('Firefox can\u2019t')) &&
+ (message.includes('wss://') || message.includes('ws://'))
+ ) {
+ return true
+ }
+ // Firefox DE + other locales often omit the word "WebSocket"
+ if (
+ message.includes('kann keine Verbindung') &&
+ (message.includes('wss://') || message.includes('ws://') || message.includes('Server unter'))
+ ) {
+ return true
+ }
+ // Gecko network codes for dead relays / TLS issues
+ if (
+ message.includes('NS_ERROR_CONNECTION_REFUSED') ||
+ message.includes('NS_ERROR_NET_RESET') ||
+ message.includes('NS_ERROR_NET_INTERRUPT') ||
+ message.includes('NS_ERROR_NET_TIMEOUT') ||
+ message.includes('NS_ERROR_UNKNOWN_HOST')
+ ) {
+ return true
+ }
+ return false
+}
+
function suppressExpectedErrors() {
// Override console.error to filter out expected errors
const originalConsoleError = console.error
console.error = (...args: any[]) => {
- const message = args.join(' ')
-
- // Suppress favicon 404 errors
- if (message.includes('favicon.ico') && message.includes('404')) {
+ const message = formatConsoleArgs(args)
+
+ if (isExpectedFaviconNetworkNoise(message)) {
return
}
-
+
+ if (isExpectedRelayWebSocketNoise(message)) {
+ return
+ }
+
+ if (message.includes('NS_BINDING_ABORTED')) {
+ return
+ }
+
// Suppress CORS errors for external websites (EN + DE Firefox strings)
if (message.includes('CORS policy') ||
message.includes('Access-Control-Allow-Origin') ||
@@ -106,11 +184,6 @@ function suppressExpectedErrors() {
return
}
- // Suppress WebSocket connection errors
- if (message.includes('WebSocket connection to') || message.includes('failed:') || message.includes('Close received after close')) {
- return
- }
-
// Suppress Ping timeout errors
if (message.includes('Ping timeout')) {
return
@@ -136,15 +209,8 @@ function suppressExpectedErrors() {
(message.includes('nicht unterstützt') ||
message.includes('Keine Decoder') ||
message.includes('Medien können nicht'))) ||
- message.includes('A resource is blocked by OpaqueResponseBlocking')) {
- return
- }
-
- // Firefox: failed WS to dead local/dev relays (wording varies by locale)
- if (
- message.includes('kann keine Verbindung') &&
- (message.includes('WebSocket') || message.includes('ws://') || message.includes('wss://'))
- ) {
+ message.includes('A resource is blocked by OpaqueResponseBlocking') ||
+ message.includes('NS_ERROR_CORRUPTED_CONTENT')) {
return
}
@@ -166,7 +232,15 @@ function suppressExpectedErrors() {
const originalConsoleWarn = console.warn
console.warn = (...args: any[]) => {
- const message = args.join(' ')
+ const message = formatConsoleArgs(args)
+
+ if (isExpectedFaviconNetworkNoise(message)) {
+ return
+ }
+
+ if (isExpectedRelayWebSocketNoise(message)) {
+ return
+ }
// Suppress invalid URI / failed media resource (e.g. empty img src)
if (message.includes('Ungültige URI') ||
@@ -191,13 +265,6 @@ function suppressExpectedErrors() {
return
}
- if (
- message.includes('kann keine Verbindung') &&
- (message.includes('WebSocket') || message.includes('ws://') || message.includes('wss://'))
- ) {
- return
- }
-
if (message.includes('NS_BINDING_ABORTED')) {
return
}
@@ -283,7 +350,11 @@ function suppressExpectedErrors() {
const originalConsoleLog = console.log
console.log = (...args: any[]) => {
- const message = args.join(' ')
+ const message = formatConsoleArgs(args)
+
+ if (isExpectedFaviconNetworkNoise(message) || isExpectedRelayWebSocketNoise(message)) {
+ return
+ }
// Firefox ORB: cross-origin favicon / relay icon requests often hit HTML or wrong MIME; not actionable in-app.
if (
diff --git a/src/main.tsx b/src/main.tsx
index baf112e7..b7ee6b8b 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,7 +1,7 @@
import './index.css'
import './polyfill'
-import './services/lightning.service'
import './lib/error-suppression'
+import './services/lightning.service'
import './lib/debug-utils'
import { fetchWithTimeout } from './lib/fetch-with-timeout'