Browse Source

free session-blocked relays manually

session-strike http relays
raise max publish relays to 20
correct relay list count
bug-fixes
imwald
Silberengel 1 month ago
parent
commit
72584f66de
  1. 127
      src/components/PostEditor/PostRelaySelector.tsx
  2. 41
      src/components/SessionRelaysTab/index.tsx
  3. 2
      src/constants.ts
  4. 7
      src/i18n/locales/de.ts
  5. 7
      src/i18n/locales/en.ts
  6. 30
      src/lib/draft-event.ts
  7. 96
      src/lib/event.ts
  8. 28
      src/lib/index-relay-http.ts
  9. 4
      src/lib/thread-reply-root-match.ts
  10. 7
      src/providers/ReplyProvider.tsx
  11. 7
      src/services/client-query.service.ts
  12. 48
      src/services/client.service.ts

127
src/components/PostEditor/PostRelaySelector.tsx

@ -2,10 +2,12 @@ import { @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -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 ? (
<span className="text-xs text-amber-600 dark:text-amber-500">
{t('Publish relay cap hint with outbox first', {
max: MAX_PUBLISH_RELAYS,
reservedSlots: publishCapPreview.outboxSlotsInPublish,
selected: publishCapPreview.selectedTotal,
selectedContacted: publishCapPreview.selectedContacted
})}
</span>
) : (
<span className="text-xs text-amber-600 dark:text-amber-500">
{t('Publish relay cap hint', {
max: MAX_PUBLISH_RELAYS,
selected: publishCapPreview.selectedTotal,
selectedContacted: publishCapPreview.selectedContacted
})}
</span>
))
if (isSmallScreen) {
return (
<div className="flex items-center gap-2">
@ -452,14 +543,7 @@ export default function PostRelaySelector({ @@ -452,14 +543,7 @@ export default function PostRelaySelector({
<div className="flex flex-col min-w-0 flex-1 gap-1">
<span className="text-lg font-medium">{t('Select relays')}</span>
<span className="text-sm text-muted-foreground truncate">{description}</span>
{selectedRelayUrls.length >= MAX_PUBLISH_RELAYS && (
<span className="text-xs text-amber-600 dark:text-amber-500">
{t('Publish relay cap hint', {
max: MAX_PUBLISH_RELAYS,
selected: selectedRelayUrls.length
})}
</span>
)}
{capHintEl}
</div>
</div>
<div className="flex-1 min-h-0 overflow-y-scroll overflow-x-hidden p-4">
@ -495,14 +579,7 @@ export default function PostRelaySelector({ @@ -495,14 +579,7 @@ export default function PostRelaySelector({
<span className="text-sm font-medium">{t('Select relays')}</span>
<span className="text-xs text-muted-foreground truncate">{description}</span>
</div>
{selectedRelayUrls.length >= MAX_PUBLISH_RELAYS && (
<span className="text-xs text-amber-600 dark:text-amber-500">
{t('Publish relay cap hint', {
max: MAX_PUBLISH_RELAYS,
selected: selectedRelayUrls.length
})}
</span>
)}
{capHintEl}
</div>
<div className="max-h-[35vh] min-h-0 overflow-y-scroll overflow-x-hidden p-3">
{content}

41
src/components/SessionRelaysTab/index.tsx

@ -1,7 +1,7 @@ @@ -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() { @@ -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() { @@ -79,13 +84,26 @@ export default function SessionRelaysTab() {
<p className="text-muted-foreground text-xs">
{t('Session relays preset striked hint')}
</p>
<ul className="rounded-lg border bg-muted/30 p-3 space-y-1 text-sm font-mono">
<ul className="rounded-lg border bg-muted/30 p-3 space-y-2 text-sm">
{debug.presetStriked.length === 0 ? (
<li className="text-muted-foreground">{t('None')}</li>
) : (
debug.presetStriked.map((url) => (
<li key={url} className="truncate" title={url}>
<li key={url} className="flex items-center justify-between gap-2">
<span className="min-w-0 truncate font-mono" title={url}>
{formatUrl(url)}
</span>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 shrink-0 gap-1 px-2 text-xs"
title={t('Session relays clear strike hint')}
onClick={() => clearStrikeForUrl(url)}
>
<RotateCcw className="h-3.5 w-3.5" aria-hidden />
{t('Session relays clear strike')}
</Button>
</li>
))
)}
@ -123,10 +141,23 @@ export default function SessionRelaysTab() { @@ -123,10 +141,23 @@ export default function SessionRelaysTab() {
<h3 className="text-sm font-medium text-muted-foreground">
{t('Session relays all striked')}
</h3>
<ul className="rounded-lg border bg-muted/30 p-3 space-y-1 text-sm font-mono text-muted-foreground">
<ul className="rounded-lg border bg-muted/30 p-3 space-y-2 text-sm">
{debug.strikedUrls.map((url) => (
<li key={url} className="truncate" title={url}>
<li key={url} className="flex items-center justify-between gap-2 text-muted-foreground">
<span className="min-w-0 truncate font-mono" title={url}>
{formatUrl(url)}
</span>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 shrink-0 gap-1 px-2 text-xs text-foreground"
title={t('Session relays clear strike hint')}
onClick={() => clearStrikeForUrl(url)}
>
<RotateCcw className="h-3.5 w-3.5" aria-hidden />
{t('Session relays clear strike')}
</Button>
</li>
))}
</ul>

2
src/constants.ts

@ -50,7 +50,7 @@ export const MAX_CONCURRENT_RELAY_CONNECTIONS = 10 @@ -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

7
src/i18n/locales/de.ts

@ -549,6 +549,9 @@ export default { @@ -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 { @@ -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...',

7
src/i18n/locales/en.ts

@ -572,6 +572,9 @@ export default { @@ -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 { @@ -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...',

30
src/lib/draft-event.ts

@ -22,7 +22,8 @@ import { @@ -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) { @@ -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

96
src/lib/event.ts

@ -31,6 +31,39 @@ export function isNip56ReportEvent(event: Pick<Event, 'kind'>): boolean { @@ -31,6 +31,39 @@ export function isNip56ReportEvent(event: Pick<Event, 'kind'>): 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<string, string[]>({ max: 10000 })
const EVENT_EMBEDDED_PUBKEYS_CACHE = new LRUCache<string, string[]>({ max: 10000 })
const EVENT_IS_REPLY_NOTE_CACHE = new LRUCache<string, boolean>({ max: 10000 })
@ -105,10 +138,10 @@ export function getParentETag(event?: Event) { @@ -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) { @@ -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) { @@ -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<string>()
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,

28
src/lib/index-relay-http.ts

@ -39,6 +39,17 @@ export function nostrFilterToIndexRelayBody(f: Filter): Record<string, unknown> @@ -39,6 +39,17 @@ export function nostrFilterToIndexRelayBody(f: Filter): Record<string, unknown>
return body
}
const INDEX_RELAY_HTTP_WARN_COOLDOWN_MS = 5000
const lastIndexRelayHttpWarnAtByEndpoint = new Map<string, number>()
function warnIndexRelayHttpThrottled(endpoint: string, message: string, meta: Record<string, unknown>) {
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<string, unknown>): NEvent | null {
try {
const id = raw.id
@ -68,17 +79,20 @@ function rawToVerifiedEvent(raw: Record<string, unknown>): NEvent | null { @@ -68,17 +79,20 @@ function rawToVerifiedEvent(raw: Record<string, unknown>): 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<NEvent[]> {
const base = normalizeHttpRelayUrl(baseUrl) || baseUrl
const endpoint = indexRelayFilterUrl(base)
const filters = Array.isArray(filter) ? filter : [filter]
const out: NEvent[] = []
const seen = new Set<string>()
let sawHardFailure = false
for (const f of filters) {
const body = nostrFilterToIndexRelayBody(filterForIndexRelay(f))
try {
@ -92,7 +106,11 @@ export async function queryIndexRelay( @@ -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,8 +126,12 @@ export async function queryIndexRelay( @@ -108,8 +126,12 @@ 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
}

4
src/lib/thread-reply-root-match.ts

@ -4,7 +4,8 @@ import { @@ -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 @@ -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)

7
src/providers/ReplyProvider.tsx

@ -9,7 +9,8 @@ import { @@ -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 }) { @@ -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) {

7
src/services/client-query.service.ts

@ -224,7 +224,7 @@ export class QueryService { @@ -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<NEvent[]>((resolve) => {
@ -247,7 +247,10 @@ export class QueryService { @@ -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++

48
src/services/client.service.ts

@ -176,10 +176,14 @@ class ClientService extends EventTarget { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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(

Loading…
Cancel
Save