diff --git a/src/components/PostEditor/PostRelaySelector.tsx b/src/components/PostEditor/PostRelaySelector.tsx
index d6cebcb6..2316730f 100644
--- a/src/components/PostEditor/PostRelaySelector.tsx
+++ b/src/components/PostEditor/PostRelaySelector.tsx
@@ -2,10 +2,12 @@ import {
ExtendedKind,
isSocialKindBlockedKind,
MAX_PUBLISH_RELAYS,
+ READ_ONLY_RELAY_URLS,
SOCIAL_KIND_BLOCKED_RELAY_URLS
} from '@/constants'
import { NOSTR_URI_FOR_REPLY_PUBKEYS_REGEX } from '@/lib/content-patterns'
-import { simplifyUrl, isLocalNetworkUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url'
+import { dedupeNormalizeRelayUrlsOrdered } from '@/lib/relay-url-priority'
+import { simplifyUrl, isLocalNetworkUrl, normalizeAnyRelayUrl, normalizeHttpRelayUrl, normalizeUrl } from '@/lib/url'
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
@@ -44,6 +46,16 @@ export default function PostRelaySelector({
mentions?: string[]
}) {
const { t } = useTranslation()
+ /** Subtitle + trigger must match {@link selectedRelayUrls} (service description ignored: cache relays are merged in after). */
+ const describeRelaySelection = useCallback(
+ (urls: string[]) => {
+ const n = urls.length
+ if (n === 0) return t('No relays selected')
+ if (n === 1) return simplifyUrl(urls[0])
+ return t('{{count}} relays', { count: n })
+ },
+ [t]
+ )
const { isSmallScreen } = useScreenSize()
useCurrentRelays() // Keep this hook call for any side effects
const { relaySets, favoriteRelays, blockedRelays } = useFavoriteRelays()
@@ -80,6 +92,62 @@ export default function PostRelaySelector({
return false
}, [_parentEvent])
+ /**
+ * Same merge order as {@link ClientService.publishEvent}: NIP-65 write list first, then relays checked here,
+ * then cap at {@link MAX_PUBLISH_RELAYS}. Drives the cap hint so users see reserved “prepended” slots.
+ */
+ const publishCapPreview = useMemo(() => {
+ const applySocialOutboxFilter =
+ !isPublicMessage &&
+ (_parentEvent == null ||
+ isDiscussionReply ||
+ (_parentEvent != null && isSocialKindBlockedKind(_parentEvent.kind)))
+
+ const wsOut = (relayList?.write ?? [])
+ .map((u) => normalizeUrl(u) || u)
+ .filter((u): u is string => !!u)
+ const httpOut = (relayList?.httpWrite ?? [])
+ .map((u) => normalizeHttpRelayUrl(u) || u)
+ .filter((u): u is string => !!u)
+ let outbox = dedupeNormalizeRelayUrlsOrdered([...httpOut, ...wsOut])
+ const readOnlySet = new Set(READ_ONLY_RELAY_URLS.map((u) => normalizeAnyRelayUrl(u) || u))
+ const socialBlockedSet = new Set(SOCIAL_KIND_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u))
+ outbox = dedupeNormalizeRelayUrlsOrdered(
+ outbox.filter((url) => {
+ const n = normalizeAnyRelayUrl(url) || url
+ if (readOnlySet.has(n)) return false
+ if (applySocialOutboxFilter && socialBlockedSet.has(n)) return false
+ return true
+ })
+ )
+
+ const merged = dedupeNormalizeRelayUrlsOrdered([...outbox, ...selectedRelayUrls])
+ const capped = merged.slice(0, MAX_PUBLISH_RELAYS)
+ const outboxNormSet = new Set(outbox)
+ const outboxSlotsInPublish = capped.filter((u) => outboxNormSet.has(u)).length
+ const selectedNorm = selectedRelayUrls.map((u) => normalizeAnyRelayUrl(u) || u)
+ const selectedContacted = selectedNorm.filter((u) => capped.includes(u)).length
+
+ const showCapHint =
+ merged.length > MAX_PUBLISH_RELAYS ||
+ selectedRelayUrls.length >= MAX_PUBLISH_RELAYS ||
+ selectedContacted < selectedRelayUrls.length
+
+ return {
+ outboxSlotsInPublish,
+ selectedContacted,
+ selectedTotal: selectedRelayUrls.length,
+ showCapHint
+ }
+ }, [
+ relayList?.write,
+ relayList?.httpWrite,
+ selectedRelayUrls,
+ isPublicMessage,
+ _parentEvent,
+ isDiscussionReply
+ ])
+
/**
* Relay selection only cares about nostr:… mentions in the draft (see relay-selection.service).
* Depending on full `postContent` re-ran the heavy relay effect on every keystroke.
@@ -179,7 +247,7 @@ export default function PostRelaySelector({
const cacheRelays = result.selectableRelays.filter(url => isLocalNetworkUrl(url))
const selectedWithCache = Array.from(new Set([...result.selectedRelays, ...cacheRelays]))
setSelectedRelayUrls(selectedWithCache)
- setDescription(result.description)
+ setDescription(describeRelaySelection(selectedWithCache))
// Reset manual selection flag if relays changed
if (selectableRelaysChanged && hasManualSelection) {
setHasManualSelection(false)
@@ -191,7 +259,7 @@ export default function PostRelaySelector({
setSelectableRelays([])
if (!hasManualSelection) {
setSelectedRelayUrls([])
- setDescription('No relays selected')
+ setDescription(t('No relays selected'))
}
} finally {
setIsLoading(false)
@@ -210,7 +278,9 @@ export default function PostRelaySelector({
relayList,
isDiscussionReply,
contentRelaySignature,
- mentions
+ mentions,
+ describeRelaySelection,
+ t
])
// Separate effect for mention changes in non-discussion replies
@@ -285,7 +355,7 @@ export default function PostRelaySelector({
const cacheRelays = result.selectableRelays.filter(url => isLocalNetworkUrl(url))
const selectedWithCache = Array.from(new Set([...result.selectedRelays, ...cacheRelays]))
setSelectedRelayUrls(selectedWithCache)
- setDescription(result.description)
+ setDescription(describeRelaySelection(selectedWithCache))
// Reset manual selection flag if relays changed
if (selectableRelaysChanged && hasManualSelection) {
setHasManualSelection(false)
@@ -313,16 +383,16 @@ export default function PostRelaySelector({
relayList,
memoizedOpenFrom,
previousSelectableCount,
- hasManualSelection
+ hasManualSelection,
+ describeRelaySelection
])
// Update description when selected relays change due to manual selection
useEffect(() => {
if (hasManualSelection && !isLoading) {
- const count = selectedRelayUrls.length
- setDescription(count === 0 ? 'No relays selected' : count === 1 ? simplifyUrl(selectedRelayUrls[0]) : `${count} relays`)
+ setDescription(describeRelaySelection(selectedRelayUrls))
}
- }, [selectedRelayUrls, hasManualSelection, isLoading])
+ }, [selectedRelayUrls, hasManualSelection, isLoading, describeRelaySelection])
// Update parent component with selected relays
useEffect(() => {
@@ -428,6 +498,27 @@ export default function PostRelaySelector({
return t('{{count}} relays', { count: selectedRelayUrls.length })
}, [selectedRelayUrls, isLoading, t])
+ const capHintEl =
+ publishCapPreview.showCapHint &&
+ (publishCapPreview.outboxSlotsInPublish > 0 ? (
+
+ {t('Publish relay cap hint with outbox first', {
+ max: MAX_PUBLISH_RELAYS,
+ reservedSlots: publishCapPreview.outboxSlotsInPublish,
+ selected: publishCapPreview.selectedTotal,
+ selectedContacted: publishCapPreview.selectedContacted
+ })}
+
+ ) : (
+
+ {t('Publish relay cap hint', {
+ max: MAX_PUBLISH_RELAYS,
+ selected: publishCapPreview.selectedTotal,
+ selectedContacted: publishCapPreview.selectedContacted
+ })}
+
+ ))
+
if (isSmallScreen) {
return (
@@ -452,14 +543,7 @@ export default function PostRelaySelector({
{t('Select relays')}
{description}
- {selectedRelayUrls.length >= MAX_PUBLISH_RELAYS && (
-
- {t('Publish relay cap hint', {
- max: MAX_PUBLISH_RELAYS,
- selected: selectedRelayUrls.length
- })}
-
- )}
+ {capHintEl}
@@ -495,14 +579,7 @@ export default function PostRelaySelector({
{t('Select relays')}
{description}
- {selectedRelayUrls.length >= MAX_PUBLISH_RELAYS && (
-
- {t('Publish relay cap hint', {
- max: MAX_PUBLISH_RELAYS,
- selected: selectedRelayUrls.length
- })}
-
- )}
+ {capHintEl}
{content}
diff --git a/src/components/SessionRelaysTab/index.tsx b/src/components/SessionRelaysTab/index.tsx
index fdbdf9ed..8dc77cf0 100644
--- a/src/components/SessionRelaysTab/index.tsx
+++ b/src/components/SessionRelaysTab/index.tsx
@@ -1,7 +1,7 @@
import client from '@/services/client.service'
import { useTranslation } from 'react-i18next'
import { useCallback, useEffect, useState } from 'react'
-import { RefreshCw, CheckCircle2, XCircle, Zap } from 'lucide-react'
+import { RefreshCw, CheckCircle2, XCircle, Zap, RotateCcw } from 'lucide-react'
import { Button } from '@/components/ui/button'
type SessionDebug = {
@@ -29,6 +29,11 @@ export default function SessionRelaysTab() {
if (debug === null) return null
+ const clearStrikeForUrl = (url: string) => {
+ client.clearSessionRelayStrikeForUrl(url)
+ refresh()
+ }
+
const formatUrl = (url: string) => {
try {
const u = new URL(url)
@@ -79,13 +84,26 @@ export default function SessionRelaysTab() {
{t('Session relays preset striked hint')}
-
+
{debug.presetStriked.length === 0 ? (
- {t('None')}
) : (
debug.presetStriked.map((url) => (
- -
- {formatUrl(url)}
+
-
+
+ {formatUrl(url)}
+
+
))
)}
@@ -123,10 +141,23 @@ export default function SessionRelaysTab() {
{t('Session relays all striked')}
-
+
{debug.strikedUrls.map((url) => (
- -
- {formatUrl(url)}
+
-
+
+ {formatUrl(url)}
+
+
))}
diff --git a/src/constants.ts b/src/constants.ts
index 4802d5bd..2fd391ab 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -50,7 +50,7 @@ export const MAX_CONCURRENT_RELAY_CONNECTIONS = 10
export const MAX_CONCURRENT_SUBS_PER_RELAY = 9
/** Max relays to publish each event to (outboxes first, then targets' inboxes, then extras). */
-export const MAX_PUBLISH_RELAYS = MAX_CONCURRENT_RELAY_CONNECTIONS
+export const MAX_PUBLISH_RELAYS = 20
/** After a publish wave, failed NIP-65 write (outbox) relays are retried once after this delay. */
export const OUTBOX_PUBLISH_RETRY_DELAY_MS = 5000
diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts
index 7604ccfc..98c8e32b 100644
--- a/src/i18n/locales/de.ts
+++ b/src/i18n/locales/de.ts
@@ -549,6 +549,9 @@ export default {
'Session relays scored random hint':
'Relays, die in dieser Session mindestens ein Publish angenommen haben; werden beim Auswählen von Zufallsrelays bevorzugt. Sortiert nach durchschnittlicher Latenz.',
'Session relays all striked': 'Alle gestrichenen Relays (alle Quellen)',
+ 'Session relays clear strike': 'Wieder zulassen',
+ 'Session relays clear strike hint':
+ 'Relay aus der Session-Sperrliste nehmen; es wird wieder genutzt, bis neue Verbindungsfehler auftreten.',
successes: 'Erfolge',
None: 'Keine',
'Cache & offline storage': 'Cache & Offline-Speicher',
@@ -1463,7 +1466,9 @@ export default {
'Select group...': 'Select group...',
'Select relays': 'Select relays',
'Publish relay cap hint':
- 'Pro Veröffentlichung werden höchstens {{max}} Relais angesprochen. Deine Outbox-Relais werden zuerst eingereiht, danach Priorität; wegen Fehlern übersprungene Relais entfallen. Du hast {{selected}} gewählt — der Rest wird nicht gesendet. Die genaue Liste steht in der Konsole unter [PublishEvent].',
+ 'Pro Veröffentlichung werden höchstens {{max}} Relais angesprochen. Von den {{selected}} hier angehakten Relais werden {{selectedContacted}} tatsächlich kontaktiert; bei Überschreitung des Limits entfallen zuerst die niedrigere Priorität. Relais mit Session-Sperre werden übersprungen. Die genaue Liste steht in der Konsole unter [PublishEvent].',
+ 'Publish relay cap hint with outbox first':
+ 'Pro Veröffentlichung höchstens {{max}} Relais. Deine NIP-65-Schreib-Relais belegen zuerst {{reservedSlots}} Plätze (vor dieser Auswahl zusammengeführt; können unten auch angehakt sein). Von den {{selected}} angehakten Relais werden {{selectedContacted}} kontaktiert. Session-gesperrte Relais entfallen. Details in der Konsole unter [PublishEvent].',
'Select the group where you want to create this discussion.':
'Select the group where you want to create this discussion.',
'Select topic...': 'Select topic...',
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts
index a3b0f915..af984970 100644
--- a/src/i18n/locales/en.ts
+++ b/src/i18n/locales/en.ts
@@ -572,6 +572,9 @@ export default {
'Session relays scored random hint':
'Relays that have accepted at least one publish this session; used to prefer faster relays when picking random relays. Sorted by average latency.',
'Session relays all striked': 'All striked relays (any source)',
+ 'Session relays clear strike': 'Allow again',
+ 'Session relays clear strike hint':
+ 'Remove this relay from the session block list; it will be used again until new connection failures.',
successes: 'successes',
None: 'None',
'Cache & offline storage': 'Cache & offline storage',
@@ -1542,7 +1545,9 @@ export default {
'Select group...': 'Select group...',
'Select relays': 'Select relays',
'Publish relay cap hint':
- 'At most {{max}} relays are contacted per publish. Your outboxes are merged in first, then priority order; session-blocked relays are skipped. You selected {{selected}} — lower-priority checks are not sent. See console [PublishEvent] for the exact list.',
+ 'At most {{max}} relays are contacted per publish. Of the {{selected}} relay(s) you checked here, {{selectedContacted}} will be contacted; lower-priority checks are skipped first if you exceed the cap. Session-blocked relays are skipped. See console [PublishEvent] for the exact list.',
+ 'Publish relay cap hint with outbox first':
+ 'At most {{max}} relays per publish. Your NIP-65 write relay(s) use {{reservedSlots}} of those slots first (merged ahead of this picker; they may also appear checked below). Of the {{selected}} relay(s) you checked here, {{selectedContacted}} will be contacted. Session-blocked relays are skipped. See console [PublishEvent] for the exact list.',
'Select the group where you want to create this discussion.':
'Select the group where you want to create this discussion.',
'Select topic...': 'Select topic...',
diff --git a/src/lib/draft-event.ts b/src/lib/draft-event.ts
index 2a11531a..c6093137 100644
--- a/src/lib/draft-event.ts
+++ b/src/lib/draft-event.ts
@@ -22,7 +22,8 @@ import {
getReplaceableCoordinateFromEvent,
getRootETag,
isProtectedEvent,
- isReplaceableEvent
+ isReplaceableEvent,
+ resolveDeclaredThreadRootEventHex
} from './event'
import {
canonicalizeRssArticleUrl,
@@ -1112,15 +1113,26 @@ async function extractRelatedEventIds(content: string, parentEvent?: Event) {
if (_rootETag) {
parentETag = buildETagWithMarker(parentEvent.id, parentEvent.pubkey, '', 'reply')
- const [, rootEventHexId, hint, , rootEventPubkey] = _rootETag
- if (rootEventPubkey) {
- rootETag = buildETagWithMarker(rootEventHexId, rootEventPubkey, hint, 'root')
- } else {
+ const [, rootEventHexId, hint, , rootEventPubkeyFromTag] = _rootETag
+ const canonicalRootHex = resolveDeclaredThreadRootEventHex(rootEventHexId)
+
+ let rootEvent = client.peekSessionCachedEvent(canonicalRootHex)
+ if (!rootEvent) {
+ rootEvent = await eventService.fetchEvent(canonicalRootHex)
+ }
+ if (!rootEvent) {
const rootEventId = generateBech32IdFromETag(_rootETag)
- const rootEvent = rootEventId ? await eventService.fetchEvent(rootEventId) : undefined
- rootETag = rootEvent
- ? buildETagWithMarker(rootEvent.id, rootEvent.pubkey, hint, 'root')
- : buildETagWithMarker(rootEventHexId, rootEventPubkey, hint, 'root')
+ rootEvent = rootEventId ? await eventService.fetchEvent(rootEventId) : undefined
+ }
+ if (rootEvent) {
+ rootETag = buildETagWithMarker(rootEvent.id, rootEvent.pubkey, hint, 'root')
+ } else {
+ rootETag = buildETagWithMarker(
+ canonicalRootHex,
+ rootEventPubkeyFromTag ?? '',
+ hint,
+ 'root'
+ )
}
} else {
// reply to root event
diff --git a/src/lib/event.ts b/src/lib/event.ts
index 40ebc28c..106f9615 100644
--- a/src/lib/event.ts
+++ b/src/lib/event.ts
@@ -31,6 +31,39 @@ export function isNip56ReportEvent(event: Pick): boolean {
return event.kind === kinds.Report || event.kind === ExtendedKind.REPORT
}
+/** `e` / `E` tags for NIP-10-style thread links (kinds 1, 11, 1111, …). */
+function listThreadLinkETags(event: Event): string[][] {
+ return event.tags.filter(([n]) => n === 'e' || n === 'E')
+}
+
+/**
+ * Parent `e` for kind 1111 / voice comment: prefer `reply` marker, else last `e` when multiple
+ * (NIP-10 root-then-reply), else first. Avoids treating the thread root as the parent when clients omit uppercase `E`.
+ */
+function getParentETagCommentOrDiscussion(event: Event): string[] | undefined {
+ const isETag = (n: string) => n === 'e' || n === 'E'
+ const byMarker = event.tags.find(([tagName, , , marker]) => isETag(tagName) && marker === 'reply')
+ if (byMarker) return byMarker
+ const etags = listThreadLinkETags(event)
+ if (etags.length >= 2) return etags[etags.length - 1]
+ return etags[0]
+}
+
+/**
+ * Root `e` for kind 1111 / voice comment: prefer `root` marker, else uppercase `E` (Jumble / NIP-22),
+ * else first `e` when multiple (NIP-10 root-before-reply), else single `e`.
+ */
+function getRootETagCommentOrDiscussion(event: Event): string[] | undefined {
+ const isETag = (n: string) => n === 'e' || n === 'E'
+ const byMarker = event.tags.find(([tagName, , , marker]) => isETag(tagName) && marker === 'root')
+ if (byMarker) return byMarker
+ const upperE = event.tags.find(tagNameEquals('E'))
+ if (upperE) return upperE
+ const etags = listThreadLinkETags(event)
+ if (etags.length >= 2) return etags[0]
+ return etags[0]
+}
+
const EVENT_EMBEDDED_NOTES_CACHE = new LRUCache({ max: 10000 })
const EVENT_EMBEDDED_PUBKEYS_CACHE = new LRUCache({ max: 10000 })
const EVENT_IS_REPLY_NOTE_CACHE = new LRUCache({ max: 10000 })
@@ -105,10 +138,10 @@ export function getParentETag(event?: Event) {
}
if (event.kind === ExtendedKind.COMMENT || event.kind === ExtendedKind.VOICE_COMMENT) {
- return event.tags.find(tagNameEquals('e')) ?? event.tags.find(tagNameEquals('E'))
+ return getParentETagCommentOrDiscussion(event)
}
- // Handle DISCUSSION events (kind 11) - they use e tag for parent reference
+ // Kind 11: keep first `e` / `E` (thread shape differs from NIP-10 comment chains).
if (event.kind === ExtendedKind.DISCUSSION) {
return event.tags.find(tagNameEquals('e')) ?? event.tags.find(tagNameEquals('E'))
}
@@ -179,10 +212,9 @@ export function getRootETag(event?: Event) {
if (!event) return undefined
if (event.kind === ExtendedKind.COMMENT || event.kind === ExtendedKind.VOICE_COMMENT) {
- return event.tags.find(tagNameEquals('E'))
+ return getRootETagCommentOrDiscussion(event)
}
- // Handle DISCUSSION events (kind 11) - they use E tag for root reference
if (event.kind === ExtendedKind.DISCUSSION) {
return event.tags.find(tagNameEquals('E'))
}
@@ -232,6 +264,62 @@ export function getRootEventHexId(event?: Event) {
return tag?.[1]
}
+const RESOLVE_DECLARED_THREAD_ROOT_MAX_HOPS = 14
+
+/** Zapped **note** id from a kind 9735 receipt (`e` / `E` hex). Kept here to avoid importing event-metadata (cycles). */
+function zapReceiptTargetNoteHexFromEvent(ev: Event): string | undefined {
+ if (ev.kind !== kinds.Zap) return undefined
+ for (const t of ev.tags) {
+ if ((t[0] === 'e' || t[0] === 'E') && t[1] && /^[0-9a-f]{64}$/i.test(t[1])) {
+ return t[1].toLowerCase()
+ }
+ }
+ return undefined
+}
+
+/**
+ * Clients that reply from a notification often emit a single `e` tag whose **id is a reaction** (kind 7 / 17)
+ * or **zap receipt** (kind 9735) but the marker is still `root` — they never saw the real OP. Walk
+ * reaction / zap → target note → further NIP-10 `e` roots (session cache) until stable, for thread UI and child `root` tags.
+ */
+export function resolveDeclaredThreadRootEventHex(startHexId: string): string {
+ let cur = startHexId.trim().toLowerCase()
+ if (!/^[0-9a-f]{64}$/i.test(cur)) return cur
+ const seen = new Set()
+ for (let hop = 0; hop < RESOLVE_DECLARED_THREAD_ROOT_MAX_HOPS; hop++) {
+ if (seen.has(cur)) return cur
+ seen.add(cur)
+ const ev = client.peekSessionCachedEvent(cur)
+ if (!ev) return cur
+ if (isNip25ReactionKind(ev.kind)) {
+ const fromParent = getParentEventHexId(ev)?.toLowerCase()
+ let next: string | undefined
+ if (fromParent && /^[0-9a-f]{64}$/i.test(fromParent)) {
+ next = fromParent
+ } else {
+ const first = getFirstHexEventIdFromETags(ev.tags)
+ next = first && /^[0-9a-f]{64}$/i.test(first) ? first.toLowerCase() : undefined
+ }
+ if (!next || next === cur) return cur
+ cur = next
+ continue
+ }
+ if (ev.kind === kinds.Zap) {
+ const next = zapReceiptTargetNoteHexFromEvent(ev)
+ if (!next || next === cur) return cur
+ cur = next
+ continue
+ }
+ const r = getRootEventHexId(ev)?.toLowerCase()
+ if (r && r !== cur && /^[0-9a-f]{64}$/i.test(r)) {
+ cur = r
+ continue
+ }
+ return cur
+ }
+ return cur
+}
+
/** True if event references target as root, parent, or quoted (#q, #a) — used to hide redundant preview when showing quotes of current note. */
export function eventReferencesEventId(
event: Event | undefined,
diff --git a/src/lib/index-relay-http.ts b/src/lib/index-relay-http.ts
index 8c1a52d4..7cc2cf31 100644
--- a/src/lib/index-relay-http.ts
+++ b/src/lib/index-relay-http.ts
@@ -39,6 +39,17 @@ export function nostrFilterToIndexRelayBody(f: Filter): Record
return body
}
+const INDEX_RELAY_HTTP_WARN_COOLDOWN_MS = 5000
+const lastIndexRelayHttpWarnAtByEndpoint = new Map()
+
+function warnIndexRelayHttpThrottled(endpoint: string, message: string, meta: Record) {
+ const now = Date.now()
+ const prev = lastIndexRelayHttpWarnAtByEndpoint.get(endpoint) ?? 0
+ if (now - prev < INDEX_RELAY_HTTP_WARN_COOLDOWN_MS) return
+ lastIndexRelayHttpWarnAtByEndpoint.set(endpoint, now)
+ logger.warn(message, meta)
+}
+
function rawToVerifiedEvent(raw: Record): NEvent | null {
try {
const id = raw.id
@@ -68,17 +79,20 @@ function rawToVerifiedEvent(raw: Record): NEvent | null {
/**
* Query one HTTP index relay. Runs one POST per filter when given an array.
+ * When every filter attempt fails (HTTP error or network) and no events are returned,
+ * {@link options.onHardFailure} runs once (used for session strike parity with WebSocket relays).
*/
export async function queryIndexRelay(
baseUrl: string,
filter: Filter | Filter[],
- options?: { signal?: AbortSignal }
+ options?: { signal?: AbortSignal; onHardFailure?: () => void }
): Promise {
const base = normalizeHttpRelayUrl(baseUrl) || baseUrl
const endpoint = indexRelayFilterUrl(base)
const filters = Array.isArray(filter) ? filter : [filter]
const out: NEvent[] = []
const seen = new Set()
+ let sawHardFailure = false
for (const f of filters) {
const body = nostrFilterToIndexRelayBody(filterForIndexRelay(f))
try {
@@ -92,7 +106,11 @@ export async function queryIndexRelay(
signal: options?.signal
})
if (!res.ok) {
- logger.warn('[IndexRelayHttp] filter request failed', { endpoint, status: res.status })
+ sawHardFailure = true
+ warnIndexRelayHttpThrottled(endpoint, '[IndexRelayHttp] filter request failed', {
+ endpoint,
+ status: res.status
+ })
continue
}
const json = (await res.json()) as { data?: unknown }
@@ -108,9 +126,13 @@ export async function queryIndexRelay(
}
} catch (e) {
if ((e as Error).name === 'AbortError') throw e
- logger.warn('[IndexRelayHttp] filter request error', { endpoint, error: e })
+ sawHardFailure = true
+ warnIndexRelayHttpThrottled(endpoint, '[IndexRelayHttp] filter request error', { endpoint, error: e })
}
}
+ if (sawHardFailure && out.length === 0 && filters.length > 0) {
+ options?.onHardFailure?.()
+ }
return out
}
diff --git a/src/lib/thread-reply-root-match.ts b/src/lib/thread-reply-root-match.ts
index 4e9277cd..53e2c30a 100644
--- a/src/lib/thread-reply-root-match.ts
+++ b/src/lib/thread-reply-root-match.ts
@@ -4,7 +4,8 @@ import {
getRootATag,
getRootEventHexId,
isNip25ReactionKind,
- kind1QuotesThreadRoot
+ kind1QuotesThreadRoot,
+ resolveDeclaredThreadRootEventHex
} from '@/lib/event'
import { getZapInfoFromEvent } from '@/lib/event-metadata'
import { getFirstHexEventIdFromETags } from '@/lib/tag'
@@ -113,6 +114,7 @@ export function eventReplyMatchesThreadRoot(evt: Event, root: TThreadRootRef): b
const rid = root.id.trim().toLowerCase()
const evtRootHex = getRootEventHexId(evt)?.toLowerCase()
if (evtRootHex === rid) return true
+ if (evtRootHex && resolveDeclaredThreadRootEventHex(evtRootHex) === rid) return true
if (replyParentIsZapToThreadHex(evt, rid)) return true
if (replyParentIsReactionToThreadHex(evt, rid)) return true
return kind1QuotesThreadRoot(evt, root)
diff --git a/src/providers/ReplyProvider.tsx b/src/providers/ReplyProvider.tsx
index 972a920d..62dc1ed0 100644
--- a/src/providers/ReplyProvider.tsx
+++ b/src/providers/ReplyProvider.tsx
@@ -9,7 +9,8 @@ import {
getQuotedReferenceFromQTags,
getRootATag,
getRootETag,
- isNip25ReactionKind
+ isNip25ReactionKind,
+ resolveDeclaredThreadRootEventHex
} from '@/lib/event'
import { getFirstHexEventIdFromETags } from '@/lib/tag'
import client from '@/services/client.service'
@@ -57,7 +58,9 @@ export function ReplyProvider({ children }: { children: React.ReactNode }) {
let rootId: string | undefined
const rootETag = getRootETag(reply)
if (rootETag) {
- rootId = rootETag[1]?.toLowerCase?.() ?? rootETag[1]
+ const raw = rootETag[1]?.toLowerCase?.() ?? rootETag[1]
+ rootId =
+ raw && /^[0-9a-f]{64}$/i.test(raw) ? resolveDeclaredThreadRootEventHex(raw) : raw
} else {
const rootATag = getRootATag(reply)
if (rootATag) {
diff --git a/src/services/client-query.service.ts b/src/services/client-query.service.ts
index 3d2b332b..2d116f31 100644
--- a/src/services/client-query.service.ts
+++ b/src/services/client-query.service.ts
@@ -224,7 +224,7 @@ export class QueryService {
.map((u) => normalizeHttpRelayUrl(u) || u)
.filter(Boolean)
)
- )
+ ).filter((base) => !this.shouldSkipRelayForSession?.(base))
const wsQueryUrls = urls.filter((u) => !isHttpRelayUrl(u))
return await new Promise((resolve) => {
@@ -247,7 +247,10 @@ export class QueryService {
: Promise.allSettled(
httpRelayBases.map(async (base) => {
try {
- const evts = await queryIndexRelay(base, filter, { signal: abortHttp.signal })
+ const evts = await queryIndexRelay(base, filter, {
+ signal: abortHttp.signal,
+ onHardFailure: () => this.onRelayConnectionFailure?.(base)
+ })
for (const evt of evts) {
if (resolved) return
eventCount++
diff --git a/src/services/client.service.ts b/src/services/client.service.ts
index 7857abc1..5d2320bc 100644
--- a/src/services/client.service.ts
+++ b/src/services/client.service.ts
@@ -176,10 +176,14 @@ class ClientService extends EventTarget {
// Initialize sub-services
this.queryService = new QueryService(this.pool, {
- shouldSkipRelayForSession: (normalizedUrl) =>
- (this.publishStrikeCount.get(normalizedUrl) ?? 0) >=
- ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD,
- onRelayConnectionFailure: (normalizedUrl) => this.recordSessionRelayFailure(normalizedUrl),
+ shouldSkipRelayForSession: (url) => {
+ const key = normalizeAnyRelayUrl(url) || url
+ return (
+ (this.publishStrikeCount.get(key) ?? 0) >=
+ ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD
+ )
+ },
+ onRelayConnectionFailure: (url) => this.recordSessionRelayFailure(url),
onRelayNoticeStrike: (normalizedUrl, noticeMessage) =>
this.recordRelayNoticeFetchFailure(normalizedUrl, noticeMessage)
})
@@ -747,7 +751,7 @@ class ClientService extends EventTarget {
hasRelayList: !!relayList,
writeRelayCount: relayList?.write?.length ?? 0,
readRelayCount: relayList?.read?.length ?? 0,
- writeRelays: relayList?.write?.slice(0, 10) ?? []
+ writeRelays: relayList?.write?.slice(0, MAX_PUBLISH_RELAYS) ?? []
})
}
const userWritesOrdered = dedupeNormalizeRelayUrlsOrdered(
@@ -809,7 +813,7 @@ class ClientService extends EventTarget {
/** One failed publish or subscribe connection per normalized URL (accumulates until {@link SESSION_RELAY_FAILURE_STRIKE_THRESHOLD}). */
/** NOTICE "failed to fetch events" (relay DB/backend) — same session strike as a failed connection. */
private recordRelayNoticeFetchFailure(url: string, noticeMessage: string) {
- const n = normalizeUrl(url) || url
+ const n = normalizeAnyRelayUrl(url) || url
if (!n) return
const prev = this.publishStrikeCount.get(n) ?? 0
if (prev >= ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD) {
@@ -823,7 +827,7 @@ class ClientService extends EventTarget {
}
private recordSessionRelayFailure(url: string) {
- const n = normalizeUrl(url) || url
+ const n = normalizeAnyRelayUrl(url) || url
if (!n) return
const prev = this.publishStrikeCount.get(n) ?? 0
if (prev >= ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD) {
@@ -841,7 +845,7 @@ class ClientService extends EventTarget {
private filterSessionStrikedRelays(urls: string[]): string[] {
return urls.filter((u) => {
- const n = normalizeUrl(u) || u
+ const n = normalizeAnyRelayUrl(u) || u
return (this.publishStrikeCount.get(n) ?? 0) < ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD
})
}
@@ -855,6 +859,20 @@ class ClientService extends EventTarget {
this.publishStrikeCount.clear()
}
+ /**
+ * Clear session failure strikes for one normalized relay URL so reads and publishes use it again
+ * until new failures accrue (same counter as {@link clearSessionRelayStrikes}).
+ */
+ clearSessionRelayStrikeForUrl(url: string): boolean {
+ const n = normalizeAnyRelayUrl(url) || url
+ if (!n) return false
+ const had = this.publishStrikeCount.delete(n)
+ if (had) {
+ logger.info('[Relay] Session strikes cleared for relay (manual)', { url: n })
+ }
+ return had
+ }
+
/**
* Apply strike filter; if that removes all candidates while some were provided, clear strikes **for those URLs
* only** and retry once. (A global clear here caused storms: e.g. NIP-65 outbox retry with 2 relays wiped strikes
@@ -866,9 +884,13 @@ class ClientService extends EventTarget {
if (filtered.length === 0 && unique.length > 0) {
let cleared = 0
for (const u of unique) {
- const n = normalizeUrl(u) || u
+ // HTTP index relays (CORS down, wrong origin) do not recover like WebSockets; clearing their strikes
+ // here caused retry storms with many parallel fetchEvents hitting the same dead endpoint.
+ if (isHttpRelayUrl(u)) continue
+ const n = normalizeAnyRelayUrl(u) || u
if (n && this.publishStrikeCount.delete(n)) cleared += 1
}
+ if (cleared === 0) return filtered
logger.info('[Relay] Batch was all session-striked — cleared strikes for this batch only', {
batchUrlCount: unique.length,
strikeEntriesCleared: cleared
@@ -880,7 +902,7 @@ class ClientService extends EventTarget {
/** Record a successful publish and its latency for session-based preference when selecting random relays. */
recordPublishSuccess(url: string, latencyMs: number) {
- const n = normalizeUrl(url) || url
+ const n = normalizeAnyRelayUrl(url) || url
const cur = this.sessionRelayPublishStats.get(n)
if (cur) {
cur.successCount += 1
@@ -899,7 +921,7 @@ class ClientService extends EventTarget {
const out: string[] = []
for (const [url, stats] of this.sessionRelayPublishStats.entries()) {
if (stats.successCount < 1) continue
- const n = normalizeUrl(url) || url
+ const n = normalizeAnyRelayUrl(url) || url
if (!n || readOnlySet.has(n)) continue
if ((this.publishStrikeCount.get(n) ?? 0) >= ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD) continue
out.push(n)
@@ -952,9 +974,9 @@ class ClientService extends EventTarget {
* preferring those that have succeeded and been fast this session. Excludes 3-strike and read-only relays.
*/
getPreferredRelaysForRandom(candidateUrls: string[], count: number): string[] {
- const readOnlySet = new Set(READ_ONLY_RELAY_URLS.map((u) => normalizeUrl(u) || u))
+ const readOnlySet = new Set(READ_ONLY_RELAY_URLS.map((u) => normalizeAnyRelayUrl(u) || u))
const normalizedCandidates = candidateUrls
- .map((u) => normalizeUrl(u) || u)
+ .map((u) => normalizeAnyRelayUrl(u) || u)
.filter((n) => n && !readOnlySet.has(n))
const unique = Array.from(new Set(normalizedCandidates))
const notStruckOut = unique.filter(