Browse Source

more bug-fixing

imwald
Silberengel 1 month ago
parent
commit
4729ecb5c1
  1. 240
      src/components/NoteList/index.tsx
  2. 6
      src/i18n/locales/de.ts
  3. 6
      src/i18n/locales/en.ts
  4. 18
      src/pages/primary/SpellsPage/fauxSpellFeeds.ts
  5. 11
      src/pages/primary/SpellsPage/index.tsx
  6. 28
      src/services/client-query.service.ts
  7. 74
      src/services/client.service.ts

240
src/components/NoteList/index.tsx

@ -24,9 +24,9 @@ import { useNostr } from '@/providers/NostrProvider'
import { useUserTrust } from '@/contexts/user-trust-context' import { useUserTrust } from '@/contexts/user-trust-context'
import { useZap } from '@/providers/ZapProvider' import { useZap } from '@/providers/ZapProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
import { TFeedSubRequest } from '@/types' import type { TFeedSubRequest, TSubRequestFilter } from '@/types'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { Event, kinds } from 'nostr-tools' import { type Event, type Filter, kinds } from 'nostr-tools'
import { decode } from 'nostr-tools/nip19' import { decode } from 'nostr-tools/nip19'
import { import {
forwardRef, forwardRef,
@ -66,6 +66,16 @@ function mergeEventBatchesById(prev: Event[], incoming: Event[], cap: number): E
.slice(0, cap) .slice(0, cap)
} }
/** When omitting `kinds` from a live REQ, require another scope so we never subscribe to a whole relay. */
function timelineFilterHasNonKindScope(f: Filter): boolean {
return (
(Array.isArray(f.authors) && f.authors.length > 0) ||
(Array.isArray(f.ids) && f.ids.length > 0) ||
(Array.isArray(f['#p']) && f['#p']!.length > 0) ||
(Array.isArray(f['#e']) && f['#e']!.length > 0)
)
}
const NoteList = forwardRef( const NoteList = forwardRef(
( (
{ {
@ -103,6 +113,16 @@ const NoteList = forwardRef(
spellFeedInstrumentToken, spellFeedInstrumentToken,
/** Spells page: fired once when the filtered list first has rows after a picker change. */ /** Spells page: fired once when the filtered list first has rows after a picker change. */
onSpellFeedFirstPaint, onSpellFeedFirstPaint,
/**
* After this many ms with no forced completion, loading is cleared so empty state can show (default 15s).
* Use a larger value for slow feeds (e.g. notifications `#p` across many relays).
*/
timelineLoadingSafetyTimeoutMs,
/**
* With {@link useFilterAsIs}: omit relay `kinds` when the subrequest filter has none, and narrow
* incoming events to {@link showKinds} before merging (so caps are not filled by unrelated kinds).
*/
clientSideKindFilter = false,
/** /**
* When true, load events with parallel {@link client.fetchEvents} per subRequest instead of * When true, load events with parallel {@link client.fetchEvents} per subRequest instead of
* {@link client.subscribeTimeline}. No live stream or `loadMore` timeline pagination; use for faux spells * {@link client.subscribeTimeline}. No live stream or `loadMore` timeline pagination; use for faux spells
@ -146,6 +166,8 @@ const NoteList = forwardRef(
spellFetchTimeoutMs?: number spellFetchTimeoutMs?: number
spellFeedInstrumentToken?: number spellFeedInstrumentToken?: number
onSpellFeedFirstPaint?: (detail: { eventCount: number; firstEventId: string }) => void onSpellFeedFirstPaint?: (detail: { eventCount: number; firstEventId: string }) => void
timelineLoadingSafetyTimeoutMs?: number
clientSideKindFilter?: boolean
oneShotFetch?: boolean oneShotFetch?: boolean
oneShotMergedCap?: number oneShotMergedCap?: number
revealBatchSize?: number revealBatchSize?: number
@ -260,6 +282,13 @@ const NoteList = forwardRef(
return JSON.stringify([...showKinds].sort((a, b) => a - b)) return JSON.stringify([...showKinds].sort((a, b) => a - b))
}, [showKinds]) }, [showKinds])
const showKindsRef = useRef(showKinds)
showKindsRef.current = showKinds
const useFilterAsIsRef = useRef(useFilterAsIs)
useFilterAsIsRef.current = useFilterAsIs
const clientSideKindFilterRef = useRef(clientSideKindFilter)
clientSideKindFilterRef.current = clientSideKindFilter
const shouldHideEvent = useCallback( const shouldHideEvent = useCallback(
(evt: Event) => { (evt: Event) => {
const pinnedEventHexIdSet = new Set() const pinnedEventHexIdSet = new Set()
@ -525,35 +554,43 @@ const NoteList = forwardRef(
setHasMore(true) setHasMore(true)
consecutiveEmptyRef.current = 0 // Reset counter on refresh consecutiveEmptyRef.current = 0 // Reset counter on refresh
const defaultKinds = showKinds.length > 0 ? showKinds : [kinds.ShortTextNote]
const mappedSubRequests = subRequestsRef.current.map(({ urls, filter }) => { const mappedSubRequests = subRequestsRef.current.map(({ urls, filter }) => {
// CRITICAL: Always ensure filter has kinds - relays require this to return events const baseLimit = filter.limit ?? (areAlgoRelays ? ALGO_LIMIT : LIMIT)
const defaultKinds = showKinds.length > 0 ? showKinds : [kinds.ShortTextNote] if (useFilterAsIs) {
const finalFilter = useFilterAsIs const finalFilter: Filter = { ...filter, limit: baseLimit }
? { const hasKindsInRequest = Array.isArray(filter.kinds) && filter.kinds.length > 0
...filter, if (clientSideKindFilter) {
// If filter doesn't have kinds, add them (required for relay queries) if (hasKindsInRequest) {
kinds: filter.kinds && filter.kinds.length > 0 ? filter.kinds : defaultKinds, finalFilter.kinds = filter.kinds
limit: filter.limit ?? (areAlgoRelays ? ALGO_LIMIT : LIMIT) } else {
} delete finalFilter.kinds
: {
...filter,
// If showKinds is empty, default to kind 1 (ShortTextNote) only
kinds: defaultKinds,
limit: areAlgoRelays ? ALGO_LIMIT : LIMIT
} }
} else if (hasKindsInRequest) {
// CRITICAL: Validate filter has kinds before subscribing finalFilter.kinds = filter.kinds
if (!finalFilter.kinds || finalFilter.kinds.length === 0) { } else {
finalFilter.kinds = [kinds.ShortTextNote] finalFilter.kinds = defaultKinds
}
return { urls, filter: finalFilter }
}
return {
urls,
filter: {
...filter,
kinds: defaultKinds,
limit: areAlgoRelays ? ALGO_LIMIT : LIMIT
}
} }
return { urls, filter: finalFilter }
}) })
// CRITICAL: Validate all filters have kinds before subscribing const filterMissingKinds = (f: Filter) => !f.kinds || f.kinds.length === 0
const invalidFilters = mappedSubRequests.filter(({ filter }) => !filter.kinds || filter.kinds.length === 0) const invalidFilters = mappedSubRequests.filter(({ filter: f }) => {
if (!filterMissingKinds(f)) return false
if (useFilterAsIs && clientSideKindFilter && timelineFilterHasNonKindScope(f)) return false
return true
})
if (invalidFilters.length > 0) { if (invalidFilters.length > 0) {
// Don't subscribe with invalid filters - this would return no events
if (oneShotDebugLabel) { if (oneShotDebugLabel) {
logger.warn(`[${oneShotDebugLabel}] abort: filter missing kinds`, { logger.warn(`[${oneShotDebugLabel}] abort: filter missing kinds`, {
subRequestsKey: timelineSubscriptionKey subRequestsKey: timelineSubscriptionKey
@ -561,10 +598,14 @@ const NoteList = forwardRef(
} }
setLoading(false) setLoading(false)
setEvents([]) setEvents([])
// Return a no-op closer function to satisfy the cleanup function
return () => {} return () => {}
} }
const narrowLiveBatch = (evs: Event[]) => {
if (!useFilterAsIs || !clientSideKindFilter) return evs
return evs.filter((e) => showKinds.includes(e.kind))
}
if (oneShotFetch) { if (oneShotFetch) {
if (!keepExistingTimelineEvents) { if (!keepExistingTimelineEvents) {
setEvents([]) setEvents([])
@ -595,9 +636,12 @@ const NoteList = forwardRef(
} }
} }
const cap = oneShotMergedCap ?? ONE_SHOT_MERGED_CAP const cap = oneShotMergedCap ?? ONE_SHOT_MERGED_CAP
const merged = [...byId.values()] let merged = [...byId.values()]
.sort((a, b) => b.created_at - a.created_at) .sort((a, b) => b.created_at - a.created_at)
.slice(0, cap) .slice(0, cap)
if (useFilterAsIs && clientSideKindFilter) {
merged = merged.filter((e) => showKinds.includes(e.kind))
}
if (oneShotDebugLabel) { if (oneShotDebugLabel) {
const f0 = mappedSubRequests[0]?.filter const f0 = mappedSubRequests[0]?.filter
const batchEventCounts = batches.map((b) => b.length) const batchEventCounts = batches.map((b) => b.length)
@ -662,53 +706,61 @@ const NoteList = forwardRef(
const eventCap = areAlgoRelays ? ALGO_LIMIT : LIMIT const eventCap = areAlgoRelays ? ALGO_LIMIT : LIMIT
timelineSubscribePromise = client.subscribeTimeline( timelineSubscribePromise = client.subscribeTimeline(
mappedSubRequests, mappedSubRequests as Array<{ urls: string[]; filter: TSubRequestFilter }>,
{ {
onEvents: (batch: Event[], eosed: boolean) => { onEvents: (batch: Event[], eosed: boolean) => {
if (!effectActive) return if (!effectActive) return
const narrowed = narrowLiveBatch(batch)
if (batch.length > 0) { if (batch.length > 0) {
if (preserveTimelineOnSubRequestsChange) { if (narrowed.length > 0) {
setEvents((prev) => { if (preserveTimelineOnSubRequestsChange) {
const next = mergeEventBatchesById(prev, batch, eventCap) setEvents((prev) => {
lastEventsForTimelinePrefetchRef.current = next const next = mergeEventBatchesById(prev, narrowed, eventCap)
return next lastEventsForTimelinePrefetchRef.current = next
}) return next
} else { })
setEvents(batch) } else {
lastEventsForTimelinePrefetchRef.current = batch setEvents(narrowed)
} lastEventsForTimelinePrefetchRef.current = narrowed
// Do not wait for full EOSE across many relays — otherwise loading/skeleton stays up for 10–30s+ }
setLoading(false) // Do not wait for full EOSE across many relays — otherwise loading/skeleton stays up for 10–30s+
setLoading(false)
// Defer profile + embed prefetch: streaming timelines fire onEvents often; starting // Defer profile + embed prefetch: streaming timelines fire onEvents often; starting
// fetchProfilesForPubkeys on every update spams relays (multi-second each) and cancels hooks. // fetchProfilesForPubkeys on every update spams relays (multi-second each) and cancels hooks.
if (timelinePrefetchDebounceRef.current) { if (timelinePrefetchDebounceRef.current) {
clearTimeout(timelinePrefetchDebounceRef.current) clearTimeout(timelinePrefetchDebounceRef.current)
} }
timelinePrefetchDebounceRef.current = setTimeout(() => { timelinePrefetchDebounceRef.current = setTimeout(() => {
timelinePrefetchDebounceRef.current = null timelinePrefetchDebounceRef.current = null
if (!effectActive) return if (!effectActive) return
const evs = lastEventsForTimelinePrefetchRef.current const evs = lastEventsForTimelinePrefetchRef.current
if (evs.length === 0) return if (evs.length === 0) return
const { hexIds, nip19Pointers } = mergePrefetchTargetsFromEvents(evs.slice(0, 50)) const { hexIds, nip19Pointers } = mergePrefetchTargetsFromEvents(evs.slice(0, 50))
const hexIdsToFetch = hexIds.filter((id) => !prefetchedEventIdsRef.current.has(id)) const hexIdsToFetch = hexIds.filter((id) => !prefetchedEventIdsRef.current.has(id))
const nip19ToFetch = nip19Pointers.filter((p) => !prefetchedEventIdsRef.current.has(p)) const nip19ToFetch = nip19Pointers.filter((p) => !prefetchedEventIdsRef.current.has(p))
if (hexIdsToFetch.length > 0 || nip19ToFetch.length > 0) { if (hexIdsToFetch.length > 0 || nip19ToFetch.length > 0) {
hexIdsToFetch.forEach((id) => prefetchedEventIdsRef.current.add(id)) hexIdsToFetch.forEach((id) => prefetchedEventIdsRef.current.add(id))
nip19ToFetch.forEach((p) => prefetchedEventIdsRef.current.add(p)) nip19ToFetch.forEach((p) => prefetchedEventIdsRef.current.add(p))
const run = async () => { const run = async () => {
try { try {
await client.prefetchHexEventIds(hexIdsToFetch) await client.prefetchHexEventIds(hexIdsToFetch)
await Promise.all(nip19ToFetch.map((p) => client.fetchEvent(p))) await Promise.all(nip19ToFetch.map((p) => client.fetchEvent(p)))
} catch { } catch {
hexIdsToFetch.forEach((id) => prefetchedEventIdsRef.current.delete(id)) hexIdsToFetch.forEach((id) => prefetchedEventIdsRef.current.delete(id))
nip19ToFetch.forEach((p) => prefetchedEventIdsRef.current.delete(p)) nip19ToFetch.forEach((p) => prefetchedEventIdsRef.current.delete(p))
}
} }
void run()
} }
void run() }, 450)
} else if (eosed) {
if (!preserveTimelineOnSubRequestsChange) {
setEvents([])
} }
}, 450) setLoading(false)
}
} else if (eosed) { } else if (eosed) {
if (!preserveTimelineOnSubRequestsChange) { if (!preserveTimelineOnSubRequestsChange) {
setEvents([]) setEvents([])
@ -738,6 +790,7 @@ const NoteList = forwardRef(
onNew: (event: Event) => { onNew: (event: Event) => {
if (!effectActive) return if (!effectActive) return
if (!useFilterAsIs && !showKinds.includes(event.kind)) return if (!useFilterAsIs && !showKinds.includes(event.kind)) return
if (clientSideKindFilter && useFilterAsIs && !showKinds.includes(event.kind)) return
if (event.kind === kinds.ShortTextNote) { if (event.kind === kinds.ShortTextNote) {
const isReply = isReplyNoteEvent(event) const isReply = isReplyNoteEvent(event)
if (isReply && !showKind1Replies) return if (isReply && !showKind1Replies) return
@ -815,7 +868,8 @@ const NoteList = forwardRef(
oneShotDebugLabel, oneShotDebugLabel,
oneShotGlobalTimeoutMs, oneShotGlobalTimeoutMs,
oneShotEoseTimeoutMs, oneShotEoseTimeoutMs,
oneShotFirstRelayGraceMs oneShotFirstRelayGraceMs,
clientSideKindFilter
]) ])
const oneShotDebugPrevLoadingRef = useRef(false) const oneShotDebugPrevLoadingRef = useRef(false)
@ -855,6 +909,8 @@ const NoteList = forwardRef(
eventsRef.current = events eventsRef.current = events
}, [events]) }, [events])
const loadingSafetyMs = timelineLoadingSafetyTimeoutMs ?? 15_000
useEffect(() => { useEffect(() => {
if (!subRequestsRef.current.length) return if (!subRequestsRef.current.length) return
let cancelled = false let cancelled = false
@ -866,12 +922,12 @@ const NoteList = forwardRef(
if (eventsRef.current.length === 0) { if (eventsRef.current.length === 0) {
setHasMore(false) setHasMore(false)
} }
}, 15_000) }, loadingSafetyMs)
return () => { return () => {
cancelled = true cancelled = true
clearTimeout(timer) clearTimeout(timer)
} }
}, [timelineSubscriptionKey, refreshCount]) }, [timelineSubscriptionKey, refreshCount, loadingSafetyMs])
// Use refs to avoid dependency issues and ensure latest values in async callbacks // Use refs to avoid dependency issues and ensure latest values in async callbacks
const showCountRef = useRef(showCount) const showCountRef = useRef(showCount)
@ -994,11 +1050,43 @@ const NoteList = forwardRef(
setLoading(false) setLoading(false)
return return
} }
// Reset consecutive empty counter on success let fetchBatch = newEvents
let toAppend =
useFilterAsIsRef.current && clientSideKindFilterRef.current
? fetchBatch.filter((e) => showKindsRef.current.includes(e.kind))
: fetchBatch
if (
useFilterAsIsRef.current &&
clientSideKindFilterRef.current &&
toAppend.length === 0 &&
fetchBatch.length > 0
) {
let skipUntil = Math.min(...fetchBatch.map((e) => e.created_at)) - 1
for (let depth = 0; depth < 8 && toAppend.length === 0; depth++) {
fetchBatch = await client.loadMoreTimeline(latestTimelineKey, skipUntil, LIMIT)
if (fetchBatch.length === 0) break
toAppend = fetchBatch.filter((e) => showKindsRef.current.includes(e.kind))
if (toAppend.length > 0) break
skipUntil = Math.min(...fetchBatch.map((e) => e.created_at)) - 1
}
}
if (toAppend.length === 0) {
consecutiveEmptyRef.current += 1
const eventCount = latestEvents.length
const shouldStop = consecutiveEmptyRef.current >= (eventCount < 50 ? 30 : 15)
if (shouldStop) {
setHasMore(false)
}
setLoading(false)
return
}
consecutiveEmptyRef.current = 0 consecutiveEmptyRef.current = 0
setEvents((oldEvents) => [...oldEvents, ...newEvents]) setEvents((oldEvents) => [...oldEvents, ...toAppend])
// After appending, the bottom sentinel may have moved below the fold. Re-check after // After appending, the bottom sentinel may have moved below the fold. Re-check after
// paint: if it's still in/near view, trigger loadMore again so user doesn't have to scroll. // paint: if it's still in/near view, trigger loadMore again so user doesn't have to scroll.
@ -1018,7 +1106,7 @@ const NoteList = forwardRef(
// CRITICAL: Prefetch profiles for newly loaded events (optimized to reduce stuttering) // CRITICAL: Prefetch profiles for newly loaded events (optimized to reduce stuttering)
// Only prefetch if we're not currently loading to avoid blocking scroll // Only prefetch if we're not currently loading to avoid blocking scroll
if (newEvents.length > 0 && !loadingRef.current) { if (toAppend.length > 0 && !loadingRef.current) {
// Use requestIdleCallback if available, otherwise setTimeout with longer delay // Use requestIdleCallback if available, otherwise setTimeout with longer delay
const schedulePrefetch = (callback: () => void) => { const schedulePrefetch = (callback: () => void) => {
if (typeof requestIdleCallback !== 'undefined') { if (typeof requestIdleCallback !== 'undefined') {
@ -1029,7 +1117,7 @@ const NoteList = forwardRef(
} }
schedulePrefetch(() => { schedulePrefetch(() => {
const { hexIds, nip19Pointers } = mergePrefetchTargetsFromEvents(newEvents.slice(0, 30)) const { hexIds, nip19Pointers } = mergePrefetchTargetsFromEvents(toAppend.slice(0, 30))
const hexIdsToFetch = hexIds.filter((id) => !prefetchedEventIdsRef.current.has(id)) const hexIdsToFetch = hexIds.filter((id) => !prefetchedEventIdsRef.current.has(id))
const nip19ToFetch = nip19Pointers.filter((p) => !prefetchedEventIdsRef.current.has(p)) const nip19ToFetch = nip19Pointers.filter((p) => !prefetchedEventIdsRef.current.has(p))
if (hexIdsToFetch.length === 0 && nip19ToFetch.length === 0) return if (hexIdsToFetch.length === 0 && nip19ToFetch.length === 0) return

6
src/i18n/locales/de.ts

@ -492,13 +492,13 @@ export default {
relayType_randomly_selected: 'Zufällig (optional)', relayType_randomly_selected: 'Zufällig (optional)',
'Session relays': 'Session-Relays', 'Session relays': 'Session-Relays',
'Session relays tab description': 'Session relays tab description':
'Relay-Logik für diese Session: funktionierende und gestrichene Preset-Relays sowie bewertete Zufallsrelays (bevorzugt schnellere, bewährte Relays beim Hinzufügen von Zufallsrelays).', 'Relay-Logik für diese Session: funktionierende und gestrichene Preset-Relays sowie bewertete Zufallsrelays. Gestrichene Relays werden für Lesen und Schreiben bis zum Neuladen der App übersprungen.',
'Session relays preset working': 'Funktionierende Preset-Relays', 'Session relays preset working': 'Funktionierende Preset-Relays',
'Session relays preset working hint': 'Session relays preset working hint':
'Preset-Relays (App-Standard), die in dieser Session keine 3 Publish-Fehler erreicht haben.', 'Preset-Relays (App-Standard), die die Session-Fehlerschwelle (2 Fehler) noch nicht erreicht haben.',
'Session relays preset striked': 'Gestrichene Preset-Relays', 'Session relays preset striked': 'Gestrichene Preset-Relays',
'Session relays preset striked hint': 'Session relays preset striked hint':
'Preset-Relays mit 3 Publish-Fehlern in dieser Session; werden für den Rest der Session übersprungen.', 'Preset-Relays mit 2 Verbindungs- oder Publish-Fehlern in dieser Session; werden für Lesen und Schreiben bis zum Neuladen übersprungen.',
'Session relays scored random': 'Bewertete Zufallsrelays', 'Session relays scored random': 'Bewertete Zufallsrelays',
'Session relays scored random hint': '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.', 'Relays, die in dieser Session mindestens ein Publish angenommen haben; werden beim Auswählen von Zufallsrelays bevorzugt. Sortiert nach durchschnittlicher Latenz.',

6
src/i18n/locales/en.ts

@ -483,13 +483,13 @@ export default {
relayType_randomly_selected: 'Random (optional)', relayType_randomly_selected: 'Random (optional)',
'Session relays': 'Session relays', 'Session relays': 'Session relays',
'Session relays tab description': 'Session relays tab description':
'Relay logic for this session: working and striked preset relays, and scored random relays (used to prefer faster, proven relays when adding random relays to publish).', 'Relay logic for this session: working and striked preset relays, and scored random relays (used to prefer faster, proven relays when adding random relays to publish). Striked relays are skipped for reads and publishes until reload.',
'Session relays preset working': 'Working preset relays', 'Session relays preset working': 'Working preset relays',
'Session relays preset working hint': 'Session relays preset working hint':
'Preset relays (from app defaults) that have not reached 3 publish failures this session.', 'Preset relays (from app defaults) that have not reached the session failure threshold (2 failures).',
'Session relays preset striked': 'Striked preset relays', 'Session relays preset striked': 'Striked preset relays',
'Session relays preset striked hint': 'Session relays preset striked hint':
'Preset relays that have reached 3 publish failures this session and are skipped for the rest of the session.', 'Preset relays that have reached 2 connection or publish failures this session and are skipped for reads and writes until you reload the app.',
'Session relays scored random': 'Scored random relays', 'Session relays scored random': 'Scored random relays',
'Session relays scored random hint': '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.', 'Relays that have accepted at least one publish this session; used to prefer faster relays when picking random relays. Sorted by average latency.',

18
src/pages/primary/SpellsPage/fauxSpellFeeds.ts

@ -49,6 +49,9 @@ export function applyFauxSpellCapsToSubRequests(requests: TFeedSubRequest[]): TF
/** /**
* Mention/notification-shaped kinds only (aligned with global notification-shaped kinds, plus zap receipts). * Mention/notification-shaped kinds only (aligned with global notification-shaped kinds, plus zap receipts).
* Not full {@link PROFILE_FEED_KINDS} that asked relays for huge multi-kind slices per `#p`. * Not full {@link PROFILE_FEED_KINDS} that asked relays for huge multi-kind slices per `#p`.
*
* Live notifications spell: REQ uses `#p` only (no relay `kinds`); {@link NOTIFICATION_SPELL_KINDS} is applied
* in NoteList via `clientSideKindFilter` so the timeline buffer is not filled by other kinds that mention you.
*/ */
export const NOTIFICATION_SPELL_KINDS = [ export const NOTIFICATION_SPELL_KINDS = [
kinds.ShortTextNote, kinds.ShortTextNote,
@ -63,6 +66,9 @@ export const NOTIFICATION_SPELL_KINDS = [
ExtendedKind.ZAP_RECEIPT ExtendedKind.ZAP_RECEIPT
] as const ] as const
/** Live notifications spell: longer than NoteList’s default 15s before empty state (slow `#p` on some relays). */
export const NOTIFICATION_SPELL_LOADING_SAFETY_MS = 90_000
/** /**
* Max distinct `t` tag values in one filter (very long `#t` arrays can hit relay limits). * Max distinct `t` tag values in one filter (very long `#t` arrays can hit relay limits).
*/ */
@ -105,9 +111,13 @@ export const MEDIA_SPELL_KINDS = [
*/ */
export const PROFILE_MEDIA_TAB_KINDS = [...MEDIA_SPELL_KINDS] as const export const PROFILE_MEDIA_TAB_KINDS = [...MEDIA_SPELL_KINDS] as const
function normalizeMentionPubkey(pubkey: string): string {
return /^[0-9a-f]{64}$/i.test(pubkey.trim()) ? pubkey.trim().toLowerCase() : pubkey.trim()
}
/** Notifications faux spell: `#p` = you, narrow kinds — see module docstring. */ /** Notifications faux spell: `#p` = you, narrow kinds — see module docstring. */
export function buildMentionsSpellFilter(pubkey: string): Filter { export function buildMentionsSpellFilter(pubkey: string): Filter {
const pk = /^[0-9a-f]{64}$/i.test(pubkey.trim()) ? pubkey.trim().toLowerCase() : pubkey.trim() const pk = normalizeMentionPubkey(pubkey)
return { return {
kinds: [...NOTIFICATION_SPELL_KINDS], kinds: [...NOTIFICATION_SPELL_KINDS],
limit: FAUX_SPELL_EVENT_LIMIT, limit: FAUX_SPELL_EVENT_LIMIT,
@ -115,6 +125,12 @@ export function buildMentionsSpellFilter(pubkey: string): Filter {
} }
} }
/** Live timeline: one REQ per relay set, any kind with `#p` = you; kinds narrowed in the client. */
export function buildNotificationsSpellSubRequests(urls: string[], pubkey: string): TFeedSubRequest[] {
const pk = normalizeMentionPubkey(pubkey)
return [{ urls, filter: { limit: FAUX_SPELL_EVENT_LIMIT, '#p': [pk] } }]
}
export function buildDiscussionFilter(): Filter { export function buildDiscussionFilter(): Filter {
return { return {
kinds: [ExtendedKind.DISCUSSION], kinds: [ExtendedKind.DISCUSSION],

11
src/pages/primary/SpellsPage/index.tsx

@ -93,7 +93,8 @@ import {
buildDiscussionFilter, buildDiscussionFilter,
buildInterestsSubRequests, buildInterestsSubRequests,
buildMediaSpellFilter, buildMediaSpellFilter,
buildMentionsSpellFilter, buildNotificationsSpellSubRequests,
NOTIFICATION_SPELL_LOADING_SAFETY_MS,
FAUX_SPELL_EVENT_LIMIT, FAUX_SPELL_EVENT_LIMIT,
MEDIA_SPELL_KINDS, MEDIA_SPELL_KINDS,
NOTIFICATION_SPELL_KINDS NOTIFICATION_SPELL_KINDS
@ -680,7 +681,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
if (selectedFauxSpell === 'notifications') { if (selectedFauxSpell === 'notifications') {
if (!pubkey || !feedUrls.length) return [] if (!pubkey || !feedUrls.length) return []
return [{ urls: feedUrls, filter: buildMentionsSpellFilter(pubkey) }] return buildNotificationsSpellSubRequests(feedUrls, pubkey)
} }
if (selectedFauxSpell === 'discussions') { if (selectedFauxSpell === 'discussions') {
// Read-only prepended in appendCuratedReadOnlyRelays so FAUX_SPELL_MAX_RELAYS still includes aggr. // Read-only prepended in appendCuratedReadOnlyRelays so FAUX_SPELL_MAX_RELAYS still includes aggr.
@ -1359,6 +1360,12 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
spellFetchTimeoutMs={1} spellFetchTimeoutMs={1}
spellFeedInstrumentToken={spellFeedInstrumentToken} spellFeedInstrumentToken={spellFeedInstrumentToken}
onSpellFeedFirstPaint={handleSpellFeedFirstPaint} onSpellFeedFirstPaint={handleSpellFeedFirstPaint}
timelineLoadingSafetyTimeoutMs={
selectedFauxSpell === 'notifications'
? NOTIFICATION_SPELL_LOADING_SAFETY_MS
: undefined
}
clientSideKindFilter={selectedFauxSpell === 'notifications'}
useFilterAsIs={fauxNoteListUseFilterAsIs} useFilterAsIs={fauxNoteListUseFilterAsIs}
oneShotFetch={false} oneShotFetch={false}
showKind1OPs={selectedFauxSpell === 'following' ? showKind1OPs : true} showKind1OPs={selectedFauxSpell === 'following' ? showKind1OPs : true}

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

@ -47,11 +47,20 @@ export interface SubscribeCallbacks {
onAllClose?: (reasons: string[]) => void onAllClose?: (reasons: string[]) => void
} }
export type QueryServiceRelaySessionOptions = {
/** Skip opening REQ/publish paths to this normalized URL for the rest of the page session. */
shouldSkipRelayForSession?: (normalizedUrl: string) => boolean
/** After failed `ensureRelay` (timeout / connection error), increment client session strike counter. */
onRelayConnectionFailure?: (normalizedUrl: string) => void
}
export class QueryService { export class QueryService {
private pool: SimplePool private pool: SimplePool
private signer?: ISigner private signer?: ISigner
private signerType?: TSignerType private signerType?: TSignerType
private shouldSkipRelayForSession?: (normalizedUrl: string) => boolean
private onRelayConnectionFailure?: (normalizedUrl: string) => void
/** Max concurrent REQ subscriptions per relay URL */ /** Max concurrent REQ subscriptions per relay URL */
private static readonly MAX_CONCURRENT_SUBS_PER_RELAY = MAX_CONCURRENT_RELAY_CONNECTIONS private static readonly MAX_CONCURRENT_SUBS_PER_RELAY = MAX_CONCURRENT_RELAY_CONNECTIONS
private activeSubCountByRelay = new Map<string, number>() private activeSubCountByRelay = new Map<string, number>()
@ -81,8 +90,10 @@ export class QueryService {
if (next) next() if (next) next()
} }
constructor(pool: SimplePool) { constructor(pool: SimplePool, relaySession?: QueryServiceRelaySessionOptions) {
this.pool = pool this.pool = pool
this.shouldSkipRelayForSession = relaySession?.shouldSkipRelayForSession
this.onRelayConnectionFailure = relaySession?.onRelayConnectionFailure
} }
setSigner(signer: ISigner | undefined, signerType: TSignerType | undefined) { setSigner(signer: ISigner | undefined, signerType: TSignerType | undefined) {
@ -347,6 +358,17 @@ export class QueryService {
const kind1BlockedSet = new Set(KIND_1_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u)) const kind1BlockedSet = new Set(KIND_1_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u))
relays = relays.filter((url) => !kind1BlockedSet.has(normalizeUrl(url) || url)) relays = relays.filter((url) => !kind1BlockedSet.has(normalizeUrl(url) || url))
} }
if (this.shouldSkipRelayForSession) {
relays = relays.filter((url) => {
const n = normalizeUrl(url) || url
return !this.shouldSkipRelayForSession!(n)
})
}
if (relays.length === 0) {
queueMicrotask(() => callbacks.oneose?.(true))
return { close: () => {} }
}
const _knownIds = new Set<string>() const _knownIds = new Set<string>()
const grouped = new Map<string, Filter[]>() const grouped = new Map<string, Filter[]>()
@ -412,6 +434,7 @@ export class QueryService {
try { try {
relay = await this.pool.ensureRelay(url, { connectionTimeout: 5000 }) relay = await this.pool.ensureRelay(url, { connectionTimeout: 5000 })
} catch (err) { } catch (err) {
this.onRelayConnectionFailure?.(relayKey)
this.releaseSubSlot(relayKey) this.releaseSubSlot(relayKey)
handleClose(i, (err as Error)?.message ?? String(err)) handleClose(i, (err as Error)?.message ?? String(err))
return return
@ -446,6 +469,7 @@ export class QueryService {
try { try {
liveRelay = await this.pool.ensureRelay(url, { connectionTimeout: 5000 }) liveRelay = await this.pool.ensureRelay(url, { connectionTimeout: 5000 })
} catch (err) { } catch (err) {
this.onRelayConnectionFailure?.(relayKey)
this.releaseSubSlot(relayKey) this.releaseSubSlot(relayKey)
handleClose(i, (err as Error)?.message ?? String(err)) handleClose(i, (err as Error)?.message ?? String(err))
return return

74
src/services/client.service.ts

@ -123,9 +123,12 @@ class ClientService extends EventTarget {
}) })
/** Session-only: relay URL -> publish failure count; after 3 strikes we skip that relay for the rest of the session. */ /**
* Session-only: connection/publish failures per normalized relay URL. After
* {@link ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD} strikes we skip that relay for reads and publishes until reload.
*/
private publishStrikeCount = new Map<string, number>() private publishStrikeCount = new Map<string, number>()
private static readonly PUBLISH_STRIKES_THRESHOLD = 3 private static readonly SESSION_RELAY_FAILURE_STRIKE_THRESHOLD = 2
/** Session-only: relay URL -> { successCount, sumLatencyMs } for preferring faster, proven relays when picking "random" relays. */ /** Session-only: relay URL -> { successCount, sumLatencyMs } for preferring faster, proven relays when picking "random" relays. */
private sessionRelayPublishStats = new Map<string, { successCount: number; sumLatencyMs: number }>() private sessionRelayPublishStats = new Map<string, { successCount: number; sumLatencyMs: number }>()
@ -142,7 +145,12 @@ class ClientService extends EventTarget {
this.pool.trackRelays = true this.pool.trackRelays = true
// Initialize sub-services // Initialize sub-services
this.queryService = new QueryService(this.pool) this.queryService = new QueryService(this.pool, {
shouldSkipRelayForSession: (normalizedUrl) =>
(this.publishStrikeCount.get(normalizedUrl) ?? 0) >=
ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD,
onRelayConnectionFailure: (normalizedUrl) => this.recordSessionRelayFailure(normalizedUrl)
})
this.eventService = new EventService(this.queryService) this.eventService = new EventService(this.queryService)
this.replaceableEventService = new ReplaceableEventService( this.replaceableEventService = new ReplaceableEventService(
this.queryService, this.queryService,
@ -660,15 +668,24 @@ class ClientService extends EventTarget {
return relays return relays
} }
/** Record publish failures for 3-strikes session policy (skip relay for rest of session after 3 rejections). */ /** One failed publish or subscribe connection per normalized URL (accumulates until {@link SESSION_RELAY_FAILURE_STRIKE_THRESHOLD}). */
private recordPublishFailures(relayStatuses: { url: string; success: boolean; error?: string }[]) { private recordSessionRelayFailure(url: string) {
relayStatuses.filter((s) => !s.success).forEach((s) => { const n = normalizeUrl(url) || url
const n = normalizeUrl(s.url) || s.url if (!n) return
const count = (this.publishStrikeCount.get(n) ?? 0) + 1 const count = (this.publishStrikeCount.get(n) ?? 0) + 1
this.publishStrikeCount.set(n, count) this.publishStrikeCount.set(n, count)
if (count >= ClientService.PUBLISH_STRIKES_THRESHOLD) { if (count >= ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD) {
logger.debug('[PublishEvent] Relay reached 3 strikes, skipping for session', { url: n }) logger.info('[Relay] Session strike threshold — relay skipped for reads/publishes until reload', {
} url: n,
strikes: count
})
}
}
private filterSessionStrikedRelays(urls: string[]): string[] {
return urls.filter((u) => {
const n = normalizeUrl(u) || u
return (this.publishStrikeCount.get(n) ?? 0) < ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD
}) })
} }
@ -695,7 +712,7 @@ class ClientService extends EventTarget {
if (stats.successCount < 1) continue if (stats.successCount < 1) continue
const n = normalizeUrl(url) || url const n = normalizeUrl(url) || url
if (!n || readOnlySet.has(n)) continue if (!n || readOnlySet.has(n)) continue
if ((this.publishStrikeCount.get(n) ?? 0) >= ClientService.PUBLISH_STRIKES_THRESHOLD) continue if ((this.publishStrikeCount.get(n) ?? 0) >= ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD) continue
out.push(n) out.push(n)
} }
out.sort((a, b) => { out.sort((a, b) => {
@ -709,6 +726,7 @@ class ClientService extends EventTarget {
/** /**
* Session-only debug info for the Session Relays settings tab: working/striked preset relays and scored random relays. * Session-only debug info for the Session Relays settings tab: working/striked preset relays and scored random relays.
* Strikes accrue from failed publishes and failed subscribe/query connections (same counter).
*/ */
getSessionRelayDebug(): { getSessionRelayDebug(): {
strikedUrls: string[] strikedUrls: string[]
@ -723,10 +741,14 @@ class ClientService extends EventTarget {
} }
const preset = Array.from(presetSet) const preset = Array.from(presetSet)
const strikedUrls = Array.from(this.publishStrikeCount.entries()) const strikedUrls = Array.from(this.publishStrikeCount.entries())
.filter(([, count]) => count >= ClientService.PUBLISH_STRIKES_THRESHOLD) .filter(([, count]) => count >= ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD)
.map(([url]) => url) .map(([url]) => url)
const presetStriked = preset.filter((url) => (this.publishStrikeCount.get(url) ?? 0) >= ClientService.PUBLISH_STRIKES_THRESHOLD) const presetStriked = preset.filter(
const presetWorking = preset.filter((url) => (this.publishStrikeCount.get(url) ?? 0) < ClientService.PUBLISH_STRIKES_THRESHOLD) (url) => (this.publishStrikeCount.get(url) ?? 0) >= ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD
)
const presetWorking = preset.filter(
(url) => (this.publishStrikeCount.get(url) ?? 0) < ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD
)
const scoredRelays = Array.from(this.sessionRelayPublishStats.entries()).map(([url, s]) => ({ const scoredRelays = Array.from(this.sessionRelayPublishStats.entries()).map(([url, s]) => ({
url, url,
successCount: s.successCount, successCount: s.successCount,
@ -746,7 +768,9 @@ class ClientService extends EventTarget {
.map((u) => normalizeUrl(u) || u) .map((u) => normalizeUrl(u) || u)
.filter((n) => n && !readOnlySet.has(n)) .filter((n) => n && !readOnlySet.has(n))
const unique = Array.from(new Set(normalizedCandidates)) const unique = Array.from(new Set(normalizedCandidates))
const notStruckOut = unique.filter((n) => (this.publishStrikeCount.get(n) ?? 0) < ClientService.PUBLISH_STRIKES_THRESHOLD) const notStruckOut = unique.filter(
(n) => (this.publishStrikeCount.get(n) ?? 0) < ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD
)
const preferred: string[] = [] const preferred: string[] = []
const rest: string[] = [] const rest: string[] = []
for (const url of notStruckOut) { for (const url of notStruckOut) {
@ -789,7 +813,7 @@ class ClientService extends EventTarget {
if (readOnlySet.has(n)) return false if (readOnlySet.has(n)) return false
if (event.kind === kinds.ShortTextNote && kind1BlockedSet.has(n)) return false if (event.kind === kinds.ShortTextNote && kind1BlockedSet.has(n)) return false
const strikes = this.publishStrikeCount.get(n) ?? 0 const strikes = this.publishStrikeCount.get(n) ?? 0
if (strikes >= ClientService.PUBLISH_STRIKES_THRESHOLD) return false if (strikes >= ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD) return false
return true return true
}) })
filtered = Array.from(new Set(filtered)) filtered = Array.from(new Set(filtered))
@ -854,6 +878,7 @@ class ClientService extends EventTarget {
if (!alreadyFinished) { if (!alreadyFinished) {
logger.warn('[PublishEvent] Marking relay as timed out', { url }) logger.warn('[PublishEvent] Marking relay as timed out', { url })
relayStatuses.push({ url, success: false, error: 'Timeout: Operation took too long' }) relayStatuses.push({ url, success: false, error: 'Timeout: Operation took too long' })
client.recordSessionRelayFailure(url)
finishedCount++ finishedCount++
} }
}) })
@ -861,7 +886,6 @@ class ClientService extends EventTarget {
// Ensure we resolve even if not all relays finished // Ensure we resolve even if not all relays finished
if (!hasResolved) { if (!hasResolved) {
hasResolved = true hasResolved = true
client.recordPublishFailures(relayStatuses)
logger.debug('[PublishEvent] Resolving due to timeout', { logger.debug('[PublishEvent] Resolving due to timeout', {
success: successCount >= uniqueRelayUrls.length / 3, success: successCount >= uniqueRelayUrls.length / 3,
successCount, successCount,
@ -955,11 +979,13 @@ class ClientService extends EventTarget {
logger.error(`[PublishEvent] Auth or publish failed`, { url, error: authError.message }) logger.error(`[PublishEvent] Auth or publish failed`, { url, error: authError.message })
errors.push({ url, error: authError }) errors.push({ url, error: authError })
relayStatuses.push({ url, success: false, error: authError.message }) relayStatuses.push({ url, success: false, error: authError.message })
that.recordSessionRelayFailure(url)
}) })
} else { } else {
logger.error(`[PublishEvent] Publish failed`, { url, error: error.message }) logger.error(`[PublishEvent] Publish failed`, { url, error: error.message })
errors.push({ url, error }) errors.push({ url, error })
relayStatuses.push({ url, success: false, error: error.message }) relayStatuses.push({ url, success: false, error: error.message })
that.recordSessionRelayFailure(url)
} }
}) })
@ -978,6 +1004,7 @@ class ClientService extends EventTarget {
success: false, success: false,
error: error instanceof Error ? error.message : 'Connection failed' error: error instanceof Error ? error.message : 'Connection failed'
}) })
that.recordSessionRelayFailure(url)
} finally { } finally {
clearTimeout(relayTimeout) clearTimeout(relayTimeout)
const currentFinished = ++finishedCount const currentFinished = ++finishedCount
@ -995,7 +1022,6 @@ class ClientService extends EventTarget {
} }
if (currentFinished >= uniqueRelayUrls.length && !hasResolved) { if (currentFinished >= uniqueRelayUrls.length && !hasResolved) {
hasResolved = true hasResolved = true
client.recordPublishFailures(relayStatuses)
logger.debug('[PublishEvent] All relays finished, resolving', { logger.debug('[PublishEvent] All relays finished, resolving', {
success: successCount >= uniqueRelayUrls.length / 3, success: successCount >= uniqueRelayUrls.length / 3,
successCount, successCount,
@ -1018,7 +1044,6 @@ class ClientService extends EventTarget {
setTimeout(() => { setTimeout(() => {
if (!hasResolved) { if (!hasResolved) {
hasResolved = true hasResolved = true
client.recordPublishFailures(relayStatuses)
logger.debug('[PublishEvent] Resolving early with enough successes', { logger.debug('[PublishEvent] Resolving early with enough successes', {
success: true, success: true,
successCount, successCount,
@ -1304,6 +1329,7 @@ class ClientService extends EventTarget {
const kind1BlockedSet = new Set(KIND_1_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u)) const kind1BlockedSet = new Set(KIND_1_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u))
relays = relays.filter((url) => !kind1BlockedSet.has(normalizeUrl(url) || url)) relays = relays.filter((url) => !kind1BlockedSet.has(normalizeUrl(url) || url))
} }
relays = this.filterSessionStrikedRelays(relays)
// eslint-disable-next-line @typescript-eslint/no-this-alias // eslint-disable-next-line @typescript-eslint/no-this-alias
const that = this const that = this
@ -1423,6 +1449,7 @@ class ClientService extends EventTarget {
try { try {
relay = await that.pool.ensureRelay(url, { connectionTimeout: SUBSCRIBE_RELAY_CONNECTION_TIMEOUT_MS }) relay = await that.pool.ensureRelay(url, { connectionTimeout: SUBSCRIBE_RELAY_CONNECTION_TIMEOUT_MS })
} catch (err) { } catch (err) {
that.recordSessionRelayFailure(url)
that.queryService.releaseSubSlot(relayKey) that.queryService.releaseSubSlot(relayKey)
handleClose(i, (err as Error)?.message ?? String(err)) handleClose(i, (err as Error)?.message ?? String(err))
return return
@ -1465,6 +1492,7 @@ class ClientService extends EventTarget {
connectionTimeout: SUBSCRIBE_RELAY_CONNECTION_TIMEOUT_MS connectionTimeout: SUBSCRIBE_RELAY_CONNECTION_TIMEOUT_MS
}) })
} catch (err) { } catch (err) {
that.recordSessionRelayFailure(url)
that.queryService.releaseSubSlot(relayKey) that.queryService.releaseSubSlot(relayKey)
handleClose(i, (err as Error)?.message ?? String(err)) handleClose(i, (err as Error)?.message ?? String(err))
return return
@ -1931,9 +1959,13 @@ class ClientService extends EventTarget {
if (!normalized) { if (!normalized) {
return { events: [], connectionError: 'Invalid relay URL' } return { events: [], connectionError: 'Invalid relay URL' }
} }
if (this.filterSessionStrikedRelays([normalized]).length === 0) {
return { events: [], connectionError: 'Relay skipped this session (repeated failures)' }
}
try { try {
await this.pool.ensureRelay(normalized, { connectionTimeout: 12_000 }) await this.pool.ensureRelay(normalized, { connectionTimeout: 12_000 })
} catch (e) { } catch (e) {
this.recordSessionRelayFailure(normalized)
const msg = e instanceof Error ? e.message : String(e) const msg = e instanceof Error ? e.message : String(e)
return { events: [], connectionError: msg } return { events: [], connectionError: msg }
} }

Loading…
Cancel
Save